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

- SQL 基线 V1__flight_state_baseline.sql 整体取代 V1.0.0–V1.4.0:
  PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD
  + 8 张资源明细表 + FLIGHT_ROUTE_POINT + SCHD_SNAP_LOG 留痕层
- 废除 FDAY 日代/SCHD_GEN/名单差删:OPERATION_DAY 不可变(应用层校验 +
  条件更新强化 §7.4),STATE 仅 ACTIVE/DELETED,物理清除只在历史归档后
- 处理器化:applyScheduleRecords(§5.1 七步同一事务,重放判定/整包
  DEAD(PROTOCOL)/归属冲突不落地)+ FLOP/FDEL/ADFT(tombstone 仅
  ACTIVE→DELETED,重复 FDEL 幂等不推进版本)
- 投递:KAFKA_SCHD 同 FLID 按最新 STATE_VERSION 合并,被压掉事件关闭,
  TOMBSTONE 发 null 值消息(键缺失=删除旧值 §7.3)
- 回填待办改为业务事务内预登记,消除提交后写待办的崩溃窗口(§7.2/§10)
- XML 解码改为 jackson-dataformat-xml 数据类直接映射(SIS 信封强类型,
  FLTR 开放标签泛型承载)
- 历史归档/物理清除顺序不可颠倒:归档确认成功集才物理删除,未接通删 0 条
- 移除 PUMP_JOB 队列/ReferenceService/FlightStoreDiffTool 等旧机制与测试,
  新增运营日/引擎/快照/FDEL/归档顺序不变性回归测试
This commit is contained in:
windyboy
2026-09-09 17:53:08 +08:00
parent 6af292d103
commit 879d658159
83 changed files with 3435 additions and 6355 deletions
+16 -17
View File
@@ -10,7 +10,7 @@ msgexchange-v2 是机场 OMMS 的上游报文处理中间件,用于替换旧
- **主要入口**:轮询共享 MySQL 的 `CMINMSGS` - **主要入口**:轮询共享 MySQL 的 `CMINMSGS`
- **兼容入口**`POST /cminmsgs/send`,供现役兼容、手工工具和对拍使用;写入信箱后返回记录 ID,不是生产收报主路径。 - **兼容入口**`POST /cminmsgs/send`,供现役兼容、手工工具和对拍使用;写入信箱后返回记录 ID,不是生产收报主路径。
- **输出**Kafka 的 `msg` / `schd` 消息、共享 MySQL 的 `COUTMSGS` 出站信箱,以及查询 HTTP 接口;不直接推送前端。 - **输出**Kafka 的 `msg` / `schd` 消息、共享 MySQL 的 `COUTMSGS` 出站信箱,以及查询 HTTP 接口;不直接推送前端。
- **当前范围(阶段 A**运营航班权威状态落自有 PostgreSQL`FLIGHT_SCHD` `SCHD_GEN`,ACM2-28 定案采纳选项 C),Redis 彻底退出动态权威与全部写路径。ES 历史投影及相关清场流程属于暂缓的阶段 B。 - **当前范围(阶段 A**航班当前态落自有 PostgreSQL`FLIGHT_SCHD` + 资源明细表 + `FLIGHT_ROUTE_POINT`,权威口径见 [flight-state.md](flight-state.md))。无 Redis 依赖;ES 历史投影属暂缓的阶段 B。
本文描述架构约束,不代表所有能力已实现;实现缺口见第 9 节。模块交互、状态机和参数详见 [design.md](design.md),需求见 [user-stories.md](user-stories.md)。历史报文契约仍以 [SIS 接口规范](legacy/SIS_AODB_RMS-V0.1.md) 和 [XSD](legacy/unisysaodbsis.xsd) 为兼容依据,其他 legacy 资料仅作参考。 本文描述架构约束,不代表所有能力已实现;实现缺口见第 9 节。模块交互、状态机和参数详见 [design.md](design.md),需求见 [user-stories.md](user-stories.md)。历史报文契约仍以 [SIS 接口规范](legacy/SIS_AODB_RMS-V0.1.md) 和 [XSD](legacy/unisysaodbsis.xsd) 为兼容依据,其他 legacy 资料仅作参考。
@@ -32,7 +32,7 @@ CIIMS / AODB 等上游
│ processing:取 FIFO 队头 → 解析 / 去重 → Handler 决策 │ │ processing:取 FIFO 队头 → 解析 / 去重 → Handler 决策 │
│ └─ PG 单事务:航班变更 + 终态 + 待发事件│ │ └─ PG 单事务:航班变更 + 终态 + 待发事件│
│ │ │ │
│ jobs在主泵空闲或消息退避窗口内执行维护作业 │ jobs独立维护线程(回填补偿 / 历史归档 / 留痕清理)
│ delivery:读取 PG 待发事件 → 投递 / 重试 │ │ delivery:读取 PG 待发事件 → 投递 / 重试 │
└─────────────────────────┬──────────────────────────────┘ └─────────────────────────┬──────────────────────────────┘
├─ Kafkamsg / schd ├─ Kafkamsg / schd
@@ -42,7 +42,7 @@ CIIMS / AODB 等上游
查询接口读取航班动态,不参与状态写入。 查询接口读取航班动态,不参与状态写入。
``` ```
收报、处理投递各使用一条专用线程,不占用 HTTP 事件循环。**只有主泵可以写航班动态及快照版本**,维护作业也必须遵守这一规则 收报、处理投递与维护作业各使用独立线程,不占用 HTTP 事件循环。**航班当前态的写入只发生在持有 `PIPELINE_LOCK` 的事务内**,由主泵串行驱动
采用 Kotlin + JDK 25、Micronaut 编译期依赖注入和 JDBC 持久化。数据库变更由 Flyway 管理,但只作用于自有 PostgreSQL。具体依赖版本以 `build.gradle.kts` 为准,不在架构文档重复维护。 采用 Kotlin + JDK 25、Micronaut 编译期依赖注入和 JDBC 持久化。数据库变更由 Flyway 管理,但只作用于自有 PostgreSQL。具体依赖版本以 `build.gradle.kts` 为准,不在架构文档重复维护。
@@ -52,20 +52,19 @@ CIIMS / AODB 等上游
|---|---| |---|---|
| `ingress` | 轮询信箱、持久化入队、补偿重扫及兼容 HTTP 写入;不解析业务报文。 | | `ingress` | 轮询信箱、持久化入队、补偿重扫及兼容 HTTP 写入;不解析业务报文。 |
| `codec` | XML 解码,区分非法报文与可修复的解码失败。 | | `codec` | XML 解码,区分非法报文与可修复的解码失败。 |
| `processing` | FIFO 调度、业务身份绑定与去重、Handler 决策、快照处理及状态提交。Handler 只返回决策,不直接访问数据库或 Kafka。 | | `processing` | FIFO 调度、业务身份绑定与去重、处理器决策(SCHD/FLOP/FDEL/ADFT)、事务提交。处理器只产出决策与落库计划,不直接触碰 Kafka。 |
| `delivery` | 消费待发事件,负责按目标保序、`schd` 聚合、投递和失败重试。 | | `delivery` | 消费待发事件,负责按目标保序、`schd` 聚合、投递和失败重试。 |
| `jobs` | 持久化维护作业,由主泵在允许的窗口执行;阶段 B 作业暂不启用。 | | `jobs` | 回填补偿扫描、历史归档与物理清除(§8.2)、留痕保留期清理;独立线程执行,不参与 FIFO。 |
| `reference` | 静态参考数据和上游请求跟踪。 |
| `domain` / `config` | 领域状态、事件和决策模型,以及运行参数。 | | `domain` / `config` | 领域状态、事件和决策模型,以及运行参数。 |
| `infra` | 仓储、外部适配器、重试、健康检查与日志;通过接口隔离基础设施Redis 已退出核心写路径)。 | | `infra` | 仓储JDBC/stub、外部适配器、重试、健康检查与日志;通过接口隔离基础设施。 |
## 4. 主流程 ## 4. 主流程
### 收报与处理 ### 收报与处理
1. `InboxPoller` 默认每秒扫描 `DATE_PROCESSED IS NULL` 的信箱记录,在自有 PG 中建立 `PROC_STATE(PENDING)`。重复扫描不能重复入队;入队失败留待重扫。 1. `InboxPoller` 默认每秒扫描 `DATE_PROCESSED IS NULL` 的信箱记录,在自有 PG 中建立 `PROC_STATE(PENDING)`。重复扫描不能重复入队;入队失败留待重扫。
2. 主泵只处理最小未完成 `CMINMSGS_ID`。解析报文、绑定业务身份并去重后,调用对应 Handler 生成决策 2. 主泵只处理最小未完成 `MSG_ID`。解析报文、绑定业务身份并去重后,分派给 SCHD/FLOP/FDEL/ADFT 处理器
3. 在自有 PG 本地事务中同时保存航班状态变更(`FLIGHT_SCHD` / `SCHD_GEN`)、处理结果`MSG_EVENT` 待发事件。 3. 在自有 PG 同一事务内(先取 `PIPELINE_LOCK`保存航班状态变更(`FLIGHT_SCHD` 与明细表)、处理结果`MSG_EVENT` 待发事件与回填待办预登记
4. 事务提交后,回填共享信箱的处理标记(外部副作用,补偿保障)。 4. 事务提交后,回填共享信箱的处理标记(外部副作用,补偿保障)。
### 投递 ### 投递
@@ -77,10 +76,10 @@ CIIMS / AODB 等上游
## 5. 必须保持的约束 ## 5. 必须保持的约束
- **消息严格 FIFO**:队头失败并退避时,后续消息仍不能越过它。只有队头完成或按失败策略进入终态后,队列才继续推进。收报重扫和水位设计必须防止较小 ID 漏入队而被后续消息越过。 - **消息严格 FIFO**:队头失败并退避时,后续消息仍不能越过它。只有队头完成或按失败策略进入终态后,队列才继续推进。收报重扫和水位设计必须防止较小 ID 漏入队而被后续消息越过。
- **动态状态单写者**`FLIGHT_SCHD` 运营航班表和快照 `SCHD_GEN` 只由主泵单线程写入。事务通过 `PIPELINE_LOCK` `FLIGHT_SCHD_WRITER` 行执`SELECT ... FOR UPDATE` 互斥;该方案在 PG/Oracle 11g 均无需数据库扩展或额外 DBA 特权。不能通过增加实例或处理线程直接扩容。 - **动态状态单写者**`FLIGHT_SCHD` 及明细表只由主泵单线程写入。事务内第一步对 `PIPELINE_LOCK` `SELECT ... FOR UPDATE` 互斥;该方案在 PG/Oracle 11g 均无需数据库扩展或额外 DBA 特权。不能通过增加实例或处理线程直接扩容。
- **身份去重**:同一业务身份只能绑定一条有效处理记录,重复报文不应再次产生业务副作用。具体身份组成和重放规则见设计文档。 - **身份去重**:同一业务身份只能绑定一条有效处理记录,重复报文不应再次产生业务副作用。具体身份组成和重放规则见设计文档。
- **快照可恢复**:快照覆盖、旧数据清理和版本推进需要原子性与重放保护;不能在恢复时把旧代数据重新写回 - **快照可恢复**:快照`PROC_STATE` 成功终态判定重放(§5.1);每次成功写入推进 `STATE_VERSION``OPERATION_DAY` 一经确定不可变(§5.3
- **作业不与消息混排**`PUMP_JOB` 是独立队列,只在没有消息队头或队头处于退避窗口时执行。执行窗口与饥饿边界需要明确验证 - **物理清除只发生在历史归档**:删除一律先标记(FDEL)或由生命周期清除;历史存储未接通时必须删 0 条(§8.2)
这些约束优先于吞吐量优化。单写者降低了并发复杂度,代价是队头阻塞和吞吐上限;如需并行化,必须先重新定义顺序与状态归属,不能只调整线程数。 这些约束优先于吞吐量优化。单写者降低了并发复杂度,代价是队头阻塞和吞吐上限;如需并行化,必须先重新定义顺序与状态归属,不能只调整线程数。
@@ -88,7 +87,7 @@ CIIMS / AODB 等上游
| 存储 | 承载内容 | 职责说明 | | 存储 | 承载内容 | 职责说明 |
|---|---|---| |---|---|---|
| 自有 PostgreSQL | 处理状态 `PROC_STATE`、待发事件 `MSG_EVENT`维护作业 `PUMP_JOB`请求跟踪 `REQ_TRACK`静态主数据 `REF_MASTER`、运营航班表 `FLIGHT_SCHD` 与日代 `SCHD_GEN` | 本系统唯一业务数据库。消息处理、快照推进与待发事件在单事务内原子提交;本地事务只在此库。 | | 自有 PostgreSQL | 单行锁 `PIPELINE_LOCK`处理状态 `PROC_STATE`、待发事件 `MSG_EVENT`、请求跟踪 `REQ_TRACK`回填待办 `BACKFILL_TODO`、航班当前态 `FLIGHT_SCHD` + 9 张明细表、留痕 `SCHD_SNAP_LOG` | 本系统唯一业务数据库。消息处理、状态推进与待发事件在单事务内原子提交;本地事务只在此库。 |
| 共享 MySQL | `CMINMSGS` 入站信箱、`COUTMSGS` 出站信箱 | 外部系统所有。仅执行约定的信箱读写和处理标记回填,不建表、不迁移 schema、不写历史表。兼容 HTTP 入口可按既有契约写入入站信箱。 | | 共享 MySQL | `CMINMSGS` 入站信箱、`COUTMSGS` 出站信箱 | 外部系统所有。仅执行约定的信箱读写和处理标记回填,不建表、不迁移 schema、不写历史表。兼容 HTTP 入口可按既有契约写入入站信箱。 |
**不使用跨库事务。** PG 事务只能保证“处理结果与待发事件一起提交”,不能覆盖 Redis 更新、MySQL 回填或 Kafka 发送。跨存储依靠幂等、重试和持久化补偿恢复: **不使用跨库事务。** PG 事务只能保证“处理结果与待发事件一起提交”,不能覆盖 Redis 更新、MySQL 回填或 Kafka 发送。跨存储依靠幂等、重试和持久化补偿恢复:
@@ -107,7 +106,7 @@ CIIMS / AODB 等上游
| 编号 | 决策及理由 | | 编号 | 决策及理由 |
|---|---| |---|---|
| D1 | 业务报文严格 FIFO维护作业窗口执行,优先保护航班状态的时序正确性。 | | D1 | 业务报文严格 FIFO,优先保护航班状态的时序正确性;维护作业独立线程执行,不参与消息序。 |
| D2 | 阶段 B 暂缓。历史写入成功后才可生成删除事件;顺序调用本身不保证原子性,恢复与去重方案需在启用前补齐。 | | D2 | 阶段 B 暂缓。历史写入成功后才可生成删除事件;顺序调用本身不保证原子性,恢复与去重方案需在启用前补齐。 |
| D3 | `schd` 只从 `flushSchd` 聚合发送,减少已被覆盖的中间状态通知。 | | D3 | `schd` 只从 `flushSchd` 聚合发送,减少已被覆盖的中间状态通知。 |
| D4 | 未实现的报文类型按 `UNSUPPORTED` 可恢复失败处理,不当作非法报文直接丢弃;补齐能力后按重放规则恢复。 | | D4 | 未实现的报文类型按 `UNSUPPORTED` 可恢复失败处理,不当作非法报文直接丢弃;补齐能力后按重放规则恢复。 |
@@ -130,7 +129,7 @@ CIIMS / AODB 等上游
**替换旧系统** **替换旧系统**
采用“影子对拍 → 切流 → 旧系统冻结”。共享信箱不能让新旧系统同时认领和回填;影子输入使用只读水位或回放。影子环境须隔离 PG schema/实例、Kafka topic 和服务注册身份,并禁止误写生产信箱。切流时保证只有一个权威写者。影子期采用 `FlightStoreDiffTool` 跨存储对拍。 采用“影子对拍 → 切流 → 旧系统冻结”。共享信箱不能让新旧系统同时认领和回填;影子输入使用只读水位或回放。影子环境须隔离 PG schema/实例、Kafka topic 和服务注册身份,并禁止误写生产信箱。切流时保证只有一个权威写者。
**可观测性要求** **可观测性要求**
@@ -143,8 +142,8 @@ CIIMS / AODB 等上游
上线前至少需要完成并验证: 上线前至少需要完成并验证:
- 真实 PG 事务、信箱水位与补扫、回填补偿、出站信箱,以及所需业务 Handler。 - 真实 PG 事务、信箱水位与补扫、回填补偿、出站信箱,以及所需业务 Handler。
- `FLIGHT_SCHD` 事务原子性、快照 SQL CAS 版本校验、故障中断回滚恢复与影子对拍 cross-store diff 工具 - `FLIGHT_SCHD` 事务原子性、`STATE_VERSION` 推进与 `OPERATION_DAY` 不可变校验、故障中断回滚恢复
- FIFO、身份去重、作业窗口和投递故障下的回归测试。 - FIFO、身份去重、FDEL/ADFT 生命周期、历史归档顺序和投递故障下的回归测试。
- 生产启动校验、单实例排他保护、影子隔离和 Kafka 配置约束;当前配置仍允许 Kafka 参数覆盖,且默认 in-flight 值与 D11 要求不同。 - 生产启动校验、单实例排他保护、影子隔离和 Kafka 配置约束;当前配置仍允许 Kafka 参数覆盖,且默认 in-flight 值与 D11 要求不同。
- 死信告警、人工重放、端到端追踪、积压指标及安全边界。 - 死信告警、人工重放、端到端追踪、积压指标及安全边界。
@@ -3,6 +3,7 @@ package com.gzzn.omms.msgexchange
import com.gzzn.omms.msgexchange.delivery.Dispatcher import com.gzzn.omms.msgexchange.delivery.Dispatcher
import com.gzzn.omms.msgexchange.ingress.InboxPoller import com.gzzn.omms.msgexchange.ingress.InboxPoller
import com.gzzn.omms.msgexchange.processing.Pump import com.gzzn.omms.msgexchange.processing.Pump
import com.gzzn.omms.msgexchange.jobs.JobRunner
import io.micronaut.context.annotation.Requires import io.micronaut.context.annotation.Requires
import io.micronaut.runtime.event.annotation.EventListener import io.micronaut.runtime.event.annotation.EventListener
import io.micronaut.runtime.server.event.ServerStartupEvent import io.micronaut.runtime.server.event.ServerStartupEvent
@@ -20,6 +21,7 @@ class PipelineLifecycle(
private val poller: InboxPoller, private val poller: InboxPoller,
private val pump: Pump, private val pump: Pump,
private val dispatcher: Dispatcher, private val dispatcher: Dispatcher,
private val jobRunner: JobRunner,
) { ) {
private val log = org.slf4j.LoggerFactory.getLogger(PipelineLifecycle::class.java) private val log = org.slf4j.LoggerFactory.getLogger(PipelineLifecycle::class.java)
private val threads = mutableListOf<Thread>() private val threads = mutableListOf<Thread>()
@@ -38,7 +40,8 @@ class PipelineLifecycle(
threads += spawn("msgx-inbox-poller", poller::loop) threads += spawn("msgx-inbox-poller", poller::loop)
threads += spawn("msgx-pump", pump::loop) threads += spawn("msgx-pump", pump::loop)
threads += spawn("msgx-dispatcher", dispatcher::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 = private fun spawn(name: String, body: () -> Unit): Thread =
@@ -49,6 +52,7 @@ class PipelineLifecycle(
poller.stop() poller.stop()
pump.stop() pump.stop()
dispatcher.stop() dispatcher.stop()
jobRunner.stop()
threads.forEach { it.interrupt() } // 解除 Thread.sleep 阻塞,加速退出 threads.forEach { it.interrupt() } // 解除 Thread.sleep 阻塞,加速退出
threads.forEach { runCatching { it.join(3000) } } threads.forEach { runCatching { it.join(3000) } }
log.info("pipeline loops stopped") log.info("pipeline loops stopped")
@@ -1,37 +1,37 @@
package com.gzzn.omms.msgexchange.codec 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.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.MetaFields import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import jakarta.inject.Singleton import jakarta.inject.Singleton
import org.w3c.dom.Element import javax.xml.stream.XMLInputFactory
import org.w3c.dom.Node
import org.xml.sax.InputSource
import java.io.StringReader
import javax.xml.XMLConstants
import javax.xml.parsers.DocumentBuilderFactory
/** /**
* SIS XML 解码XXE 防护;META + TYPE 分派 + FLOP 集合解析)。 * SIS XML 解码jackson-dataformat-xml 直接映射到 [SisMessage] 数据类(不再手写 DOM)。
* 阶段 2 先覆盖 FLOP-GTDT 纵向链路所需字段;其余 STYP 仅完成 META/FLID 提取 * XXE 防护:XMLInputFactory 禁 DTD/外部实体
* 覆盖:SCHD DNLD/RESPFLTR 记录集,§5 入口)、SCHD ADFT(单记录 §2.1)、
* FLOP(单航班增量 §6.1)、FDEL(终止实例 §6.2)。
*/ */
@Singleton @Singleton
class JacksonXmlCodec : XmlCodec { class JacksonXmlCodec : XmlCodec {
private val builderFactory: DocumentBuilderFactory = DocumentBuilderFactory.newInstance().apply { private val mapper: XmlMapper = XmlMapper(
isNamespaceAware = false XmlFactory(
isExpandEntityReferences = false XMLInputFactory.newInstance().apply {
setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) setProperty(XMLInputFactory.SUPPORT_DTD, false)
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false)
setFeature("http://xml.org/sax/features/external-general-entities", false) },
setFeature("http://xml.org/sax/features/external-parameter-entities", false) ),
try { ).apply {
setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "") registerKotlinModule()
setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "") configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
} catch (_: IllegalArgumentException) {
// JDK 实现差异:忽略不支持的属性
}
} }
override fun decode(rawXml: String): DecodeResult { override fun decode(rawXml: String): DecodeResult {
@@ -39,35 +39,35 @@ class JacksonXmlCodec : XmlCodec {
if (trimmed.isEmpty() || !trimmed.startsWith("<")) { if (trimmed.isEmpty() || !trimmed.startsWith("<")) {
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "empty-or-non-xml")) return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "empty-or-non-xml"))
} }
return try { val msg = try {
val doc = builderFactory.newDocumentBuilder().parse(InputSource(StringReader(trimmed))) mapper.readValue(trimmed, SisMessage::class.java)
val root = doc.documentElement ?: return DecodeResult.Err( } catch (e: Exception) {
DecodeFailure(ErrorClass.MALFORMED, "missing-root"), return DecodeResult.Err(
DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message?.take(200) ?: e.javaClass.simpleName}"),
) )
if (!root.tagName.equals("MSG", ignoreCase = true)) {
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "root-not-msg:${root.tagName}"))
} }
val meta = msg.meta
val metaEl = firstChildElement(root, "META")
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-meta")) ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-meta"))
val sndr = childText(metaEl, "SNDR") val sndr = meta.sndr?.trim()?.takeIf { it.isNotEmpty() }
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-sndr")) ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-sndr"))
val seqn = childText(metaEl, "SEQN")?.toLongOrNull() val seqn = meta.seqn
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-seqn")) ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-seqn"))
val dttm = childText(metaEl, "DTTM")?.toLongOrNull() val dttm = meta.dttm
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-dttm")) ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-dttm"))
val type = childText(metaEl, "TYPE")?.uppercase() val type = meta.type?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type")) ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
val styp = childText(metaEl, "STYP")?.uppercase() val styp = meta.styp?.uppercase()
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-styp")) ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-styp"))
val kind = kindOf(type, styp) val kind = kindOf(type, styp)
val body = when (kind) { val body: Any? = when (kind) {
is MsgKind.Flop -> parseFlopBody(root, type) is MsgKind.Schd -> parseScheduleBody(msg)
is MsgKind.Schd -> null is MsgKind.Flop -> parseFlightSection(msg.flop)
MsgKind.Fdel -> parseFlightSection(msg.fdel)
is MsgKind.Unsupported -> null
} }
DecodeResult.Ok( return DecodeResult.Ok(
DecodedMessage( DecodedMessage(
meta = MetaFields(sndr = sndr, type = type, styp = styp, seqn = seqn, dttm = dttm), meta = MetaFields(sndr = sndr, type = type, styp = styp, seqn = seqn, dttm = dttm),
kind = kind, kind = kind,
@@ -75,11 +75,6 @@ class JacksonXmlCodec : XmlCodec {
body = body, body = body,
), ),
) )
} catch (e: NumberFormatException) {
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "invalid-number:${e.message}"))
} catch (e: Exception) {
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message ?: e.javaClass.simpleName}"))
}
} }
override fun encodeRqrd(kind: String, rangeJson: String): String = override fun encodeRqrd(kind: String, rangeJson: String): String =
@@ -89,82 +84,92 @@ class JacksonXmlCodec : XmlCodec {
"SCHD" -> MsgKind.Schd( "SCHD" -> MsgKind.Schd(
when (styp) { when (styp) {
"RESP" -> MsgKind.SchdSubtype.RESP "RESP" -> MsgKind.SchdSubtype.RESP
"DNLD" -> MsgKind.SchdSubtype.DNLD
"ADFT" -> MsgKind.SchdSubtype.ADFT "ADFT" -> MsgKind.SchdSubtype.ADFT
else -> MsgKind.SchdSubtype.DNLD else -> MsgKind.SchdSubtype.DNLD
}, },
) )
"FDEL" -> MsgKind.Fdel
"FLOP" -> MsgKind.Flop(styp) "FLOP" -> MsgKind.Flop(styp)
else -> MsgKind.Flop(styp) else -> MsgKind.Unsupported("$type-$styp") // §9 未支持类型,不猜测分派
} }
private fun parseFlopBody(root: Element, type: String): FlopPayload? { /** SCHD DNLD/RESP/ADFTRECS 缺失/非法 → -1(处理层 §5.2 整包拒绝)。 */
val section = firstChildElement(root, type) ?: return null 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 scalars = linkedMapOf<String, String>()
val collections = linkedMapOf<String, MutableList<Map<String, String>>>() val collections = linkedMapOf<String, MutableList<Map<String, String>>>()
var flid: String? = null
section.childNodes.let { nodes -> section.forEach { (key, value) ->
for (i in 0 until nodes.length) { val tag = key.uppercase()
val node = nodes.item(i)
if (node.nodeType != Node.ELEMENT_NODE) continue
val el = node as Element
val tag = el.tagName.uppercase()
when { when {
tag in COLLECTION_TAGS -> { tag == "FLID" -> flid = value.toString().trim()
collections.getOrPut(tag) { mutableListOf() }.add(parseCollectionItem(el)) tag in COLLECTION_TAGS -> value.toCollectionItems()?.let { items ->
collections.getOrPut(tag) { mutableListOf() }.addAll(items)
} }
tag == "FLID" -> scalars["FLID"] = el.textContent.trim() value is String -> scalars[tag] = value.trim()
hasElementChildren(el) -> Unit
else -> scalars[tag] = el.textContent.trim()
} }
} }
return flid?.takeIf { it.isNotEmpty() }?.let {
FlopPayload(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() })
}
} }
val flid = scalars.remove("FLID") ?: return null /** FLTR Map → ScheduleRecord:标量/集合分离,集合项含源序号属性。 */
return FlopPayload(flid = flid, scalars = scalars, collections = collections.mapValues { it.value.toList() }) 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 flid?.takeIf { it.isNotEmpty() }?.let {
ScheduleRecord(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() }, seqn = seqn)
}
} }
private fun parseCollectionItem(el: Element): Map<String, String> { /**
val item = linkedMapOf<String, String>() * 集合值归一:重复元素 → List<Map>(属性与子元素展平);单元素 → 单项列表;
if (el.hasAttributes()) { * 混合文本(如 DELY 备注文本)落到 REMC 键(XmlMapper 绑定的已知弱项,尽力保留)。
for (i in 0 until el.attributes.length) { */
val attr = el.attributes.item(i) private fun Any?.toCollectionItems(): List<Map<String, String>>? = when (this) {
item[attr.nodeName.uppercase()] = attr.nodeValue.trim() is List<*> -> mapNotNull { it?.toCollectionItem() }
} is Map<*, *> -> listOfNotNull(toCollectionItem(this))
} is String -> listOf(mapOf("REMC" to trim()))
el.childNodes.let { nodes -> else -> null
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? { private fun toCollectionItem(map: Map<*, *>): Map<String, String> {
parent.childNodes.let { nodes -> val out = linkedMapOf<String, String>()
for (i in 0 until nodes.length) { map.forEach { (k, v) ->
val node = nodes.item(i) val key = k.toString().uppercase()
if (node.nodeType == Node.ELEMENT_NODE && (node as Element).tagName.equals(tag, ignoreCase = true)) { when (v) {
return node is String -> out[key] = v.trim()
is Number -> out[key] = v.toString()
} }
} }
} return out
return null
} }
private fun childText(parent: Element, tag: String): String? = private fun Any?.toCollectionItem(): Map<String, String>? = when (this) {
firstChildElement(parent, tag)?.textContent?.trim()?.takeIf { it.isNotEmpty() } is Map<*, *> -> toCollectionItem(this)
is String -> mapOf("REMC" to trim())
private fun hasElementChildren(el: Element): Boolean { else -> null
el.childNodes.let { nodes ->
for (i in 0 until nodes.length) {
if (nodes.item(i).nodeType == Node.ELEMENT_NODE) return true
}
}
return false
} }
companion object { companion object {
@@ -1,11 +1,73 @@
package com.gzzn.omms.msgexchange.codec 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 命令化输入)。 * FLOP 报文解析体(单航班增量 §6.1)。
* scalarsFLID/FFID 等标量;collectionsGTDT/CKDT 等重复集合(含源序号属性)。 * scalarsFLID 以外的标量;collectionsGTDT/CKDT 等重复集合(含源序号属性)。
*/ */
data class FlopPayload( data class FlopPayload(
val flid: String, val flid: String,
val scalars: Map<String, String> = emptyMap(), val scalars: Map<String, String> = emptyMap(),
val collections: Map<String, List<Map<String, String>>> = emptyMap(), val collections: Map<String, List<Map<String, String>>> = emptyMap(),
) )
/**
* SCHDDNLD/RESP/ADFT)解析体(docs/flight-state.md §5)。
* RECS 声明数与实收 FLTR 记录分离承载,完整性校验(§5.2)在处理层执行;
* 协议无报文级覆盖日字段——scope 由处理层按记录 SODT 推导。
*/
data class ScheduleBody(
val recsDeclared: Int,
val records: List<ScheduleRecord>,
val scopeStart: LocalDate? = null,
val scopeEnd: LocalDate? = null,
)
// =====================================================================
// XML 直接映射(jackson-dataformat-xml 数据绑定,不再手写 DOM 遍历)。
// 信封强类型(META/段结构),FLTR/航班段内标签为开放集 → Map<String, Any>
// 泛型承载(45+ 标签逐一建模不成比例);集合项 = 属性 + 子元素展平的 Map。
// 已知弱项:DELY 的混合文本(元素文本)在 XmlMapper 绑定下不保证保留,
// 属性 CODE/STRT/DURA 保留;备注文本待真实报文验收(§10)。
// =====================================================================
@JsonIgnoreProperties(ignoreUnknown = true)
data class SisMessage(
@JacksonXmlProperty(localName = "META")
val meta: SisMeta? = null,
@JacksonXmlProperty(localName = "SCHD")
val schd: SchdSection? = null,
@JacksonXmlProperty(localName = "FLOP")
val flop: Map<String, Any>? = null,
@JacksonXmlProperty(localName = "FDEL")
val fdel: Map<String, Any>? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class SisMeta(
@JacksonXmlProperty(localName = "SNDR")
val sndr: String? = null,
@JacksonXmlProperty(localName = "SEQN")
val seqn: Long? = null,
@JacksonXmlProperty(localName = "DTTM")
val dttm: Long? = null,
@JacksonXmlProperty(localName = "TYPE")
val type: String? = null,
@JacksonXmlProperty(localName = "STYP")
val styp: String? = null,
)
/** SCHD 段:RECS 声明数 + FLTR 记录集(SIS §3.16 样例结构)。 */
@JsonIgnoreProperties(ignoreUnknown = true)
data class SchdSection(
@JacksonXmlProperty(localName = "RECS")
val recs: Int? = null,
@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "FLTR")
val fltr: List<Map<String, Any>> = emptyList(),
)
@@ -0,0 +1,25 @@
package com.gzzn.omms.msgexchange.config
import io.micronaut.context.annotation.ConfigurationProperties
/** 生命周期与保留期(docs/flight-state.md §8)。窗口按机场时区计算。 */
@ConfigurationProperties("msgx.history")
class HistoryProps {
/** 已取消(CNCL 非空)超过 N 小时。 */
var cancelledHours: Long = 48
/** 到港/离港终态(NAAT/NEAT,含义待术语表确认 §10)超过 N 小时。 */
var terminalHours: Long = 48
/** STATE = DELETED 超过 N 小时。 */
var deletedHours: Long = 48
/** 无终态字段:最后有效更新超过兜底期限(默认 7 天)。 */
var idleHours: Long = 24 * 7
/** 留痕 SCHD_SNAP_LOG 保留天数(§8.3,按 (SCOPE_END, RECV_AT) 清理)。 */
var snapLogRetentionDays: Long = 90
/** 是否接通历史存储——未接通时 HISTORY_SWEEP 必须删 0 条(§8.2)。 */
var historyStoreEnabled: Boolean = false
}
@@ -0,0 +1,16 @@
package com.gzzn.omms.msgexchange.config
import io.micronaut.context.annotation.ConfigurationProperties
/**
* 运营日计算(docs/flight-state.md §3.5):由 SODTddMMMyyHHmm)与机场时区计算;
* 切日边界业务配置——不得假设等于接收日期或自然日零点(默认 0 点为占位,待业务确认 §10)。
*/
@ConfigurationProperties("msgx.operation-day")
class OperationDayProps {
/** 机场时区(IANA)。 */
var zone: String = "Asia/Shanghai"
/** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23)。 */
var cutoffHour: Int = 0
}
@@ -5,20 +5,16 @@ import java.time.Duration
/** /**
* ACMA-8 参数表(v4)初值;阶段 0 现网基线校准。 * ACMA-8 参数表(v4)初值;阶段 0 现网基线校准。
* U03N02):Micronaut 要求嵌套配置类同样标注 @ConfigurationProperties,否则 msgx.pipeline/schd/ * U03N02):Micronaut 要求嵌套配置类同样标注 @ConfigurationProperties,否则
* identity/consistency-check.* 全部静默回落 Kotlin 默认值(当前 yml 初值与默认值一致,现象被掩盖) * msgx.pipeline/schd/identity.* 全部静默回落 Kotlin 默认值
*/ */
@ConfigurationProperties("msgx") @ConfigurationProperties("msgx")
class PipelineProps { class PipelineProps {
var phase: Phase = Phase.A
var serviceName: String = "msgexchangeapi" var serviceName: String = "msgexchangeapi"
var registerEureka: Boolean = true var registerEureka: Boolean = true
var pipeline: Pipeline = Pipeline() var pipeline: Pipeline = Pipeline()
var schd: Schd = Schd() var schd: Schd = Schd()
var identity: Identity = Identity() var identity: Identity = Identity()
var consistencyCheck: ConsistencyCheck = ConsistencyCheck()
enum class Phase { A, B }
@ConfigurationProperties("pipeline") @ConfigurationProperties("pipeline")
class Pipeline { class Pipeline {
@@ -50,10 +46,4 @@ class PipelineProps {
/** CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持 false,计算集中此处(I3)。 */ /** CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持 false,计算集中此处(I3)。 */
var includeDayBoundary: Boolean = false 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 package com.gzzn.omms.msgexchange.delivery
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus 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.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
@@ -12,26 +12,29 @@ import jakarta.inject.Singleton
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
/** 对外投递端口(Kafka 同步确认;阶段 B 追加 ES/Redis 投影写入)。 */ /** 对外投递端口(Kafka 同步确认at-least-once;阶段 B 追加 ES 投影写入)。 */
interface DeliveryPort { interface DeliveryPort {
/** Kafka 发送(同步确认,at-least-once)。topic 由 target 映射:KAFKA:msg→"msg"KAFKA:schd→"schd"。 */ /** KAFKA_MSG 变化通知(key=FLID。 */
fun sendKafka(topic: String, payloadJson: String) fun sendKafka(topic: String, key: String, payloadJson: String)
/** 阶段 BES flight_hts 写入。 */ /**
fun indexFlightHts(payloadJson: String) * KAFKA_SCHD 整态(key=FLID)。KAFKA_MSG 与 KAFKA_SCHD 映射同一 topic 语义由适配层定;
* target→topicKAFKA:msg→"msg"KAFKA:schd→"schd"。
*/
fun sendKafkaSchd(topic: String, key: String, payloadJson: String)
/** TOMBSTONEkey=FLID、value=null——整态键缺失表示删除旧值(§7.3)。 */
fun sendKafkaNull(topic: String, key: String)
/** 连通性探测(健康检查用);默认 true,真实 Kafka 实装时覆写为 producer metadata 校验。 */ /** 连通性探测(健康检查用);默认 true,真实 Kafka 实装时覆写为 producer metadata 校验。 */
fun ping(): Boolean = true fun ping(): Boolean = true
} }
/** /**
* ACMA-8 流程 3:投递调度——每 target 严格 FIFO(I1 双层同策略)。 * 投递调度(docs/flight-state.md §7.3):逐条 KAFKA_MSG 严格 FIFO
* N03U06):KAFKA_SCHD 不走逐条循环(唯一出口是 flushSchd 批量聚合),逐条循环显式排除。 * KAFKA_SCHD flushSchd 批量——同一 FLID 未发事件按最新 STATE_VERSION 合并输出,
* U08 闭环:逐条与批量失败都在持有具体事件/批次的边界完成状态迁移—— * TOMBSTONE 发 null 值消息。两主题间不保证顺序(§7.3)。
* · 逐条:scheduleRetry(attempts+1, backoff) / 达上限 markDead(EXHAUSTED)DLQ 保留行); * 批量闭环:队首退避未到期不 claim;发送失败整批 attempts+1 退避,达上限整批 DEAD/DLQ。
* · 批量(flushSchd):整批退避,队首 nextAttemptAt 未到不 claim;达上限整批 DEAD/DLQ
* · loop 只作最后防线,不吞 InterruptedException(致命/中断错误不被普通恢复吞掉)。
*/ */
@Singleton @Singleton
class Dispatcher( class Dispatcher(
@@ -45,9 +48,9 @@ class Dispatcher(
@Volatile @Volatile
private var running = true private var running = true
private var lastFlush: Instant = Instant.EPOCH private var lastFlush: Instant? = null
/** 优雅停机:loop 收尾后退出;线程中断由 Runner 负责。 */ /** 优雅停机:loop 收尾后退出;线程中断由 PipelineLifecycle 负责。 */
fun stop() { fun stop() {
running = false running = false
} }
@@ -60,88 +63,84 @@ class Dispatcher(
Thread.currentThread().interrupt() Thread.currentThread().interrupt()
return return
} catch (e: Exception) { } catch (e: Exception) {
// U08:投递循环只作最后防线;致命 Error 不吞。tick 内失败迁移已完成。
sleepQuietly(props.pipeline.pollInterval) sleepQuietly(props.pipeline.pollInterval)
} }
// N18:轮询间隔取参数表(下限 50ms,避免退避节律被吞)
sleepQuietly(props.pipeline.pollInterval.coerceAtLeast(Duration.ofMillis(50)))
} }
} }
internal fun tick() { internal fun tick() {
val targets = if (props.phase == PipelineProps.Phase.A) Targets.phaseA else Targets.phaseB val head = msgEvents.headUnsent(Targets.KAFKA_MSG)
for (t in targets) { if (head != null && (head.state == EventStatus.SENT || head.nextAttemptAt == null || head.nextAttemptAt <= scheduler.now())) {
if (t == Targets.KAFKA_SCHD) continue // N03schd 唯一出口 flushSchd deliver(Targets.KAFKA_MSG, head)
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()
} }
if (flushDue()) flushSchd()
if (running) sleepQuietly(props.pipeline.pollInterval)
} }
private fun flushDue(): Boolean = private fun flushDue(): Boolean =
Duration.between(lastFlush, scheduler.now()) >= props.schd.flushPeriod lastFlush?.let { Duration.between(it, scheduler.now()) >= props.schd.flushPeriod } ?: false
/** 单条事件失败迁移:attempts+1;达上限 DEAD(EXHAUSTED)DLQattempts 落库审计),否则退避重试。 */ private fun deliver(target: String, e: MsgEvent) {
private fun retryOrDead(e: MsgEvent, lastError: String) { try {
val attempts = e.attempts + 1 when (target) {
if (scheduler.exhausted(attempts)) { Targets.KAFKA_MSG -> port.sendKafka("msg", e.partitionKey, e.payloadJson)
log.error("event DEAD(DLQ) eventId={} attempts={} lastError={}", e.eventId, attempts, lastError) }
msgEvents.markDead(e.eventId!!, ErrorClass.EXHAUSTED, lastError, attempts) msgEvents.markSent(e.eventId ?: return)
} else { } catch (ex: Exception) {
log.warn("event retry scheduled eventId={} attempts={} nextAttemptAt={}", e.eventId, attempts, scheduler.nextAttemptAt(attempts)) retryOrDead(e, ex.message ?: ex.javaClass.simpleName)
msgEvents.scheduleRetry(e.eventId!!, scheduler.nextAttemptAt(attempts), attempts)
} }
} }
private fun deliver(target: String, e: MsgEvent) = when (target) { /** flushSchd:同 FLID 未发事件按最新 STATE_VERSION 合并(§7.3);TOMBSTONE 发 null。 */
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 仅在成功(含空批)后推进。
*/
internal fun flushSchd() { internal fun flushSchd() {
val pending = msgEvents.claimBatch(Targets.KAFKA_SCHD, limit = props.schd.flushLimit) val batch = try {
if (pending.isEmpty()) { msgEvents.mergePendingSchd(props.schd.flushLimit)
} catch (e: Exception) {
log.warn("mergePendingSchd failed: {}", e.message)
return
}
if (batch.isEmpty()) {
lastFlush = scheduler.now() lastFlush = scheduler.now()
return return
} }
val due = pending.first() val failures = mutableListOf<MsgEvent>()
if (due.nextAttemptAt != null && due.nextAttemptAt > scheduler.now()) { for (e in batch) {
return // 队首仍在退避:整批等待,不推进 lastFlush(到期再试)
}
val payload = SchdAggregation.latestPerFlight(pending).joinToString(",", "[", "]")
log.debug("flushSchd batch size={} payloadLen={}", pending.size, payload.length)
try { try {
port.sendKafka("schd", payload) when (e.eventType) {
} catch (e: Exception) { EventType.TOMBSTONE -> port.sendKafkaNull("schd", e.partitionKey)
log.warn("flushSchd send failed batch={} -> batch retryOrDead: {}", pending.size, e.message) EventType.UPSERT -> port.sendKafkaSchd("schd", e.partitionKey, e.payloadJson)
pending.forEach { retryOrDead(it, "schd-send: ${e.message ?: "unknown"}") }
return // 不推进 lastFlush:整批退避(含 DEAD 出队)后到期重试
} }
msgEvents.markAllSent(pending.mapNotNull { it.eventId }) } catch (ex: Exception) {
log.info("flushSchd sent batch={}", pending.size) failures.add(e)
}
}
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() lastFlush = scheduler.now()
} }
/** 单条事件失败迁移:attempts+1;达上限 DEAD(EXHAUSTED)DLQattempts 落库审计),否则退避重试。 */
private fun retryOrDead(e: MsgEvent, lastError: String) {
val eventId = e.eventId ?: return
val attempts = e.attempts + 1
if (scheduler.exhausted(attempts)) {
msgEvents.markDead(eventId, ErrorClass.EXHAUSTED, lastError, attempts)
} else {
msgEvents.scheduleRetry(eventId, scheduler.nextAttemptAt(attempts), attempts)
}
}
private fun sleepQuietly(d: Duration) { private fun sleepQuietly(d: Duration) {
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1)) 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 package com.gzzn.omms.msgexchange.domain
/** import com.gzzn.omms.msgexchange.domain.flight.MergeChange
* ACMA-8 流程 2Handler 决策(纯函数)产物——状态与报文进,变更与事件出, import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
* 不直接触碰数据库或 Kafka。 import java.time.LocalDate
*/
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
)
/** /**
* 航班状态变更(阶段 A 落自有库 FLIGHT_SCHD 宽表,与事件同事务原子提交;ACM2-28 定案) * 处理决策(docs/flight-state.md)——解码与校验进,落库计划出,纯数据不触碰 DB/Kafka
* fields = 本报文变更的字段集(field → value,与 legacy flightInfo hash 同构), * 事务边界、PIPELINE_LOCK、事件登记由处理层执行(§5.1/§6)。
* 仓储按「字段级合并」落库(仅新增/覆盖,不删除缺失字段,与 legacy hmset 同语义)。
*/ */
data class FlightChange( sealed interface Decision {
val flid: String, /**
val fields: Map<String, String>, * SCHD DNLD/RESP:已过 §5.2 报文完整性校验的记录集。
val maid: String? = null, * §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( /** FDEL 标记删除(§6.2)。 */
val flid: String, data class Delete(val flid: String) : Decision
val payloadJson: String, // KAFKA_SCHD 出站载荷(报文线格式,与库内展开存储无关)
val eventSeq: Long = 0, // 由事务插入时赋 EVENT_ID 语义序,聚合取 max
)
data class OutboundIntent( /** 幂等无变化(重复/迟到等,记成功但不推进任何状态)。 */
val coutmsgsXml: String, data object NoOp : Decision
val ackReqd: Boolean = true, }
)
data class RefUpsert(
val rtype: String,
val rkey: String,
val payloadJson: String,
val source: String,
)
@@ -11,10 +11,14 @@ data class MetaFields(
val dttm: Long, val dttm: Long,
) )
/** 消息分派(sealed + 穷尽 whenACMA-6 选型;取代 legacy 反射 get{TYPE}())。 */ /** 消息分派(sealed + 穷尽 when;FDEL 一等公民——终止航班实例 §6.2)。 */
sealed interface MsgKind { sealed interface MsgKind {
data class Schd(val subtype: SchdSubtype) : MsgKind data class Schd(val subtype: SchdSubtype) : MsgKind
data class Flop(val subtype: String) : MsgKind // 29 类 STYP,阶段 2/3 逐类翻译 data class Flop(val subtype: String) : MsgKind // 运行动态 STYPFDEL 除外)
data object Fdel : MsgKind
/** 未支持类型(§9FAILED(UNSUPPORTED),达阈值转 DEAD)。 */
data class Unsupported(val tag: String) : MsgKind
enum class SchdSubtype { RESP, DNLD, ADFT } enum class SchdSubtype { RESP, DNLD, ADFT }
} }
@@ -30,5 +34,7 @@ data class DecodedMessage(
get() = when (val k = kind) { get() = when (val k = kind) {
is MsgKind.Schd -> "SCHD-${k.subtype.name}" is MsgKind.Schd -> "SCHD-${k.subtype.name}"
is MsgKind.Flop -> "FLOP-${k.subtype}" is MsgKind.Flop -> "FLOP-${k.subtype}"
MsgKind.Fdel -> "FDEL"
is MsgKind.Unsupported -> k.tag
} }
} }
@@ -1,29 +1,22 @@
package com.gzzn.omms.msgexchange.domain package com.gzzn.omms.msgexchange.domain
/** /** MSG_EVENT 事件形态(§7.3)。 */
* ACMA-8 MSG_EVENT(统一投递事件 / outbox)。 enum class EventType { UPSERT, TOMBSTONE }
* TARGET 阶段化:KAFKA:msg、KAFKA:schd 自阶段 AES:flight_hts、REDIS:flightInfo 仅阶段 B 投影期。
*/
object Targets {
const val KAFKA_MSG = "KAFKA:msg"
const val KAFKA_SCHD = "KAFKA:schd"
const val ES_FLIGHT_HTS = "ES:flight_hts"
@Deprecated("Retired in ACM2-28: Redis projection removed")
const val REDIS_FLIGHT_INFO = "REDIS:flightInfo"
/** 阶段 A 投递目标(仅 Kafka)——ACM2-28Delivery 阶段 A/B 均不写 Redis。 */
val phaseA: List<String> = listOf(KAFKA_MSG, KAFKA_SCHD)
/** 阶段 B 追加投影目标(仅 ES 历史库;Redis 投影按 ACM2-28 废弃)。 */
val phaseB: List<String> = phaseA + listOf(ES_FLIGHT_HTS)
}
/** MSG_EVENT 投递状态(outbox 状态机)。 */
enum class EventStatus { PENDING, SENT, DEAD } enum class EventStatus { PENDING, SENT, DEAD }
/**
* MSG_EVENToutbox,§3.2):状态、变更、删除通知。
* KAFKA_SCHD 整态 + KAFKA_MSG 变化通知;TOMBSTONE 仅在 ACTIVE→DELETED 时
* 与删除同事务登记(§7.3),投递失败持续重试。
*/
data class MsgEvent( data class MsgEvent(
val eventId: Long? = null, val eventId: Long? = null,
val target: String, val target: String,
val partitionKey: String? = null, // schd 事件恒为 FLIDv4 显式声明) val partitionKey: String, // 恒为 FLID
val eventType: EventType = EventType.UPSERT,
val stateVersion: Long = 0, // 发布时航班版本;Dispatcher 合并同 FLID 未发事件取最新(§7.3)
val payloadJson: String, val payloadJson: String,
val state: EventStatus = EventStatus.PENDING, val state: EventStatus = EventStatus.PENDING,
val attempts: Int = 0, val attempts: Int = 0,
@@ -0,0 +1,49 @@
package com.gzzn.omms.msgexchange.domain
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
/**
* 运营日计算(docs/flight-state.md §3.5):由计划运行时间字段 SODTddMMMyyHHmm
* 与机场时区计算;切日边界业务配置——默认 0 点为占位,待业务确认(§10)。
*/
class OperationDayCalculator(
zone: ZoneId,
cutoffHour: Int,
) {
private val zone: ZoneId = zone
/** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23,越界按 0)。 */
private val cutoffHour: Int = cutoffHour.coerceIn(0, 23)
/** SODT → 运营日;输入 null/空/非法返回 null(不抛异常,调用方按"运营日不可计算"处理)。 */
fun compute(sodt: String?): LocalDate? {
if (sodt.isNullOrBlank()) return null
val local = parseSodt(sodt.trim()) ?: return null
val day = local.atZone(zone).toLocalDate()
return if (local.hour < cutoffHour) day.minusDays(1) else day
}
companion object {
/**
* SIS SODT 线格式:ddMMMyyHHmm(如 15DEC031723),月份英文三字母、大小写不敏感。
* 两位年显式按 2000 基准展开(java.time 的 yy reduced-value 解析跨实现不一致,
* 显式展开保证 AODB 侧年份窗口唯一口径;基准年待真实报文验收确认 §10)。
*/
private val SODT_REGEX = Regex("(\\d{1,2})([A-Za-z]{3})(\\d{2})(\\d{2})(\\d{2})")
private val MONTHS = mapOf(
"JAN" to 1, "FEB" to 2, "MAR" to 3, "APR" to 4, "MAY" to 5, "JUN" to 6,
"JUL" to 7, "AUG" to 8, "SEP" to 9, "OCT" to 10, "NOV" to 11, "DEC" to 12,
)
internal fun parseSodt(raw: String): LocalDateTime? {
val m = SODT_REGEX.matchEntire(raw.uppercase()) ?: return null
val (d, mon, yy, hh, mm) = m.destructured
val month = MONTHS[mon] ?: return null
return runCatching {
LocalDateTime.of(2000 + yy.toInt(), month, d.toInt(), hh.toInt(), mm.toInt())
}.getOrNull()
}
}
}
@@ -1,16 +1,18 @@
package com.gzzn.omms.msgexchange.domain 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 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( data class ProcState(
val cminmsgsId: Long, val msgId: Long,
val state: ProcStatus, val state: ProcStatus,
val identityKey: String? = null, // decode 后首次绑定FAILED 重试不重绑I3 val identityKey: String? = null, // SNDR|TYPE|STYP|SEQNdecode 后首次绑定FAILED 重试不重绑
val attempts: Int = 0, val attempts: Int = 0,
val nextAttemptAt: java.time.Instant? = null, val nextAttemptAt: java.time.Instant? = null,
val errorClass: ErrorClass? = null, val errorClass: ErrorClass? = null,
@@ -20,6 +22,6 @@ data class ProcState(
val isTerminal: Boolean val isTerminal: Boolean
get() = state == ProcStatus.SUCCEEDED || state == ProcStatus.SKIPPED || state == ProcStatus.DEAD get() = state == ProcStatus.SUCCEEDED || state == ProcStatus.SKIPPED || state == ProcStatus.DEAD
/** 终态皆可归档(矩阵 #12SUCCEEDED SKIPPED DEADPENDING/FAILED 不迁。 */ /** 终态皆可归档;PENDING/FAILED 不迁。 */
val archivable: Boolean get() = isTerminal val archivable: Boolean get() = isTerminal
} }
@@ -0,0 +1,26 @@
package com.gzzn.omms.msgexchange.domain
import java.time.Instant
import java.time.LocalDate
/**
* SCHD 快照留痕模型(docs/flight-state.md §5.5):
* RESULT 与 FLAGS 分列(可「成功且告警」);一行 = 一次尝试,重放也记;
* 留痕不参与决策;写失败只记指标;保留 90 天,按 (SCOPE_END, RECV_AT) 清理。
*/
enum class SnapshotResult { COMMITTED, REPLAY_SKIPPED, ROLLED_BACK }
enum class SnapshotFlag { EMPTY, RECS_DROP, SEQN_REGRESSION, DAY_MISMATCH, SCHD_REVIVE_CONFLICT }
data class SnapshotLogEntry(
val msgId: Long,
val recvAt: Instant,
val scopeStart: LocalDate,
val scopeEnd: LocalDate,
val recs: Int,
val upserted: Int,
val durationMs: Long,
val result: SnapshotResult,
val flags: Set<SnapshotFlag> = emptySet(),
val archiveKey: String? = null, // 证据层引用(尚未交付 §3.2)
)
@@ -0,0 +1,10 @@
package com.gzzn.omms.msgexchange.domain
/**
* MSG_EVENT 投递目标(§7.3KAFKA_SCHD 发整态、KAFKA_MSG 只通知变化;
* 两主题间不保证顺序)。ES 投影属阶段 B,暂不登记目标。
*/
object Targets {
const val KAFKA_MSG = "KAFKA:msg"
const val KAFKA_SCHD = "KAFKA:schd"
}
@@ -1,23 +0,0 @@
package com.gzzn.omms.msgexchange.domain.flight
/**
* 显式字段命令(docs/flight-state.md §4)。
*/
sealed interface ScalarCommand {
data object Unchanged : ScalarCommand
data class Set(val value: String) : ScalarCommand
data object Clear : ScalarCommand
}
sealed interface CollectionCommand {
data object Unchanged : CollectionCommand
data class Replace(val items: List<Map<String, String>>) : CollectionCommand
data object Clear : CollectionCommand
data class Apply(val item: Map<String, String>, val sourceSeq: String) : CollectionCommand
}
data class FlightFieldCommands(
val flid: String,
val scalars: Map<String, ScalarCommand> = emptyMap(),
val collections: Map<String, CollectionCommand> = emptyMap(),
)
@@ -0,0 +1,89 @@
package com.gzzn.omms.msgexchange.domain.flight
import java.time.Instant
import java.time.LocalDate
/**
* 航班实例当前态模型(docs/flight-state.md §3)。
*
* 身份:FLID 唯一关联键;OPERATION_DAY 一经确定不可变(§3.5/§5.3);
* STATE 仅 ACTIVE/DELETED(§3.1,无 ARCHIVED——物理清除只发生在历史归档成功之后 §8.2)。
*/
enum class FlightState { ACTIVE, DELETED }
/** FLIGHT_SCHD 主行的身份与追踪字段(不含标量载荷)。 */
data class FlightMainRow(
val flid: String,
val operationDay: LocalDate?,
val state: FlightState,
val stateVersion: Long,
val lastMsgId: Long?,
val updatedAt: Instant,
)
/**
* SCHD DNLD/RESP 单条 FLTR 记录(解码产物,§5 入口)。
* scalars/collections 只含报文中出现的字段——完整快照语义下
* 出现 = Set/Replace,未出现 = 清除/替换空集(§5.4)。
*/
data class ScheduleRecord(
val flid: String,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
val seqn: Long = 0,
)
/**
* 完整快照落库载荷(§5.1 步骤 6):按记录整体替换映射内字段,
* 仓储层负责明细先删后插与 `OPERATION_DAY` 不可变条件更新(§7.4)。
*/
data class SnapshotPatch(
val flid: String,
val operationDay: LocalDate,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>>,
)
/** 快照 upsert 结果(§5.1 步骤 6DELETED 航班保持 DELETED 并告警,不恢复)。 */
enum class UpsertOutcome { INSERTED, UPDATED, REVIVE_CONFLICT }
/**
* FLOP/ADFT 增量载荷(§6.1/§2.1)。
* scalars 出现 = Set,缺失 = 保留本地值;collections 出现 = Replace
* (FLOP 集合语义未定案前沿用 Replace,§10 当前偏差明示)。
*/
data class MergeChange(
val flid: String,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
)
/**
* 完整当前态(§3.3):主行 + 全部明细 = 完整当前态;
* 读取须在一致性读事务中,且展示层过滤 STATE = ACTIVE(§9)。
*/
data class FlightSnapshot(
val flid: String,
val operationDay: LocalDate?,
val state: FlightState,
val stateVersion: Long,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>>,
)
/** §8.1 历史判定窗口(按机场时区计算;窗口值业务配置)。 */
data class HistoryRules(
val cancelledHours: Long = 48, // CNCL 非空超过 N 小时
val terminalHours: Long = 48, // NAAT/NEAT 终态超过 N 小时(字段含义待术语表确认 §10)
val deletedHours: Long = 48, // STATE = DELETED 超过 N 小时
val idleHours: Long = 24 * 7, // 无终态字段:最后更新超过兜底期限
)
/** §8.1 命中历史判定的航班(归档 → 物理清除候选)。 */
data class HistoryCandidate(
val flid: String,
val state: FlightState,
val stateVersion: Long,
/** 是否未经 FDEL 而被生命周期清除——清除前须补发一次删除事件(§7.3/§8.2)。 */
val wasNeverFdel: Boolean,
)
@@ -1,24 +0,0 @@
package com.gzzn.omms.msgexchange.domain.flight
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
data class FlightNextState(
val flid: String,
val scalars: Map<String, String>,
val collections: Map<String, List<Map<String, String>>>,
val stateVersion: Long,
val lastMessageId: String,
/** 本次迁移中被显式清除的标量/异常/文本键(仓储据此写 NULL 列;快照全量替换下无意义)。 */
val clearedKeys: Set<String> = emptySet(),
) {
fun toFlightFields(mapper: ObjectMapper = ObjectMapper()): FlightFields {
val out = linkedMapOf<String, String>()
out["FLID"] = flid
scalars.forEach { (k, v) -> out[k] = v }
collections.forEach { (key, items) ->
out[key] = mapper.writeValueAsString(items)
}
return out
}
}
@@ -1,157 +1,134 @@
package com.gzzn.omms.msgexchange.domain.flight package com.gzzn.omms.msgexchange.domain.flight
import com.fasterxml.jackson.databind.JsonNode import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.fasterxml.jackson.databind.ObjectMapper import com.gzzn.omms.msgexchange.domain.SnapshotFlag
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields 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 { 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", "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;
* 快照内不重复;每条记录运营日可计算且在报文覆盖范围内。任一失败整包不落地。
/** 异常/单值载荷的显式清除形态:空串、null 字面量、空对象(Oracle 空串即 NULL 语义的显式来源)。 */ */
private fun isClearPayload(value: String): Boolean { fun validateMessage(
val trimmed = value.trim() recsDeclared: Int,
if (trimmed.isEmpty() || trimmed == "null") return true records: List<ScheduleRecord>,
val node = runCatching { mapper.readTree(trimmed) }.getOrNull() ?: return false scopeStart: LocalDate?,
return node.isNull || (node.isObject && node.size() == 0) scopeEnd: LocalDate?,
} opDay: OperationDayCalculator,
): SnapshotValidation {
private val SEQ_ATTR = mapOf( val flags = linkedSetOf<SnapshotFlag>()
"GTDT" to "GTNO", if (recsDeclared !in 0..9999) return SnapshotValidation.Invalid("RECS out of range: $recsDeclared", setOf(SnapshotFlag.RECS_DROP))
"CKDT" to "CKNO", if (records.size != recsDeclared) return SnapshotValidation.Invalid(
"CLDT" to "CLNO", "RECS ($recsDeclared) != received FLTR count (${records.size})",
"PSDT" to "PSNO", setOf(SnapshotFlag.RECS_DROP),
"CHDT" to "CHNO",
"ABTM" to "ASNO",
"CHOT" to "CSNO",
"ROUT" to "RTNO",
"ERUT" to "RTNO",
) )
// 注意:DELY 无协议序号属性(DLNO 非法)→ 不支持 Apply;清除走空数组 Replace(空集) if (records.isEmpty()) return SnapshotValidation.Ok(emptyMap(), setOf(SnapshotFlag.EMPTY))
/** DNLD/FLOP 字段集 → 命令(出现即 Set/Replace;未出现即 Unchanged;序号 0 条目 = 显式清除标记)。 */ val perRecordDay = linkedMapOf<String, LocalDate>()
fun commandsFromFields(flid: String, fields: FlightFields): FlightFieldCommands { val seen = linkedSetOf<String>()
val scalars = linkedMapOf<String, ScalarCommand>() for (record in records) {
val collections = linkedMapOf<String, CollectionCommand>() if (!record.flid.matches(FLID_REGEX)) {
fields.forEach { (key, value) -> return SnapshotValidation.Invalid("illegal FLID: '${record.flid}'", emptySet())
if (key == "FLID") return@forEach }
when { if (!seen.add(record.flid)) {
key in COLLECTION_KEYS -> { return SnapshotValidation.Invalid("duplicate FLID in snapshot: ${record.flid}", emptySet())
val seqAttr = SEQ_ATTR[key] }
val parsed = parseCollection(value) val day = opDay.compute(record.scalars["SODT"])
val markers = parsed.filter { seqAttr != null && it[seqAttr] == "0" } if (day == null) {
collections[key] = when { return SnapshotValidation.Invalid("operation day not computable, flid=${record.flid}", setOf(SnapshotFlag.DAY_MISMATCH))
markers.isEmpty() -> CollectionCommand.Replace(parsed) }
markers.size == parsed.size -> CollectionCommand.Clear val inScope = (scopeStart == null || !day.isBefore(scopeStart)) &&
else -> throw IllegalArgumentException( (scopeEnd == null || !day.isAfter(scopeEnd))
"$key mixes explicit clear marker (seq=0) with regular items; refusing to guess", if (!inScope) {
return SnapshotValidation.Invalid(
"operation day $day outside coverage [$scopeStart, $scopeEnd], flid=${record.flid}",
setOf(SnapshotFlag.DAY_MISMATCH),
) )
} }
perRecordDay[record.flid] = day
} }
key in EXCEPTION_KEYS && isClearPayload(value) -> scalars[key] = ScalarCommand.Clear return SnapshotValidation.Ok(perRecordDay, flags)
else -> scalars[key] = ScalarCommand.Set(value)
}
}
return FlightFieldCommands(flid, scalars, collections)
} }
fun apply( /**
current: FlightNextState?, * 完整快照语义(§5.4 DNLD/RESP):标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
commands: FlightFieldCommands, * `keepDeleted = true` 时 STATE 保持 DELETED(§5.1 步骤 6:普通 SCHD 不恢复)。
messageId: String, */
bumpVersion: Boolean, fun snapshotState(
): FlightNextState { current: FlightSnapshot?,
val baseScalars = current?.scalars?.toMutableMap() ?: mutableMapOf() record: ScheduleRecord,
val baseCollections = current?.collections?.mapValues { it.value.toMutableList() } operationDay: LocalDate,
?.toMutableMap() ?: mutableMapOf() keepDeleted: Boolean,
val baseVersion = current?.stateVersion ?: 0L ): FlightSnapshot {
val cleared = mutableSetOf<String>() val state = when {
current == null -> FlightState.ACTIVE
commands.scalars.forEach { (key, cmd) -> keepDeleted -> FlightState.DELETED
when (cmd) { else -> current.state
ScalarCommand.Unchanged -> Unit
is ScalarCommand.Set -> {
baseScalars[key] = cmd.value
cleared.remove(key)
} }
ScalarCommand.Clear -> { // §5.4 完整快照:标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
baseScalars.remove(key) // 完整替换 = 新状态只由记录决定,不从 current 继承任何字段。
cleared += key 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 FlightSnapshot(
flid = record.flid,
commands.collections.forEach { (key, cmd) -> operationDay = operationDay,
when (cmd) { state = state,
CollectionCommand.Unchanged -> Unit stateVersion = (current?.stateVersion ?: 0L) + 1,
is CollectionCommand.Replace -> baseCollections[key] = cmd.items.toMutableList() scalars = scalars,
CollectionCommand.Clear -> baseCollections.remove(key) collections = collections,
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(),
) )
} }
fun parseCollection(raw: String): List<Map<String, String>> { /**
val node = mapper.readTree(raw) * FLOP 增量合并(§6.1):标量出现覆盖、缺失保留;集合出现 Replace、缺失保留
val items = when { * ——FLOP 集合语义未定案,Replace 为当前偏差明示沿用(§10)。
node.isNull -> emptyList() */
node.isArray -> node.toList() fun mergedState(current: FlightSnapshot, change: MergeChange): FlightSnapshot {
node.isObject -> listOf(node) val scalars = buildMap {
else -> throw IllegalArgumentException("collection value must be array or object") 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(供增量合并)。 */ /** FLID:数字型,SIS §3.16.2 Number(1-12)。 */
fun fromFlightFields(flid: String, fields: FlightFields, stateVersion: Long = 0L, lastMessageId: String = ""): FlightNextState { private val FLID_REGEX = Regex("\\d{1,12}")
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)
} }
/** §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 package com.gzzn.omms.msgexchange.infra.persistence
import com.gzzn.omms.msgexchange.domain.ErrorClass 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.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
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.Instant
import java.time.LocalDate
import java.time.ZoneId
/** // =====================================================================
* ACM2-28 仓储接口(接口驱动,主泵/调度循环可单测;Micronaut Data JDBC 实装属 U05 批次)。 // 仓储契约(docs/flight-state.md §3.2 表职责)。
* 存储边界:自有 PostgreSQLdatasources.default= 本文件除 CminmsgInboxRepository 外 // 决策层写路径约定:所有 FLIGHT_SCHD 及明细写操作必须发生在
* 的全部接口(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、21 类 REF_MASTER); // 「持有 PIPELINE_LOCK 的同一事务」内(§1:锁只串行化 DB 事务)。
* 共享 MySQL 信箱(CMINMSGS / COUTMSGS)经信箱封装访问,仅 DML、不建表; // =====================================================================
* 主路径=上游外部写 CMINMSGS → 本系统 JDBC 轮询读;compat=insertRaw HTTP 写;
* 自有库 = 消息管道 + 运营航班 FLIGHT_SCHD/SCHD_GEN + 静态数据;Redis 已退出阶段 A 权威与写路径。 /** 自有 PG 单事务原子保障(§1 目标 3:状态、事件、处理终态同事务提交)。 */
*/ interface PipelineTransactionManager {
/** 单航班字段集(field → value,与 legacy flightInfo hash 同构)。 */ fun <T> inTransaction(block: () -> T): T
typealias FlightFields = Map<String, String> }
/** 单行锁(§3.2 PIPELINE_LOCK):事务内第一步 SELECT ... FOR UPDATE,串行化状态写事务。 */
interface PipelineLockRepository {
fun lock()
}
/** PROC_STATE:每消息一行;兼作快照重放判定(§5.1 步骤 2:MSG_ID 已有成功终态 → 重放)。 */
interface ProcStateRepository { interface ProcStateRepository {
fun insert(cminmsgsId: Long, state: ProcStatus = ProcStatus.PENDING) fun insert(msgId: Long, state: ProcStatus = ProcStatus.PENDING)
/** 主路径轮询/compat 入队前判重(PG 已有行则跳过)。 */ /** 主路径轮询/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? fun headUnfinished(): ProcState?
/** I3identity 首次绑定;返回 false = 另一条消息已持有该键。 */ /** identity 首次绑定;返回 false = 另一条消息已持有该键。 */
fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean fun tryBindIdentity(msgId: Long, identityKey: String): Boolean
fun ownerOfIdentity(identityKey: String): Long? fun ownerOfIdentity(identityKey: String): Long?
fun update( fun update(
cminmsgsId: Long, msgId: Long,
state: ProcStatus, state: ProcStatus,
nextAttemptAt: Instant? = null, nextAttemptAt: Instant? = null,
attempts: Int? = null, attempts: Int? = null,
@@ -41,21 +57,20 @@ interface ProcStateRepository {
lastError: String? = null, lastError: String? = null,
) )
/** /** 显式重放入口:仅把给定 errorClass 集合中的行从 FAILED/DEAD 置回 PENDINGATTEMPTS=0)。 */
* U11 显式重放入口(ReplayService):仅把给定 errorClass 集合中的行从 FAILED/DEAD 置回 PENDING
* 以便主泵重新领取。实现约定:ATTEMPTS=0、NEXT_ATTEMPT_AT=NULL(立即重试),
* ERROR_CLASS/LAST_ERROR 保留作审计。返回受影响行数。
*/
fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int
} }
/** MSG_EVENT outbox(§7.3)。KAFKA_SCHD 合并同 FLID 未发事件按最新 STATE_VERSION 输出。 */
interface MsgEventRepository { interface MsgEventRepository {
fun insertAll(events: List<MsgEvent>): List<Long> fun insertAll(events: List<MsgEvent>): List<Long>
/** I1 双层同策略:每 target 严格 FIFO 队头。 */
fun headUnsent(target: String): MsgEvent? 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) fun markSent(eventId: Long)
@@ -64,108 +79,100 @@ interface MsgEventRepository {
fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int)
fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int? = null) fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int? = null)
}
/** 阶段 B(定案 2):Delivery 同线程在 ES 投递成功后同步 enqueue 删除事件。 */ /** 完整态落库结果:DAY_GUARD_VIOLATION = OPERATION_DAY 不可变条件更新未命中(§7.4)。 */
fun insertSync(events: List<MsgEvent>) enum class PersistOutcome { INSERTED, UPDATED, DAY_GUARD_VIOLATION }
/**
* 航班当前态权威(§3.2 FLIGHT_SCHD + 9 张明细表)。
* 唯一写路径 = persistFullState:写入前在内存生成完整新状态(引擎产物)再落库,
* 明细集合按组先删后插(§3.3)。
*/
interface FlightStateRepository {
/** 主行点查(身份/版本判定;批量用于快照归属校验 §5.3)。 */
fun findMainRow(flid: String): FlightMainRow?
fun findMainRows(flids: Collection<String>): Map<String, FlightMainRow>
/** 完整当前态:主行 + 全部明细(一致性读边界由调用方事务保证,§9)。 */
fun loadFullSnapshot(flid: String): FlightSnapshot?
/**
* 完整当前态落库(§3.3):主行 upsert + 全部明细按组先删后插;
* STATE_VERSION 以 snapshot.stateVersion 落库。
* SCHD(整体替换/清除)、FLOP/ADFT(合并后全量写)共用此唯一写路径。
* 条件更新带 `WHERE operation_day IS NULL OR operation_day = :day`(§7.4 不可变强化)。
*/
fun persistFullState(snapshot: FlightSnapshot, msgId: Long, now: Instant): PersistOutcome
/**
* FDEL(§6.2):ACTIVE → 置 DELETED、推进版本、明细保留、返回 true(发布删除事件);
* 已 DELETED 或不存在 → 返回 false(幂等成功,不推进版本不重复发布)。
*/
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>
/**
* §8.2 步骤 3:物理删除主行与明细(仅历史存储确认成功后调用;
* 历史存储未接通时调用方必须传空集合——删 0 条)。
*/
fun purgeArchived(flids: Collection<String>): Int
/** §8.3 观测:OPERATION_DAY 仍为 NULL 的航班数(只增不删,终止规则未定 §10)。 */
fun countOperationDayNull(): Int
}
/** §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)
} }
/** /**
* 阶段 A 运营航班权威与日计划代(ACM2-28 定案 + ACM2-29 全宽表·零子表定案): * 共享信箱回填补偿待办(§7.2):业务事务内预登记(消除崩溃窗口 §10 偏差),
* - 表 FLIGHT_SCHD:一行一航班的运营航班宽表(FLID 主键 + FDAY 可空所属代 + SCHD.FLTR 标量字段列 * 提交后由 BackfillSweepJob 重试;回填失败不得把 SUCCEEDED 改回 FAILED。
* + 集合平铺标量列/紧凑 VARCHAR 字串列),零子表、零 CLOB,字段即列、天然可索引可直查,
* PG/Oracle 11g 方言一致;读侧视图由平铺列重建集合键,与 legacy flightInfo hash 同构;
* - 运营资源消息语义 = 单资源集合级全量快照替换;外层航班增量仍为字段级合并(仅新增/覆盖);
* - 表 PIPELINE_LOCK:单写者行级互斥(SELECT ... FOR UPDATEPG/11g 同构,无 DBMS_LOCK 特权依赖);
* - 表 SCHD_GEN:各日代版本(SQL CAS);表 SCHD_GEN_FLID:当前代有效航班 FLID 集合(差删依据);
* - Redis 退出动态权威与全部写路径;事务 2 与快照发布全在自有库内单事务原子提交;
* - 增量更新(FLOP/ADFT):新插 FDAY=NULL,已有行保留原 FDAY,按字段列更新
* (与 legacy flightInfo hash「仅新增/覆盖、不删除缺失字段」同语义);
* - 快照(DNLD):声明/更新 FDAY 归属,航班字段整体替换;
* - 按代差删域化:DELETE FROM FLIGHT_SCHD WHERE FDAY = :day AND FLID IN (:diffSet)
* 仅删除仍属旧代的行,ADFT(FDAY=NULL)与已迁移至新代的同 FLID 行天然存活。
*/
interface FlightSchdRepository {
data class FlightRecord(
val flid: String,
val fday: String?,
val fields: FlightFields,
val createdAt: Instant,
val updatedAt: Instant,
)
data class GenMeta(
val fday: String,
val version: Long,
val flids: Set<String>,
val lastMessageId: String? = null,
val updatedAt: Instant = Instant.now(),
)
/** v2P4 退场):legacy upsertSnapshotBatch/upsertIncremental 已删除——唯一写路径是 persistNextStates。 */
/** 按代差删域化:仅删除 FDAY = day 且在 delFlids 中的记录(ADFT 与跨代已迁移行受保护)。 */
fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int
/** 点查单航班字段集;无字段行(含航班不存在)返回 null。 */
fun findByFlid(flid: String): FlightFields?
/** v2:点查单航班 nextState(含 state_version / last_message_id 追踪字段)。 */
fun findNextStateByFlid(flid: String): com.gzzn.omms.msgexchange.domain.flight.FlightNextState?
/** 点查多航班字段集:FLID → 字段集,仅含实际存在的航班。 */
fun findByFlids(flids: Collection<String>): Map<String, FlightFields>
/** 按计划日查询当前有效航班。 */
fun findByDay(day: String): List<Pair<String, FlightFields>>
/** 全量查询(供影子对拍 / 一致性对账)。 */
fun findAll(): Map<String, FlightFields>
/**
* 历史清场删除:仅删除已确认归档至 ES 的 FLID 集合;空集合不执行;分批参数化删除。
* 返回实际删除行数(允许重放时为 0)。
*/
fun deleteByFlids(flids: Set<String>): Int
/** 读计划代(SCHD_GEN)。 */
fun getGen(day: String): GenMeta?
/**
* 计划代 SQL 版本 CAS:仅当 expected 与数据库中当前版本一致(或不存在且 expected=0)时写入/推进新版本。
* 返回 true 表示推进成功,false 表示发生 CAS 冲突。
*/
fun putGenIfVersion(day: String, expected: Long, newGen: GenMeta, now: Instant = Instant.now()): Boolean
/**
* 历史代清理:清理 cutoffDay 之前的历史代记录(与 FLIGHT_SCHD 历史清场生命周期对齐)。
*/
fun deleteGenBefore(cutoffDay: String): Int
/**
* v2 无损明细:将 nextState 写入宽表 + 明细表,并更新追踪字段。
* 必须在 PipelineTransactionManager 事务内调用。
*/
fun persistNextStates(
day: String?,
states: List<com.gzzn.omms.msgexchange.domain.flight.FlightNextState>,
snapshotReplace: Boolean,
now: Instant = Instant.now(),
)
}
/** 事务管理器抽象:自有 PG 单事务原子保障。 */
interface PipelineTransactionManager {
fun <T> inTransaction(block: () -> T): T
}
/**
* v2 §5ACM2-29 P2-3):提交后共享信箱回填的持久补偿待办。
* 业务事务已提交(PROC_STATE=SUCCEEDED)后 backfillOnSuccess 失败 → 落本表重试;
* 回填失败不得把已成功的业务事务重新标记为失败,也不得重放业务变更。
*/ */
interface BackfillTodoRepository { interface BackfillTodoRepository {
data class BackfillTask( data class BackfillTask(
val cminmsgsId: Long, val msgId: Long,
val sndr: String, val sndr: String,
val type: String, val type: String,
val styp: String, val styp: String,
@@ -176,104 +183,25 @@ interface BackfillTodoRepository {
/** 失败即落库(幂等 upsert,同 ID 重复失败只刷新错误与重试时间)。 */ /** 失败即落库(幂等 upsert,同 ID 重复失败只刷新错误与重试时间)。 */
fun record(task: BackfillTask, lastError: String?, now: Instant = Instant.now()) 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> fun findDue(now: Instant = Instant.now(), limit: Int = 50): List<BackfillTask>
/** 重试失败:累加 attempts 并按退避推后 next_attempt_at。 */ fun markFailed(msgId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant = Instant.now())
fun markFailed(cminmsgsId: Long, lastError: String?, nextAttemptAt: Instant, now: Instant = Instant.now())
/** 重试成功:移除补偿待办。 */ fun delete(msgId: Long)
fun delete(cminmsgsId: Long)
/** 当前待办数量(测试/对账用)。 */
fun count(): Int 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` 表 * 共享 MySQL 信箱 CMINMSGS 访问(他人系统库,本系统不建表)。
* (ACM2-12:与消息管道同自有库;SOURCE=ADMINAPI/AODB/PIPELINEN19 对齐) * 主路径 JDBC 轮询读 + 处理回填;compat HTTP 写;出站写 COUTMSGS 由出站适配层承担
* 与主链弱事务耦合:写入者为 ReferenceService21 类同步)与请求应答路径。
*/
interface StaticRefRepository {
fun upsertAll(refs: List<RefUpsert>)
fun findByType(type: String): List<RefUpsert>
}
/**
* 15 类请求状态机——自有 PG `REQ_TRACK`ACM2-12Reference & Query 域)。
* 与共享库 COUTMSGS(出站信箱)跨库:先 COUTMSGS 落库成功 → 再 markSent,补偿重扫,最终一致。
*/
interface ReqTrackRepository {
data class Req(
val reqId: Long,
val reqType: String,
val state: String, // REGISTERED/SENT/WAITING/DONE/EXPIRED
val sentAt: Instant? = null,
)
fun findOpenByKind(kind: String): Req?
fun forceExpireOpenOf(kind: String)
fun insert(kind: String, paramsJson: String): Long
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
fun markSent(reqId: Long, sentAt: Instant)
fun expireIfWaiting(reqId: Long)
fun markDone(reqId: Long)
}
/**
* 泵作业调度记录——自有 PG `PUMP_JOB`ACM2-12)。
* 决策 1 修订:作业不插队,仅在消息队头空闲/退避窗口由主泵执行(跨库/异队列无全序);
* 作业动作本身(归档写共享库 CMINMSGS_HST、清场删 ES+PG 等)仍在各自目标存储。
*/
interface PumpJobRepository {
data class Job(val jobId: Long, val kind: String) // ARCHIVE/HISTORY_SWEEP/PROJECTION_REBUILD
fun enqueue(kind: String)
fun headQueued(): Job?
fun markRunning(jobId: Long)
fun markDone(jobId: Long)
fun markFailed(jobId: Long, lastError: String)
}
/**
* 共享信箱 CMINMSGS 访问(ACM2-12:库属他人系统,本系统不建表)。
*
* **主路径(生产)**`pollNew` / `rawOf` — JDBC 轮询/读取上游外部写入的报文(U05 InboxPoller)。
* **compat 路径**`insertRaw` — HTTP `POST /cminmsgs/send` 辅助写(手工/对拍,非主拓扑)。
*
* 入队模型:发现或 compat 写得到 CMINMSGS_ID → 自有 PG 建 PROC_STATE(PENDING)
* PG 建行失败以共享库 DATE_PROCESSED IS NULL 重扫补建。
* `backfillOnSuccess` = 处理成功后的外部回填(DATE_PROCESSED/STATUS,最终一致)。
*/ */
interface CminmsgInboxRepository { interface CminmsgInboxRepository {
/** compat HTTP 写路径:向共享信箱插入原文(U16 契约对拍)。 */
fun insertRaw(rawXml: String): Long fun insertRaw(rawXml: String): Long
/** 主路径/处理:按 CMINMSGS_ID 读取上游已落信的原文 XML。 */ fun rawOf(msgId: Long): String?
fun rawOf(cminmsgsId: Long): String?
/**
* 主路径 JDBC 轮询:共享库未处理报文 ID 列表(legacy `getNewMsgsAfterId` 同语义)。
* @param afterId 下界(legacy 现役传 0);仅返回 `DATE_PROCESSED IS NULL` 行。
*/
fun pollUnprocessed(afterId: Long, limit: Int): List<Long> fun pollUnprocessed(afterId: Long, limit: Int): List<Long>
fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) fun backfillOnSuccess(msgId: Long, sndr: String, type: String, styp: String, seqn: Long)
} }
@@ -1,162 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
import java.time.Instant
import javax.sql.DataSource
/**
* v2 明细表读写(docs/flight-state.md §3.2)。
* 主表保存标量,明细表保存重复集合;不存在旧槽位读写回退。
*/
internal object FlightDetailTables {
private data class TableSpec(
val table: String,
val collectionKey: String,
val seqAttr: String,
val columns: List<Pair<String, String>>, // json attr -> db column
)
private val SPECS = listOf(
TableSpec("flight_gate", "GTDT", "GTNO", listOf(
"GATE" to "gate", "PGOT" to "pgot", "PGCT" to "pgct", "GOTM" to "gotm", "GCTM" to "gctm", "GTYP" to "gtyp",
)),
TableSpec("flight_checkin", "CKDT", "CKNO", listOf(
"CHKC" to "chkc", "CCLS" to "ccls", "PCOT" to "pcot", "PCCT" to "pcct", "COTM" to "cotm", "CCTM" to "cctm", "CTYP" to "ctyp",
)),
TableSpec("flight_belt", "CLDT", "CLNO", listOf(
"BELT" to "belt", "BCLS" to "bcls", "PCOT" to "pcot", "PCCT" to "pcct", "FBAG" to "fbag", "LBAG" to "lbag", "BTYP" to "btyp",
)),
TableSpec("flight_stand_plan", "PSDT", "PSNO", listOf(
"PSST" to "psst", "STST" to "stst", "STET" to "stet",
)),
TableSpec("flight_chute", "CHDT", "CHNO", listOf(
"CHUT" to "chut", "CHCLS" to "chcls", "PCBT" to "pcbt", "PCET" to "pcet", "CBTM" to "cbtm", "CETM" to "cetm", "CHTYP" to "chtyp",
)),
TableSpec("flight_delay", "DELY", "DLNO", listOf(
"CODE" to "code", "STRT" to "strt", "DURA" to "dura", "REMC" to "remc",
)),
TableSpec("flight_bridge_op", "ABTM", "ASNO", listOf(
"ABDG" to "abdg", "ABOP" to "abop", "AOTM" to "aotm",
)),
TableSpec("flight_chock_op", "CHOT", "CSNO", listOf(
"CHID" to "chid", "CHST" to "chst", "CHTM" to "chtm",
)),
)
fun replaceAll(ds: DataSource, state: FlightNextState, now: Instant) {
val ts = now.toSqlTimestamp()
val conn = ds.obtainConnection()
try {
for (spec in SPECS) {
ds.update("DELETE FROM ${spec.table} WHERE flid = ?") { ps -> ps.setString(1, state.flid) }
val items = state.collections[spec.collectionKey] ?: continue
if (items.isEmpty()) continue
val colNames = listOf("flid", "ordinal", "source_seq", "record_version") +
spec.columns.map { it.second } + listOf("created_at", "updated_at")
val sql = "INSERT INTO ${spec.table} (${colNames.joinToString(", ")}) VALUES (${
colNames.joinToString(", ") { "?" }
})"
conn.prepareStatement(sql).use { ps ->
items.forEachIndexed { index, item ->
var i = 1
ps.setString(i++, state.flid)
ps.setInt(i++, index + 1)
ps.setString(i++, item[spec.seqAttr])
ps.setLong(i++, state.stateVersion)
spec.columns.forEach { (attr, _) -> ps.setString(i++, item[attr]) }
ps.setTimestamp(i++, ts)
ps.setTimestamp(i++, ts)
ps.addBatch()
}
ps.executeBatch()
}
}
replaceRoutes(ds, state, now)
} finally {
conn.releaseIfNotInTransaction()
}
}
private fun replaceRoutes(ds: DataSource, state: FlightNextState, now: Instant) {
val ts = now.toSqlTimestamp()
ds.update("DELETE FROM flight_route_point WHERE flid = ?") { ps -> ps.setString(1, state.flid) }
listOf("ROUT" to "ROUT", "ERUT" to "ERUT").forEach { (key, kind) ->
val items = state.collections[key] ?: return@forEach
if (items.isEmpty()) return@forEach
val conn = ds.obtainConnection()
try {
conn.prepareStatement(
"INSERT INTO flight_route_point (flid, ordinal, source_seq, record_version, route_kind, apcd, scat, scdt, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
).use { ps ->
items.forEachIndexed { index, item ->
ps.setString(1, state.flid)
ps.setInt(2, index + 1)
ps.setString(3, item["RTNO"])
ps.setLong(4, state.stateVersion)
ps.setString(5, kind)
ps.setString(6, item["APCD"])
ps.setString(7, item["SCAT"])
ps.setString(8, item["SCDT"])
ps.setTimestamp(9, ts)
ps.setTimestamp(10, ts)
ps.addBatch()
}
ps.executeBatch()
}
} finally {
conn.releaseIfNotInTransaction()
}
}
}
fun deleteForFlids(ds: DataSource, flids: Collection<String>) {
if (flids.isEmpty()) return
val tables = SPECS.map { it.table } + "flight_route_point"
for (table in tables) {
for (chunk in flids.chunked(200)) {
val placeholders = chunk.joinToString(",") { "?" }
ds.update("DELETE FROM $table WHERE flid IN ($placeholders)") { ps ->
chunk.forEachIndexed { i, flid -> ps.setString(i + 1, flid) }
}
}
}
}
fun loadCollections(ds: DataSource, flid: String): Map<String, String> {
val out = linkedMapOf<String, String>()
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
for (spec in SPECS) {
val rows = ds.query(
"SELECT * FROM ${spec.table} WHERE flid = ? ORDER BY ordinal ASC",
{ ps -> ps.setString(1, flid) },
) { rs ->
// 只输出非空属性(缺失键语义),保证 write→read 线格式一致(评审 F5)
spec.columns.associate { (attr, col) -> attr to rs.getString(col) }
.filterValues { it != null }
.toMutableMap()
.apply { rs.getString("source_seq")?.let { put(spec.seqAttr, it) } }
}
if (rows.isNotEmpty()) {
out[spec.collectionKey] = mapper.writeValueAsString(rows)
}
}
listOf("ROUT" to "ROUT", "ERUT" to "ERUT").forEach { (key, kind) ->
val rows = ds.query(
"SELECT source_seq, apcd, scat, scdt FROM flight_route_point WHERE flid = ? AND route_kind = ? ORDER BY ordinal ASC",
{ ps -> ps.setString(1, flid); ps.setString(2, kind) },
) { rs ->
buildMap {
rs.getString("source_seq")?.let { put("RTNO", it) }
rs.getString("apcd")?.let { put("APCD", it) }
rs.getString("scat")?.let { put("SCAT", it) }
rs.getString("scdt")?.let { put("SCDT", it) }
}
}
if (rows.isNotEmpty()) {
out[key] = mapper.writeValueAsString(rows)
}
}
return out
}
}
@@ -1,70 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
/**
* FLIGHT_SCHD 读侧视图组装(v2 权威读,P4 退场后唯一读路径)。
*
* - 标量列直读 + 无界集合文本列(SRVT/VIPF/MAFL
* - FDIV/FRET/FLAB 由主表前缀标量列重建对象
* - 10 类重复集合由明细表重建(保序、保源序号),legacy 槽位/里程碑列已随 V1.4.0 退场
*
* 过渡期三条读路径(assembleMerged/assembleLegacySlotsOnly)与 FS7 双读对拍
* 已随回退窗口关闭一并移除(V1.4.0,ACM2-29 P4)。
*/
object FlightSchdReadAssembler {
/** SCHD.FLTR 标量列 + legacy 派生列(与 V1.1.0 迁移一致)。 */
val SCALAR_COLUMNS: List<String> = listOf(
"ALCD", "ALSC", "FLNO", "MVIN", "SODT", "FLTY", "FLIN", "ACFT", "RENO",
"TAOP", "TAFL", "TAID", "TRML", "MAXP", "CSOP", "CSFT", "MAID",
"ESTT", "ACTT", "STND", "PHAG", "CNCL", "REMC", "BOTM", "LACL", "FINT",
"APPT", "EGSR", "EGST", "FHAG", "MHAG", "VIPP", "VIPR", "LBNO", "LBWT",
"PAXC", "EXSC", "EXSR", "FTSS", "PEDT", "NEAT", "PADT", "NAAT",
"ABDG", "LPSDT", "ABN",
)
val EXCEPTION_COLUMNS: List<String> = listOf(
"FDIV_DDES", "FDIV_DDIR", "FDIV_REMC", "FRET_REID", "FRET_RSN",
"FLAB_ARES", "FLAB_RSN",
)
val TEXT_COLUMNS: List<String> = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT")
/** 库列名全集(读侧 SELECT 稳定顺序;与 V1.4.0 后表结构一致)。 */
val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS
private val mapper = ObjectMapper()
/** v2 权威读:标量 + 异常 + 文本列 + 明细表集合。 */
fun assembleDetailOnly(
flid: String,
row: Map<String, String?>,
detailCollections: Map<String, String>,
): FlightFields {
val fields = assembleScalarsAndExceptions(flid, row)
detailCollections.forEach { (key, value) -> fields[key] = value }
return fields
}
private fun assembleScalarsAndExceptions(flid: String, row: Map<String, String?>): LinkedHashMap<String, String> {
val fields = linkedMapOf<String, String>()
fields["FLID"] = flid
SCALAR_COLUMNS.forEach { column -> row[column]?.let { fields[column] = it } }
TEXT_COLUMNS.forEach { column -> row[column]?.let { fields[column.removeSuffix("_TEXT")] = it } }
exceptionView("FDIV", listOf("DDES" to "FDIV_DDES", "DDIR" to "FDIV_DDIR", "REMC" to "FDIV_REMC"), row)
?.let { fields["FDIV"] = it }
exceptionView("FRET", listOf("REID" to "FRET_REID", "RSN" to "FRET_RSN"), row)
?.let { fields["FRET"] = it }
exceptionView("FLAB", listOf("ARES" to "FLAB_ARES", "RSN" to "FLAB_RSN"), row)
?.let { fields["FLAB"] = it }
return fields
}
private fun exceptionView(key: String, attrs: List<Pair<String, String>>, row: Map<String, String?>): String? {
val node = mapper.createObjectNode()
attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
return if (node.size() == 0) null else mapper.writeValueAsString(node)
}
}
@@ -27,10 +27,10 @@ class JdbcCminmsgInboxRepository(
ps.setString(1, rawXml) ps.setString(1, rawXml)
} }
override fun rawOf(cminmsgsId: Long): String? = override fun rawOf(msgId: Long): String? =
ds.queryOne( ds.queryOne(
"SELECT CMINMSGS_CLOB_MSG FROM cminmsgs WHERE CMINMSGS_ID = ?", "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") } ) { rs -> rs.getString("CMINMSGS_CLOB_MSG") }
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
@@ -47,7 +47,7 @@ class JdbcCminmsgInboxRepository(
}, },
) { rs -> rs.getLong("CMINMSGS_ID") } ) { 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( ds.update(
""" """
UPDATE cminmsgs UPDATE cminmsgs
@@ -57,7 +57,7 @@ class JdbcCminmsgInboxRepository(
""".trimIndent(), """.trimIndent(),
) { ps -> ) { ps ->
ps.setString(1, "PROCESSED") ps.setString(1, "PROCESSED")
ps.setLong(2, cminmsgsId) ps.setLong(2, msgId)
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,23 +0,0 @@
package com.gzzn.omms.msgexchange.infra.pipeline
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.processing.CodecHolder
import com.gzzn.omms.msgexchange.processing.Handler
import com.gzzn.omms.msgexchange.processing.HandlerHolder
import com.gzzn.omms.msgexchange.processing.HandlerRegistry
import io.micronaut.context.annotation.Bean
import io.micronaut.context.annotation.Factory
import jakarta.inject.Singleton
/** 主泵解码与 Handler 装配(stub / 实装模式共用真实 XmlCodec + 已注册 Handler)。 */
@Factory
class PipelineHolderFactory {
@Bean
@Singleton
fun codecHolder(codec: JacksonXmlCodec): CodecHolder = CodecHolder(codec)
@Bean
@Singleton
fun handlerHolder(handlers: List<Handler>): HandlerHolder = HandlerHolder(HandlerRegistry(handlers))
}
@@ -20,14 +20,14 @@ class ProcFailure(
val attempts = head.attempts + 1 val attempts = head.attempts + 1
if (scheduler.exhausted(attempts)) { if (scheduler.exhausted(attempts)) {
procState.update( procState.update(
head.cminmsgsId, ProcStatus.DEAD, head.msgId, ProcStatus.DEAD,
attempts = attempts, attempts = attempts,
errorClass = ErrorClass.EXHAUSTED, errorClass = ErrorClass.EXHAUSTED,
lastError = "$reason; attempts=$attempts", lastError = "$reason; attempts=$attempts",
) )
} else { } else {
procState.update( procState.update(
head.cminmsgsId, ProcStatus.FAILED, head.msgId, ProcStatus.FAILED,
attempts = attempts, attempts = attempts,
nextAttemptAt = scheduler.nextAttemptAt(attempts), nextAttemptAt = scheduler.nextAttemptAt(attempts),
errorClass = ec, errorClass = ec,
@@ -5,16 +5,28 @@ import io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton import jakarta.inject.Singleton
/** /**
* U07stub 适配层——DeliveryPort 内存实现,仅在 msgx.stubs=true 时生效。 * stub 适配层——DeliveryPort 内存实现,仅在 msgx.stubs=true 时生效。
* XmlCodec / Handler 由 [com.gzzn.omms.msgexchange.infra.pipeline.PipelineHolderFactory] 统一装配 * 记录 (topic, key, payload)payload = null 表示 TOMBSTONE(§7.3 键缺失=删除旧值)
*/ */
@Requires(property = "msgx.stubs", value = "true") @Requires(property = "msgx.stubs", value = "true")
@Singleton @Singleton
class StubDeliveryPort : DeliveryPort { 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 } fun clear() = sent.clear()
override fun indexFlightHts(payloadJson: String) = Unit
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.ErrorClass
import com.gzzn.omms.msgexchange.domain.EventStatus 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.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
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.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository 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.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository 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.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 io.micronaut.context.annotation.Requires
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Instant import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
/** /**
* U07(U01 遗留 stub):内存仓储装配——仅当 `msgx.stubs=true`dev/影子冒烟)时生效 * stub 仓储(msgx.stubs=true 时装配)——内存实现全部契约,测试与无库环境用。
* @Requires),让「启动 + compat HTTP 写 + 主泵/投递循环 + /beans 端到端」在无 MySQL/Redis/Kafka 时可跑 * 事务管理器直接执行 block(无嵌套语义);锁为 no-op(单线程测试前提)
* 与真实 Micronaut Data 实装按模块替换;切换点条件显式,不误入生产。
*/ */
@Requires(property = "msgx.stubs", value = "true")
@Singleton @Singleton
class StubProcState : ProcStateRepository { @Requires(property = "msgx.stubs", value = "true")
private val rows = linkedMapOf<Long, ProcState>() class StubPipelineTx : PipelineTransactionManager {
private val bound = mutableMapOf<String, Long>() override fun <T> inTransaction(block: () -> T): T = block()
fun clear() { rows.clear(); bound.clear() }
override fun insert(cminmsgsId: Long, state: ProcStatus) {
rows[cminmsgsId] = ProcState(cminmsgsId, state)
} }
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId) @Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubPipelineLock : PipelineLockRepository {
override fun lock() = Unit
}
@Singleton
@Requires(property = "msgx.stubs", value = "true")
class StubProcState : ProcStateRepository {
val rows = linkedMapOf<Long, ProcState>()
val bound = linkedMapOf<String, Long>()
fun clear() {
rows.clear(); bound.clear()
}
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? = override fun headUnfinished(): ProcState? =
rows.filterValues { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED } rows.values.filter { it.state == ProcStatus.PENDING || it.state == ProcStatus.FAILED }.minByOrNull { it.msgId }
.minByOrNull { it.key }?.value
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean { override fun tryBindIdentity(msgId: Long, identityKey: String): Boolean {
val owner = bound[identityKey] val owner = bound[identityKey]
if (owner != null && owner != cminmsgsId) return false if (owner != null && owner != msgId) return false
bound[identityKey] = cminmsgsId bound[identityKey] = msgId
rows[cminmsgsId] = (rows[cminmsgsId] ?: ProcState(cminmsgsId, ProcStatus.PENDING)).copy(identityKey = identityKey) rows[msgId] = (rows[msgId] ?: ProcState(msgId, ProcStatus.PENDING)).copy(identityKey = identityKey)
return true return true
} }
override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey] override fun ownerOfIdentity(identityKey: String): Long? = bound[identityKey]
override fun update( override fun update(
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?, msgId: Long,
errorClass: ErrorClass?, lastError: String?, state: ProcStatus,
nextAttemptAt: Instant?,
attempts: Int?,
errorClass: ErrorClass?,
lastError: String?,
) { ) {
val old = rows[cminmsgsId] ?: ProcState(cminmsgsId, state) val old = rows[msgId] ?: ProcState(msgId, state)
rows[cminmsgsId] = old.copy( rows[msgId] = old.copy(
state = state, state = state,
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt, nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
attempts = attempts ?: old.attempts, attempts = attempts ?: old.attempts,
errorClass = errorClass ?: old.errorClass, errorClass = errorClass ?: old.errorClass,
lastError = lastError ?: old.lastError, lastError = lastError ?: old.lastError,
updatedAt = Instant.now(),
) )
} }
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int { override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
var n = 0 var n = 0
rows.keys.toList().forEach { id -> rows.forEach { (id, s) ->
val s = rows[id]!! if (s.errorClass in errorClasses && (s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
if (s.errorClass != null && s.errorClass in errorClasses &&
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
rows[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null) rows[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
n++ n++
} }
@@ -82,360 +108,252 @@ class StubProcState : ProcStateRepository {
return n return n
} }
/** 测试/运维观测用:读取当前状态行。 */ fun snapshotOf(msgId: Long): ProcState? = rows[msgId]
fun snapshotOf(cminmsgsId: Long): ProcState? = rows[cminmsgsId]
} }
@Requires(property = "msgx.stubs", value = "true")
@Singleton @Singleton
class StubInbox : CminmsgInboxRepository { @Requires(property = "msgx.stubs", value = "true")
private val raws = linkedMapOf<Long, String>() class StubMsgEvents : MsgEventRepository {
private val processed = mutableSetOf<Long>() val rows = linkedMapOf<Long, MsgEvent>()
fun clear() {
raws.clear()
processed.clear()
}
private val ids = AtomicLong(0) private val ids = AtomicLong(0)
/** 模拟上游外部写信箱(不经 compat HTTP、不自动入队 PG)。 */ fun clear() = rows.clear()
fun simulateExternalWrite(rawXml: String): Long {
override fun insertAll(events: List<MsgEvent>): List<Long> =
events.map { e ->
val id = ids.incrementAndGet() val id = ids.incrementAndGet()
raws[id] = rawXml 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()
rows[id] = ReqTrackRepository.Req(id, reqType, operationDay, sender, ReqTrackRepository.ReqState.PENDING)
return id 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 { override fun insertRaw(rawXml: String): Long {
val id = ids.incrementAndGet() val id = ids.incrementAndGet()
raws[id] = rawXml raws[id] = rawXml
return id 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> = override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> =
raws.keys raws.keys.filter { it > afterId }.sorted().take(limit)
.filter { it > afterId && it !in processed }
.sorted()
.take(limit)
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) = Unit
processed += cminmsgsId
}
}
@Requires(property = "msgx.stubs", value = "true") /** 测试辅助:模拟上游外部写入共享信箱(不经本系统)。 */
@Singleton fun simulateExternalWrite(rawXml: String): Long = insertRaw(rawXml)
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
} }
@@ -18,7 +18,7 @@ class InboxController(private val inbox: InboxService) {
@Produces(MediaType.TEXT_PLAIN) @Produces(MediaType.TEXT_PLAIN)
fun send(@Body rawXml: String): HttpResponse<String> { fun send(@Body rawXml: String): HttpResponse<String> {
val receipt = inbox.accept(rawXml) 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 按契约冻结清单补齐。 // TODO(阶段2): /schd/sync、/all/flights、/kafka/topics/{name}/msgs 按契约冻结清单补齐。
@@ -8,9 +8,9 @@ import jakarta.inject.Singleton
class InboxEnqueue(private val procState: ProcStateRepository) { class InboxEnqueue(private val procState: ProcStateRepository) {
/** @return true 若新建 PENDING 行;false 若已存在(轮询重扫/compensate 幂等)。 */ /** @return true 若新建 PENDING 行;false 若已存在(轮询重扫/compensate 幂等)。 */
fun enqueue(cminmsgsId: Long): Boolean { fun enqueue(msgId: Long): Boolean {
if (procState.exists(cminmsgsId)) return false if (procState.exists(msgId)) return false
procState.insert(cminmsgsId) procState.insert(msgId)
return true return true
} }
} }
@@ -29,7 +29,7 @@ class InboxPoller(
for (id in ids) { for (id in ids) {
if (enqueue.enqueue(id)) { if (enqueue.enqueue(id)) {
enqueued++ enqueued++
log.info("polled cminmsgsId={}", id) log.info("polled msgId={}", id)
} }
} }
sweepBackfillTodos() sweepBackfillTodos()
@@ -12,12 +12,12 @@ class InboxService(
) { ) {
private val log = org.slf4j.LoggerFactory.getLogger(InboxService::class.java) 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 { fun accept(rawXml: String): Receipt {
val id = inbox.insertRaw(rawXml) val id = inbox.insertRaw(rawXml)
enqueue.enqueue(id) enqueue.enqueue(id)
log.info("compat-accepted cminmsgsId={}", id) log.info("compat-accepted msgId={}", id)
return Receipt(id, Instant.now()) return Receipt(id, Instant.now())
} }
} }
@@ -40,11 +40,11 @@ class BackfillSweepJob(
var failed = 0 var failed = 0
for (task in due) { for (task in due) {
try { try {
inbox.backfillOnSuccess(task.cminmsgsId, task.sndr, task.type, task.styp, task.seqn) inbox.backfillOnSuccess(task.msgId, task.sndr, task.type, task.styp, task.seqn)
todo.delete(task.cminmsgsId) todo.delete(task.msgId)
succeeded++ succeeded++
} catch (e: Exception) { } 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++ failed++
} }
} }
@@ -0,0 +1,80 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.config.HistoryProps
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.domain.flight.HistoryRules
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
import java.time.ZoneId
/**
* 历史归档与物理清除(docs/flight-state.md §8.2,顺序不可颠倒):
* 1. HISTORY_SWEEP 选出满足 §8.1 的航班(含 DELETED);
* 2. 写入历史存储;
* 3. 历史存储返回成功的 FLID 集合 → 物理删除主行与明细(归档结果只记录在历史存储,当前态无 ARCHIVED 态);
* 4. 未经 FDEL 的航班在清除前补发一次删除事件(§7.3);其余不发;
* 5. 失败或不明确的保留重试。
* 前提红线:历史存储未接通时必须删 0 条(§8.2)。
*/
@Singleton
class HistorySweepJob(
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val props: HistoryProps,
/** 历史存储端口:返回归档成功的 FLID 集合;未接通时不注入(null)。 */
private val historyStore: HistoryStore? = null,
/** §8.3 留痕清理端口(90 天,按 (SCOPE_END, RECV_AT));未接通时不注入。 */
private val snapLogPurge: SnapshotLogPurge? = null,
) {
/** 历史存储端口(由部署侧适配实现;脚手架默认未接通)。 */
fun interface HistoryStore {
/** 归档候选航班;返回归档确认成功的 FLID 集合(部分成功允许)。 */
fun archive(candidates: List<HistoryCandidate>): Set<String>
}
fun interface SnapshotLogPurge {
fun purgeBefore(instant: Instant): Int
}
data class SweepOutcome(val selected: Int, val archived: Int, val purged: Int, val snapLogPurged: Int = 0)
fun run(now: Instant = Instant.now()): SweepOutcome {
if (!props.historyStoreEnabled || historyStore == null) {
// 红线:历史存储未接通必须删 0 条;绝不允许先删当前态再补历史(§8.2)
return SweepOutcome(selected = 0, archived = 0, purged = 0)
}
val rules = HistoryRules(props.cancelledHours, props.terminalHours, props.deletedHours, props.idleHours)
val zone = ZoneId.of("Asia/Shanghai") // §8.1:窗口按机场时区计算
val candidates = flightState.findHistoryCandidates(rules, zone, now)
if (candidates.isEmpty()) return SweepOutcome(0, 0, 0)
val archivedFlids = historyStore.archive(candidates)
if (archivedFlids.isEmpty()) return SweepOutcome(candidates.size, archived = 0, purged = 0)
val toPurge = candidates.filter { it.flid in archivedFlids }
// §8.2 步骤 4:未经 FDEL、由生命周期直接清除的航班,清除前补发一次删除事件
val preDelete = toPurge.filter { it.wasNeverFdel }
if (preDelete.isNotEmpty()) {
msgEvents.insertAll(preDelete.map { tombstone(it) })
}
val purged = flightState.purgeArchived(archivedFlids)
val snapLogPurged = snapLogPurge?.purgeBefore(now.minus(Duration.ofDays(props.snapLogRetentionDays))) ?: 0
return SweepOutcome(candidates.size, archivedFlids.size, purged, snapLogPurged)
}
private fun tombstone(candidate: HistoryCandidate) = MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = candidate.flid,
eventType = EventType.TOMBSTONE,
stateVersion = candidate.stateVersion,
payloadJson = """{"flid":"${candidate.flid}","stateVersion":${candidate.stateVersion},"deleted":true}""",
)
}
@@ -1,99 +0,0 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import jakarta.inject.Singleton
/**
* ACMA-8:泵作业执行器。cron 触发入队(PUMP_JOB),主泵 FIFO 执行(决策 1)——
* 产物不绕过队头顺序;阶段 A 的清场同步链与归档在此落地(I4/I5)。
*/
@Singleton
class JobExecutor(
private val historySweep: HistorySweepJob,
private val archive: ArchiveJob,
private val projectionRebuild: ProjectionRebuildJob,
private val backfillSweep: BackfillSweepJob,
) {
fun execute(job: PumpJobRepository.Job) = when (job.kind) {
"HISTORY_SWEEP" -> historySweep.run()
"ARCHIVE" -> archive.run()
"PROJECTION_REBUILD" -> projectionRebuild.run()
"BACKFILL_SWEEP" -> backfillSweep.sweep()
else -> error("unknown job ${job.kind}")
}
}
/**
* 流程 4 runJob(HISTORY_SWEEP)3:30 清场——主泵作业内同步链(I4,KEEP 现役
* FlightHisScheduled 同步语义):判史 → 同步写 ES(成功集)→ 仅删成功集。
*/
@Singleton
class HistorySweepJob(
private val flightSchd: FlightSchdRepository,
) {
companion object {
var historyPicker: ((Map<String, FlightFields>) -> Map<String, FlightFields>)? = null
var esArchiver: ((Map<String, FlightFields>) -> Set<String>)? = null
var cutoffProvider: (() -> String?)? = null
}
fun run() {
val all = flightSchd.findAll()
val history = pickHistory(all) // U10/T07saveSync 接线前判史为空集——禁止“全量可删”默认
val success = esArchiver?.invoke(history) ?: emptySet() // 仅收集已确认写入 ES 的成功 FLID 集合
if (success.isNotEmpty()) {
flightSchd.deleteByFlids(success) // FS2/FS4:仅删除已确认写入 ES 的成功集合,分批参数化删除
}
val cutoff = cutoffProvider?.invoke()
if (cutoff != null) {
flightSchd.deleteGenBefore(cutoff)
}
}
/** U10/T07(修订):接入 ES success 集之前的门禁——占位默认返回空集;
* 现役五条判史规则(SODT 3 天 / CNCL 1 小时 / 备降 / 离港 / 到港)golden 通过后才允许接线。 */
private fun pickHistory(all: Map<String, FlightFields>): Map<String, FlightFields> =
historyPicker?.invoke(all) ?: emptyMap()
}
/** 流程 5 runJob(ARCHIVE)3:00 归档——1 天前且仅终态可迁(矩阵 #12)。 */
@Singleton
class ArchiveJob(
// TODO(阶段1后续): CMINMSGS⇆CMINMSGS_HST 迁移 SQLJOIN PROC_STATESTATE ∈ {SUCCEEDED,DEAD,SKIPPED}
) {
fun run() {
// TODO: 迁移(DEAD 带 ERROR_CLASS、SKIPPED 带 duplicate-of 审计随行保留)
}
}
/** 流程 7:阶段 B 投影重建——切入阶段 B 时全量重建一次,此后增量走事件。 */
@Singleton
class ProjectionRebuildJob(
private val flightSchd: FlightSchdRepository,
private val msgEvents: MsgEventRepository,
) {
fun run() {
for (day in activeDays()) {
val flights = flightSchd.findByDay(day)
flights.forEach { (flid, fields) ->
msgEvents.insertSync(
listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = flid,
payloadJson = FlightFieldsJson.toJson(fields),
),
),
)
}
}
}
private fun activeDays(): List<String> = emptyList() // TODO(阶段B)
}
@@ -0,0 +1,75 @@
package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.config.HistoryProps
import com.gzzn.omms.msgexchange.config.PipelineProps
import jakarta.inject.Singleton
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* 维护作业调度(docs/flight-state.md §8):单 daemon 线程,独立于主泵——
* 旧「作业不插队 PUMP_JOB 队列」机制随审计口径移除(§3.2 表清单无 PUMP_JOB);
* 历史归档/留痕清理均为内部清理路径,不参与 FIFO 消息序。
* 触发:回填补偿 30s 固定间隔;历史归档/留痕清理每日 03:30(机场时区)后首个 tick。
*/
@Singleton
class JobRunner(
private val backfillSweep: BackfillSweepJob,
private val historySweep: HistorySweepJob,
@Suppress("unused") private val pipelineProps: PipelineProps,
@Suppress("unused") private val historyProps: HistoryProps,
) {
private val log = org.slf4j.LoggerFactory.getLogger(JobRunner::class.java)
private val zone: ZoneId = ZoneId.of("Asia/Shanghai")
@Volatile
private var running = true
@Volatile
private var lastHistoryDay: LocalDate? = null
private var thread: Thread? = null
fun start() {
if (thread != null) return
running = true
thread = Thread.ofPlatform().name("msgx-jobs").daemon(true).start { loop() }
log.info("job runner started (backfill 30s, history daily 03:30 {})", zone)
}
fun stop() {
running = false
thread?.interrupt()
thread = null
}
internal fun loop() {
while (running) {
try {
backfillSweep.sweep()
maybeHistorySweep()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
return
} catch (e: Exception) {
log.warn("job tick failed: {}", e.message ?: e.javaClass.simpleName)
}
if (running) runCatching { Thread.sleep(Duration.ofSeconds(30).toMillis()) }
}
}
private fun maybeHistorySweep() {
val today = LocalDate.now(zone)
if (lastHistoryDay == today) return
val now = java.time.LocalTime.now(zone)
if ((now.hour == 3 && now.minute >= 30) || now.hour > 3) {
val outcome = historySweep.run()
lastHistoryDay = today
if (outcome.selected > 0 || outcome.snapLogPurged > 0) {
log.info("history sweep: {}", outcome)
}
}
}
}
@@ -0,0 +1,214 @@
package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.domain.flight.MergeChange
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import jakarta.inject.Singleton
import java.time.Instant
import java.time.ZoneId
/**
* FLOP(§6.1):读取完整当前态 → 合并变化 → 保留运营日 → STATE_VERSION+1 →
* 同事务登记 KAFKA_MSG / KAFKA_SCHD 与处理终态。
*/
@Singleton
class FlopProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val backfillTodo: BackfillTodoRepository?,
private val mapper: ObjectMapper,
) {
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
lock.lock()
val current = flightState.loadFullSnapshot(payload.flid)
?: return@inTransaction idempotentAbsent(head, msg) // 迟到/未知航班:幂等成功,不创建
val change = MergeChange(flid = payload.flid, scalars = payload.scalars, collections = payload.collections)
val next = FlightStateEngine.mergedState(current, change)
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
msgEvents.insertAll(eventsFor(next, mapper))
preRegisterBackfill(head, msg, backfillTodo)
ApplyResult.Succeeded
}
}
/**
* FDEL(§6.2):ACTIVE → 置 DELETED、推进版本、明细保留、发布 tombstone;
* 已 DELETED / 不存在 → 幂等成功,不推进版本、不重复发布。
*/
@Singleton
class FdelProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val backfillTodo: BackfillTodoRepository?,
private val mapper: ObjectMapper,
) {
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
lock.lock()
val deleted = flightState.markDeleted(payload.flid, msgId = head.msgId, now = Instant.now())
if (deleted) {
val current = flightState.loadFullSnapshot(payload.flid)
// tombstone 仅在 ACTIVE→DELETED 时登记(§7.3),与删除同事务
msgEvents.insertAll(
listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = payload.flid,
eventType = EventType.TOMBSTONE,
stateVersion = current?.stateVersion ?: 0L,
payloadJson = mapper.writeValueAsString(
mapOf(
"flid" to payload.flid,
"stateVersion" to (current?.stateVersion ?: 0L),
"deleted" to true,
),
),
),
MsgEvent(
target = Targets.KAFKA_MSG,
partitionKey = payload.flid,
stateVersion = current?.stateVersion ?: 0L,
payloadJson = mapper.writeValueAsString(
mapOf("flid" to payload.flid, "stateVersion" to (current?.stateVersion ?: 0L), "deleted" to true),
),
),
),
)
preRegisterBackfill(head, msg, backfillTodo)
}
ApplyResult.Succeeded // 未命中 = 迟到/重复,幂等成功(§6.2 步骤 3/4)
}
}
/**
* ADFT(§6.3 + §2.1):字段缺失语义待确认——确认前按保守 Set-only 处理
* (出现字段覆盖,缺失不 Clear,不沿用 FLOP 全量合并规则)。
* FLID 已存在且 DELETED → 生命周期重激活;不存在 → 新实例建立(含运营日计算 §3.5)。
*/
@Singleton
class AdftProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val backfillTodo: BackfillTodoRepository?,
operationDayProps: OperationDayProps,
private val mapper: ObjectMapper,
) {
private val opDay = OperationDayCalculator(
zone = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") },
cutoffHour = operationDayProps.cutoffHour,
)
fun apply(head: ProcState, msg: DecodedMessage, record: com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord): ApplyResult =
txManager.inTransaction {
lock.lock()
val main = flightState.findMainRow(record.flid)
if (main != null && main.state == com.gzzn.omms.msgexchange.domain.flight.FlightState.DELETED) {
// §6.3 重激活:DELETED → ACTIVE,推进版本,登记状态事件
if (flightState.revive(record.flid, msgId = head.msgId, now = Instant.now())) {
val current = flightState.loadFullSnapshot(record.flid)
if (current != null) {
val next = FlightStateEngine.mergedState(current, setOnly(record))
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
msgEvents.insertAll(eventsFor(next, mapper))
}
}
preRegisterBackfill(head, msg, backfillTodo)
return@inTransaction ApplyResult.Succeeded
}
val current = flightState.loadFullSnapshot(record.flid)
val next: FlightSnapshot = if (current == null) {
// 新实例建立:ADFT 含 SODT 时直接计算运营日(§2.1),不可算则置 null 待快照收录
val day = opDay.compute(record.scalars["SODT"])
FlightSnapshot(
flid = record.flid,
operationDay = day, // 待确认项 §2.1:不可算时不得默认写接收日
state = com.gzzn.omms.msgexchange.domain.flight.FlightState.ACTIVE,
stateVersion = 1L,
scalars = record.scalars,
collections = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.COLLECTION_KEYS.associateWith { key ->
record.collections[key] ?: emptyList()
},
)
} else {
FlightStateEngine.mergedState(current, setOnly(record))
}
val outcome = flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
check(outcome != com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome.DAY_GUARD_VIOLATION) {
"operation-day guard violated flid=${record.flid}"
}
msgEvents.insertAll(eventsFor(next, mapper))
preRegisterBackfill(head, msg, backfillTodo)
ApplyResult.Succeeded
}
/** §2.1 保守语义:仅出现字段 Set;集合出现 Replace、缺失保留。 */
private fun setOnly(record: com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord) = MergeChange(
flid = record.flid,
scalars = record.scalars,
collections = record.collections,
)
}
// =====================================================================
// 共享小工具(处理器层私有约定)
// =====================================================================
/** 航班不存在/迟到:幂等成功(§9 队头不阻塞;不创建实例——创建入口只有 SCHD/ADFT)。 */
private fun idempotentAbsent(head: ProcState, msg: DecodedMessage): ApplyResult = ApplyResult.Succeeded
/** KAFKA_SCHD 整态 + KAFKA_MSG 变化通知(§7.3)。 */
internal fun eventsFor(next: FlightSnapshot, mapper: ObjectMapper): List<MsgEvent> {
val payload = linkedMapOf<String, Any>(
"flid" to next.flid,
"stateVersion" to next.stateVersion,
"scalars" to next.scalars,
"collections" to next.collections,
)
return listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = next.flid,
stateVersion = next.stateVersion,
payloadJson = mapper.writeValueAsString(payload),
),
MsgEvent(
target = Targets.KAFKA_MSG,
partitionKey = next.flid,
stateVersion = next.stateVersion,
payloadJson = mapper.writeValueAsString(mapOf("flid" to next.flid, "stateVersion" to next.stateVersion)),
),
)
}
/** §7.2 业务事务内预登记回填待办(与终态同事务)。 */
internal fun preRegisterBackfill(head: ProcState, msg: DecodedMessage, backfillTodo: BackfillTodoRepository?) {
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
styp = msg.meta.styp, seqn = msg.meta.seqn,
),
null,
)
}
@@ -1,37 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.MsgKind
/**
* ACMA-8 流程 2Handler = 纯函数(状态与报文进,Decision 出,不碰 Redis/Kafka)。
* 32 个 Handlerflop 29 + schd 3)翻译属阶段 2/3,逐条对照 ACMA-4 基线与兼容矩阵 KEEP/FIX。
*/
interface Handler {
val kind: MsgKind
/**
* 在线状态以只读视图传入:FLID → 字段集(field → value,与 legacy flightInfo hash /
* hgetAllFlightInfo 同构;阶段 A 由调用方点查自有库 FLIGHT_SCHD 宽表装配,ACM2-28 定案)。
*/
fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision
}
/**
* sealed 穷尽分派(ACMA-6 选型:取代 legacy 反射 get{TYPE}())。
*/
class HandlerRegistry(handlers: List<Handler>) {
private val byKind: Map<String, Handler> = handlers.associateBy { keyOf(it.kind) }
fun dispatcherFor(msg: DecodedMessage): Handler? = byKind[keyOf(msg.kind)]
companion object {
fun keyOf(kind: MsgKind): String = when (kind) {
is MsgKind.Schd -> "SCHD-${kind.subtype.name}"
is MsgKind.Flop -> "FLOP-${kind.subtype}"
}
}
}
// TODO(阶段2/3): 注册 3+29 Handler;本阶段仅含骨架(阶段 2 最小纵向链路先做 1 SCHD(DNLD) + 1 FLOP)。
@@ -1,37 +1,32 @@
package com.gzzn.omms.msgexchange.processing 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.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.ProcState import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus import com.gzzn.omms.msgexchange.domain.ProcStatus
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.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository 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.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.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.jobs.JobExecutor
import jakarta.inject.Singleton import jakarta.inject.Singleton
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
/** /**
* ACMA-8 流程 2:处理主泵——严格 FIFO + HOL + 毒丸 DEAD 升级I1 * 处理主泵(docs/flight-state.md §1 目标 2):单活动主泵严格 FIFO + HOL + 毒丸 DEAD 升级;
* 阶段 A 运营航班 FLIGHT_SCHD 与待发事件、终态同事务原子提交(I2/I5ACM2-28 定案)。 * 航班状态、事件、处理终态在处理器事务原子提交(§1 目标 3)。
*/ */
@Singleton @Singleton
class Pump( class Pump(
private val procState: ProcStateRepository, private val procState: ProcStateRepository,
private val pumpJobs: PumpJobRepository,
private val inbox: CminmsgInboxRepository, private val inbox: CminmsgInboxRepository,
private val processor: MessageProcessor, private val processor: MessageProcessor,
private val jobExecutor: JobExecutor,
private val props: PipelineProps, private val props: PipelineProps,
) { ) {
private val log = org.slf4j.LoggerFactory.getLogger(Pump::class.java) private val log = org.slf4j.LoggerFactory.getLogger(Pump::class.java)
@@ -39,7 +34,7 @@ class Pump(
@Volatile @Volatile
private var running = true private var running = true
/** 优雅停机:loop 在当前 tick 收尾后退出;线程中断由 Runner 负责。 */ /** 优雅停机:loop 在当前 tick 收尾后退出;线程中断由 PipelineLifecycle 负责。 */
fun stop() { fun stop() {
running = false running = false
} }
@@ -52,27 +47,23 @@ class Pump(
Thread.currentThread().interrupt() Thread.currentThread().interrupt()
return return
} catch (e: Exception) { } catch (e: Exception) {
// U08loop 只作最后防线——失败状态迁移已在 processOne/execute 的边界内完成; // 最后防线失败状态迁移已在 processOne 边界内完成;致命 Error 不捕获
// 致命 Error 不在此捕获(任其终止进程,保证“异常必可见”)。
sleepQuietly(props.pipeline.pollInterval) sleepQuietly(props.pipeline.pollInterval)
} }
} }
} }
internal fun tick() { internal fun tick() {
val job = pumpJobs.headQueued()
val head = procState.headUnfinished() val head = procState.headUnfinished()
// 统一 FIFO:JOB 与消息同队列语义(定时任务产物不绕过队头顺序,决策 1)。
val nextJob = job?.takeIf { jobBefore(job, head) }
when { when {
nextJob != null -> execute(nextJob)
head == null -> sleepQuietly(props.pipeline.pollInterval) head == null -> sleepQuietly(props.pipeline.pollInterval)
head.state == ProcStatus.FAILED && (head.nextAttemptAt ?: Instant.EPOCH) > Instant.now() -> head.state == ProcStatus.FAILED && (head.nextAttemptAt ?: Instant.EPOCH) > Instant.now() ->
if (poisoned(head)) { if (poisoned(head)) {
log.error("poison -> DEAD id={} attempts={} lastError={}", head.cminmsgsId, head.attempts, head.lastError) log.error("poison -> DEAD msgId={} attempts={} lastError={}", head.msgId, head.attempts, head.lastError)
procState.update(head.cminmsgsId, ProcStatus.DEAD, procState.update(
errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "head-deadline-exceeded") head.msgId, ProcStatus.DEAD,
errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "head-deadline-exceeded",
)
} else { } else {
sleepQuietly(Duration.between(Instant.now(), head.nextAttemptAt)) sleepQuietly(Duration.between(Instant.now(), head.nextAttemptAt))
} }
@@ -85,69 +76,41 @@ class Pump(
head.attempts >= props.pipeline.maxAttempts || head.attempts >= props.pipeline.maxAttempts ||
Duration.between(head.updatedAt, Instant.now()) > props.pipeline.headDeadline 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) { private fun sleepQuietly(d: Duration) {
if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1)) if (!d.isNegative && !d.isZero) Thread.sleep(d.toMillis().coerceAtLeast(1))
} }
} }
/** /**
* ACMA-8 流程 2 processOne:解码→绑定→纯函数决策→提交 * processOne:解码 → 绑定 → 处理器(事务内决策+落库)→ 终态迁移 → 回填
* U08/U10/U11:失败状态迁移全部在“持有具体 head”的边界内完成—— * 边界化失败迁移(ProcFailure):任何意外异常归于本条 headFAILED(INFRA)+退避,不穿出杀泵;
* 任何意外异常 → FAILED(INFRA)+退避(ProcFailure);attempts 达上限 → DEAD(EXHAUSTED) * MALFORMED / PROTOCOL 直接 DEAD 不重试(§9)。
* MALFORMED 直接 DEADInterruptedException 恢复中断位并上抛(不被普通恢复逻辑吞掉)。
*/ */
@Singleton @Singleton
class MessageProcessor( class MessageProcessor(
private val inbox: CminmsgInboxRepository, private val inbox: CminmsgInboxRepository,
private val procState: ProcStateRepository, private val procState: ProcStateRepository,
private val msgEvents: MsgEventRepository, private val codec: JacksonXmlCodec,
private val codecHolder: CodecHolder, private val scheduleProcessor: ScheduleProcessor,
private val handlers: HandlerHolder, private val flopProcessor: FlopProcessor,
private val flightSchd: FlightSchdRepository, private val fdelProcessor: FdelProcessor,
private val snapshotFlow: SnapshotFlow, private val adftProcessor: AdftProcessor,
private val procFailure: ProcFailure, private val procFailure: ProcFailure,
private val props: PipelineProps, private val props: PipelineProps,
private val txManager: PipelineTransactionManager,
private val backfillTodo: BackfillTodoRepository? = null, private val backfillTodo: BackfillTodoRepository? = null,
) { ) {
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java) 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) { fun processOne(head: ProcState) {
com.gzzn.omms.msgexchange.infra.log.TraceLog.withTrace(head.cminmsgsId) { TraceLog.withTrace(head.msgId) {
try { try {
processInternal(head) processInternal(head)
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
Thread.currentThread().interrupt() Thread.currentThread().interrupt()
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
// U08边界化——异常归于本条 head,写 FAILED/DEAD,而不是穿出杀 pump // 边界化异常归于本条 head,写 FAILED(INFRA)/DEAD,而不是穿出杀 pump
log.warn("processOne unexpected failure id={} ec=INFRA msg={}", head.cminmsgsId, e.message ?: e.javaClass.simpleName) 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) procFailure.fail(head, ErrorClass.INFRA, e.message ?: e.javaClass.simpleName)
} }
} }
@@ -156,126 +119,134 @@ class MessageProcessor(
private fun processInternal(head: ProcState) { private fun processInternal(head: ProcState) {
// 守卫:手工/遗留 FAILED 行若 attempts 已达上限,直接终态(防止退避到期后无限重试) // 守卫:手工/遗留 FAILED 行若 attempts 已达上限,直接终态(防止退避到期后无限重试)
if (head.state == ProcStatus.FAILED && procFailure.scheduler.exhausted(head.attempts)) { if (head.state == ProcStatus.FAILED && procFailure.scheduler.exhausted(head.attempts)) {
log.error("head exhausted at entry -> DEAD id={} attempts={}", head.cminmsgsId, head.attempts) log.error("head exhausted at entry -> DEAD msgId={} attempts={}", head.msgId, head.attempts)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.EXHAUSTED, procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.EXHAUSTED, lastError = head.lastError ?: "max-attempts")
lastError = head.lastError ?: "max-attempts")
return return
} }
val raw = inbox.rawOf(head.cminmsgsId) ?: run { val raw = inbox.rawOf(head.msgId) ?: run {
log.error("raw missing -> DEAD(MALFORMED) id={}", head.cminmsgsId) log.error("raw missing -> DEAD(MALFORMED) msgId={}", head.msgId)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = "raw-missing") procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = "raw-missing")
return 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.Ok -> r.message
is com.gzzn.omms.msgexchange.codec.DecodeResult.Err -> { is com.gzzn.omms.msgexchange.codec.DecodeResult.Err -> {
// T06U11):MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避 // MALFORMED(报文非法)→ DEAD 不重试;CODEC_ERROR(可随 codec 修复重放)→ FAILED 退避
if (r.failure.errorClass == ErrorClass.MALFORMED) { if (r.failure.errorClass == ErrorClass.MALFORMED) {
log.error("decode MALFORMED -> DEAD id={} detail={}", head.cminmsgsId, r.failure.detail) log.error("decode MALFORMED -> DEAD msgId={} detail={}", head.msgId, r.failure.detail)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = r.failure.detail)
lastError = r.failure.detail)
} else { } 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) procFailure.fail(head, r.failure.errorClass, r.failure.detail)
} }
return return
} }
} }
// I3identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑(N25:入参传递 head,不再二次查询) // I3identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑
if (head.identityKey == null) { if (head.identityKey == null) {
val identity = Identity.of(decoded, props.identity) 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 val owner = procState.ownerOfIdentity(identity) ?: -1L
log.info("duplicate-of:{} -> SKIPPED id={}", owner, head.cminmsgsId) log.info("duplicate-of:{} -> SKIPPED msgId={}", owner, head.msgId)
procState.update(head.cminmsgsId, ProcStatus.SKIPPED, lastError = "duplicate-of:$owner") procState.update(head.msgId, ProcStatus.SKIPPED, lastError = "duplicate-of:$owner")
// 重复报文自身也是一条信箱记录:同样回填共享信箱,防止被反复轮询(失败落补偿)
compensateBackfill(head, decoded) compensateBackfill(head, decoded)
return return
} }
} }
// 快照消息走专属流程(流程 4:staging → SQL 批处理/域内差删/CAS 同事务)
val handler = handlers.registry.dispatcherFor(decoded) // 处理器分派:SCHD 快照主链路(§5.1/ FLOP / FDEL / ADFT;缺载荷按 MALFORMED 终态
if (handler == null) { val result: ApplyResult = when (val kind = decoded.kind) {
// U10(N21):未注册 ≠ 报文非法——写 FAILED(可重放),绝不写终态 is MsgKind.Schd -> {
log.warn("no-handler:{} -> FAILED(UNSUPPORTED) id={}", decoded.typeTag, head.cminmsgsId) val body = decoded.body as? ScheduleBody
procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${decoded.typeTag}") if (body == null) {
deadMalformed(head, "missing-schd-body")
return return
} }
val schdKind = decoded.kind as? com.gzzn.omms.msgexchange.domain.MsgKind.Schd when (kind.subtype) {
if (schdKind != null && MsgKind.SchdSubtype.DNLD, MsgKind.SchdSubtype.RESP ->
schdKind.subtype == com.gzzn.omms.msgexchange.domain.MsgKind.SchdSubtype.DNLD scheduleProcessor.applyScheduleRecords(head, decoded)
) { MsgKind.SchdSubtype.ADFT -> {
snapshotFlow.publishSnapshot(head, decoded) val record = body.records.singleOrNull()
if (record == null) {
deadMalformed(head, "adft-needs-single-fltr")
return
}
adftProcessor.apply(head, decoded, record)
}
}
}
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 return
} }
val candidateFlids = extractCandidateFlids(decoded)
val flightView = if (candidateFlids.isNotEmpty()) {
flightSchd.findByFlids(candidateFlids)
} else {
emptyMap()
} }
val decision = handler.decide(flightView, decoded) // 纯函数 when (result) {
is ApplyResult.Succeeded, ApplyResult.ReplaySkipped ->
// 事务 2(自有 PG 单事务原子): procState.update(head.msgId, ProcStatus.SUCCEEDED)
// upsert FLIGHT_SCHD(decision.flightChanges) + insert MSG_EVENT + PROC_STATE → SUCCEEDED is ApplyResult.DeadProtocol -> {
val events = buildList { // §9:整包拒绝 DEAD(PROTOCOL),立即释放队头,交人工确认
decision.msgNotifies.forEach { add(MsgEvent(target = Targets.KAFKA_MSG, payloadJson = it.payloadJson)) } log.error("DEAD(PROTOCOL) msgId={} reason={} flags={}", head.msgId, result.reason, result.flags)
decision.schdPush.forEach { add(MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = it.flid, payloadJson = it.payloadJson)) } procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.PROTOCOL, lastError = result.reason.take(1000))
compensateBackfill(head, decoded) // 拒绝包同样要回填信箱,防止反复轮询
return
}
} }
val messageId = head.cminmsgsId.toString() backfill(head, decoded)
txManager.inTransaction { log.info("SUCCEEDED msgId={} kind={}", head.msgId, decoded.typeTag)
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)
}
flightSchd.persistNextStates(null, nextStates, snapshotReplace = false)
}
if (events.isNotEmpty()) {
msgEvents.insertAll(events)
}
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
} }
/** §7.2:提交后回填共享信箱;失败不得把 SUCCEEDED 改回 FAILED,待办已事务内预登记。 */
private fun backfill(head: ProcState, decoded: DecodedMessage) {
try { 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)
backfillTodo?.delete(head.msgId)
} catch (e: Exception) { } catch (e: Exception) {
log.error("backfill failed after SUCCEEDED id={} (compensation required)", head.cminmsgsId, e) log.error("backfill failed after SUCCEEDED msgId={} (todo pre-registered, sweep will retry)", head.msgId, e)
recordBackfillTodo(head.cminmsgsId, decoded, e)
} }
log.info("SUCCEEDED id={} events={} flightChanges={}", head.cminmsgsId, events.size, decision.flightChanges.size)
} }
/** v2 §5:回填失败落补偿待办(成功终态不降级,业务不重放);由 BackfillSweepJob 到期重试。 */ /** SKIPPED/DEAD(PROTOCOL) 包的回填(无预登记待办):失败落补偿待办。 */
private fun compensateBackfill(head: ProcState, decoded: DecodedMessage) { private fun compensateBackfill(head: ProcState, decoded: DecodedMessage) {
try { 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) { } catch (e: Exception) {
log.error("backfill failed for SKIPPED duplicate id={} (compensation required)", head.cminmsgsId, e) log.error("backfill failed msgId={} (compensation required)", head.msgId, e)
recordBackfillTodo(head.cminmsgsId, decoded, e)
}
}
private fun recordBackfillTodo(cminmsgsId: Long, decoded: DecodedMessage, e: Exception) {
backfillTodo?.record( backfillTodo?.record(
BackfillTodoRepository.BackfillTask( BackfillTodoRepository.BackfillTask(
cminmsgsId = cminmsgsId, msgId = head.msgId,
sndr = decoded.meta.sndr, sndr = decoded.meta.sndr,
type = decoded.meta.type, type = decoded.meta.type,
styp = decoded.meta.styp, styp = decoded.meta.styp,
seqn = decoded.meta.seqn, seqn = decoded.meta.seqn,
), ),
e.message ?: e.javaClass.simpleName, e.message ?: e.javaClass.simpleName,
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted id={}", cminmsgsId) ) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted msgId={}", head.msgId)
} }
} }
/** 延迟装配占位(阶段 1 后续以 Micronaut Bean 替换直连构造)。 */ private fun deadMalformed(head: ProcState, detail: String) {
class CodecHolder(val codec: com.gzzn.omms.msgexchange.codec.XmlCodec) procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = detail)
class HandlerHolder(val registry: HandlerRegistry) }
}
@@ -0,0 +1,207 @@
package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.codec.ScheduleBody
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
import com.gzzn.omms.msgexchange.domain.SnapshotResult
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
import com.gzzn.omms.msgexchange.domain.flight.FlightState
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
import com.gzzn.omms.msgexchange.domain.flight.SnapshotValidation
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
import jakarta.inject.Singleton
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/** 处理器执行结果——终态迁移由 MessageProcessor 统一落库。 */
sealed interface ApplyResult {
/** 业务成功(含幂等成功)。 */
data object Succeeded : ApplyResult
/** §5.1 步骤 2:MSG_ID 已有成功终态 → 重放,直接记幂等成功。 */
data object ReplaySkipped : ApplyResult
/** §9:整包拒绝 DEAD(PROTOCOL),不重试,交人工确认。 */
data class DeadProtocol(val reason: String, val flags: Set<SnapshotFlag> = emptySet()) : ApplyResult
}
/** §5.3 第四行:归属日不符 = 串日/错发/污染,整包拒绝。 */
class ProtocolViolation(message: String) : RuntimeException(message)
/**
* SCHD 快照主链路(docs/flight-state.md §5.1 applyScheduleRecords,同一事务):
* 对单日快照与滚动窗口统一适用,不做名单层面的处理。
*/
@Singleton
class ScheduleProcessor(
private val txManager: PipelineTransactionManager,
private val lock: PipelineLockRepository,
private val procState: ProcStateRepository,
private val flightState: FlightStateRepository,
private val msgEvents: MsgEventRepository,
private val snapshotLog: SnapshotLogRepository,
private val backfillTodo: BackfillTodoRepository?,
operationDayProps: OperationDayProps,
private val mapper: ObjectMapper,
) {
private val opDay = OperationDayCalculator(
zone = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") },
cutoffHour = operationDayProps.cutoffHour,
)
fun applyScheduleRecords(head: ProcState, msg: DecodedMessage): ApplyResult {
val body = msg.body as? ScheduleBody ?: return ApplyResult.DeadProtocol("missing-schd-body")
val started = System.nanoTime()
// ② 重放判定:MSG_ID 已有成功终态 → 幂等成功(§5.1 步骤 2,重放也记留痕)
if (procState.findSuccessTerminal(head.msgId)) {
logSnapshot(head, body, SnapshotResult.REPLAY_SKIPPED, upserted = 0, flags = emptySet(), started)
return ApplyResult.ReplaySkipped
}
// ③ 报文完整性(§5.2 五项):任一失败整包不落地 → DEAD(PROTOCOL)
val validation = FlightStateEngine.validateMessage(
recsDeclared = body.recsDeclared,
records = body.records,
scopeStart = body.scopeStart,
scopeEnd = body.scopeEnd,
opDay = opDay,
)
if (validation is SnapshotValidation.Invalid) {
logSnapshot(head, body, SnapshotResult.ROLLED_BACK, upserted = 0, validation.flags, started)
return ApplyResult.DeadProtocol(validation.reason, validation.flags)
}
val ok = validation as SnapshotValidation.Ok
if (ok.perRecordDay.isEmpty()) {
// 空快照:合法但无写入(§5.5 EMPTY),仍算成功终态
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted = 0, setOf(SnapshotFlag.EMPTY), started)
return ApplyResult.Succeeded
}
val flags = linkedSetOf<SnapshotFlag>()
return try {
// ①④⑤⑥⑦ 同一事务:锁 → 归属校验 → upsert → 版本/事件/待办预登记/终态
val upserted = txManager.inTransaction {
lock.lock()
// ⑤ 归属校验(§5.3):OPERATION_DAY 不可变,批量点查避免逐航班往返
val mains = flightState.findMainRows(ok.perRecordDay.keys)
ok.perRecordDay.forEach { (flid, day) ->
val existing = mains[flid] ?: return@forEach
if (existing.operationDay != null && existing.operationDay != day) {
throw ProtocolViolation(
"SAME_FLID_ACROSS_OPERATION_DAYS flid=$flid existing=${existing.operationDay} incoming=$day",
)
}
}
var written = 0
val events = mutableListOf<MsgEvent>()
ok.perRecordDay.forEach { (flid, day) ->
val record = body.records.first { it.flid == flid }
val existingMain = mains[flid]
val keepDeleted = existingMain?.state == FlightState.DELETED
if (keepDeleted) flags.add(SnapshotFlag.SCHD_REVIVE_CONFLICT) // §5.1 步骤 6:不恢复
val current = if (existingMain != null) flightState.loadFullSnapshot(flid) else null
val next = FlightStateEngine.snapshotState(
current = current,
record = record,
operationDay = day,
keepDeleted = keepDeleted,
)
when (flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())) {
PersistOutcome.DAY_GUARD_VIOLATION ->
throw ProtocolViolation("operation-day guard violated flid=$flid")
else -> written++
}
events += snapshotEvents(next)
}
if (events.isNotEmpty()) msgEvents.insertAll(events)
// §7.2 目标形态:业务事务内预登记回填待办(提交后由 MessageProcessor 回填并删待办)
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
styp = msg.meta.styp, seqn = msg.meta.seqn,
),
null,
)
written
}
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted, flags, started)
ApplyResult.Succeeded
} catch (e: ProtocolViolation) {
logSnapshot(head, body, SnapshotResult.ROLLED_BACK, upserted = 0, flags, started)
ApplyResult.DeadProtocol(e.message ?: "protocol-violation", flags)
}
}
/** 状态事件:整态出站(§7.3 KAFKA_SCHD+ 变化通知(KAFKA_MSG)。 */
private fun snapshotEvents(next: FlightSnapshot): List<MsgEvent> {
// KAFKA_SCHD 整态:缺失集合输出空集 → 消费者删除旧值(§5.4/§7.3)
val payload = linkedMapOf<String, Any>(
"flid" to next.flid,
"stateVersion" to next.stateVersion,
"scalars" to next.scalars,
"collections" to next.collections,
)
val notify = mapper.writeValueAsString(mapOf("flid" to next.flid, "stateVersion" to next.stateVersion))
return listOf(
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = next.flid,
stateVersion = next.stateVersion,
payloadJson = mapper.writeValueAsString(payload),
),
MsgEvent(target = Targets.KAFKA_MSG, partitionKey = next.flid, stateVersion = next.stateVersion, payloadJson = notify),
)
}
/** §5.5 留痕:事务外追加,失败只记 error 不阻塞;scope 按记录归属运营日推导。 */
private fun logSnapshot(
head: ProcState,
body: ScheduleBody,
result: SnapshotResult,
upserted: Int,
flags: Set<SnapshotFlag>,
startedNanos: Long,
) {
runCatching {
val days = body.records.mapNotNull { opDay.compute(it.scalars["SODT"]) }
snapshotLog.append(
SnapshotLogEntry(
msgId = head.msgId,
recvAt = Instant.now(),
scopeStart = days.minOrNull() ?: LocalDate.now(),
scopeEnd = days.maxOrNull() ?: LocalDate.now(),
recs = body.records.size,
upserted = upserted,
durationMs = (System.nanoTime() - startedNanos) / 1_000_000,
result = result,
flags = flags,
),
)
}.onFailure { log.error("snap-log write failed (metric only) msgId={}", head.msgId, it) }
}
private companion object {
private val log = org.slf4j.LoggerFactory.getLogger(ScheduleProcessor::class.java)
}
}
@@ -1,175 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import jakarta.inject.Singleton
/**
* staging(内存瞬态,崩溃从 raw 整包重放)→ PG 单事务原子“覆盖+按代差删+SQL CAS 推进+事件入队+SUCCEEDED”(I4/I5ACM2-28 定案)。
* U10/T07 占位安全化 + U08 统一失败迁移(ProcFailure):staging 未实装 → FAILED(UNSUPPORTED)+退避
* (可重放,绝不写终态);CAS 冲突 → FAILED(INFRA)+退避;达上限统一 DEAD(EXHAUSTED)。
*/
@Singleton
class SnapshotFlow(
private val procState: ProcStateRepository,
private val flightSchd: FlightSchdRepository,
private val msgEvents: MsgEventRepository,
private val reqTrack: ReqTrackRepository,
private val procFailure: ProcFailure,
private val txManager: PipelineTransactionManager,
private val inbox: CminmsgInboxRepository,
private val backfillTodo: BackfillTodoRepository? = null,
) {
private val log = org.slf4j.LoggerFactory.getLogger(SnapshotFlow::class.java)
fun publishSnapshot(head: ProcState, msg: DecodedMessage) {
val messageId = head.cminmsgsId.toString()
val staged = StageResult.stagingOf(msg)
if (staged is StageResult.Invalid) {
log.warn("staging not implemented -> FAILED(UNSUPPORTED) id={} reason={}", head.cminmsgsId, staged.reason)
procFailure.fail(head, ErrorClass.UNSUPPORTED, staged.reason)
return
}
val ok = staged as StageResult.Ok
val normalized = ok.flights
val day = ok.day
if (normalized.size > MAX_FLIGHTS_PER_SNAPSHOT) {
log.error("snapshot flights exceed limit -> DEAD(MALFORMED) id={} size={}", head.cminmsgsId, normalized.size)
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED,
lastError = "flights-exceed-limit:${normalized.size}")
return
}
// v2 §5 事务流程:锁外只做解析与整包校验;取得 PIPELINE_LOCK 后在锁内
// 复核快照身份 → 读取当前代与当前航班态 → 计算 nextState → 写入 → 提交。
var replayNoOp = false
try {
txManager.inTransaction {
// 锁内复核快照身份:同一消息已成功提交 → 重放短路(不加版本、不重复发事件)
val gen = flightSchd.getGen(day)
if (gen?.lastMessageId == messageId) {
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
replayNoOp = true
return@inTransaction
}
val expected = gen?.version ?: 0L
val newFlids = normalized.map { it.first }.toSet()
val delFields = gen?.flids?.minus(newFlids) ?: emptySet()
val newVersion = expected + 1L
// 锁内读取当前航班态,计算 nextState(单写者互斥下读到的一定是已提交最新态)
val nextStates = normalized.map { (flid, fields) ->
val current = flightSchd.findNextStateByFlid(flid)
val commands = FlightStateEngine.commandsFromFields(flid, fields)
FlightStateEngine.apply(current, commands, messageId, bumpVersion = true)
}
flightSchd.persistNextStates(day, nextStates, snapshotReplace = true)
if (delFields.isNotEmpty()) {
flightSchd.deleteDiffByDay(day, delFields)
}
// 锁内 CAS:expected 即锁内刚读到的最新版本,仍失败属数据异常——
// 一律回滚进入可重试 FAILED(INFRA),绝不凭版本号推断“已经是我的提交”(v2 §5)
val casSuccess = flightSchd.putGenIfVersion(
day = day,
expected = expected,
newGen = FlightSchdRepository.GenMeta(
fday = day,
version = newVersion,
flids = newFlids,
lastMessageId = messageId,
),
)
if (!casSuccess) {
throw CasConflictException(
"gen-cas-conflict: expected=$expected msg=$messageId (lock-held CAS must not fail)",
)
}
val schdKind = msg.kind as? MsgKind.Schd
if (schdKind?.subtype == MsgKind.SchdSubtype.RESP) {
reqTrack.findOpenByKind("SCHD")?.let { req ->
reqTrack.markDone(req.reqId)
}
}
val events = nextStates.map { state ->
MsgEvent(
target = Targets.KAFKA_SCHD,
partitionKey = state.flid,
payloadJson = FlightFieldsJson.toJson(state.toFlightFields()),
)
}
if (events.isNotEmpty()) {
msgEvents.insertAll(events)
}
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
}
// 提交后补偿:重放短路同样补做回填(其待办可能仍在重试中)
safeBackfill(head, msg)
if (replayNoOp) {
log.info("snapshot replay no-op id={} day={}", head.cminmsgsId, day)
} else {
log.info("snapshot SUCCEEDED id={} day={} flights={}", head.cminmsgsId, day, normalized.size)
}
} catch (e: CasConflictException) {
log.warn("gen CAS conflict -> FAILED(INFRA) id={} msg={}", head.cminmsgsId, e.message)
procFailure.fail(head, ErrorClass.INFRA, e.message ?: "gen-cas-conflict")
}
}
private fun safeBackfill(head: ProcState, msg: DecodedMessage) {
try {
inbox.backfillOnSuccess(head.cminmsgsId, msg.meta.sndr, msg.meta.type, msg.meta.styp, msg.meta.seqn)
} catch (e: Exception) {
log.error("backfill failed after SUCCEEDED id={} (compensation required)", head.cminmsgsId, e)
backfillTodo?.record(
BackfillTodoRepository.BackfillTask(
cminmsgsId = head.cminmsgsId,
sndr = msg.meta.sndr,
type = msg.meta.type,
styp = msg.meta.styp,
seqn = msg.meta.seqn,
),
e.message ?: e.javaClass.simpleName,
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted id={}", head.cminmsgsId)
}
}
/** staging 结果(骨架)。 */
sealed interface StageResult {
data class Ok(val day: String, val flights: List<Pair<String, FlightFields>>) : StageResult
data class Invalid(val reason: String) : StageResult
companion object {
var parser: ((DecodedMessage) -> StageResult)? = null
fun stagingOf(msg: DecodedMessage): StageResult =
parser?.invoke(msg) ?: Invalid("staging-not-implemented(${msg.typeTag})") // TODO(阶段2)
}
}
}
class CasConflictException(message: String) : RuntimeException(message)
const val MAX_FLIGHTS_PER_SNAPSHOT = 10000
@@ -1,44 +0,0 @@
package com.gzzn.omms.msgexchange.processing.handlers
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
import com.gzzn.omms.msgexchange.processing.Handler
import jakarta.inject.Singleton
/**
* FLOP-GTDT:登机门集合级快照替换(SIS §3.34;GTNO=0 清除)。
* 产出 FlightChange.fields["GTDT"] 为 JSON 数组,供 FlightStateEngine → persistNextStates 写入明细表。
*/
@Singleton
class GtdtHandler(
private val mapper: ObjectMapper = ObjectMapper(),
) : Handler {
override val kind: MsgKind = MsgKind.Flop("GTDT")
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision {
val body = msg.body as? FlopPayload
?: throw IllegalArgumentException("GTDT handler requires FlopPayload body")
val gtdtItems = body.collections["GTDT"] ?: emptyList()
val fields = linkedMapOf<String, String>()
fields["FLID"] = body.flid
body.scalars.forEach { (k, v) -> fields[k] = v }
fields["GTDT"] = mapper.writeValueAsString(gtdtItems)
val current = flightView[body.flid]?.let { FlightStateEngine.fromFlightFields(body.flid, it) }
val commands = FlightStateEngine.commandsFromFields(body.flid, fields)
val preview = FlightStateEngine.apply(current, commands, messageId = "", bumpVersion = false)
val payloadJson = FlightFieldsJson.toJson(preview.toFlightFields(mapper))
return Decision(
flightChanges = listOf(FlightChange(body.flid, fields)),
schdPush = listOf(SchdPush(body.flid, payloadJson)),
)
}
}
@@ -1,19 +0,0 @@
package com.gzzn.omms.msgexchange.reference
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import jakarta.inject.Singleton
/**
* ACMA-8 决策 621 类 admin-api 基础数据同步(阶段 6)。
* 落点 = 21 类静态主数据独立 PostgreSQL 参考库(StaticRefRepositoryACM2-11);
* 只产静态主数据行(权威写唯一入口);影子实例不运行本模块(v4 影子二分)。
*/
@Singleton
class ReferenceService(
private val staticRef: StaticRefRepository,
// TODO(阶段6): @Client(id="ADMINAPI") 声明式客户端 + 21 类端点配置化清单
) {
fun refresh(type: String) {
// TODO(阶段6): 拉取 → staticRef.upsertAll(rows, source=ADMINAPI);失败旧数据可用(参考库保留旧值)
}
}
@@ -1,60 +0,0 @@
package com.gzzn.omms.msgexchange.reference
import com.gzzn.omms.msgexchange.domain.RefUpsert
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
import jakarta.inject.Singleton
import java.time.Instant
/**
* ACMA-8 流程 6:请求式查询状态机(15 类,决策树版 v4)。
* 同类并发=1(注册新请求先强制旧请求 EXPIRED,终态不再被匹配);
* 应答匹配:回显字段 CONFIRM(矩阵 #13)→ 精确匹配;否则退化模式(DTTM ≥ SENT 才应用 + 审计)。
* 应答落库目标 = 21 类静态主数据(独立 PG 参考库,StaticRefRepositoryACM2-11);
* 注:N04 量纲缺陷(dttm vs epochMillis)与 N16 死分支未修,接线阶段 6 前处理(U19–U21)。
*/
@Singleton
class RequestCoordinator(
private val reqTrack: ReqTrackRepository,
private val staticRef: StaticRefRepository,
) {
fun request(kind: String, rangeJson: String): Long {
reqTrack.forceExpireOpenOf(kind) // 防护①
val reqId = reqTrack.insert(kind, rangeJson)
// 出站走既有 COUTMSGS outboxTODO(阶段6): codec.encodeRqrd + coutmsgs 落库 + link
reqTrack.markSent(reqId, Instant.now())
return reqId
// TODO(阶段6): timer.at(kind.timeout) { expireIfWaiting(reqId) }
}
/** 应答处理(决策树):msg.dttm 与回显字段由 codec 解出后传入。 */
fun onResp(
kind: String,
dttm: Long,
echoSeqn: Long?,
records: List<RefUpsert>, // 应答载荷 → 静态主数据(source=AODB
): String {
val req = reqTrack.findOpenByKind(kind) ?: return audit("late/unknown resp kind=$kind")
val echoConfirmed = false // CONFIRM(矩阵 #13)待机场方结论
return when {
echoConfirmed && echoSeqn != null -> { // ① 精确匹配(TODO: matchBy echoSeqn
applyResp(req.reqId, records); "matched"
}
dttm < epochMillis(req.sentAt) -> audit("stale resp kind=$kind") // ② DTTM < SENT → 丢弃
else -> { // ③ 退化模式应用;残余跨代错配风险明示接受并审计
applyResp(req.reqId, records); "applied-degraded"
}
}
}
private fun applyResp(reqId: Long, records: List<RefUpsert>) {
staticRef.upsertAll(records) // 独立 PG 参考库(阶段 6 接线)
reqTrack.markDone(reqId)
}
private fun audit(reason: String): String = reason.also {
// TODO(阶段1后续): 审计表/日志(跨代错配残余风险可观测)
}
private fun epochMillis(i: Instant?): Long = i?.toEpochMilli() ?: 0
}
@@ -1,342 +0,0 @@
package com.gzzn.omms.msgexchange.tools
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.ValueNode
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import java.math.BigDecimal
/**
* ACM2-28 FS7:影子对拍跨存储 Diff 工具。
*
* 核心目标:
* 对齐 CMINMSGS_ID 水位后,比对 nextgen 自有库 FLIGHT_SCHD11g 修订:宽表列值)
* vs legacy 现役 Redis (flightInfo)。
*
* 字段归一化规则(ACM2-28 评论 4 项 4,随宽表存储形态修订):
* 1. PG 侧为宽表列值映射(字段名 → 字符串值),legacy 侧解析 FLTR JSON 后按字段逐一比对,
* 天然规避 JSONB/文本的 key 排序与序列化形态差异,绝不裸字符串比对;
* 2. 数值精度归一化(PG "100.0" vs legacy 100 视为等价;PG 值不可解析为数值则判不一致);
* 3. 空值规范化(legacy JSON null/字段缺失 与 PG 列 NULL 视为等价,规避无语义偏离);
* 4. 嵌套/集合值(legacy 为对象或数组):PG 侧序列化文本按 JSON 解析后递归比对。
*
* 已知合法偏离声明(ACM2-28 方案六):
* ① FDAY 域化差删对跨代迁移行的保护(legacy 误删、nextgen 不删,属于修正性偏离)。
*/
class FlightStoreDiffTool(
private val mapper: ObjectMapper = ObjectMapper(),
) {
enum class DeviationKind {
/** 已知合法偏离 ①:跨日迁移航班在 nextgen PG 中受 FDAY 保护未被误删,而 legacy 误删 */
CROSS_DAY_PROTECTED,
/** 非预期偏离:字段值不匹配 */
FIELD_MISMATCH,
/** 非预期偏离:PG 缺失该航班 */
MISSING_IN_PG,
/** 非预期偏离:Redis 缺失且非已知迁移保护行 */
UNEXPECTED_EXTRA_IN_PG,
}
data class Deviation(
val flid: String,
val kind: DeviationKind,
val path: String? = null,
val pgValue: Any? = null,
val legacyValue: Any? = null,
val detail: String,
)
data class DiffReport(
val totalPg: Int,
val totalLegacy: Int,
val matchedCount: Int,
val knownDeviations: List<Deviation>,
val unexpectedDeviations: List<Deviation>,
) {
val isGreen: Boolean
get() = unexpectedDeviations.isEmpty()
fun formatSummary(): String {
val sb = StringBuilder()
sb.appendLine("========== 影子对拍跨存储 Diff 报告 ==========")
sb.appendLine("Nextgen PG 航班总数 : $totalPg")
sb.appendLine("Legacy Redis 航班总数 : $totalLegacy")
sb.appendLine("完全一致航班数 : $matchedCount")
sb.appendLine("已知合法偏离数 (偏差①) : ${knownDeviations.size}")
sb.appendLine("非预期异常数 : ${unexpectedDeviations.size}")
sb.appendLine("验收红绿灯状态 : ${if (isGreen) "GREEN (通过)" else "RED (未通过)"}")
sb.appendLine("----------------------------------------------")
if (knownDeviations.isNotEmpty()) {
sb.appendLine("[已知合法偏离]")
knownDeviations.forEach { d ->
sb.appendLine(" FLID=${d.flid}: ${d.detail}")
}
}
if (unexpectedDeviations.isNotEmpty()) {
sb.appendLine("[非预期异常偏离]")
unexpectedDeviations.forEach { d ->
sb.appendLine(" FLID=${d.flid} [${d.kind}] path=${d.path}: PG=${d.pgValue}, Legacy=${d.legacyValue} (${d.detail})")
}
}
sb.appendLine("==============================================")
return sb.toString()
}
}
/**
* FS7 v2 双读对拍已随回退窗口关闭移除(V1.4.0,ACM2-29 P4):
* legacy 槽位/里程碑列退场后无对比基准,损失性差异已由 P2-1 对拍记录留档。
*/
/** 单航班 FlightFields 对称比对(集合值为 JSON 文本时递归解析)。 */
internal fun compareFlightFields(flid: String, left: FlightFields, right: FlightFields): List<Deviation> {
val mismatches = mutableListOf<Deviation>()
val allKeys = (left.keys + right.keys).toSortedSet()
for (key in allKeys) {
compareFieldValues(flid, key, left[key], right[key], mismatches)
}
return mismatches
}
private fun compareFieldValues(flid: String, path: String, left: String?, right: String?, acc: MutableList<Deviation>) {
val leftNode = left?.let { parseFieldValue(it) }
val rightNode = right?.let { parseFieldValue(it) }
compareNodes(flid, path, leftNode, rightNode, acc)
}
private fun parseFieldValue(value: String): JsonNode =
runCatching { mapper.readTree(value) }.getOrNull()
?: mapper.nodeFactory.textNode(value)
/**
* 执行全量比对。
* @param pgFlights nextgen FLIGHT_SCHD 行(flid -> 宽表列值映射)
* @param legacyFlights legacy Redis flightInfo 行(flid -> value string
* @param crossDayMigratedFlids 已知在多日之间迁移的航班 FLID 集合(用于识别合法偏差 ①)
*/
fun diff(
pgFlights: Map<String, FlightFields>,
legacyFlights: Map<String, String>,
crossDayMigratedFlids: Set<String> = emptySet(),
): DiffReport {
val allFlids = (pgFlights.keys + legacyFlights.keys).toSortedSet()
var matched = 0
val known = mutableListOf<Deviation>()
val unexpected = mutableListOf<Deviation>()
for (flid in allFlids) {
val pgFields = pgFlights[flid]
val legacyRaw = legacyFlights[flid]
if (pgFields == null) {
unexpected += Deviation(
flid = flid,
kind = DeviationKind.MISSING_IN_PG,
legacyValue = legacyRaw,
detail = "Flight present in Redis but missing in PG",
)
continue
}
if (legacyRaw == null) {
if (flid in crossDayMigratedFlids) {
known += Deviation(
flid = flid,
kind = DeviationKind.CROSS_DAY_PROTECTED,
pgValue = pgFields,
detail = "Known Deviation 1: Cross-day migrated flight protected by FDAY domain-scoped delete in nextgen PG, erroneously deleted in legacy Redis",
)
} else {
unexpected += Deviation(
flid = flid,
kind = DeviationKind.UNEXPECTED_EXTRA_IN_PG,
pgValue = pgFields,
detail = "Flight present in PG but missing in Redis without cross-day migration justification",
)
}
continue
}
// 两侧均存在:逐字段归一化比对(PG 列值 vs legacy JSON 解析树)
val mismatches = compareFlight(flid, pgFields, legacyRaw)
if (mismatches.isEmpty()) {
matched++
} else {
unexpected.addAll(mismatches)
}
}
return DiffReport(
totalPg = pgFlights.size,
totalLegacy = legacyFlights.size,
matchedCount = matched,
knownDeviations = known,
unexpectedDeviations = unexpected,
)
}
/**
* 单航班比对:PG 宽表列值映射 vs legacy FLTR JSON 文本。
* 遍历两侧字段名并集,按归一化规则逐字段判定。
*/
internal fun compareFlight(flid: String, pgFields: FlightFields, legacyJson: String): List<Deviation> {
val legacyNode = try {
mapper.readTree(legacyJson)
} catch (e: Exception) {
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "Legacy JSON parse failed: ${e.message}"))
}
if (!legacyNode.isObject) {
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "Legacy JSON is not an object"))
}
val legacyObj = legacyNode as ObjectNode
val mismatches = mutableListOf<Deviation>()
val allKeys = (pgFields.keys + legacyObj.fieldNames().asSequence().toSet()).toSortedSet()
for (key in allKeys) {
val pgValue: String? = pgFields[key]
val legacyChild = legacyObj.get(key)
compareFieldNode(flid, key, pgValue, legacyChild, mismatches)
}
return mismatches
}
/** 单字段判定:PG 列值(字符串或缺失)vs legacy JSON 节点。 */
private fun compareFieldNode(flid: String, path: String, pgValue: String?, legacyNode: JsonNode?, acc: MutableList<Deviation>) {
fun mismatch(pg: Any?, legacy: Any?, detail: String) {
acc += Deviation(flid, DeviationKind.FIELD_MISMATCH, path = path, pgValue = pg, legacyValue = legacy, detail = detail)
}
val legacyAbsent = legacyNode == null || legacyNode.isNull
val pgAbsent = pgValue == null
// 规范化:legacy JSON null/缺失 与 PG 列 NULL 等价
if (legacyAbsent && pgAbsent) return
if (legacyAbsent != pgAbsent) {
mismatch(pgValue, legacyNode?.asText(), "One side is null/absent while other is present")
return
}
val legacy = legacyNode!!
// 数值归一化(PG 列值可解析为数值时按数值比较)
if (legacy.isNumber) {
val pgNumber = pgValue!!.trim().toBigDecimalOrNull()
val legacyNumber = BigDecimal(legacy.asText()).stripTrailingZeros()
if (pgNumber == null || pgNumber.stripTrailingZeros().compareTo(legacyNumber) != 0) {
mismatch(pgValue, legacy.asText(), "Numeric value mismatch: $pgValue != ${legacy.asText()}")
}
return
}
// 嵌套/集合值:PG 序列化文本按 JSON 解析后递归比对
if (legacy.isObject || legacy.isArray) {
val pgNode = try {
mapper.readTree(pgValue)
} catch (_: Exception) {
null
}
if (pgNode == null) {
mismatch(pgValue, "<${legacy.nodeType}>", "PG value is not parseable as JSON while legacy is ${legacy.nodeType}")
return
}
compareNodes(flid, path, pgNode, legacy, acc)
return
}
// 基本类型:文本值比较
if (pgValue != legacy.asText()) {
mismatch(pgValue, legacy.asText(), "Value mismatch: $pgValue != ${legacy.asText()}")
}
}
private fun compareNodes(flid: String, path: String, n1: JsonNode?, n2: JsonNode?, acc: MutableList<Deviation>) {
if (n1 == null && n2 == null) return
// 规范化:null 节点与缺失节点视为等价
val isN1Empty = n1 == null || n1.isNull
val isN2Empty = n2 == null || n2.isNull
if (isN1Empty && isN2Empty) return
if (isN1Empty != isN2Empty) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = n1?.asText(),
legacyValue = n2?.asText(),
detail = "One side is null/absent while other is present",
)
return
}
val nonNull1 = n1!!
val nonNull2 = n2!!
// 数值归一化比较
if (nonNull1.isNumber && nonNull2.isNumber) {
val d1 = BigDecimal(nonNull1.asText()).stripTrailingZeros()
val d2 = BigDecimal(nonNull2.asText()).stripTrailingZeros()
if (d1.compareTo(d2) != 0) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = nonNull1.numberValue(),
legacyValue = nonNull2.numberValue(),
detail = "Numeric value mismatch: $d1 != $d2",
)
}
return
}
// 对象类型:递归属性比较(忽略 key 顺序)
if (nonNull1.isObject && nonNull2.isObject) {
val obj1 = nonNull1 as ObjectNode
val obj2 = nonNull2 as ObjectNode
val allKeys = (obj1.fieldNames().asSequence().toSet() + obj2.fieldNames().asSequence().toSet()).toSortedSet()
for (key in allKeys) {
val nextPath = if (path.isEmpty()) key else "$path.$key"
compareNodes(flid, nextPath, obj1.get(key), obj2.get(key), acc)
}
return
}
// 数组类型:逐项比较
if (nonNull1.isArray && nonNull2.isArray) {
val arr1 = nonNull1 as ArrayNode
val arr2 = nonNull2 as ArrayNode
if (arr1.size() != arr2.size()) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = "size=${arr1.size()}",
legacyValue = "size=${arr2.size()}",
detail = "Array size mismatch",
)
return
}
for (i in 0 until arr1.size()) {
compareNodes(flid, "$path[$i]", arr1.get(i), arr2.get(i), acc)
}
return
}
// 基本类型:文本值比较
if (nonNull1.asText() != nonNull2.asText()) {
acc += Deviation(
flid = flid,
kind = DeviationKind.FIELD_MISMATCH,
path = path,
pgValue = nonNull1.asText(),
legacyValue = nonNull2.asText(),
detail = "Value mismatch: ${nonNull1.asText()} != ${nonNull2.asText()}",
)
}
}
}
+14 -9
View File
@@ -5,12 +5,11 @@
# 口令/端点全部环境变量外置——零入库。 # 口令/端点全部环境变量外置——零入库。
# 存储边界:自有 PostgreSQLdatasources.default,全部内部状态与运营航班权威 FLIGHT_SCHD/SCHD_GEN+ # 存储边界:自有 PostgreSQLdatasources.default,全部内部状态与运营航班权威 FLIGHT_SCHD/SCHD_GEN+
# 共享 MySQL 信箱(mailbox.shared-mysql,仅 DML,不建表)+ # 共享 MySQL 信箱(mailbox.shared-mysql,仅 DML,不建表)+
# Kafka + Eureka/logstash(见 ACM2-28 与 docsRedis 彻底退出阶段 A 权威与写路径) # Kafka + Eureka/logstash。权威设计:docs/flight-state.md
# 键位口径:Micronaut 5.1U03):datasources.* / flyway.datasources.* / # 键位口径:Micronaut 5.1U03):datasources.* / flyway.datasources.* /
# kafka.producers.<name>.* / eureka.client.*Micronaut 原生键)。 # kafka.producers.<name>.* / eureka.client.*Micronaut 原生键)。
# ===================================================================== # =====================================================================
msgx: msgx:
phase: A # 航班动态权威落自有 PG(FLIGHT_SCHD,主泵单线程写,I5ACM2-28 定案)
service-name: msgexchangeapi # 契约冻结;影子实例用 msgexchangeapi-shadow service-name: msgexchangeapi # 契约冻结;影子实例用 msgexchangeapi-shadow
register-eureka: true # 影子对拍期置 false 或独立服务名 register-eureka: true # 影子对拍期置 false 或独立服务名
pipeline: # —— ACMA-8 参数表初值(阶段 0 基线校准)—— pipeline: # —— ACMA-8 参数表初值(阶段 0 基线校准)——
@@ -24,11 +23,16 @@ msgx:
schd: schd:
flush-period: 3s # KEEP 现役推送节律 flush-period: 3s # KEEP 现役推送节律
flush-limit: 500 # 批上限,防积压尖峰 flush-limit: 500 # 批上限,防积压尖峰
identity: operation-day: # §3.5:SODT + 机场时区 + 切日边界(默认占位 0 点,待业务确认 §10)
include-day-boundary: false # CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持关闭 zone: Asia/Shanghai
consistency-check: # 一致性哨兵(实装属 U25 cutoff-hour: 0
on-startup: true history: # §8:历史判定窗口与保留期
daily-sample-ratio: 0.01 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: micronaut:
application: application:
@@ -37,8 +41,9 @@ micronaut:
port: 8080 port: 8080
# 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml # 管理端点(U03/N32):/env、/beans 默认 sensitive;仅开发/影子环境放开——见 application-dev.yml
# 自有 PostgreSQL:全部内部状态(消息管道 PROC_STATE/MSG_EVENT、PUMP_JOB、REQ_TRACK、 # 自有 PostgreSQL:全部内部状态(PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/
# 21 类 REF_MASTER、运营航班表 FLIGHT_SCHD 与 SCHD_GEN;迁移 db/migrationACM2-28)。数据层实装前 enabled=false # FLIGHT_SCHD 及明细表/SCHD_SNAP_LOG;迁移 db/migration/V1__flight_state_baseline.sql
# 数据层实装前 enabled=falsestub 模式不建连)。
datasources: datasources:
default: default:
enabled: false 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)。本地事务只在此库。
-- · 共享 MySQLcdairport,他人系统库):仅信箱 DML —— 上游外部写 CMINMSGS
-- 本系统 JDBC 轮询读 + 处理回填;出站写 COUTMSGS(他人读取发送);
-- 本系统不在此建表/schema(迁移不覆盖)。
-- · Redis:航班动态 flightInfo + 快照 SCHD_GENLua 原子,另行脚本)。
-- · 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|SEQNdecode 后首次绑定(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:schdA起);ES:flight_hts / REDIS:flightInfoB起)
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_SCHDSCHD 日计划快照 + 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);
-- 交叉实例单写者互斥行(I5SELECT ... 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.0V1.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_VERSIONKafka 判旧 = (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|SEQNdecode 后首次绑定;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 实例 IDNumber(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, -- 计划运行时间(ddMMMyyHHmmOPERATION_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(桥号 ABDGA/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(机位 CHIDON/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.210 类集合 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.3KAFKA_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 - V1 起:`V1__flight_state_baseline.sql` 的 11g 等价 DDL——
的 11g DDL——`NUMBER`/序列、`TIMESTAMP WITH TIME ZONE``VARCHAR2` BYTE/CHAR 语义钉死。 PIPELINE_LOCK/PROC_STATE/MSG_EVENT/REQ_TRACK/BACKFILL_TODO/FLIGHT_SCHD + 9 张明细表/SCHD_SNAP_LOG
- FLIGHT_SCHD 宽表 + V1.3.0 等价明细表 DDL(无 `ON CONFLICT`/`LATERAL` 依赖) `NUMBER`/序列替代 `BIGSERIAL``TIMESTAMP WITH TIME ZONE``VARCHAR2` BYTE/CHAR 语义钉死
- `SRVT_TEXT/VIPF_TEXT/MAFL_TEXT` 映射 `CLOB`PG 为 TEXT)。 - JdbcFlightStateRepository 的 `INSERT ... ON CONFLICT` 改 MERGEOPERATION_DAY 不可变条件 §7.4)。
- `flight_schd_display` 11g 版视图(无 LATERAL,用标量子查询)。
- 空串按 NULL 的语义回归:命令层 presence 信息不得被 11g 空串语义吞掉 - 空串按 NULL 的语义回归:命令层 presence 信息不得被 11g 空串语义吞掉
docs/flight-state.md §5)。 docs/flight-state.md §5/§10)。
版本号与 PG location 各自独立推进,禁止复用版本号语义。 版本号与 PG location 各自独立推进,禁止复用版本号语义。
@@ -58,18 +58,17 @@ class PipelineSmokeTest {
} }
companion object { companion object {
/** 合法 META + FLOP,但未注册 Handler → FAILED(UNSUPPORTED) */ /** 合法 META,但 TYPE 未在支持范围 → FAILED(UNSUPPORTED)(§9 */
val UNSUPPORTED_FLOP_XML = """ val UNSUPPORTED_XML = """
<MSG> <MSG>
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260908120000</DTTM><TYPE>FLOP</TYPE><STYP>DELY</STYP></META> <META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260908120000</DTTM><TYPE>XYZQ</TYPE><STYP>FOO</STYP></META>
<FLOP><FLID>F1</FLID></FLOP>
</MSG> </MSG>
""".trimIndent() """.trimIndent()
} }
@Test @Test
fun `accept then pump tick transitions message to FAILED UNSUPPORTED with backoff`() { fun `accept then pump tick transitions message to FAILED UNSUPPORTED with backoff`() {
val receipt = controller.send(UNSUPPORTED_FLOP_XML) val receipt = controller.send(UNSUPPORTED_XML)
assertNotNull(receipt.body()) // 受理 ID assertNotNull(receipt.body()) // 受理 ID
val id = receipt.body()!!.toLong() val id = receipt.body()!!.toLong()
@@ -86,7 +85,7 @@ class PipelineSmokeTest {
@Test @Test
fun `replay reopens failed UNSUPPORTED row to PENDING`() { fun `replay reopens failed UNSUPPORTED row to PENDING`() {
val receipt = controller.send(UNSUPPORTED_FLOP_XML) val receipt = controller.send(UNSUPPORTED_XML)
val id = receipt.body()!!.toLong() val id = receipt.body()!!.toLong()
pump.tick() pump.tick()
val stub = ctx.getBean(StubProcState::class.java) val stub = ctx.getBean(StubProcState::class.java)
@@ -106,14 +105,14 @@ class PipelineSmokeTest {
val events = ctx.getBean(StubMsgEvents::class.java) val events = ctx.getBean(StubMsgEvents::class.java)
val port = ctx.getBean(StubDeliveryPort::class.java) val port = ctx.getBean(StubDeliveryPort::class.java)
events.insertAll(listOf( events.insertAll(listOf(
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"old"}"""), MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", stateVersion = 1, payloadJson = """{"FLID":"F1","v":"old"}"""),
MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", payloadJson = """{"FLID":"F1","v":"new"}"""), MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = "F1", stateVersion = 2, payloadJson = """{"FLID":"F1","v":"new"}"""),
)) ))
dispatcher.flushSchd() dispatcher.flushSchd()
assertEquals(1, port.sent.count { it.first == "schd" }) assertEquals(1, port.sent.count { it.topic == "schd" })
val payload = port.sent.first { it.first == "schd" }.second val payload = port.sent.first { it.topic == "schd" }.payload!!
assertTrue(payload.contains("new") && !payload.contains("old")) assertTrue(payload.contains("new") && !payload.contains("old"))
} }
} }
@@ -24,24 +24,24 @@ class JacksonXmlCodecTest {
</META> </META>
<FLOP> <FLOP>
<FLID>121112312</FLID> <FLID>121112312</FLID>
<FFID>CA-CA101-A-12DEC031345-D</FFID> <FFID>CA-CA101-A-12DEC261345-D</FFID>
<GTDT GTNO="1"> <GTDT GTNO="1">
<GATE>G28</GATE> <GATE>G28</GATE>
<PGOT>15DEC031805</PGOT> <PGOT>15DEC261805</PGOT>
<PGCT>15DEC031925</PGCT> <PGCT>15DEC261925</PGCT>
<GOTM>15DEC031825</GOTM> <GOTM>15DEC261825</GOTM>
<GTYP>D</GTYP> <GTYP>D</GTYP>
</GTDT> </GTDT>
<GTDT GTNO="2"> <GTDT GTNO="2">
<GATE>G33</GATE> <GATE>G33</GATE>
<PGOT>15DEC031805</PGOT> <PGOT>15DEC261805</PGOT>
<PGCT>15DEC031925</PGCT> <PGCT>15DEC261925</PGCT>
<GTYP>I</GTYP> <GTYP>I</GTYP>
</GTDT> </GTDT>
<GTDT GTNO="3"> <GTDT GTNO="3">
<GATE>G23</GATE> <GATE>G23</GATE>
<PGOT>15DEC031805</PGOT> <PGOT>15DEC261805</PGOT>
<PGCT>15DEC031925</PGCT> <PGCT>15DEC261925</PGCT>
<GTYP>D</GTYP> <GTYP>D</GTYP>
</GTDT> </GTDT>
</FLOP> </FLOP>
@@ -57,7 +57,7 @@ class JacksonXmlCodecTest {
val body = msg.body as FlopPayload val body = msg.body as FlopPayload
assertEquals("121112312", body.flid) assertEquals("121112312", body.flid)
assertEquals("CA-CA101-A-12DEC031345-D", body.scalars["FFID"]) assertEquals("CA-CA101-A-12DEC261345-D", body.scalars["FFID"])
assertEquals(3, body.collections["GTDT"]!!.size) assertEquals(3, body.collections["GTDT"]!!.size)
assertEquals("G28", body.collections["GTDT"]!![0]["GATE"]) assertEquals("G28", body.collections["GTDT"]!![0]["GATE"])
assertEquals("1", body.collections["GTDT"]!![0]["GTNO"]) assertEquals("1", body.collections["GTDT"]!![0]["GTNO"])
@@ -12,7 +12,7 @@ import java.time.Duration
/** /**
* U03N02)验收:msgx.* 嵌套键「改值—回读」离线化。 * U03N02)验收:msgx.* 嵌套键「改值—回读」离线化。
* 个嵌套类(Pipeline/Schd/Identity/ConsistencyCheck)须标注 @ConfigurationProperties 才会被绑定; * 个嵌套类(Pipeline/Schd/Identity)须标注 @ConfigurationProperties 才会被绑定;
* 未标注时下列覆盖值会静默回落 Kotlin 默认值(本测试即红)。仅注入配置 bean,不触碰仓储/DB。 * 未标注时下列覆盖值会静默回落 Kotlin 默认值(本测试即红)。仅注入配置 bean,不触碰仓储/DB。
* 注:Micronaut Test 5.x 移除 @Property 注解,改以 TestPropertyProvider 注入测试属性(需 PER_CLASS)。 * 注:Micronaut Test 5.x 移除 @Property 注解,改以 TestPropertyProvider 注入测试属性(需 PER_CLASS)。
*/ */
@@ -25,7 +25,6 @@ class PipelinePropsBindingTest : TestPropertyProvider {
"msgx.pipeline.max-attempts" to "9", "msgx.pipeline.max-attempts" to "9",
"msgx.schd.flush-limit" to "321", "msgx.schd.flush-limit" to "321",
"msgx.identity.include-day-boundary" to "true", "msgx.identity.include-day-boundary" to "true",
"msgx.consistency-check.daily-sample-ratio" to "0.05",
) )
@Inject @Inject
@@ -37,12 +36,10 @@ class PipelinePropsBindingTest : TestPropertyProvider {
assertEquals(9, props.pipeline.maxAttempts) assertEquals(9, props.pipeline.maxAttempts)
assertEquals(321, props.schd.flushLimit) assertEquals(321, props.schd.flushLimit)
assertTrue(props.identity.includeDayBoundary) assertTrue(props.identity.includeDayBoundary)
assertEquals(0.05, props.consistencyCheck.dailySampleRatio, 1e-9)
} }
@Test @Test
fun `top-level scalars still bind`() { fun `top-level scalars still bind`() {
assertEquals(PipelineProps.Phase.A, props.phase)
assertEquals("msgexchangeapi", props.serviceName) assertEquals("msgexchangeapi", props.serviceName)
assertFalse(props.registerEureka) // application-test.yml 置 false assertFalse(props.registerEureka) // application-test.yml 置 false
} }
@@ -2,185 +2,131 @@ package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.MutableClock import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.config.PipelineProps import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.stub.StubDeliveryPort
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Instant import java.time.Instant
/** /**
* U06N03+ U08 闭环:schd 出口只走 flushSchd * 投递调度(docs/flight-state.md §7.3):
* 批发送失败 → 整批 attempts 递增 + 指数退避(队首未到期不 claim),达上限整批 DEAD/DLQ * KAFKA_MSG 逐条 FIFOKAFKA_SCHD 唯一出口 flushSchd——同 FLID 未发事件按最新
* 时间经可注入 ClockMutableClock),不依赖真实睡眠 * STATE_VERSION 合并;TOMBSTONE 发 null 值消息;失败退避重试、达上限 DEAD
*/ */
class DispatcherTickTest { class DispatcherTickTest {
private class FakeRepo : MsgEventRepository {
val events = mutableListOf<MsgEvent>()
fun enqueue(e: MsgEvent) { events += e }
override fun insertAll(events: List<MsgEvent>) = events.map { it.eventId ?: 0L }
override fun headUnsent(target: String): MsgEvent? =
events.filter { it.target == target && it.state != EventStatus.SENT && it.state != EventStatus.DEAD }
.minByOrNull { it.eventId ?: Long.MAX_VALUE }
override fun claimBatch(target: String, limit: Int): List<MsgEvent> =
events.filter { it.target == target && it.state == EventStatus.PENDING }
.sortedBy { it.eventId ?: Long.MAX_VALUE }
.take(limit)
override fun markSent(eventId: Long) {
val i = events.indexOfFirst { it.eventId == eventId }
if (i >= 0) events[i] = events[i].copy(state = EventStatus.SENT)
}
override fun markAllSent(eventIds: List<Long>) = eventIds.forEach(::markSent)
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) {
val i = events.indexOfFirst { it.eventId == eventId }
if (i >= 0) events[i] = events[i].copy(state = EventStatus.PENDING, attempts = attempts, nextAttemptAt = nextAttemptAt)
}
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) {
val i = events.indexOfFirst { it.eventId == eventId }
if (i >= 0) events[i] = events[i].copy(state = EventStatus.DEAD, errorClass = errorClass, lastError = lastError,
attempts = attempts ?: events[i].attempts)
}
override fun insertSync(events: List<MsgEvent>) { syncInserted += events }
val syncInserted = mutableListOf<MsgEvent>()
}
private class FakePort : DeliveryPort {
val sent = mutableListOf<Pair<String, String>>()
var failTopic: String? = null
override fun sendKafka(topic: String, payloadJson: String) {
if (failTopic == topic) throw RuntimeException("send-fail:$topic")
sent += topic to payloadJson
}
override fun indexFlightHts(payloadJson: String) = Unit
}
private val clock = MutableClock(MutableClock.BASE) private val clock = MutableClock(MutableClock.BASE)
private fun dispatcher(repo: FakeRepo, port: FakePort, p: PipelineProps = PipelineProps()): Dispatcher = private fun dispatcher(repo: MsgEventRepository, port: DeliveryPort, p: PipelineProps = PipelineProps()): Dispatcher =
Dispatcher(repo, port, p, FailureScheduler(p, clock)) Dispatcher(repo, port, p, FailureScheduler(p, clock))
private fun ev(id: Long, target: String, key: String?, payload: String) = private fun ev(id: Long, target: String, key: String, payload: String, version: Long = 0) =
MsgEvent(eventId = id, target = target, partitionKey = key, payloadJson = payload, state = EventStatus.PENDING) MsgEvent(eventId = id, target = target, partitionKey = key, stateVersion = version, payloadJson = payload)
@Test @Test
fun `schd flushed once via batch path, per-target tick never consumes it`() { fun `per-target tick delivers KAFKA_MSG only and never consumes schd`() {
val repo = FakeRepo() val repo = StubMsgEvents()
val port = FakePort() val port = StubDeliveryPort()
val d = dispatcher(repo, port) val props = PipelineProps().apply { schd.flushPeriod = java.time.Duration.ofHours(1) }
repo.enqueue(ev(1, Targets.KAFKA_MSG, null, """{"msg":1}""")) repo.insertAll(
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":1}""")) listOf(
ev(1, Targets.KAFKA_MSG, "F1", """{"flid":"F1"}"""),
d.tick() // lastFlush=EPOCH → 首 tick 即到 flush 周期 ev(2, Targets.KAFKA_SCHD, "F1", """{"flid":"F1"}""", 1),
),
assertEquals(1, port.sent.count { it.first == "msg" }) )
assertEquals(1, port.sent.count { it.first == "schd" })
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state)
repo.enqueue(ev(4, Targets.KAFKA_SCHD, "F1", """{"FLID":"F1","v":4}"""))
d.tick() // 时钟未推进 → flush 周期未到;逐条循环不得消费 schd
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 4L }.state)
assertEquals(1, port.sent.count { it.first == "schd" })
}
@Test
fun `flushSchd aggregates latest per flight and marks all sent`() {
val repo = FakeRepo()
val port = FakePort()
val d = dispatcher(repo, port)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", "old"))
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", "only"))
repo.enqueue(ev(3, Targets.KAFKA_SCHD, "F1", "new"))
d.flushSchd()
val payload = port.sent.first { it.first == "schd" }.second
assertTrue(payload.contains("new") && payload.contains("only") && !payload.contains("old"))
assertEquals(2, payload.removeSurrounding("[", "]").split(",").size) // 同 FLID 只发最新(矩阵 #7)
assertTrue(repo.events.all { it.state == EventStatus.SENT })
}
@Test
fun `flush failure schedules per-event backoff and retries after due`() {
val repo = FakeRepo()
val port = FakePort().apply { failTopic = "schd" }
val d = dispatcher(repo, port)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
d.flushSchd()
val e1 = repo.events.single()
assertEquals(EventStatus.PENDING, e1.state)
assertEquals(1, e1.attempts)
assertNotNull(e1.nextAttemptAt) // 指数退避落库(attempts=1 → 首档)
assertEquals(0, port.sent.size)
clock.advance(1000) // 退避到期
port.failTopic = null
d.flushSchd()
assertEquals(EventStatus.SENT, repo.events.single().state)
assertEquals(1, port.sent.count { it.first == "schd" })
}
@Test
fun `repeated batch failures backoff grows and whole batch goes DEAD DLQ at limit`() {
val props = PipelineProps().apply { pipeline.maxAttempts = 2 }
val repo = FakeRepo()
val port = FakePort().apply { failTopic = "schd" }
val d = dispatcher(repo, port, props) val d = dispatcher(repo, port, props)
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}""")) d.tick()
repo.enqueue(ev(2, Targets.KAFKA_SCHD, "F2", """{"v":"2"}"""))
d.flushSchd() // 失败#1attempts=1,退避 backoff[1]=1000ms assertEquals(1, port.sent.count { it.topic == "msg" })
val after1 = repo.events.map { it.attempts to it.state } assertEquals(0, port.sent.count { it.topic == "schd" }) // N03schd 唯一出口 flushSchd
assertEquals(listOf(1 to EventStatus.PENDING, 1 to EventStatus.PENDING), after1) }
assertTrue(repo.events.all { it.nextAttemptAt == MutableClock.BASE.plusMillis(1000) })
d.flushSchd() // 时钟未推进 → 队首未到期,不 claim(不推进 lastFlush @Test
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts) fun `flushSchd aggregates latest version per flight and marks sent`() {
val repo = StubMsgEvents()
clock.advance(1000) val port = StubDeliveryPort()
d.flushSchd() // 失败#2attempts=2 == maxAttempts → 整批 DEAD repo.insertAll(
assertTrue(repo.events.all { it.state == EventStatus.DEAD && it.errorClass == ErrorClass.EXHAUSTED }) listOf(
assertTrue(repo.events.all { it.attempts == 2 }) ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"old"}""", 1),
assertEquals(0, port.sent.size) ev(2, Targets.KAFKA_SCHD, "F1", """{"v":"new"}""", 2),
ev(3, Targets.KAFKA_SCHD, "F2", """{"v":"f2"}""", 1),
// DEAD 行不再参与 claim:空批 → 无新发送、无异常 ),
)
val d = dispatcher(repo, port)
d.flushSchd() d.flushSchd()
assertEquals(0, port.sent.size)
val schd = port.sent.filter { it.topic == "schd" }
assertEquals(2, schd.size)
assertEquals(1, schd.count { it.key == "F1" && it.payload!!.contains("new") && !it.payload.contains("old") })
assertEquals(0, repo.rows.values.count { it.state.name == "PENDING" })
}
@Test
fun `tombstone is delivered as null value message`() {
val repo = StubMsgEvents()
val port = StubDeliveryPort()
repo.insertAll(
listOf(
MsgEvent(
eventId = 1, target = Targets.KAFKA_SCHD, partitionKey = "F1",
eventType = EventType.TOMBSTONE, stateVersion = 3, payloadJson = """{"flid":"F1","deleted":true}""",
),
),
)
val d = dispatcher(repo, port)
d.flushSchd()
assertEquals(1, port.tombstones.size)
assertEquals("F1", port.tombstones.single().key) // 整态键缺失表示删除旧值(§7.3)
assertNull(port.tombstones.single().payload)
}
@Test
fun `schd send failure schedules backoff and goes DEAD at limit`() {
val repo = StubMsgEvents()
val p = PipelineProps()
val d = dispatcher(repo, FailingSchdPort(), p)
repo.insertAll(listOf(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}""", 1)))
d.flushSchd()
assertEquals(1, repo.rows[1]!!.attempts)
repeat(p.pipeline.maxAttempts - 1) {
clock.advance(p.pipeline.backoffFor(repo.rows[1]!!.attempts) + 1)
d.flushSchd()
}
val final = repo.rows[1]!!
assertTrue(final.attempts >= p.pipeline.maxAttempts || final.state.name == "DEAD")
} }
@Test @Test
fun `KAFKA msg delivery unaffected while schd batch is retrying`() { fun `KAFKA msg delivery unaffected while schd batch is retrying`() {
val props = PipelineProps().apply { pipeline.maxAttempts = 2 } val repo = StubMsgEvents()
val repo = FakeRepo() val port = StubDeliveryPort()
val port = FakePort().apply { failTopic = "schd" } val d = dispatcher(repo, port)
val d = dispatcher(repo, port, props) repo.insertAll(listOf(ev(1, Targets.KAFKA_MSG, "F9", """{"flid":"F9"}""")))
repo.enqueue(ev(1, Targets.KAFKA_SCHD, "F1", """{"v":"1"}"""))
repo.enqueue(ev(2, Targets.KAFKA_MSG, null, """{"m":1}"""))
d.tick() d.tick()
assertEquals(1, port.sent.count { it.topic == "msg" && it.key == "F9" })
assertEquals(EventStatus.SENT, repo.events.first { it.eventId == 2L }.state) // msg 正常 }
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts) // schd 进退避
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 1L }.state)
} }
/** schd 发送恒失败的端口(退避/终态闭环验证用)。 */
private class FailingSchdPort : DeliveryPort {
var calls = 0
override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) {
calls++
throw IllegalStateException("broker-down")
}
override fun sendKafkaNull(topic: String, key: String) {
throw IllegalStateException("broker-down")
}
} }
@@ -1,56 +0,0 @@
package com.gzzn.omms.msgexchange.delivery
import com.gzzn.omms.msgexchange.domain.EventStatus
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.Targets
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/**
* ACMA-8 流程 3 / 兼容矩阵 #7(FIX):同一 FLID 多次变更只发最新一态;
* v4 显式“组内取 EVENT_ID 最大”,不依赖集合遍历序。
*/
class SchdAggregationTest {
private fun ev(flid: String, eventId: Long, payload: String) = MsgEvent(
eventId = eventId,
target = Targets.KAFKA_SCHD,
partitionKey = flid,
payloadJson = payload,
state = EventStatus.PENDING,
)
@Test
fun `same flight collapsed to max EVENT_ID`() {
val pending = listOf(
ev("F1", 1, """{"FLID":"F1","v":"old"}"""),
ev("F2", 2, """{"FLID":"F2","v":"only"}"""),
ev("F1", 3, """{"FLID":"F1","v":"new"}"""),
)
val latest = SchdAggregation.latestPerFlight(pending)
assertEquals(2, latest.size)
assertEquals("""{"FLID":"F1","v":"new"}""", latest.first { it.contains("F1") })
assertEquals("""{"FLID":"F2","v":"only"}""", latest.first { it.contains("F2") })
}
@Test
fun `order-independent grouping yields same latest per flight`() {
val a = listOf(
ev("F1", 1, "old"),
ev("F1", 2, "new"),
)
val b = a.reversed()
assertEquals(
SchdAggregation.latestPerFlight(a).toSet(),
SchdAggregation.latestPerFlight(b).toSet(),
)
}
@Test
fun `wire shape is FLTR JSON array`() {
val payload = SchdAggregation
.latestPerFlight(listOf(ev("F1", 1, "a"), ev("F2", 2, "b")))
.joinToString(",", "[", "]")
assertEquals("[a,b]", payload)
}
}
@@ -0,0 +1,47 @@
package com.gzzn.omms.msgexchange.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
/**
* 运营日计算(docs/flight-state.md §3.5):SODTddMMMyyHHmm+ 机场时区 + 业务切日边界。
*/
class OperationDayTest {
private val zone: ZoneId = ZoneId.of("Asia/Shanghai")
@Test
fun `computes operation day from SODT with zero cutoff`() {
val calc = OperationDayCalculator(zone, cutoffHour = 0)
assertEquals(LocalDate.of(2026, 12, 15), calc.compute("15DEC261723"))
}
@Test
fun `time before cutoff rolls back to previous operation day`() {
val calc = OperationDayCalculator(zone, cutoffHour = 4)
// 04:00 之前 → 前一运营日
assertEquals(LocalDate.of(2026, 12, 14), calc.compute("15DEC260230"))
// 04:00 整点起归属当日
assertEquals(LocalDate.of(2026, 12, 15), calc.compute("15DEC260400"))
}
@Test
fun `invalid or missing SODT returns null`() {
val calc = OperationDayCalculator(zone, 0)
assertNull(calc.compute(null))
assertNull(calc.compute(""))
assertNull(calc.compute("garbage"))
assertNull(calc.compute("99XXC12345"))
}
@Test
fun `cutoff outside 0 to 23 is clamped to 23`() {
val calc = OperationDayCalculator(zone, cutoffHour = 99)
// 越界收敛为 23:当日 00:00 早于 23 点 → 归属前一运营日
assertEquals(LocalDate.of(2026, 12, 14), calc.compute("15DEC260000"))
}
}
@@ -1,202 +1,113 @@
package com.gzzn.omms.msgexchange.domain.flight package com.gzzn.omms.msgexchange.domain.flight
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
/**
* 航班状态引擎(docs/flight-state.md §3.3/§5.2/§5.4/§6.1)不变量:
* 快照整体替换(缺失=清除)、DELETED 不被 SCHD 恢复、FLOP 增量保留缺失字段、
* §5.2 五项校验整包拒绝语义。
*/
class FlightStateEngineTest { class FlightStateEngineTest {
private val opDay = OperationDayCalculator(ZoneId.of("Asia/Shanghai"), 0)
private val day = LocalDate.of(2026, 12, 15)
@Test @Test
fun `scalar unchanged does not modify existing value`() { fun `snapshot replaces scalars and clears absent ones`() {
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 1L, "m1") val current = FlightSnapshot(
val next = FlightStateEngine.apply( "121", day, FlightState.ACTIVE, 5,
scalars = mapOf("FLNO" to "CA001", "REMC" to "old-note"),
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
)
val next = FlightStateEngine.snapshotState(
current, current,
FlightFieldCommands("F1", scalars = emptyMap()), ScheduleRecord("121", scalars = mapOf("FLNO" to "CA002")),
"m2", operationDay = day,
bumpVersion = true, keepDeleted = false,
) )
assertEquals("CA100", next.scalars["FLNO"])
assertEquals(2L, next.stateVersion) assertEquals("CA002", next.scalars["FLNO"])
assertFalse(next.scalars.containsKey("REMC")) // 缺失 = Clear(§5.4
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"]) // 缺失 = Replace 空集
assertEquals(6, next.stateVersion) // 每次成功写入 +1(§4
assertEquals(FlightState.ACTIVE, next.state)
} }
@Test @Test
fun `collection replace preserves order and source sequence`() { fun `snapshot keeps DELETED state and flags revive conflict by caller`() {
val items = listOf( val current = FlightSnapshot("121", day, FlightState.DELETED, 3, mapOf(), emptyMap())
mapOf("GTNO" to "1", "GATE" to "A1"), val next = FlightStateEngine.snapshotState(
mapOf("GTNO" to "3", "GATE" to "B2"), current, ScheduleRecord("121", mapOf("FLNO" to "CA001")), day, keepDeleted = true,
mapOf("GTNO" to "7", "GATE" to "C3"),
) )
val next = FlightStateEngine.apply( assertEquals(FlightState.DELETED, next.state) // §5.1 步骤 6:普通 SCHD 不恢复
null, assertEquals(4, next.stateVersion)
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
"m1",
bumpVersion = true,
)
assertEquals(3, next.collections["GTDT"]!!.size)
assertEquals("3", next.collections["GTDT"]!![1]["GTNO"])
assertEquals("C3", next.collections["GTDT"]!![2]["GATE"])
} }
@Test @Test
fun `collection clear removes prior entries`() { fun `flop merge retains absent scalars and collections`() {
val current = FlightNextState( val current = FlightSnapshot(
"F1", "121", day, FlightState.ACTIVE, 1,
emptyMap(), scalars = mapOf("FLNO" to "CA001", "ESTT" to "15DEC261807"),
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))), collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
1L,
"m1",
) )
val next = FlightStateEngine.apply( val next = FlightStateEngine.mergedState(
current, current,
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Clear)), MergeChange("121", scalars = mapOf("ESTT" to "15DEC261900")),
"m2",
bumpVersion = true,
) )
assertFalse(next.collections.containsKey("GTDT"))
assertEquals("15DEC261900", next.scalars["ESTT"])
assertEquals("CA001", next.scalars["FLNO"]) // 缺失 = 保留(§6.1
assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"])
assertEquals(2, next.stateVersion)
} }
@Test @Test
fun `same gate number different attributes both retained on replace`() { fun `validation rejects RECS mismatch and duplicate flid and day outside scope`() {
val items = listOf( val ok = FlightStateEngine.validateMessage(
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "D"), 1, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
mapOf("GTNO" to "1", "GATE" to "A1", "GTYP" to "I"),
) )
val next = FlightStateEngine.apply( assertTrue(ok is SnapshotValidation.Ok)
null,
FlightFieldCommands("F1", collections = mapOf("GTDT" to CollectionCommand.Replace(items))),
"m1",
bumpVersion = true,
)
assertEquals("D", next.collections["GTDT"]!![0]["GTYP"])
assertEquals("I", next.collections["GTDT"]!![1]["GTYP"])
}
@Test val recsMismatch = FlightStateEngine.validateMessage(
fun `incremental apply bumps state version from persisted value`() { 2, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
val current = FlightNextState("F1", mapOf("FLNO" to "CA100"), emptyMap(), 5L, "m1") ) as SnapshotValidation.Invalid
val next = FlightStateEngine.apply( assertTrue(recsMismatch.flags.contains(SnapshotFlag.RECS_DROP))
current,
FlightFieldCommands("F1", scalars = mapOf("STAT" to ScalarCommand.Set("DEP"))),
"m2",
bumpVersion = true,
)
assertEquals(6L, next.stateVersion)
}
@Test val duplicate = FlightStateEngine.validateMessage(
fun `apply by source sequence updates single row`() { 2,
val current = FlightNextState( listOf(
"F1", ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
emptyMap(), ScheduleRecord("121", mapOf("SODT" to "15DEC261723")),
mapOf("GTDT" to listOf(mapOf("GTNO" to "2", "GATE" to "OLD"))),
1L,
"m1",
)
val next = FlightStateEngine.apply(
current,
FlightFieldCommands(
"F1",
collections = mapOf(
"GTDT" to CollectionCommand.Apply(mapOf("GTNO" to "2", "GATE" to "NEW"), "2"),
), ),
), day, day, opDay,
"m2",
bumpVersion = true,
) )
assertEquals("NEW", next.collections["GTDT"]!![0]["GATE"]) assertTrue(duplicate is SnapshotValidation.Invalid)
val outOfScope = FlightStateEngine.validateMessage(
1, listOf(ScheduleRecord("121", mapOf("SODT" to "20DEC261723"))), day, day, opDay,
) as SnapshotValidation.Invalid
assertTrue(outOfScope.flags.contains(SnapshotFlag.DAY_MISMATCH)) // §5.2 覆盖范围校验
val empty = FlightStateEngine.validateMessage(0, emptyList(), day, day, opDay) as SnapshotValidation.Ok
assertTrue(empty.flags.contains(SnapshotFlag.EMPTY))
} }
@Test @Test
fun `zero sequence marker alone translates to explicit clear`() { fun `validation rejects non numeric or oversized flid`() {
val commands = FlightStateEngine.commandsFromFields( val bad = FlightStateEngine.validateMessage(
"F1", 1, listOf(ScheduleRecord("ABC", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
mapOf("GTDT" to """[{"GTNO":"0"}]"""),
) )
assertEquals(CollectionCommand.Clear, commands.collections["GTDT"]) assertTrue(bad is SnapshotValidation.Invalid)
} val tooLong = FlightStateEngine.validateMessage(
1, listOf(ScheduleRecord("1234567890123", mapOf("SODT" to "15DEC261723"))), day, day, opDay,
@Test
fun `zero sequence marker mixed with regular items fails fast instead of guessing`() {
val error = runCatching {
FlightStateEngine.commandsFromFields(
"F1",
mapOf("GTDT" to """[{"GTNO":"1","GATE":"A1"},{"GTNO":"0"}]"""),
) )
}.exceptionOrNull() assertTrue(tooLong is SnapshotValidation.Invalid) // SIS §3.16.2 Number(1-12)
assertTrue(error is IllegalArgumentException)
}
@Test
fun `empty array replaces collection with empty set keeping key semantics`() {
val current = FlightNextState(
"F1",
emptyMap(),
mapOf("GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "A1"))),
1L,
"m1",
)
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("GTDT" to "[]"))
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"])
}
@Test
fun `apply on DELY fails fast because protocol defines no sequence attribute`() {
val current = FlightNextState(
"F1",
emptyMap(),
mapOf("DELY" to listOf(mapOf("CODE" to "YY"))),
1L,
"m1",
)
val error = runCatching {
FlightStateEngine.apply(
current,
FlightFieldCommands(
"F1",
collections = mapOf("DELY" to CollectionCommand.Apply(mapOf("CODE" to "ZZ"), "1")),
),
"m2",
bumpVersion = true,
)
}.exceptionOrNull()
assertTrue(error is IllegalArgumentException)
}
@Test
fun `exception null payload translates to explicit clear and apply records cleared key`() {
val current = FlightNextState(
"F1",
mapOf("FLNO" to "CA100", "FRET" to """{"REID":"R1","RSN":"diverted"}"""),
emptyMap(),
3L,
"m1",
)
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to "null"))
assertEquals(ScalarCommand.Clear, commands.scalars["FRET"])
val next = FlightStateEngine.apply(current, commands, "m2", bumpVersion = true)
assertFalse(next.scalars.containsKey("FRET"))
assertTrue(next.clearedKeys.contains("FRET"))
assertEquals("CA100", next.scalars["FLNO"])
// 非空载荷仍为 Set,且清空后的下一跳 Set 恢复键(clearedKeys 不跨消息携带)
val restored = FlightStateEngine.apply(
next,
FlightStateEngine.commandsFromFields("F1", mapOf("FRET" to """{"REID":"R2"}""")),
"m3",
bumpVersion = true,
)
assertEquals("""{"REID":"R2"}""", restored.scalars["FRET"])
assertFalse(restored.clearedKeys.contains("FRET"))
}
@Test
fun `exception clear accepts null literal empty object and empty string`() {
for (payload in listOf("null", "{}", "")) {
val commands = FlightStateEngine.commandsFromFields("F1", mapOf("FDIV" to payload))
assertEquals(ScalarCommand.Clear, commands.scalars["FDIV"], "payload=[$payload] must be Clear")
}
} }
} }
@@ -12,14 +12,16 @@ import org.junit.jupiter.api.Test
class HealthIndicatorsTest { class HealthIndicatorsTest {
private class FakePort(private val pingResult: Boolean) : DeliveryPort { private class FakePort(private val pingResult: Boolean) : DeliveryPort {
override fun sendKafka(topic: String, payloadJson: String) = Unit override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun indexFlightHts(payloadJson: String) = Unit override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaNull(topic: String, key: String) = Unit
override fun ping(): Boolean = pingResult override fun ping(): Boolean = pingResult
} }
private class ThrowingPort : DeliveryPort { private class ThrowingPort : DeliveryPort {
override fun sendKafka(topic: String, payloadJson: String) = Unit override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit
override fun indexFlightHts(payloadJson: String) = Unit override fun sendKafkaSchd(topic: String, key: String, payloadJson: String) = Unit
override fun sendKafkaNull(topic: String, key: String) = Unit
override fun ping(): Boolean = throw RuntimeException("metadata fetch failed") override fun ping(): Boolean = throw RuntimeException("metadata fetch failed")
} }
@@ -1,28 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence
import com.fasterxml.jackson.databind.ObjectMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FlightFieldsJsonTest {
private val mapper = ObjectMapper()
@Test
fun `collection JSON is emitted as a native array`() {
val json = FlightFieldsJson.toJson(
mapOf("FLID" to "F1", "GTDT" to """[{"GTNO":"1","GATE":"A1"}]"""),
)
val root = mapper.readTree(json)
assertTrue(root["GTDT"].isArray)
assertEquals("A1", root["GTDT"][0]["GATE"].asText())
}
@Test
fun `malformed collection text stays a string instead of breaking delivery`() {
val root = mapper.readTree(FlightFieldsJson.toJson(mapOf("GTDT" to "legacy-value")))
assertTrue(root["GTDT"].isTextual)
assertEquals("legacy-value", root["GTDT"].asText())
}
}
@@ -1,815 +0,0 @@
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import com.gzzn.omms.msgexchange.support.PgTestSupport
import com.zaxxer.hikari.HikariDataSource
import java.sql.DriverManager
import java.time.Instant
import java.time.ZoneId
import java.util.TimeZone
/**
* ACM2-28 FS6 + ACM2-29PostgreSQL 真实方言集成测试(JdbcFlightSchdRepository
* 验证:
* 1. DNLD 快照批处理写入(JDBC batch200 批次);
* 2. 增量 Upsert FDAY 保留策略(ON CONFLICT 保留原代,新插置 NULL);
* 3. 域化差删(按 FDAY 严格隔离);
* 4. SCHD_GEN SQL CAS(防并发断言);
* 5. 单事务原子回滚(崩溃无残留);
* 6. ES 历史清场 deleteByFlids 幂等删除;
* 7. ACM2-29 零子表:集合平铺槽位列写读(集合级全量替换/序号 0 清除/里程碑与航路紧凑列),
* 读侧视图重建 legacy 同构集合键;
* 8. I5PIPELINE_LOCK 行级单写者互斥与提交释放。
*/
class FlightSchdJdbcPgTest {
private lateinit var ds: HikariDataSource
private lateinit var repo: JdbcFlightSchdRepository
private val mapper = com.fasterxml.jackson.databind.ObjectMapper()
@BeforeEach
fun setUp() {
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
ds = HikariDataSource().apply {
jdbcUrl = PgTestSupport.jdbcUrl
username = PgTestSupport.user
password = PgTestSupport.password
driverClassName = "org.postgresql.Driver"
maximumPoolSize = 2
}
// 幂等前置迁移:全新容器(或库缺失 schema)时补齐 V1.0.0→V1.3.0
org.flywaydb.core.Flyway.configure()
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
.locations("classpath:db/migration")
.load()
.migrate()
repo = JdbcFlightSchdRepository(ds)
cleanup()
}
@AfterEach
fun tearDown() {
if (::ds.isInitialized) {
cleanup()
ds.close()
}
}
private fun cleanup() {
try {
val testFlids = ds.query(
"SELECT flid FROM flight_schd WHERE flid LIKE 'TEST_%'",
{},
) { rs -> rs.getString("flid") }
if (testFlids.isNotEmpty()) {
com.gzzn.omms.msgexchange.infra.persistence.jdbc.FlightDetailTables.deleteForFlids(ds, testFlids)
}
ds.update("DELETE FROM flight_schd WHERE flid LIKE 'TEST_%'") {}
ds.update("DELETE FROM schd_gen WHERE fday >= '2026-09-01'") {}
} catch (_: Exception) {
}
}
/** v2 写路径便捷构造:字段集 → 快照 nextState(版本顺序推进)。 */
private fun snapshotStates(
day: String,
flights: List<Pair<String, Map<String, String>>>,
version: Long = 1L,
messageId: String = "msg-test",
) = flights.map { (flid, fields) ->
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.apply(
null,
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.commandsFromFields(flid, fields),
messageId,
bumpVersion = false,
).copy(stateVersion = version)
}
@Test
fun `day diff preserves all details of migrated and unowned flights`() {
val day = "2026-09-07"
val otherDay = "2026-09-08"
val fields = mapOf("GTDT" to """[{"GTNO":"3","GATE":"G23"}]""")
val removed = "TEST_DIFF_REMOVED"
val moved = "TEST_DIFF_MOVED"
val unowned = "TEST_DIFF_ADFT"
repo.persistNextStates(day, snapshotStates(day, listOf(removed to fields)), snapshotReplace = true)
repo.persistNextStates(otherDay, snapshotStates(otherDay, listOf(moved to fields)), snapshotReplace = true)
repo.persistNextStates(null, snapshotStates(day, listOf(unowned to fields)), snapshotReplace = false)
val beforeMoved = repo.findByFlid(moved)
val beforeUnowned = repo.findByFlid(unowned)
JdbcPipelineTransactionManager(ds).inTransaction {
assertEquals(1, repo.deleteDiffByDay(day, listOf(removed, moved, unowned)))
}
assertNull(repo.findByFlid(removed))
assertTrue(FlightDetailTables.loadCollections(ds, removed).isEmpty())
assertEquals(beforeMoved, repo.findByFlid(moved))
assertEquals(beforeUnowned, repo.findByFlid(unowned))
}
@Test
fun `PG dialect - snapshot batch upsert, find, and domain diff delete`() {
val day = "2026-09-07"
// 构造 250 条记录跨越 JDBC batch 200 门限
val flights = (1..250).map { i ->
"TEST_FL_$i" to mapOf("FLID" to "TEST_FL_$i", "FLNO" to "CA$i")
}
repo.persistNextStates(day, snapshotStates(day, flights), snapshotReplace = true)
// 验证全量写入成功
val byDay = repo.findByDay(day)
assertEquals(250, byDay.size)
// 点查测试
val f1 = repo.findByFlid("TEST_FL_1")
assertNotNull(f1)
assertEquals("CA1", f1!!["FLNO"])
// 多键点查测试(分批查)
val subset = (1..50).map { "TEST_FL_$it" }
val map = repo.findByFlids(subset)
assertEquals(50, map.size)
// 域内差删:删除 1..50
val deleted = repo.deleteDiffByDay(day, subset)
assertEquals(50, deleted)
assertEquals(200, repo.findByDay(day).size)
assertNull(repo.findByFlid("TEST_FL_1"))
}
@Test
fun `PG dialect - incremental upsert preserves existing FDAY and sets NULL for new`() {
val day = "2026-09-07"
// 1. 快照写入 REG_01FDAY 为 2026-09-07
repo.persistNextStates(
day,
snapshotStates(day, listOf("TEST_REG_01" to mapOf("FLID" to "TEST_REG_01", "REMC" to "1"))),
snapshotReplace = true,
)
// 2. 增量更新 REG_01(已有行)与 ADFT_01(新行)
repo.persistNextStates(
null,
listOf("TEST_REG_01" to "2", "TEST_ADFT_01" to "1").map { (flid, remc) ->
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.apply(
null,
com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.commandsFromFields(
flid, mapOf("FLID" to flid, "REMC" to remc),
),
"msg-inc-01",
bumpVersion = true,
)
},
snapshotReplace = false,
)
// 3. 验证 REG_01 内容更新但 FDAY 依然保留为 2026-09-07
val reg01 = ds.queryOne("SELECT fday, remc FROM flight_schd WHERE flid = 'TEST_REG_01'", {}) { rs ->
rs.getDate("fday")?.toString() to rs.getString("remc")
}
assertNotNull(reg01)
assertEquals("2026-09-07", reg01!!.first)
assertEquals("2", reg01.second)
// 4. 验证 ADFT_01 新插入行 FDAY 恒为 NULL
val adft01 = ds.queryOne("SELECT fday, remc FROM flight_schd WHERE flid = 'TEST_ADFT_01'", {}) { rs ->
rs.getDate("fday")?.toString() to rs.getString("remc")
}
assertNotNull(adft01)
assertNull(adft01!!.first)
// 5. 执行 2026-09-07 的差删,ADFT_01 绝不被误删
val deleted = repo.deleteDiffByDay(day, listOf("TEST_ADFT_01"))
assertEquals(0, deleted)
assertNotNull(repo.findByFlid("TEST_ADFT_01"))
}
@Test
fun `resource collections replace as a whole and zero sequence clears them losslessly`() {
val day = "2026-09-07"
val flid = "TEST_RESOURCE"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
// 快照:两个登机门 → 明细表两行(保序保 GTNO)
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"1","GATE":"A1"},{"GTNO":"2","GATE":"A2"}]""")),
"msg-res-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
val gates = ds.query(
"SELECT ordinal, source_seq, gate FROM flight_gate WHERE flid = ? ORDER BY ordinal",
{ ps -> ps.setString(1, flid) },
) { rs -> Triple(rs.getInt("ordinal"), rs.getString("source_seq"), rs.getString("gate")) }
assertEquals(listOf(Triple(1, "1", "A1"), Triple(2, "2", "A2")), gates)
// 读侧明细权威视图:两个登机门完整读回
val view = repo.findByFlid(flid)!!["GTDT"]?.let { mapper.readTree(it) }
assertEquals(2, view!!.size())
assertEquals("A2", view[1]["GATE"].asText())
assertEquals("2", view[1]["GTNO"].asText())
// 增量 = 集合级全量替换:1 个登机门(非连续 GTNO=3 保真)
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"3","GATE":"B1"}]""")),
"msg-res-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
val replaced = mapper.readTree(repo.findByFlid(flid)!!["GTDT"])
assertEquals(1, replaced.size())
assertEquals("B1", replaced[0]["GATE"].asText())
assertEquals("3", replaced[0]["GTNO"].asText())
// 序号属性 "0" = 显式删除标记 → 集合清除,明细行删除,视图无 GTDT 键
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"0"}]""")),
"msg-res-3", bumpVersion = true,
),
),
snapshotReplace = false,
)
assertNull(repo.findByFlid(flid)!!["GTDT"])
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
}
@Test
fun `milestone delay route and exception collections persist losslessly in v2 storage`() {
val day = "2026-09-07"
val flid = "TEST_SCALAR"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(
flid,
mapOf(
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200","REMC":"Flight Delayed"}]""",
"ROUT" to """[{"RTNO":"1","APCD":"ORD","SCAT":"07SEP261125","SCDT":"07SEP261315"},{"RTNO":"2","APCD":"MSP"}]""",
"ABTM" to """[{"ASNO":"1","ABDG":"B01","ABOP":"A","AOTM":"07SEP261700"}]""",
"FDIV" to """{"DDES":"PEK","DDIR":"TO","REMC":"weather"}""",
),
),
"msg-scalar-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
// 库内明细表保序保源序号:延误、航路(route_kind 区分)、靠桥操作
val delay = ds.queryOne(
"SELECT source_seq, code, strt, remc FROM flight_delay WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("source_seq"), rs.getString("code"), rs.getString("strt"), rs.getString("remc")) }
assertEquals(listOf(null, "YY", "07SEP261605", "Flight Delayed"), delay)
val routes = ds.query(
"SELECT route_kind, source_seq, apcd, scdt FROM flight_route_point WHERE flid = ? ORDER BY ordinal",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("route_kind"), rs.getString("source_seq"), rs.getString("apcd"), rs.getString("scdt")) }
assertEquals(
listOf(
listOf("ROUT", "1", "ORD", "07SEP261315"),
listOf("ROUT", "2", "MSP", null),
),
routes,
)
val bridge = ds.queryOne(
"SELECT source_seq, abdg, abop, aotm FROM flight_bridge_op WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("source_seq"), rs.getString("abdg"), rs.getString("abop"), rs.getString("aotm")) }
assertEquals(listOf("1", "B01", "A", "07SEP261700"), bridge)
// 异常对象 = 主表前缀标量列;读侧视图重建 FDIV 键
val fdiv = ds.queryOne(
"SELECT fdiv_ddes, fdiv_ddir, fdiv_remc FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("fdiv_ddes"), rs.getString("fdiv_ddir"), rs.getString("fdiv_remc")) }
assertEquals(listOf("PEK", "TO", "weather"), fdiv)
val fields = repo.findByFlid(flid)!!
assertEquals("YY", mapper.readTree(fields["DELY"])[0]["CODE"].asText())
assertEquals(2, mapper.readTree(fields["ROUT"]).size())
assertEquals("A", mapper.readTree(fields["ABTM"])[0]["ABOP"].asText())
assertEquals("PEK", mapper.readTree(fields["FDIV"])["DDES"].asText())
// 增量:空延误数组 = 集合替换为空 → 明细行删除;航路整集合替换
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(
flid,
mapOf(
"DELY" to "[]",
"ROUT" to """[{"RTNO":"1","APCD":"CTU"}]""",
),
),
"msg-scalar-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertNull(repo.findByFlid(flid)!!["DELY"])
val replacedRoute = mapper.readTree(repo.findByFlid(flid)!!["ROUT"])
assertEquals(1, replacedRoute.size())
assertEquals("CTU", replacedRoute[0]["APCD"].asText())
}
@Test
fun `PIPELINE_LOCK row lock guards the writer transaction and releases on commit`() {
val txManager = JdbcPipelineTransactionManager(ds)
// 持锁事务内:另一连接 NOWAIT 获取同行锁必须立即失败(互斥生效)
assertEquals("held", txManager.inTransaction {
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { other ->
other.autoCommit = false
try {
other.prepareStatement(
"SELECT lock_key FROM pipeline_lock WHERE lock_key = 'FLIGHT_SCHD_WRITER' FOR UPDATE NOWAIT",
).use { ps -> ps.executeQuery() }
throw AssertionError("expected PIPELINE_LOCK contention")
} catch (_: java.sql.SQLException) {
// 期望:行锁已被写者事务持有
} finally {
other.rollback()
}
}
"held"
})
// 提交后锁释放:可再次进入,且种子行完整
assertEquals("again", txManager.inTransaction { "again" })
val owner = ds.queryOne(
"SELECT owner_info FROM pipeline_lock WHERE lock_key = 'FLIGHT_SCHD_WRITER'",
{},
) { rs -> rs.getString("owner_info") }
assertNull(owner)
}
@Test
fun `PG dialect - SCHD_GEN SQL CAS version advance and conflict rejection`() {
val day = "2026-09-07"
// 初始 CAS: expected = 0,版本推进至 1
val gen1 = FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_A", "TEST_B"))
val ok1 = repo.putGenIfVersion(day, 0L, gen1)
assertTrue(ok1)
val stored1 = repo.getGen(day)
assertNotNull(stored1)
assertEquals(1L, stored1!!.version)
assertEquals(setOf("TEST_A", "TEST_B"), stored1.flids)
// 成功 CAS: expected = 1,版本推进至 2
val gen2 = FlightSchdRepository.GenMeta(day, 2L, setOf("TEST_A", "TEST_C"))
val ok2 = repo.putGenIfVersion(day, 1L, gen2)
assertTrue(ok2)
assertEquals(2L, repo.getGen(day)!!.version)
// 冲突 CAS: expected 依然传 1(已过期的旧版本),应被拒并返回 false
val genConflict = FlightSchdRepository.GenMeta(day, 3L, setOf("TEST_CONFLICT"))
val conflictResult = repo.putGenIfVersion(day, 1L, genConflict)
assertFalse(conflictResult)
// 确认版本未被非法篡改,仍为 2
assertEquals(2L, repo.getGen(day)!!.version)
}
@Test
fun `PG dialect - Transaction rollback leaves zero partial rows on failure`() {
val day = "2026-09-07"
try {
ds.withTransaction {
repo.persistNextStates(
day,
snapshotStates(day, listOf("TEST_TX_01" to mapOf("REMC" to "tx"))),
snapshotReplace = true,
)
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TX_01")))
// 模拟事务内部抛出异常
throw IllegalStateException("forced-abort-for-rollback-test")
}
} catch (_: IllegalStateException) {
// 异常捕获
}
// 断言:由于事务原子回滚,数据库中零残留
assertNull(repo.findByFlid("TEST_TX_01"))
assertNull(repo.getGen(day))
}
@Test
fun `PG dialect - ES history sweep deleteByFlids idempotent batch execution`() {
val day = "2026-09-07"
val flids = (1..50).map { "TEST_SWEEP_$it" }
repo.persistNextStates(
day,
snapshotStates(day, flids.map { it to mapOf("FLID" to it, "GTDT" to """[{"GTNO":"1","GATE":"A"}]""") }),
snapshotReplace = true,
)
assertEquals(50, repo.findByFlids(flids).size)
assertEquals(50, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid LIKE 'TEST_SWEEP_%'", {}) { rs -> rs.getInt(1) }.first())
// 第一次分批删除:主表与明细级联删除
val deleted1 = repo.deleteByFlids(flids.toSet())
assertEquals(50, deleted1)
assertEquals(0, repo.findByFlids(flids).size)
assertEquals(0, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid LIKE 'TEST_SWEEP_%'", {}) { rs -> rs.getInt(1) }.first())
// 重放删除:安全返回 0,无副作用
val deleted2 = repo.deleteByFlids(flids.toSet())
assertEquals(0, deleted2)
}
@Test
fun `PG dialect - non-UTC JVM default timezone preserves exact Instant timestamps without drift`() {
val originalTz = TimeZone.getDefault()
try {
// 1. 设置 JVM 默认时区为东京 (UTC+09:00)
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val day = "2026-09-07"
val fixedInstant = Instant.parse("2026-09-07T08:30:15.123456Z")
repo.persistNextStates(
day,
snapshotStates(day, listOf("TEST_TZ_01" to mapOf("FLID" to "TEST_TZ_01"))),
snapshotReplace = true,
now = fixedInstant,
)
repo.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_TZ_01")), fixedInstant)
// 东京时区下回读
val genTokyo = repo.getGen(day)
assertNotNull(genTokyo)
assertEquals(fixedInstant, genTokyo!!.updatedAt)
// 2. 切换 JVM 默认时区为纽约 (UTC-05:00)
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
// 纽约时区下再次回读同一条记录
val genNy = repo.getGen(day)
assertNotNull(genNy)
// 断言:TIMESTAMPTZ 映射到 Instant 完全相等,绝无 JVM 时区偏移
assertEquals(fixedInstant, genNy!!.updatedAt)
val flightNy = ds.queryOne("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_TZ_01'", {}) { rs ->
rs.getInstant("created_at") to rs.getInstant("updated_at")
}
assertNotNull(flightNy)
assertEquals(fixedInstant, flightNy!!.first)
assertEquals(fixedInstant, flightNy.second)
} finally {
TimeZone.setDefault(originalTz)
}
}
@Test
fun `PG dialect - non-UTC database session timezone produces zero Instant drift across sessions`() {
val day = "2026-09-07"
val writeInstant = Instant.parse("2026-09-07T14:45:00.654321Z")
// 1. 在会话 1 中显式设置会话时区为 'Asia/Shanghai' (+08:00) 并写入数据
ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'Asia/Shanghai'") }
conn.prepareStatement(
"""
INSERT INTO flight_schd (flid, fday, remc, created_at, updated_at)
VALUES ('TEST_SESS_01', '2026-09-07', 'shanghai', ?, ?)
""".trimIndent(),
).use { ps ->
ps.setTimestamp(1, writeInstant.toSqlTimestamp())
ps.setTimestamp(2, writeInstant.toSqlTimestamp())
ps.executeUpdate()
}
}
// 2. 在会话 2 中显式设置会话时区为 'America/Chicago' (-05:00) 并回读数据
val instantChicago = ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'America/Chicago'") }
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
ps.executeQuery().use { rs ->
assertTrue(rs.next())
rs.getInstant("created_at")
}
}
}
// 3. 在会话 3 中显式设置会话时区为 'UTC' 并回读数据
val instantUtc = ds.connection.use { conn ->
conn.createStatement().use { it.execute("SET TIME ZONE 'UTC'") }
conn.prepareStatement("SELECT created_at, updated_at FROM flight_schd WHERE flid = 'TEST_SESS_01'").use { ps ->
ps.executeQuery().use { rs ->
assertTrue(rs.next())
rs.getInstant("created_at")
}
}
}
// 断言:跨不同数据库会话时区(+08:00 vs -05:00 vs UTC),读取出的 Instant 与写入的 UTC Instant 完全一致!
assertEquals(writeInstant, instantChicago)
assertEquals(writeInstant, instantUtc)
assertEquals(instantChicago, instantUtc)
}
@Test
fun `PG dialect - persistNextStates roundtrip preserves collections source_seq and state_version`() {
val day = "2026-09-07"
val flid = "TEST_V2_ROUNDTRIP"
val gates = listOf(
mapOf("GTNO" to "1", "GATE" to "G28", "GTYP" to "D"),
mapOf("GTNO" to "3", "GATE" to "G33", "GTYP" to "I"),
mapOf("GTNO" to "5", "GATE" to "G23", "GTYP" to "D"),
)
val state = com.gzzn.omms.msgexchange.domain.flight.FlightNextState(
flid = flid,
scalars = mapOf("FLNO" to "CA888"),
collections = mapOf("GTDT" to gates),
stateVersion = 1L,
lastMessageId = "msg-dnld-001",
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
val roundtrip = repo.findNextStateByFlid(flid)!!
assertEquals(1L, roundtrip.stateVersion)
assertEquals("msg-dnld-001", roundtrip.lastMessageId)
// 明细表读回保序保源序号;未填属性以空串回填,按语义字段断言
val roundtripGates = roundtrip.collections["GTDT"]!!
assertEquals(3, roundtripGates.size)
gates.forEachIndexed { i, expected ->
val actual = roundtripGates[i]
assertEquals(expected["GTNO"], actual["GTNO"])
assertEquals(expected["GATE"], actual["GATE"])
assertEquals(expected["GTYP"], actual["GTYP"])
}
val replaced = state.copy(
collections = mapOf("GTDT" to listOf(mapOf("GTNO" to "2", "GATE" to "Z9"))),
stateVersion = 2L,
lastMessageId = "msg-flop-002",
)
repo.persistNextStates(null, listOf(replaced), snapshotReplace = false)
assertEquals("Z9", repo.findNextStateByFlid(flid)!!.collections["GTDT"]!![0]["GATE"])
assertEquals(2L, repo.findNextStateByFlid(flid)!!.stateVersion)
val detailRows = ds.query(
"SELECT ordinal, source_seq, gate FROM flight_gate WHERE flid = ? ORDER BY ordinal",
{ ps -> ps.setString(1, flid) },
) { rs -> Triple(rs.getInt("ordinal"), rs.getString("source_seq"), rs.getString("gate")) }
assertEquals(1, detailRows.size)
assertEquals(Triple(1, "2", "Z9"), detailRows[0])
}
@Test
fun `PG dialect - persistNextStates leaves all legacy collection storage columns null`() {
val day = "2026-09-07"
val flid = "TEST_V2_ALL_COLS"
val state = com.gzzn.omms.msgexchange.domain.flight.FlightNextState(
flid = flid,
scalars = mapOf("FLNO" to "MU123"),
collections = mapOf(
"GTDT" to listOf(mapOf("GTNO" to "1", "GATE" to "G1", "GTYP" to "D")),
"CKDT" to listOf(mapOf("CKNO" to "1", "CHKC" to "C1", "CTYP" to "Eco")),
"CLDT" to listOf(mapOf("CLNO" to "1", "BELT" to "B1", "BTYP" to "D")),
"DELY" to listOf(mapOf("DLNO" to "1", "CODE" to "01", "STRT" to "1200", "DURA" to "30")),
"ABTM" to listOf(mapOf("ASNO" to "1", "ABDG" to "B12", "ABOP" to "A", "AOTM" to "0730")),
"CHOT" to listOf(mapOf("CSNO" to "1", "CHID" to "ON", "CHTM" to "0800")),
"ROUT" to listOf(mapOf("RTNO" to "1", "APCD" to "CTU")),
),
stateVersion = 1L,
lastMessageId = "msg-dnld-002",
)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
assertEquals("MU123", repo.findByFlid(flid)!!["FLNO"])
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_gate WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
assertEquals(1, ds.query("SELECT COUNT(*) FROM flight_delay WHERE flid = ?", { ps -> ps.setString(1, flid) }) { rs -> rs.getInt(1) }.first())
}
@Test
fun `PG dialect - schd_gen stores last_message_id and rejects stale CAS without advancing`() {
val day = "2026-09-07"
// 初始提交:last_message_id = "m1",版本 1
val ok1 = repo.putGenIfVersion(
day, 0L,
FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_CAS_M"), lastMessageId = "m1"),
)
assertTrue(ok1)
assertEquals("m1", repo.getGen(day)!!.lastMessageId)
// 重放判定:以过期 expected=0 重试必须失败(同一消息的身份凭 getGen().lastMessageId 判定,不是靠版本相等)
val replayAttempt = repo.putGenIfVersion(
day, 0L,
FlightSchdRepository.GenMeta(day, 1L, setOf("TEST_CAS_M"), lastMessageId = "m1"),
)
assertFalse(replayAttempt)
assertEquals(1L, repo.getGen(day)!!.version)
// 正常推进 expected=1 → 版本 2last_message_id 换为 "m2"
val ok2 = repo.putGenIfVersion(
day, 1L,
FlightSchdRepository.GenMeta(day, 2L, setOf("TEST_CAS_M"), lastMessageId = "m2"),
)
assertTrue(ok2)
val gen = repo.getGen(day)!!
assertEquals(2L, gen.version)
assertEquals("m2", gen.lastMessageId)
}
@Test
fun `PG dialect - flight_schd_display view projects first second resources and totals from detail tables`() {
val day = "2026-09-07"
val flid = "TEST_DISPLAY"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(
flid,
mapOf(
"FLNO" to "CA display",
"GTDT" to """[{"GTNO":"1","GATE":"G28"},{"GTNO":"2","GATE":"G33"},{"GTNO":"3","GATE":"G23"}]""",
"CKDT" to """[{"CKNO":"1","CHKC":"01","CCLS":"Y"},{"CKNO":"2","CHKC":"17","CCLS":"F"}]""",
"CLDT" to """[{"CLNO":"1","BELT":"B01"}]""",
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200"}]""",
),
),
"msg-display-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
val row = ds.queryOne(
"""
SELECT flno, gate1, gate2, gate_total, chkc1, chkc2, checkin_total, belt1, belt_total, dely_code, dely_strt
FROM flight_schd_display WHERE flid = ?
""".trimIndent(),
{ ps -> ps.setString(1, flid) },
) { rs ->
listOf(
rs.getString("flno"), rs.getString("gate1"), rs.getString("gate2"), rs.getInt("gate_total"),
rs.getString("chkc1"), rs.getString("chkc2"), rs.getInt("checkin_total"),
rs.getString("belt1"), rs.getInt("belt_total"),
rs.getString("dely_code"), rs.getString("dely_strt"),
)
}
// 视图提供首个/第二个资源 + 总数;完整明细仍在明细表可查(三门不丢失)
assertEquals(
listOf(
"CA display", "G28", "G33", 3,
"01", "17", 2,
"B01", 1,
"YY", "07SEP261605",
),
row,
)
// 集合替换后视图随明细表同步(3 门 → 1 门)
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("GTDT" to """[{"GTNO":"5","GATE":"Z1"}]""")),
"msg-display-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
val after = ds.queryOne(
"SELECT gate1, gate2, gate_total FROM flight_schd_display WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> Triple(rs.getString("gate1"), rs.getString("gate2"), rs.getInt("gate_total")) }
assertEquals(Triple("Z1", null, 1), after)
}
@Test
fun `PG dialect - exception object explicit clear writes null columns and omits wire key`() {
val day = "2026-09-07"
val flid = "TEST_EXC_CLEAR"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
// 1. 快照写入返航异常对象 → 主表前缀标量列
repo.persistNextStates(
day,
listOf(
engine.apply(
null,
engine.commandsFromFields(flid, mapOf("FRET" to """{"REID":"CA108","RSN":"diverted"}""")),
"msg-exc-1", bumpVersion = true,
),
),
snapshotReplace = true,
)
val stored1 = ds.queryOne(
"SELECT fret_reid, fret_rsn FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("fret_reid"), rs.getString("fret_rsn")) }
assertEquals(listOf("CA108", "diverted"), stored1)
// 2. 增量显式清除(FRET="null")→ 列置 NULL,读视图无 FRET 键(评审 F4 修复闭环)
repo.persistNextStates(
null,
listOf(
engine.apply(
repo.findNextStateByFlid(flid),
engine.commandsFromFields(flid, mapOf("FRET" to "null")),
"msg-exc-2", bumpVersion = true,
),
),
snapshotReplace = false,
)
val stored2 = ds.queryOne(
"SELECT fret_reid, fret_rsn FROM flight_schd WHERE flid = ?",
{ ps -> ps.setString(1, flid) },
) { rs -> listOf(rs.getString("fret_reid"), rs.getString("fret_rsn")) }
assertEquals(listOf(null, null), stored2)
assertNull(repo.findByFlid(flid)!!["FRET"])
assertNull(repo.findNextStateByFlid(flid)!!.scalars["FRET"])
}
@Test
fun `PG dialect - readAfterWrite equals nextState across all structures and wire stays consistent`() {
val day = "2026-09-07"
val flid = "TEST_MATRIX"
val engine = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
val fields = mapOf(
"FLNO" to "CA_MATRIX",
"SODT" to "07SEP260800",
"GTDT" to """[{"GTNO":"1","GATE":"G28","GTYP":"D"},{"GTNO":"3","GATE":"G33"},{"GTNO":"3","GATE":"G23"}]""",
"CKDT" to """[{"CKNO":"1","CHKC":"01","CCLS":"Y"},{"CKNO":"2","CHKC":"01","CCLS":"F"}]""",
"CLDT" to """[{"CLNO":"1","BELT":"B01"},{"CLNO":"2","BELT":"B02"}]""",
"PSDT" to """[{"PSNO":"1","PSST":"S209"}]""",
"CHDT" to """[{"CHNO":"1","CHUT":"C1"}]""",
"DELY" to """[{"CODE":"YY","STRT":"07SEP261605","DURA":"0200","REMC":"delayed"},{"CODE":"WX","STRT":"07SEP261800","DURA":"0030"}]""",
"ABTM" to """[{"ASNO":"1","ABDG":"B01","ABOP":"A","AOTM":"07SEP261700"},{"ASNO":"2","ABDG":"B01","ABOP":"D","AOTM":"07SEP261900"}]""",
"CHOT" to """[{"CSNO":"1","CHID":"ON","CHTM":"07SEP261650"},{"CSNO":"2","CHID":"OFF","CHTM":"07SEP261920"}]""",
"ROUT" to """[{"RTNO":"1","APCD":"CTU"},{"RTNO":"2","APCD":"PEK","SCDT":"07SEP261100"}]""",
"FDIV" to """{"DDES":"PEK","DDIR":"TO"}""",
"SRVT" to """[{"SRTC":"WHEEL"}]""",
)
val state = engine.apply(null, engine.commandsFromFields(flid, fields), "msg-matrix-1", bumpVersion = true)
repo.persistNextStates(day, listOf(state), snapshotReplace = true)
// 断言一:readAfterWrite == nextState(评审 F5 的持久化侧闭环)
val readBack = repo.findNextStateByFlid(flid)!!
assertEquals(state.stateVersion, readBack.stateVersion)
assertEquals(state.lastMessageId, readBack.lastMessageId)
assertEquals(state.scalars.keys, readBack.scalars.keys)
state.scalars.forEach { (k, v) -> assertJsonEquivalent(v, readBack.scalars[k], "scalar[$k]") }
assertEquals(state.collections.keys, readBack.collections.keys)
state.collections.forEach { (key, items) ->
val actual = readBack.collections[key]!!
assertEquals(items.size, actual.size, "collection[$key] row count")
items.forEachIndexed { i, item ->
item.forEach { (attr, v) -> assertEquals(v, actual[i][attr], "collection[$key][$i].$attr") }
}
}
// 断言二:wire(nextState) == wire(readAfterWrite)(出站载荷与库态同源,F5 的线格式侧闭环)
val wireBefore = mapper.readTree(com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson.toJson(state.toFlightFields()))
val wireAfter = mapper.readTree(com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson.toJson(repo.findByFlid(flid)!!))
assertEquals(wireBefore, wireAfter)
}
/** JSON 文本按解析树等价比较(键序无关);非 JSON 退化为字符串比较。 */
private fun assertJsonEquivalent(expected: String?, actual: String?, path: String) {
if (expected == null || actual == null) {
assertEquals(expected, actual, path)
return
}
val e = runCatching { mapper.readTree(expected) }.getOrNull()
val a = runCatching { mapper.readTree(actual) }.getOrNull()
if (e != null && a != null) assertEquals(e, a, path) else assertEquals(expected, actual, path)
}
}
@@ -3,40 +3,43 @@ package com.gzzn.omms.msgexchange.infra.persistence.jdbc
import com.gzzn.omms.msgexchange.support.PgTestSupport import com.gzzn.omms.msgexchange.support.PgTestSupport
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.sql.DriverManager import java.sql.DriverManager
/** /**
* P2-1 验证测试:Flyway 迁移引擎在真实 PostgreSQL 上的端到端自动迁移验证。 * Flyway 迁移引擎端到端验证(docs/flight-state.md §3.2 表职责基线):
* 断言: * 1. V1__flight_state_baseline.sql 在真实 PostgreSQL 上自动迁移成功;
* 1. Flyway 自动执行 V1.0.0__own_pg_pipeline.sql 与 V1.1.0__flight_schd.sql * 2. flyway_schema_history 落库且 success = true
* 2. 真实落库 flyway_schema_history 元数据表,且 success = true * 3. 决策层/管道层/留痕层全表就绪;PIPELINE_LOCK 单行种子就位。
* 3. 运营航班表 FLIGHT_SCHD 与计划代 SCHD_GEN 结构完整就绪。
*/ */
class FlywayMigrationTest { class FlywayMigrationTest {
private val expectedTables = setOf(
"pipeline_lock", "proc_state", "flight_schd",
"flight_gate", "flight_checkin", "flight_belt", "flight_stand_plan", "flight_chute",
"flight_delay", "flight_bridge_op", "flight_chock_op", "flight_route_point",
"msg_event", "req_track", "backfill_todo", "schd_snap_log",
)
@Test @Test
fun `Flyway automatically migrates V1_0_0 and V1_1_0 onto real PostgreSQL`() { fun `Flyway migrates flight-state baseline onto real PostgreSQL`() {
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage()) assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
val url = PgTestSupport.jdbcUrl val url = PgTestSupport.jdbcUrl
val user = PgTestSupport.user val user = PgTestSupport.user
val pass = PgTestSupport.password val pass = PgTestSupport.password
val flyway = Flyway.configure() val migrateResult = Flyway.configure()
.dataSource(url, user, pass) .dataSource(url, user, pass)
.locations("classpath:db/migration") .locations("classpath:db/migration")
.load() .load()
.migrate()
val migrateResult = flyway.migrate()
assertTrue(migrateResult.success, "Flyway migration must succeed") assertTrue(migrateResult.success, "Flyway migration must succeed")
// 验证 flyway_schema_history 元数据表与执行记录
DriverManager.getConnection(url, user, pass).use { conn -> DriverManager.getConnection(url, user, pass).use { conn ->
conn.createStatement().use { stmt -> conn.createStatement().use { stmt ->
// 迁移记录:单基线 V1
stmt.executeQuery( stmt.executeQuery(
"SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC", "SELECT version, script, success FROM flyway_schema_history ORDER BY installed_rank ASC",
).use { rs -> ).use { rs ->
@@ -44,115 +47,38 @@ class FlywayMigrationTest {
while (rs.next()) { while (rs.next()) {
records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success"))) records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success")))
} }
assertTrue(records.size >= 2, "At least 2 migrations must be recorded in flyway_schema_history") assertTrue(records.isNotEmpty(), "flyway_schema_history must record migrations")
assertEquals("1.0.0", records[0].first) assertEquals("1", records[0].first)
assertEquals("V1.0.0__own_pg_pipeline.sql", records[0].second) assertEquals("V1__flight_state_baseline.sql", records[0].second)
assertTrue(records[0].third) assertTrue(records[0].third)
assertEquals("1.1.0", records[1].first)
assertEquals("V1.1.0__flight_schd.sql", records[1].second)
assertTrue(records[1].third)
} }
// 验证 FLIGHT_SCHD 与 SCHD_GEN 存在 // §3.2 全表就绪
stmt.executeQuery( stmt.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('flight_schd', 'schd_gen')", "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'",
).use { rs -> ).use { rs ->
val tables = mutableSetOf<String>() val tables = mutableSetOf<String>()
while (rs.next()) { while (rs.next()) tables.add(rs.getString("table_name"))
tables.add(rs.getString("table_name")) val missing = expectedTables - tables
} assertTrue(missing.isEmpty(), "missing tables: $missing")
assertTrue(tables.contains("flight_schd"))
assertTrue(tables.contains("schd_gen"))
}
}
}
} }
/** // 身份不变量基础结构:FLIGHT_SCHD 主键 + STATE 列
* P2-4:带数据的 V1.1.0 → 最新版本升级链验证(隔离 schema,不污染 public)。
* V1.2.0 删除 *_TXT 集合列是「已知损失边界」(评审 F6):标量列与结构完整存活,
* 集合文本不可恢复——升级前必须完整导出或重放(docs/flight-state.md)。
*/
@Test
fun `upgrade chain from seeded V1_1_0 data keeps scalars and adds v2 tracking`() {
assumeTrue(PgTestSupport.canConnect(), PgTestSupport.skipMessage())
val schema = "upgrade_chain_test"
// 清理历史遗留的隔离 schema
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
conn.createStatement().use { it.execute("DROP SCHEMA IF EXISTS $schema CASCADE") }
}
// 1. 迁移到 V1.1.0(含 *_TXT 集合文本列形态)并写入带数据行
Flyway.configure()
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
.locations("classpath:db/migration")
.schemas(schema)
.defaultSchema(schema)
.target("1.1.0")
.load()
.migrate()
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
conn.createStatement().use {
it.execute(
"""
INSERT INTO $schema.flight_schd (flid, fday, flno, remc, gtdt_txt, created_at, updated_at)
VALUES ('UPG_FL_1', '2026-09-07', 'CA_UPG', 'legacy-note', '[{"GTNO":"1","GATE":"G1"}]', now(), now())
""".trimIndent(),
)
}
}
// 2. 一路升级到最新版本
val result = Flyway.configure()
.dataSource(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password)
.locations("classpath:db/migration")
.schemas(schema)
.defaultSchema(schema)
.load()
.migrate()
assertTrue(result.success)
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
conn.createStatement().use { stmt ->
// 3. 标量数据存活;v2 追踪列就位(存量行 state_version 默认 0
stmt.executeQuery( stmt.executeQuery(
"SELECT flno, remc, state_version, last_message_id FROM $schema.flight_schd WHERE flid = 'UPG_FL_1'", "SELECT column_name FROM information_schema.columns WHERE table_name = 'flight_schd' " +
"AND column_name IN ('flid', 'operation_day', 'state', 'state_version', 'last_msg_id')",
).use { rs -> ).use { rs ->
assertTrue(rs.next()) val cols = mutableSetOf<String>()
assertEquals("CA_UPG", rs.getString("flno")) while (rs.next()) cols.add(rs.getString("column_name"))
assertEquals("legacy-note", rs.getString("remc")) assertEquals(setOf("flid", "operation_day", "state", "state_version", "last_msg_id"), cols)
assertEquals(0L, rs.getLong("state_version"))
assertNull(rs.getString("last_message_id"))
} }
// 4. 已知损失边界:*_TXT 集合列已不存在(升级前须导出/重放,评审 F6 // PIPELINE_LOCK 单行种子(§3.2
stmt.executeQuery( stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
"SELECT column_name FROM information_schema.columns WHERE table_schema = '$schema' AND table_name = 'flight_schd' AND column_name LIKE '%\\_TXT'",
).use { rs ->
assertFalse(rs.next(), "legacy *_TXT columns must be gone after V1.2.0 (documented lossy boundary)")
}
// 5. v2 结构就位:display 视图可查询(存量行 gate_total=0)、backfill_todo 与明细表存在
stmt.executeQuery(
"SELECT flno, gate_total FROM $schema.flight_schd_display WHERE flid = 'UPG_FL_1'",
).use { rs ->
assertTrue(rs.next()) assertTrue(rs.next())
assertEquals("CA_UPG", rs.getString("flno")) assertEquals(1, rs.getInt(1))
assertEquals(0, rs.getInt("gate_total"))
}
stmt.executeQuery("SELECT COUNT(*) FROM $schema.backfill_todo").use { rs ->
assertTrue(rs.next())
}
stmt.executeQuery("SELECT COUNT(*) FROM $schema.flight_gate WHERE flid = 'UPG_FL_1'").use { rs ->
assertTrue(rs.next())
assertEquals(0, rs.getInt(1))
} }
} }
} }
DriverManager.getConnection(PgTestSupport.jdbcUrl, PgTestSupport.user, PgTestSupport.password).use { conn ->
conn.createStatement().use { it.execute("DROP SCHEMA IF EXISTS $schema CASCADE") }
}
} }
} }
@@ -23,13 +23,15 @@ class ReplayServiceTest {
rows[id] = ProcState(id, status, identityKey = "k$id", attempts = 3, errorClass = ec, lastError = "x") rows[id] = ProcState(id, status, identityKey = "k$id", attempts = 3, errorClass = ec, lastError = "x")
} }
override fun insert(cminmsgsId: Long, state: ProcStatus) = Unit override fun insert(msgId: Long, state: ProcStatus) = Unit
override fun exists(cminmsgsId: Long): Boolean = rows.containsKey(cminmsgsId) 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? = null override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean = true override fun tryBindIdentity(msgId: Long, identityKey: String): Boolean = true
override fun ownerOfIdentity(identityKey: String): Long? = null override fun ownerOfIdentity(identityKey: String): Long? = null
override fun update( override fun update(
cminmsgsId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?, msgId: Long, state: ProcStatus, nextAttemptAt: Instant?, attempts: Int?,
errorClass: ErrorClass?, lastError: String?, errorClass: ErrorClass?, lastError: String?,
) = Unit ) = Unit
@@ -56,10 +56,10 @@ class BackfillSweepJobTest {
assertEquals(BackfillSweepJob.SweepOutcome(inspected = 1, succeeded = 0, failed = 1), outcome) assertEquals(BackfillSweepJob.SweepOutcome(inspected = 1, succeeded = 0, failed = 1), outcome)
assertEquals(1, todo.count()) assertEquals(1, todo.count())
assertEquals(1, todo.tasks().single().attempts) assertEquals(1, todo.tasks.values.single().attempts)
assertEquals("mysql-down", todo.lastErrorOf(901L)) assertEquals("mysql-down", todo.lastErrorOf(901L))
// 一次失败后 30s 内不再到期(退避生效) // 一次失败后 30s 内不再到期(退避生效)
assertEquals(emptyList<BackfillTodoRepository.BackfillTask>(), todo.findDue(t0.plusSeconds(29))) assertEquals(0, todo.findDue(t0.plusSeconds(29)).size)
assertEquals(1, todo.findDue(t0.plusSeconds(30)).size) assertEquals(1, todo.findDue(t0.plusSeconds(30)).size)
} }
@@ -1,168 +1,103 @@
package com.gzzn.omms.msgexchange.jobs package com.gzzn.omms.msgexchange.jobs
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository import com.gzzn.omms.msgexchange.config.HistoryProps
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.support.seedSnapshot import com.gzzn.omms.msgexchange.domain.flight.FlightState
import org.junit.jupiter.api.AfterEach import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Instant
/** /**
* ACM2-28 FS6 历史清场测试 * 历史归档与物理清除(docs/flight-state.md §8.2,顺序不可颠倒)
* 覆盖 ES 全成功、部分成功、全部失败、ES 成功后 PG 删除前崩溃、PG 删除重放五种场景 * 历史存储未接通必须删 0 条;先归档确认再物理清除
* 断言失败项不丢、成功项最终删除、重复执行无副作用 * 未经 FDEL 的航班在清除前补发删除事件
*/ */
class HistorySweepJobTest { class HistorySweepJobTest {
private lateinit var flightSchd: StubFlightSchd private val now: Instant = Instant.parse("2026-09-09T00:00:00Z")
private lateinit var sweepJob: HistorySweepJob
@BeforeEach private fun seededFlight(flid: String, deleted: Boolean, idleDays: Long): StubFlightState {
fun setUp() { val f = StubFlightState()
flightSchd = StubFlightSchd() f.persistFullState(
sweepJob = HistorySweepJob(flightSchd) com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
HistorySweepJob.historyPicker = null flid, java.time.LocalDate.of(2026, 9, 1), if (deleted) FlightState.DELETED else FlightState.ACTIVE,
HistorySweepJob.esArchiver = null 1, mapOf("FLNO" to "CA001"), emptyMap(),
HistorySweepJob.cutoffProvider = null ),
} msgId = 1,
now = now.minusSeconds(idleDays * 86400),
@AfterEach
fun tearDown() {
HistorySweepJob.historyPicker = null
HistorySweepJob.esArchiver = null
HistorySweepJob.cutoffProvider = null
}
// 1. 场景一:ES 全成功
@Test
fun `Scenario 1 - ES All Success removes all confirmed flights and cleans old gens`() {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
"F2" to mapOf("FLID" to "F2"),
"F3" to mapOf("FLID" to "F3"),
) )
seedSnapshot(flightSchd, "2026-09-01", flights) return f
flightSchd.putGenIfVersion("2026-09-01", 0L, FlightSchdRepository.GenMeta("2026-09-01", 1L, setOf("F1", "F2", "F3"))) }
// 模拟判史:F1, F2, F3 全部为历史候选 private class RecordingHistoryStore : HistorySweepJob.HistoryStore {
HistorySweepJob.historyPicker = { it } val archivedBatches = mutableListOf<List<HistoryCandidate>>()
// 模拟 ES 写入:全部成功返回 val confirmed = mutableSetOf<String>()
HistorySweepJob.esArchiver = { it.keys } var fail = false
// 模拟历史代清理:清理 2026-09-02 之前的代
HistorySweepJob.cutoffProvider = { "2026-09-02" }
sweepJob.run() override fun archive(candidates: List<HistoryCandidate>): Set<String> {
archivedBatches.add(candidates)
// 断言:PG 中 F1, F2, F3 全部被删除 if (fail) return emptySet()
assertNull(flightSchd.findByFlid("F1")) return candidates.map { it.flid }.toSet().intersect(confirmed.ifEmpty { candidates.map { it.flid }.toSet() })
assertNull(flightSchd.findByFlid("F2")) }
assertNull(flightSchd.findByFlid("F3"))
// 断言:过期代元数据被清理
assertNull(flightSchd.getGen("2026-09-01"))
} }
// 2. 场景二:ES 部分成功
@Test @Test
fun `Scenario 2 - ES Partial Success deletes only successful flights and keeps failed ones`() { fun `history store not connected must delete zero rows`() {
val flights = listOf( val f = seededFlight("F1", deleted = true, idleDays = 30)
"F1" to mapOf("FLID" to "F1"), val events = StubMsgEvents()
"F2" to mapOf("FLID" to "F2"), val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false })
"F3" to mapOf("FLID" to "F3"),
val outcome = job.run(now)
assertEquals(0, outcome.purged) // §8.2 红线:先删历史再补当前态绝不允许
assertTrue(f.findMainRow("F1") != null)
assertEquals(0, events.rows.size)
}
@Test
fun `archive success precedes physical purge and confirmed rows only`() {
val f = seededFlight("F1", deleted = true, idleDays = 30)
f.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"F2", java.time.LocalDate.of(2026, 9, 1), FlightState.DELETED, 1, mapOf("FLNO" to "CA002"), emptyMap(),
),
msgId = 2, now = now.minusSeconds(30 * 86400),
) )
seedSnapshot(flightSchd, "2026-09-01", flights) val events = StubMsgEvents()
val store = RecordingHistoryStore().apply { confirmed.add("F1") } // 只有 F1 归档确认成功
HistorySweepJob.historyPicker = { it } val job = HistorySweepJob(
// 模拟 ES 写入:F1 和 F2 成功,F3 失败 f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
HistorySweepJob.esArchiver = { setOf("F1", "F2") }
sweepJob.run()
// 断言:成功项最终删除
assertNull(flightSchd.findByFlid("F1"))
assertNull(flightSchd.findByFlid("F2"))
// 断言:失败项 F3 绝不丢失,继续保留在 PG 中供下次重试
assertNotNull(flightSchd.findByFlid("F3"))
}
// 3. 场景三:ES 全部失败
@Test
fun `Scenario 3 - ES All Failure deletes nothing and keeps all candidates in PG`() {
val flights = listOf(
"F1" to mapOf("FLID" to "F1"),
"F2" to mapOf("FLID" to "F2"),
) )
seedSnapshot(flightSchd, "2026-09-01", flights)
HistorySweepJob.historyPicker = { it } val outcome = job.run(now)
// 模拟 ES 全部写入失败
HistorySweepJob.esArchiver = { emptySet() }
sweepJob.run() assertEquals(2, outcome.selected)
assertEquals(1, outcome.archived)
// 断言:PG 未执行任何删除,全部候选均安全保留 assertEquals(1, outcome.purged)
assertNotNull(flightSchd.findByFlid("F1")) assertEquals(null, f.findMainRow("F1")) // §8.2 步骤 3:确认成功 → 物理删除
assertNotNull(flightSchd.findByFlid("F2")) assertTrue(f.findMainRow("F2") != null) // 失败或不明确的保留重试(步骤 5)
assertEquals(0, events.rows.size) // DELETED 航班清除不再发业务删除事件
} }
// 4. 场景四:ES 成功后 PG 删除前崩溃重放
@Test @Test
fun `Scenario 4 - Crash after ES success before PG delete recovers idempotently on next run`() { fun `never-fdel lifecycle purge emits tombstone before deletion`() {
val flights = listOf( val f = seededFlight("F3", deleted = false, idleDays = 30) // ACTIVE 且超兜底窗(§8.1
"F1" to mapOf("FLID" to "F1"), val events = StubMsgEvents()
val store = RecordingHistoryStore()
val job = HistorySweepJob(
f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
) )
seedSnapshot(flightSchd, "2026-09-01", flights)
// 第一次运行模拟:ES 写入成功,但在调用 deleteByFlids 前崩溃(PG 依然保留 F1 job.run(now)
val esUpserted = mutableSetOf<String>()
var crashed = false
HistorySweepJob.historyPicker = { it } // §7.3/§8.2 步骤 4:未经 FDEL 的航班,清除前补发一次删除事件
HistorySweepJob.esArchiver = { val tombstone = events.rows.values.single { it.eventType == EventType.TOMBSTONE }
esUpserted.addAll(it.keys) assertEquals("F3", tombstone.partitionKey)
if (!crashed) { assertTrue(tombstone.payloadJson.contains("\"deleted\":true"))
crashed = true assertEquals(null, f.findMainRow("F3"))
throw RuntimeException("crash-before-pg-delete")
}
it.keys
}
try {
sweepJob.run()
} catch (_: RuntimeException) {
// 崩溃发生
}
// 崩溃后断言:ES 已有数据,但 PG 中 F1 仍然存在作为可恢复事实
assertTrue(esUpserted.contains("F1"))
assertNotNull(flightSchd.findByFlid("F1"))
// 下一次作业重试:
sweepJob.run()
// 断言:重试成功,PG 中 F1 最终完成删除
assertNull(flightSchd.findByFlid("F1"))
}
// 5. 场景五:PG 删除重复执行(无副作用与幂等性)
@Test
fun `Scenario 5 - Replaying deleteByFlids is completely idempotent with no side effects`() {
val flights = listOf("F1" to mapOf("FLID" to "F1"))
seedSnapshot(flightSchd, "2026-09-01", flights)
// 首次删除
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
assertEquals(1, deletedCount1)
assertNull(flightSchd.findByFlid("F1"))
// 重复执行删除(重放)
val deletedCount2 = flightSchd.deleteByFlids(setOf("F1"))
// 断言:允许重放时影响行数为 0,不抛出异常,无任何副作用
assertEquals(0, deletedCount2)
assertNull(flightSchd.findByFlid("F1"))
} }
} }
@@ -0,0 +1,172 @@
package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.EventType
import com.gzzn.omms.msgexchange.domain.MetaFields
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.domain.flight.FlightState
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.LocalDate
/**
* FDEL(§6.2+ ADFT(§6.3/§2.1)不变量:
* ACTIVE→DELETED 发布一次 tombstone 且明细保留;重复/迟到 FDEL 幂等不推进版本;
* ADFT 重激活恢复 ACTIVE;新实例建立;缺失标量不误删(§2.1 保守语义)。
*/
class FdelAndAdftProcessorTest {
private val msgId = 7L
private fun head() = ProcState(msgId, ProcStatus.PENDING)
private fun msg() = DecodedMessage(
meta = MetaFields("AODB", "FDEL", "", 9L, 1L),
kind = MsgKind.Fdel,
rawXml = "<MSG/>",
body = FlopPayload("121"),
)
private fun flights(): StubFlightState {
val f = StubFlightState()
val outcome = f.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 4,
mapOf("FLNO" to "CA001"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
check(outcome != PersistOutcome.DAY_GUARD_VIOLATION)
return f
}
@Test
fun `active flight deletion publishes single tombstone and keeps details`() {
val f = flights()
val events = StubMsgEvents()
val todo = StubBackfillTodo()
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, todo, ObjectMapper())
.apply(head(), msg(), FlopPayload("121"))
assertEquals(ApplyResult.Succeeded, result)
assertEquals(FlightState.DELETED, f.findMainRow("121")!!.state)
assertEquals(5L, f.findMainRow("121")!!.stateVersion)
val tombstones = events.rows.values.filter { it.eventType == EventType.TOMBSTONE }
assertEquals(1, tombstones.size)
assertEquals(Targets.KAFKA_SCHD, tombstones.single().target)
assertTrue(tombstones.single().payloadJson.contains("\"deleted\":true"))
assertEquals(1, todo.count())
}
@Test
fun `repeated FDEL is idempotent without version bump or duplicate event`() {
val f = flights()
val events = StubMsgEvents()
val proc = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
proc.apply(head(), msg(), FlopPayload("121"))
val version = f.findMainRow("121")!!.stateVersion
val result = proc.apply(head(), msg(), FlopPayload("121"))
assertEquals(ApplyResult.Succeeded, result) // §6.2 步骤 3:幂等成功
assertEquals(version, f.findMainRow("121")!!.stateVersion)
assertEquals(1, events.rows.values.count { it.eventType == EventType.TOMBSTONE })
}
@Test
fun `FDEL of unknown flight is idempotent success`() {
val f = StubFlightState()
val events = StubMsgEvents()
val result = FdelProcessor(StubPipelineTx(), StubPipelineLock(), f, events, null, ObjectMapper())
.apply(head(), msg(), FlopPayload("999"))
assertEquals(ApplyResult.Succeeded, result) // §6.2 步骤 4:迟到不报错
assertEquals(0, events.rows.size)
}
@Test
fun `ADFT revives deleted flight and sets provided scalars`() {
val f = flights()
f.markDeleted("121", msgId = 2, now = java.time.Instant.now())
val events = StubMsgEvents()
val adft = AdftProcessor(
StubPipelineTx(), StubPipelineLock(), f, events, null,
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
)
val record = com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(
"121", mapOf("SODT" to "15DEC261723", "FLNO" to "CA002"),
)
val result = adft.apply(head(), msg(), record)
assertEquals(ApplyResult.Succeeded, result)
val main = f.findMainRow("121")!!
assertEquals(FlightState.ACTIVE, main.state) // §6.3 生命周期重激活
assertEquals(LocalDate.of(2026, 12, 15), main.operationDay)
assertEquals("CA002", f.loadFullSnapshot("121")!!.scalars["FLNO"])
}
@Test
fun `ADFT of unknown flight creates new instance with operation day from SODT`() {
val f = StubFlightState()
val events = StubMsgEvents()
val adft = AdftProcessor(
StubPipelineTx(), StubPipelineLock(), f, events, null,
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
)
val result = adft.apply(
head(), msg(),
com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord("555", mapOf("SODT" to "20DEC261723")),
)
assertEquals(ApplyResult.Succeeded, result)
val main = f.findMainRow("555")!!
assertEquals(FlightState.ACTIVE, main.state)
assertEquals(LocalDate.of(2026, 12, 20), main.operationDay) // §2.1:含 SODT 直接计算
}
@Test
fun `ADFT merge does not clear absent scalars`() {
val f = StubFlightState()
f.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"555", LocalDate.of(2026, 12, 20), FlightState.ACTIVE, 2,
mapOf("FLNO" to "XX100", "REMC" to "keep-me"), emptyMap(),
),
msgId = 1, now = java.time.Instant.now(),
)
val adft = AdftProcessor(
StubPipelineTx(), StubPipelineLock(), f, StubMsgEvents(), null,
OperationDayProps().apply { zone = "Asia/Shanghai" }, ObjectMapper(),
)
adft.apply(head(), msg(), com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord("555", mapOf("FLNO" to "XX200")))
val scalars = f.loadFullSnapshot("555")!!.scalars
assertEquals("XX200", scalars["FLNO"])
assertEquals("keep-me", scalars["REMC"]) // §2.1 保守 Set-only:缺失不 Clear
}
@Suppress("unused")
private fun unused() {
MsgEvent(target = "x", partitionKey = "y", payloadJson = "")
}
}
@@ -1,355 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgEvent
import com.gzzn.omms.msgexchange.domain.MsgKind
import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SchdPush
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
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.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import com.gzzn.omms.msgexchange.support.seedIncremental
import com.gzzn.omms.msgexchange.support.seedSnapshot
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZoneId
import java.util.TimeZone
/**
* U09 / U29 不变量门禁测试(ACM2-28 FS6 核心验收门槛):
* 1. I2 不变量:航班状态变更、MSG_EVENT 插入、PROC_STATE 终态在单事务原子提交;失败整体回滚重放;
* 2. 门槛 1(崩溃幂等无自增):重放相同快照报文后 SCHD_GEN.VERSION 绝不发生二次自增;
* 3. 门槛 2(CAS 防并发):伪造版本过期模拟并发快照,准确拦截 CAS 冲突并转入 FAILED(INFRA) 与退避;
* 4. 门槛 3ADFT 存活保障):增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删;跨代迁移保护;
* 5. UTC 方言测试:非 UTC JVM 时区下读写与幂等判据无漂移。
*/
class FlightSchdInvariantTest {
private lateinit var flightSchd: StubFlightSchd
private lateinit var procState: StubProcState
private lateinit var msgEvents: StubMsgEvents
private lateinit var reqTrack: StubReqTrack
private lateinit var txManager: StubPipelineTransactionManager
private lateinit var inbox: FakeInbox
private lateinit var clock: MutableClock
private lateinit var scheduler: FailureScheduler
private lateinit var procFailure: ProcFailure
private lateinit var props: PipelineProps
class FakeInbox : CminmsgInboxRepository {
val raws = mutableMapOf<Long, String>()
val backfilled = mutableListOf<Long>()
override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(cminmsgsId: Long): String? = raws[cminmsgsId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
backfilled.add(cminmsgsId)
}
}
class SimpleCodec(private val decodedMsg: DecodedMessage) : XmlCodec {
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decodedMsg)
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
@BeforeEach
fun setUp() {
flightSchd = StubFlightSchd()
procState = StubProcState()
msgEvents = StubMsgEvents()
reqTrack = StubReqTrack()
txManager = StubPipelineTransactionManager()
inbox = FakeInbox()
clock = MutableClock(MutableClock.BASE)
props = PipelineProps()
scheduler = FailureScheduler(props, clock)
procFailure = ProcFailure(procState, scheduler)
SnapshotFlow.StageResult.parser = null
}
@AfterEach
fun tearDown() {
SnapshotFlow.StageResult.parser = null
}
// =========================================================================
// I2 不变量测试:普通报文事务 2 扩展(变更 + 事件 + SUCCEEDED 原子提交)
// =========================================================================
@Test
fun `I2 - MessageProcessor atomically commits flightChanges, msgEvents, and SUCCEEDED`() {
val headId = 101L
inbox.raws[headId] = "<MSG><FLID>F101</FLID></MSG>"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "FLOP", "DELY", 1L, 20260907120000L)
val decoded = DecodedMessage(meta, MsgKind.Flop("DELY"), "<MSG><FLID>F101</FLID></MSG>")
val codecHolder = CodecHolder(SimpleCodec(decoded))
val handler = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision {
return Decision(
flightChanges = listOf(FlightChange("F101", mapOf("FLID" to "F101", "STAT" to "DELAYED"))),
msgNotifies = listOf(NotifyPayload("""{"flid":"F101","event":"DELAY"}""")),
schdPush = listOf(SchdPush("F101", """{"FLID":"F101","STAT":"DELAYED"}""")),
)
}
}
val handlerHolder = HandlerHolder(HandlerRegistry(listOf(handler)))
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents, codecHolder, handlerHolder,
flightSchd, snapshotFlow, procFailure, props, txManager,
)
val head = procState.headUnfinished()!!
processor.processOne(head)
// 断言:FLIGHT_SCHD 有更新(字段列 DELAYED 已落库)
val fields = flightSchd.findByFlid("F101")
assertNotNull(fields)
assertEquals("DELAYED", fields!!["STAT"])
// 断言:MSG_EVENT 写入了 KAFKA_MSG 和 KAFKA_SCHD 两条事件
val evtMsg = msgEvents.headUnsent(Targets.KAFKA_MSG)
assertNotNull(evtMsg)
val evtSchd = msgEvents.headUnsent(Targets.KAFKA_SCHD)
assertNotNull(evtSchd)
// 断言:伴生状态更新为 SUCCEEDED
val currentHead = procState.headUnfinished()
assertNull(currentHead) // 无未完成队头,说明 101 已终态
// 断言:共享信箱 backfill 成功调用
assertTrue(inbox.backfilled.contains(headId))
}
// =========================================================================
// FS6 门槛 1:崩溃幂等无自增(重放相同快照报文,SCHD_GEN.VERSION 绝不发生二次自增)
// =========================================================================
@Test
fun `Threshold 1 - Crash idempotency without version re-increment`() {
val headId = 201L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 1L, 20260907030000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
// 配置 staging 解析模拟产出 2 条航班
val flights = listOf(
"FL_01" to mapOf("FLID" to "FL_01", "FLNO" to "CA1234"),
"FL_02" to mapOf("FLID" to "FL_02", "FLNO" to "MU5678"),
)
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 模拟快照单事务内崩溃注入(例如在提交前崩溃回滚)
var crashInjected = true
val rollbackTxManager = object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T {
if (crashInjected) {
try {
block()
} finally {
// 事务回滚:清除未提交修改并恢复状态
flightSchd.clear()
procState.update(headId, ProcStatus.PENDING)
}
throw RuntimeException("crash-before-pg-commit")
}
return block()
}
}
val snapshotFlowCrashing = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, rollbackTxManager, inbox)
// 首次运行:事务内崩溃注入
val head1 = procState.headUnfinished()!!
try {
snapshotFlowCrashing.publishSnapshot(head1, decoded)
} catch (_: RuntimeException) {
// 崩溃发生
}
// 崩溃后断言:由于 PG 单事务回滚,SCHD_GEN 不存在半成品中间态,版本未推进
assertNull(flightSchd.getGen(day))
// 重放相同报文(正常完成)
crashInjected = false
val snapshotFlowNormal = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val headReplay = procState.headUnfinished()!!
snapshotFlowNormal.publishSnapshot(headReplay, decoded)
// 门槛 1 断言:版本绝不发生二次自增,版本号精确为 1L(根除旧 Redis 两阶段二次自增缺陷)
val genAfterReplay = flightSchd.getGen(day)
assertNotNull(genAfterReplay)
assertEquals(1L, genAfterReplay!!.version)
assertEquals(setOf("FL_01", "FL_02"), genAfterReplay.flids)
}
// =========================================================================
// FS6 门槛 2:CAS 防并发(伪造版本过期模拟并发快照,拦截 CAS 冲突并转入 FAILED(INFRA)
// =========================================================================
@Test
fun `Threshold 2 - CAS prevents concurrency and enters FAILED INFRA`() {
val headId = 202L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 2L, 20260907033000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val flights = listOf("FL_01" to mapOf("FLID" to "FL_01"))
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, flights) }
// 先预置日代版本为 5L(模拟另一并发实例已经推进了版本)
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD")))
// 构造一个在读取版本后版本被篡改的场景(模拟读到 5L 后,外部并发变成了 6L)
val mockFlightSchd = object : FlightSchdRepository by flightSchd {
override fun getGen(day: String): FlightSchdRepository.GenMeta? {
// 模拟读取时返回版本 5L
return FlightSchdRepository.GenMeta(day, 5L, setOf("FL_OLD"))
}
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
// 模拟 CAS 校验失败(数据库已被并发推进,expected 5 已过期)
return false
}
}
val snapshotFlow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 2 断言:CAS 冲突被拦截,状态转换为 FAILED(INFRA),带有退避与告警错误信息
val failedHead = procState.headUnfinished()!!
assertEquals(ProcStatus.FAILED, failedHead.state)
assertEquals(ErrorClass.INFRA, failedHead.errorClass)
assertTrue(failedHead.lastError?.contains("gen-cas-conflict") == true)
assertNotNull(failedHead.nextAttemptAt)
}
// =========================================================================
// FS6 门槛 3ADFT 存活保障(增量 ADFT 航班在后续 DNLD 跨代替换后天然存活、不被差删)
// =========================================================================
@Test
fun `Threshold 3 - ADFT flights survive subsequent DNLD replacement`() {
val day = "2026-09-07"
// 1. 增量更新写入一条临时加飞航班 ADFT(FDAY 为 NULL
val adftChange = FlightChange(flid = "ADFT_888", fields = mapOf("FLID" to "ADFT_888", "FLTY" to "ADFT"))
seedIncremental(flightSchd, listOf(adftChange))
// 2. 写入旧代的一条定期计划航班 REG_OLDFDAY 为 2026-09-07
seedSnapshot(flightSchd, day, listOf("REG_OLD" to mapOf("FLID" to "REG_OLD", "FLTY" to "REG")))
flightSchd.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("REG_OLD")))
// 3. 执行下一轮快照 DNLD,新代仅包含 REG_NEW(REG_OLD 不在新代中,属于待删差集;ADFT 也不在新代中)
val headId = 203L
procState.insert(headId, ProcStatus.PENDING)
val meta = MetaFields("AODB", "SCHD", "DNLD", 3L, 20260907040000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val newFlights = listOf("REG_NEW" to mapOf("FLID" to "REG_NEW", "FLTY" to "REG"))
SnapshotFlow.StageResult.parser = { SnapshotFlow.StageResult.Ok(day, newFlights) }
val snapshotFlow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val head = procState.headUnfinished()!!
snapshotFlow.publishSnapshot(head, decoded)
// 门槛 3 核心断言:
// ① 旧代航班 REG_OLD 被按代差删清除
assertNull(flightSchd.findByFlid("REG_OLD"))
// ② 新代航班 REG_NEW 成功写入
assertNotNull(flightSchd.findByFlid("REG_NEW"))
// ③ 增量 ADFT_888 航班由于 FDAY=NULL 天然存活、绝不被误删!
val adftRecord = flightSchd.findByFlid("ADFT_888")
assertNotNull(adftRecord)
assertEquals("ADFT_888", adftRecord!!["FLID"])
}
@Test
fun `Threshold 3 - Migrated flight across days is protected from diff deletion of old day`() {
// 场景:航班 FL_MIG 原属 2026-09-07,后来迁移到了 2026-09-08FDAY 更新为 09-08
val dayOld = "2026-09-07"
val dayNew = "2026-09-08"
// 初始属旧代
seedSnapshot(flightSchd, dayOld, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "07")))
flightSchd.putGenIfVersion(dayOld, 0L, FlightSchdRepository.GenMeta(dayOld, 1L, setOf("FL_MIG")))
// 随后 09-08 快照写入将 FDAY 更新为 2026-09-08
seedSnapshot(flightSchd, dayNew, listOf("FL_MIG" to mapOf("FLID" to "FL_MIG", "REMC" to "08")))
// 此时 09-07 再次执行差删(差集中包含 FL_MIG)
val deleted = flightSchd.deleteDiffByDay(dayOld, listOf("FL_MIG"))
// 断言:FL_MIG 虽在差集,但由于 FDAY 已迁移至 09-08,受到域化差删保护,删除数为 0,记录依然存活!
assertEquals(0, deleted)
assertNotNull(flightSchd.findByFlid("FL_MIG"))
}
// =========================================================================
// UTC 时区规范测试:非 UTC JVM 默认时区与会话下写入读取无漂移
// =========================================================================
@Test
fun `UTC Dialect - Timestamps and instant evaluations are immune to JVM timezone drift`() {
val originalTz = TimeZone.getDefault()
try {
// 切换 JVM 默认时区为非 UTC(东京 +09:00 与 纽约 -05:00
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("Asia/Tokyo")))
val now = Instant.parse("2026-09-07T08:00:00.123456Z")
seedSnapshot(flightSchd, "2026-09-07", listOf("TZ_01" to mapOf("FLID" to "TZ_01")), now)
flightSchd.putGenIfVersion("2026-09-07", 0L, FlightSchdRepository.GenMeta("2026-09-07", 1L, setOf("TZ_01")), now)
val gen = flightSchd.getGen("2026-09-07")
assertNotNull(gen)
assertEquals(now, gen!!.updatedAt)
// 再次切换到西五区
TimeZone.setDefault(TimeZone.getTimeZone(ZoneId.of("America/New_York")))
val genNy = flightSchd.getGen("2026-09-07")
assertNotNull(genNy)
assertEquals(now, genNy!!.updatedAt) // 绝无时区漂移
} finally {
TimeZone.setDefault(originalTz)
}
}
}
@@ -1,82 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import com.gzzn.omms.msgexchange.processing.handlers.GtdtHandler
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/** P2-0-1GTDT 报文端到端 SUCCEEDED + 三门读回(真实 XmlCodec + GtdtHandler)。 */
class GtdtPipelineTest {
@Test
fun `GTDT XML end-to-end SUCCEEDED with three gates persisted`() {
val raw = """
<?xml version="1.0" encoding="UTF-8"?>
<MSG>
<META>
<SNDR>AODB</SNDR><SEQN>9001</SEQN><DTTM>20260908120000</DTTM>
<TYPE>FLOP</TYPE><STYP>GTDT</STYP>
</META>
<FLOP>
<FLID>FL_GTDT_E2E</FLID>
<GTDT GTNO="1"><GATE>A1</GATE><GTYP>D</GTYP></GTDT>
<GTDT GTNO="3"><GATE>B2</GATE><GTYP>I</GTYP></GTDT>
<GTDT GTNO="7"><GATE>C3</GATE><GTYP>D</GTYP></GTDT>
</FLOP>
</MSG>
""".trimIndent()
val procState = StubProcState()
val flightSchd = StubFlightSchd()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = raw
override fun pollUnprocessed(afterId: Long, limit: Int) = emptyList<Long>()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val clock = MutableClock(MutableClock.BASE)
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
val codec = JacksonXmlCodec()
val handler = GtdtHandler()
val snapshot = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents,
CodecHolder(codec),
HandlerHolder(HandlerRegistry(listOf(handler))),
flightSchd, snapshot, procFailure, PipelineProps(), txManager,
)
val headId = 42L
procState.insert(headId, ProcStatus.PENDING)
processor.processOne(ProcState(headId, ProcStatus.PENDING))
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
val next = flightSchd.findNextStateByFlid("FL_GTDT_E2E")!!
assertEquals(3, next.collections["GTDT"]!!.size)
assertEquals("3", next.collections["GTDT"]!![1]["GTNO"])
assertEquals("C3", next.collections["GTDT"]!![2]["GATE"])
assertEquals(1L, next.stateVersion)
val roundtrip = FlightStateEngine.fromFlightFields("FL_GTDT_E2E", flightSchd.findByFlid("FL_GTDT_E2E")!!)
assertEquals(3, roundtrip.collections["GTDT"]!!.size)
}
}
@@ -1,388 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeFailure
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.Decision
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.MsgKind
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.NotifyPayload
import com.gzzn.omms.msgexchange.domain.ProcState
import com.gzzn.omms.msgexchange.domain.ProcStatus
import com.gzzn.omms.msgexchange.domain.SchdPush
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.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.PumpJobRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
/**
* U08/U10/U11processOne 失败闭环——
* ① 意外异常在边界内写 FAILED(INFRA)+退避,重试达上限转 DEAD(EXHAUSTED)Pump 内部异常不杀泵);
* ② 未注册/CODEC_ERROR/staging 未实装 → FAILED(绝不直接终态);MALFORMED → DEAD
* ③ InterruptedException/致命 Error 不被普通恢复逻辑吞掉。
*/
class MessageProcessorTest {
private class FakeProcState : ProcStateRepository {
var record = mutableMapOf<Long, ProcState>()
val bound = mutableMapOf<String, Long>()
override fun insert(cminmsgsId: Long, state: ProcStatus) { record[cminmsgsId] = ProcState(cminmsgsId, state) }
override fun exists(cminmsgsId: Long): Boolean = record.containsKey(cminmsgsId)
override fun headUnfinished(): ProcState? = null
override fun tryBindIdentity(cminmsgsId: Long, identityKey: String): Boolean {
val owner = bound[identityKey]
if (owner != null && owner != cminmsgsId) return false
bound[identityKey] = cminmsgsId
record[cminmsgsId] = record[cminmsgsId]?.copy(identityKey = identityKey) ?: ProcState(cminmsgsId, ProcStatus.PENDING, 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?,
) {
val old = record[cminmsgsId] ?: ProcState(cminmsgsId, state)
record[cminmsgsId] = old.copy(
state = state,
nextAttemptAt = nextAttemptAt ?: old.nextAttemptAt,
attempts = attempts ?: old.attempts,
errorClass = errorClass ?: old.errorClass,
lastError = lastError ?: old.lastError,
)
}
override fun requeueByErrorClasses(errorClasses: List<ErrorClass>): Int {
var n = 0
record.keys.toList().forEach { id ->
val s = record[id]!!
if (s.errorClass != null && s.errorClass in errorClasses &&
(s.state == ProcStatus.FAILED || s.state == ProcStatus.DEAD)) {
record[id] = s.copy(state = ProcStatus.PENDING, attempts = 0, nextAttemptAt = null)
n++
}
}
return n
}
fun state(id: Long): ProcState = record.getValue(id)
}
private class FakeInbox : CminmsgInboxRepository {
var raws = mutableMapOf<Long, String>()
var throwOnRawOf: Throwable? = null
var throwOnBackfill: Throwable? = null
val backfilled = mutableListOf<List<Any?>>()
override fun insertRaw(rawXml: String): Long = 0
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun rawOf(cminmsgsId: Long): String? {
throwOnRawOf?.let { throw it }
return raws[cminmsgsId]
}
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
throwOnBackfill?.let { throw it }
backfilled += listOf(cminmsgsId, sndr, type, styp, seqn)
}
}
private class FakeEvents : MsgEventRepository {
val inserted = mutableListOf<List<MsgEvent>>()
override fun insertAll(events: List<MsgEvent>): List<Long> { inserted += events; return events.indices.map { it.toLong() + 1 } }
override fun headUnsent(target: String): MsgEvent? = null
override fun claimBatch(target: String, limit: Int): List<MsgEvent> = emptyList()
override fun markSent(eventId: Long) = Unit
override fun markAllSent(eventIds: List<Long>) = Unit
override fun scheduleRetry(eventId: Long, nextAttemptAt: Instant, attempts: Int) = Unit
override fun markDead(eventId: Long, errorClass: ErrorClass, lastError: String, attempts: Int?) = Unit
override fun insertSync(events: List<MsgEvent>) = Unit
}
private class FakeCodec : XmlCodec {
var result: DecodeResult = DecodeResult.Ok(
DecodedMessage(
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = 1L, dttm = 20260906120000L),
kind = MsgKind.Flop("DELY"),
rawXml = "<MSG/>",
),
)
override fun decode(rawXml: String): DecodeResult = result
override fun encodeRqrd(kind: String, rangeJson: String): String = ""
}
private class FakeReqTrack : ReqTrackRepository {
override fun findOpenByKind(kind: String): ReqTrackRepository.Req? = null
override fun forceExpireOpenOf(kind: String) = Unit
override fun insert(kind: String, paramsJson: String): Long = 1L
override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) = Unit
override fun markSent(reqId: Long, sentAt: Instant) = Unit
override fun expireIfWaiting(reqId: Long) = Unit
override fun markDone(reqId: Long) = Unit
}
private class FakeFlightSchd : FlightSchdRepository {
val flights = mutableMapOf<String, FlightFields>()
val gens = mutableMapOf<String, FlightSchdRepository.GenMeta>()
val incrementalChanges = mutableListOf<com.gzzn.omms.msgexchange.domain.FlightChange>()
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = 0
override fun findByFlid(flid: String): FlightFields? = flights[flid]
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> =
flids.mapNotNull { f -> flights[f]?.let { f to it } }.toMap()
override fun findByDay(day: String): List<Pair<String, FlightFields>> = flights.map { it.key to it.value }
override fun findAll(): Map<String, FlightFields> = flights.toMap()
override fun deleteByFlids(flids: Set<String>): Int = 0
override fun getGen(day: String): FlightSchdRepository.GenMeta? = gens[day]
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean {
val cur = gens[day]?.version ?: 0L
if (cur != expected) return false
gens[day] = newGen
return true
}
override fun deleteGenBefore(cutoffDay: String): Int = 0
override fun findNextStateByFlid(flid: String): com.gzzn.omms.msgexchange.domain.flight.FlightNextState? =
flights[flid]?.let { com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.fromFlightFields(flid, it) }
override fun persistNextStates(
day: String?,
states: List<com.gzzn.omms.msgexchange.domain.flight.FlightNextState>,
snapshotReplace: Boolean,
now: Instant,
) {
states.forEach { state ->
val scalarFields = linkedMapOf("FLID" to state.flid)
state.scalars.forEach { (k, v) -> scalarFields[k] = v }
flights[state.flid] = scalarFields + state.collections.mapValues { (_, items) ->
com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(items)
}
}
}
}
private class FakeTxManager : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
// ---------- helpers ----------
private val clock = MutableClock(MutableClock.BASE)
private fun msg(seqn: String) = DecodedMessage(
meta = MetaFields(sndr = "AODB", type = "FLOP", styp = "DELY", seqn = seqn.toLong(), dttm = 20260906120000L),
kind = MsgKind.Flop("DELY"),
rawXml = "<MSG/>",
)
private fun head(id: Long = 1, status: ProcStatus = ProcStatus.PENDING, attempts: Int = 0, identityKey: String? = null) =
ProcState(id, status, identityKey = identityKey, attempts = attempts)
private fun deliveHandler() = object : Handler {
override val kind: MsgKind = MsgKind.Flop("DELY")
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision = Decision(
msgNotifies = listOf(NotifyPayload("""{"n":1}""")),
schdPush = listOf(SchdPush(flid = "F1", payloadJson = """{"FLID":"F1","v":2}""")),
)
}
private fun processor(
procState: FakeProcState, inbox: FakeInbox, events: FakeEvents, codec: FakeCodec,
registry: HandlerRegistry = HandlerRegistry(emptyList()),
backfillTodo: BackfillTodoRepository? = null,
): MessageProcessor {
val props = PipelineProps()
val scheduler = FailureScheduler(props, clock)
val procFailure = ProcFailure(procState, scheduler)
val flightSchd = FakeFlightSchd()
val txManager = FakeTxManager()
val snapshot = SnapshotFlow(procState, flightSchd, events, FakeReqTrack(), procFailure, txManager, inbox, backfillTodo = backfillTodo)
return MessageProcessor(inbox, procState, events, CodecHolder(codec), HandlerHolder(registry), flightSchd, snapshot, procFailure, props, txManager, backfillTodo)
}
// ---------- tests ----------
@Test
fun `no handler is FAILED UNSUPPORTED with backoff - never terminal`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.UNSUPPORTED, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
assertTrue(s.lastError?.startsWith("no-handler:FLOP-DELY") == true)
}
@Test
fun `CODEC_ERROR decode failure is FAILED retryable`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.CODEC_ERROR, "boom")) }
processor(ps, inbox, ev, codec).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.CODEC_ERROR, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
}
@Test
fun `MALFORMED decode failure stays DEAD terminal`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val codec = FakeCodec().apply { result = DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "bad-xml")) }
processor(ps, inbox, ev, codec).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.DEAD, s.state)
assertEquals(ErrorClass.MALFORMED, s.errorClass)
}
@Test
fun `legacy failed head at max attempts upgrades to DEAD at entry`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(status = ProcStatus.FAILED, attempts = 5, identityKey = "k")
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
assertEquals(ProcStatus.DEAD, ps.state(1).state)
assertEquals(ErrorClass.EXHAUSTED, ps.state(1).errorClass)
}
@Test
fun `success path inserts events backfills and marks SUCCEEDED`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head(); inbox.raws[1] = "<MSG/>"
val registry = HandlerRegistry(listOf(deliveHandler()))
processor(ps, inbox, ev, FakeCodec(), registry).processOne(ps.state(1))
assertEquals(ProcStatus.SUCCEEDED, ps.state(1).state)
val events = ev.inserted.single()
assertEquals(listOf(Targets.KAFKA_MSG, Targets.KAFKA_SCHD), events.map { it.target })
assertEquals("F1", events.first { it.target == Targets.KAFKA_SCHD }.partitionKey)
assertEquals(listOf(1L, "AODB", "FLOP", "DELY", 1L), inbox.backfilled.single())
}
@Test
fun `duplicate identity leads to SKIPPED`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.raws[1] = "<MSG/>"
ps.bound["AODB|FLOP|DELY|1"] = 2L // 另一条消息已持有该键
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.SKIPPED, s.state)
assertTrue(s.lastError == "duplicate-of:2")
assertTrue(ev.inserted.isEmpty())
}
@Test
fun `skipped duplicate still compensates mailbox backfill and persists todo on failure`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.raws[1] = "<MSG/>"
inbox.throwOnBackfill = IllegalStateException("mysql-down")
ps.bound["AODB|FLOP|DELY|1"] = 2L
val todo = com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo()
processor(ps, inbox, ev, FakeCodec(), backfillTodo = todo).processOne(ps.state(1))
// SKIPPED 不变,但共享信箱回填的补偿待办必须落库(否则重复报文被反复轮询)
assertEquals(ProcStatus.SKIPPED, ps.state(1).state)
assertEquals(1, todo.count())
assertEquals(1L, todo.tasks().single().cminmsgsId)
assertEquals("mysql-down", todo.lastErrorOf(1L))
}
// ---------- U08 边界化(评审要求①③) ----------
@Test
fun `unexpected exception maps to FAILED INFRA with backoff at boundary`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = RuntimeException("db-down")
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
val s = ps.state(1)
assertEquals(ProcStatus.FAILED, s.state)
assertEquals(ErrorClass.INFRA, s.errorClass)
assertEquals(1, s.attempts)
assertNotNull(s.nextAttemptAt)
assertTrue(s.lastError == "db-down")
}
@Test
fun `repeated unexpected exceptions escalate to DEAD EXHAUSTED at max attempts`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = RuntimeException("db-down")
val p = processor(ps, inbox, ev, FakeCodec())
var attempts = 0
while (ps.state(1).state != ProcStatus.DEAD && attempts < 10) {
p.processOne(ps.state(1))
clock.advance(60_000) // 推进时钟:让退避/毒丸时间语义确定
attempts++
}
val s = ps.state(1)
assertEquals(ProcStatus.DEAD, s.state, "should exhaust within maxAttempts(=5)")
assertEquals(ErrorClass.EXHAUSTED, s.errorClass)
assertTrue(s.lastError?.contains("attempts=5") == true)
assertEquals(5, s.attempts)
}
@Test
fun `fatal Error is not swallowed by exception recovery`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = AssertionError("fatal")
assertThrows(AssertionError::class.java) {
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
}
// 未被普通恢复逻辑改写成 FAILEDError 不落入 catch Exception
assertEquals(ProcStatus.PENDING, ps.state(1).state)
}
@Test
fun `InterruptedException restores flag and propagates`() {
val ps = FakeProcState(); val inbox = FakeInbox(); val ev = FakeEvents()
ps.record[1] = head()
inbox.throwOnRawOf = InterruptedException("stop")
assertThrows(InterruptedException::class.java) {
processor(ps, inbox, ev, FakeCodec()).processOne(ps.state(1))
}
assertTrue(Thread.interrupted(), "interrupt flag must be restored")
}
}
@@ -0,0 +1,205 @@
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.MetaFields
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.SnapshotFlag
import com.gzzn.omms.msgexchange.domain.SnapshotResult
import com.gzzn.omms.msgexchange.domain.Targets
import com.gzzn.omms.msgexchange.domain.flight.FlightState
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 com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
import com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.LocalDate
/**
* §5.1 applyScheduleRecords 不变量:重放幂等、完整性校验整包拒绝 DEAD(PROTOCOL)、
* 归属日冲突不落地、DELETED 不恢复且记冲突告警、成功路径版本推进+事件+留痕+待办预登记。
*/
class ScheduleProcessorTest {
private val msgId = 42L
private fun head() = ProcState(msgId, ProcStatus.PENDING)
private fun message(body: ScheduleBody) = DecodedMessage(
meta = MetaFields("AODB", "SCHD", "DNLD", 100L, 1L),
kind = MsgKind.Schd(MsgKind.SchdSubtype.DNLD),
rawXml = "<MSG/>",
body = body,
)
private fun makeBody(vararg recs: Pair<String, String>) = ScheduleBody(
recsDeclared = recs.size,
records = recs.map { (flid, sodt) ->
com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord(flid, mapOf("SODT" to sodt))
},
)
private class TxRunner(private val failOn: () -> Boolean) : PipelineTransactionManager {
var entered = 0
override fun <T> inTransaction(block: () -> T): T {
entered++
if (failOn()) throw IllegalStateException("db-down")
return block()
}
}
private fun processor(
proc: StubProcState = StubProcState(),
flights: StubFlightState = StubFlightState(),
events: StubMsgEvents = StubMsgEvents(),
log: StubSnapshotLog = StubSnapshotLog(),
todo: StubBackfillTodo? = StubBackfillTodo(),
tx: PipelineTransactionManager? = null,
): ScheduleProcessor {
val txRunner = tx ?: object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
return ScheduleProcessor(
txManager = txRunner,
lock = object : PipelineLockRepository { override fun lock() = Unit },
procState = proc,
flightState = flights,
msgEvents = events,
snapshotLog = log,
backfillTodo = todo,
operationDayProps = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 },
mapper = ObjectMapper(),
)
}
@Test
fun `happy path persists snapshot with version bump, events, snap log and backfill todo`() {
val flights = StubFlightState()
val events = StubMsgEvents()
val log = StubSnapshotLog()
val todo = StubBackfillTodo()
val result = processor(flights = flights, events = events, log = log, todo = todo)
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
assertEquals(ApplyResult.Succeeded, result)
val main = flights.findMainRow("121")!!
assertEquals(1L, main.stateVersion)
assertEquals(LocalDate.of(2026, 12, 15), main.operationDay)
assertEquals(FlightState.ACTIVE, main.state)
assertEquals(2, events.rows.size) // KAFKA_SCHD 整态 + KAFKA_MSG 通知
assertEquals(1, log.entries.size)
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
assertEquals(1, log.entries.single().upserted)
assertEquals(1, todo.count()) // §7.2 事务内预登记
}
@Test
fun `replay of succeeded message records idempotent success without writes`() {
val proc = StubProcState()
proc.insert(msgId, ProcStatus.SUCCEEDED)
val flights = StubFlightState()
val log = StubSnapshotLog()
val result = processor(proc = proc, flights = flights, log = log)
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
assertEquals(ApplyResult.ReplaySkipped, result) // §5.1 步骤 2
assertNull(flights.findMainRow("121"))
assertEquals(SnapshotResult.REPLAY_SKIPPED, log.entries.single().result)
}
@Test
fun `validation failure is DEAD protocol without any persistence`() {
val flights = StubFlightState()
val log = StubSnapshotLog()
val schdBody = ScheduleBody(recsDeclared = 2, records = makeBody("121" to "15DEC261723").records)
val result = processor(flights = flights, log = log).applyScheduleRecords(head(), message(schdBody)) as ApplyResult.DeadProtocol
assertTrue(result.flags.contains(SnapshotFlag.RECS_DROP)) // §5.2 RECS = 实收数
assertNull(flights.findMainRow("121"))
assertEquals(SnapshotResult.ROLLED_BACK, log.entries.single().result)
}
@Test
fun `same flid across operation days is rejected whole-batch without writes`() {
val flights = StubFlightState()
// 预置 121 已归属 12-15;报文声称 12-16 → §5.3 第四行
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1, mapOf("SODT" to "15DEC261723"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
val log = StubSnapshotLog()
val result = processor(flights = flights, log = log).applyScheduleRecords(
head(), message(makeBody("121" to "16DEC261723")),
)
assertTrue(result is ApplyResult.DeadProtocol)
assertEquals(LocalDate.of(2026, 12, 15), flights.findMainRow("121")!!.operationDay) // 不迁移归属
assertEquals(1L, flights.findMainRow("121")!!.stateVersion) // 不改写
assertEquals(SnapshotResult.ROLLED_BACK, log.entries.single().result)
}
@Test
fun `DELETED flight stays deleted with revive conflict flag`() {
val flights = StubFlightState()
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.DELETED, 7,
mapOf("SODT" to "15DEC261723"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
val log = StubSnapshotLog()
val result = processor(flights = flights, log = log).applyScheduleRecords(
head(), message(makeBody("121" to "15DEC261723")),
)
assertEquals(ApplyResult.Succeeded, result)
assertEquals(FlightState.DELETED, flights.findMainRow("121")!!.state) // §5.1 步骤 6 不恢复
assertEquals(8L, flights.findMainRow("121")!!.stateVersion)
assertEquals(SnapshotResult.COMMITTED, log.entries.single().result)
assertTrue(log.entries.single().flags.contains(SnapshotFlag.SCHD_REVIVE_CONFLICT))
}
@Test
fun `infra failure inside transaction throws and leaves nothing persisted`() {
val proc = StubProcState()
val flights = StubFlightState()
val log = StubSnapshotLog()
val todo = StubBackfillTodo()
proc.insert(msgId)
val p = processor(proc = proc, flights = flights, log = log, todo = todo, tx = TxRunner { true })
// §9:数据库/内部故障 → 异常上抛,MessageProcessor 记 FAILED(INFRA) 退避;不写任何终态
org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException::class.java) {
p.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
}
assertNull(flights.findMainRow("121"))
assertEquals(0, todo.count())
assertEquals(0, log.entries.size)
assertEquals(ProcStatus.PENDING, proc.find(msgId)!!.state)
}
}
@@ -1,118 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MetaFields
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.infra.persistence.CminmsgInboxRepository
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.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.config.PipelineProps
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/** v2 §2.6 / §5:提交后回填失败不得回滚 PROC_STATE。 */
class SnapshotFlowBackfillTest {
private class FailingInbox : CminmsgInboxRepository {
override fun insertRaw(rawXml: String): Long = 1L
override fun rawOf(cminmsgsId: Long): String? = null
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) {
throw RuntimeException("mysql-down")
}
}
@Test
fun `backfill failure after commit leaves PROC_STATE SUCCEEDED`() {
val flightSchd = StubFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val inbox = FailingInbox()
val clock = MutableClock(MutableClock.BASE)
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
val headId = 901L
procState.insert(headId, ProcStatus.PENDING)
val day = "2026-09-07"
val meta = MetaFields("AODB", "SCHD", "DNLD", 9L, 20260907090000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
SnapshotFlow.StageResult.parser = {
SnapshotFlow.StageResult.Ok(day, listOf("FL_X" to mapOf("FLID" to "FL_X")))
}
// v2 §5(P2-3):回填失败必须落持久补偿待办(否则重启后无从对账)
val todo = com.gzzn.omms.msgexchange.infra.stub.StubBackfillTodo()
val flow = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox, backfillTodo = todo)
flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertTrue(flightSchd.findByFlid("FL_X") != null)
assertEquals(1, todo.count())
val recorded = todo.tasks().single()
assertEquals(901L, recorded.cminmsgsId)
assertEquals("SCHD", recorded.type)
assertEquals("DNLD", recorded.styp)
assertEquals("mysql-down", todo.lastErrorOf(901L))
}
@Test
fun `CAS replay requires matching lastMessageId not version alone`() {
val base = StubFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = null
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val clock = MutableClock(MutableClock.BASE)
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), clock))
val headId = 902L
val day = "2026-09-07"
procState.insert(headId, ProcStatus.PENDING)
val conflictingGen = FlightSchdRepository.GenMeta(day, 2L, setOf("OTHER"), lastMessageId = "999")
base.putGenIfVersion(day, 0L, FlightSchdRepository.GenMeta(day, 1L, setOf("OTHER"), lastMessageId = "999"))
val mockFlightSchd = object : FlightSchdRepository by base {
override fun putGenIfVersion(
day: String,
expected: Long,
newGen: FlightSchdRepository.GenMeta,
now: java.time.Instant,
): Boolean {
base.putGenIfVersion(day, 1L, conflictingGen)
return false
}
}
SnapshotFlow.StageResult.parser = {
SnapshotFlow.StageResult.Ok(day, listOf("FL_Y" to mapOf("FLID" to "FL_Y")))
}
val meta = MetaFields("AODB", "SCHD", "DNLD", 10L, 20260907100000L)
val decoded = DecodedMessage(meta, MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
val flow = SnapshotFlow(procState, mockFlightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
flow.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
assertEquals(ProcStatus.FAILED, procState.snapshotOf(headId)!!.state)
}
}
@@ -1,111 +0,0 @@
package com.gzzn.omms.msgexchange.processing
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.config.PipelineProps
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.MetaFields
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.infra.persistence.CminmsgInboxRepository
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.processing.handlers.GtdtHandler
import com.gzzn.omms.msgexchange.support.GuardedFlightSchd
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.nio.file.Paths
import kotlin.io.path.readText
class V2PipelineWritePathContractTest {
@Test
fun `SnapshotFlow and MessageProcessor sources must not call legacy upsert helpers`() {
val processingDir = Paths.get("src/main/kotlin/com/gzzn/omms/msgexchange/processing")
val snapshotSource = processingDir.resolve("SnapshotFlow.kt").readText()
val pumpSource = processingDir.resolve("Pump.kt").readText()
assertFalse(snapshotSource.contains("upsertSnapshotBatch"))
assertFalse(snapshotSource.contains("upsertIncremental"))
assertTrue(snapshotSource.contains("persistNextStates"))
assertFalse(pumpSource.contains("upsertSnapshotBatch"))
assertFalse(pumpSource.contains("upsertIncremental"))
assertTrue(pumpSource.contains("persistNextStates"))
}
@Test
fun `SnapshotFlow DNLD path calls persistNextStates not legacy upsert helpers`() {
val flightSchd = GuardedFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = null
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), MutableClock(MutableClock.BASE)))
val headId = 1001L
procState.insert(headId, ProcStatus.PENDING)
val day = "2026-09-07"
SnapshotFlow.StageResult.parser = {
SnapshotFlow.StageResult.Ok(day, listOf("FL_DNLD" to mapOf("FLID" to "FL_DNLD", "FLNO" to "CA100", "GTDT" to """[{"GTNO":"1","GATE":"A1"}]""")))
}
val decoded = DecodedMessage(MetaFields("AODB", "SCHD", "DNLD", 11L, 20260907110000L), MsgKind.Schd(MsgKind.SchdSubtype.DNLD), "<SCHD/>")
SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
.publishSnapshot(ProcState(headId, ProcStatus.PENDING), decoded)
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
}
@Test
fun `MessageProcessor FLOP path calls persistNextStates not legacy upsert helpers`() {
val flightSchd = GuardedFlightSchd()
val procState = StubProcState()
val msgEvents = StubMsgEvents()
val reqTrack = StubReqTrack()
val txManager = StubPipelineTransactionManager()
val raws = mutableMapOf<Long, String>()
val inbox = object : CminmsgInboxRepository {
override fun insertRaw(rawXml: String) = 1L
override fun rawOf(cminmsgsId: Long) = raws[cminmsgsId]
override fun pollUnprocessed(afterId: Long, limit: Int): List<Long> = emptyList()
override fun backfillOnSuccess(cminmsgsId: Long, sndr: String, type: String, styp: String, seqn: Long) = Unit
}
val procFailure = ProcFailure(procState, FailureScheduler(PipelineProps(), MutableClock(MutableClock.BASE)))
val handler = GtdtHandler()
val snapshot = SnapshotFlow(procState, flightSchd, msgEvents, reqTrack, procFailure, txManager, inbox)
val processor = MessageProcessor(
inbox, procState, msgEvents,
CodecHolder(JacksonXmlCodec()),
HandlerHolder(HandlerRegistry(listOf(handler))),
flightSchd, snapshot, procFailure, PipelineProps(), txManager,
)
val headId = 1002L
procState.insert(headId, ProcStatus.PENDING)
raws[headId] = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>1</SEQN><DTTM>20260907120000</DTTM><TYPE>FLOP</TYPE><STYP>GTDT</STYP></META>
<FLOP><FLID>FL_FLOP</FLID><GTDT GTNO="2"><GATE>B9</GATE></GTDT></FLOP>
</MSG>
""".trimIndent()
processor.processOne(ProcState(headId, ProcStatus.PENDING))
assertEquals(ProcStatus.SUCCEEDED, procState.snapshotOf(headId)!!.state)
assertEquals(1, flightSchd.persistNextStatesCalls)
}
}
@@ -1,41 +0,0 @@
package com.gzzn.omms.msgexchange.processing.handlers
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class GtdtHandlerTest {
private val handler = GtdtHandler()
@Test
fun `decide replaces GTDT collection for three gates`() {
val msg = DecodedMessage(
meta = MetaFields("AODB", "FLOP", "GTDT", 1L, 20260908120000L),
kind = MsgKind.Flop("GTDT"),
rawXml = "<MSG/>",
body = FlopPayload(
flid = "121112312",
scalars = mapOf("FFID" to "CA-CA101-A-12DEC031345-D"),
collections = mapOf(
"GTDT" to listOf(
mapOf("GTNO" to "1", "GATE" to "G28", "GTYP" to "D"),
mapOf("GTNO" to "2", "GATE" to "G33", "GTYP" to "I"),
mapOf("GTNO" to "3", "GATE" to "G23", "GTYP" to "D"),
),
),
),
)
val decision = handler.decide(emptyMap(), msg)
assertEquals(1, decision.flightChanges.size)
assertEquals("121112312", decision.flightChanges[0].flid)
val gtdtJson = decision.flightChanges[0].fields["GTDT"]!!
assertEquals(true, gtdtJson.contains("\"G28\""))
assertEquals(1, decision.schdPush.size)
assertEquals("121112312", decision.schdPush[0].flid)
}
}
@@ -1,44 +0,0 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.FlightChange
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import java.time.Instant
/** 测试种子助手:v2 写路径(persistNextStates)等价替代已退场的 legacy upsertP4)。 */
fun seedSnapshot(
repo: FlightSchdRepository,
day: String,
flights: List<Pair<String, FlightFields>>,
now: Instant = Instant.now(),
) {
repo.persistNextStates(
day,
flights.map { (flid, fields) ->
FlightStateEngine.apply(
null,
FlightStateEngine.commandsFromFields(flid, fields),
"seed-$day",
bumpVersion = false,
).copy(stateVersion = 1L)
},
snapshotReplace = true,
now = now,
)
}
fun seedIncremental(repo: FlightSchdRepository, changes: List<FlightChange>, messageId: String = "seed-inc") {
repo.persistNextStates(
null,
changes.map { change ->
FlightStateEngine.apply(
repo.findNextStateByFlid(change.flid),
FlightStateEngine.commandsFromFields(change.flid, change.fields),
messageId,
bumpVersion = true,
)
},
snapshotReplace = false,
)
}
@@ -1,31 +0,0 @@
package com.gzzn.omms.msgexchange.support
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
import com.gzzn.omms.msgexchange.infra.stub.StubFlightSchd
import java.time.Instant
/** v2 写路径守卫(P4 退场后):persistNextStates 是唯一写路径,legacy upsert 入口已从接口删除。 */
class GuardedFlightSchd(
private val inner: FlightSchdRepository = StubFlightSchd(),
) : FlightSchdRepository {
var persistNextStatesCalls = 0
override fun persistNextStates(day: String?, states: List<FlightNextState>, snapshotReplace: Boolean, now: Instant) {
persistNextStatesCalls++
inner.persistNextStates(day, states, snapshotReplace, now)
}
override fun deleteDiffByDay(day: String, delFlids: Collection<String>): Int = inner.deleteDiffByDay(day, delFlids)
override fun findByFlid(flid: String): FlightFields? = inner.findByFlid(flid)
override fun findNextStateByFlid(flid: String): FlightNextState? = inner.findNextStateByFlid(flid)
override fun findByFlids(flids: Collection<String>): Map<String, FlightFields> = inner.findByFlids(flids)
override fun findByDay(day: String): List<Pair<String, FlightFields>> = inner.findByDay(day)
override fun findAll(): Map<String, FlightFields> = inner.findAll()
override fun deleteByFlids(flids: Set<String>): Int = inner.deleteByFlids(flids)
override fun getGen(day: String): FlightSchdRepository.GenMeta? = inner.getGen(day)
override fun putGenIfVersion(day: String, expected: Long, newGen: FlightSchdRepository.GenMeta, now: Instant): Boolean =
inner.putGenIfVersion(day, expected, newGen, now)
override fun deleteGenBefore(cutoffDay: String): Int = inner.deleteGenBefore(cutoffDay)
}
@@ -1,115 +0,0 @@
package com.gzzn.omms.msgexchange.tools
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class FlightStoreDiffToolTest {
private val tool = FlightStoreDiffTool()
@Test
fun `Field map comparison ignores legacy JSON key ordering differences`() {
val pg = mapOf(
"F1" to mapOf(
"flid" to "F1",
"airline" to "CA",
"flightNo" to "123",
"status" to "SCHD",
),
)
val legacy = mapOf(
"F1" to """{"status":"SCHD","flightNo":"123","airline":"CA","flid":"F1"}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Field map comparison normalizes numeric precision and null equivalents`() {
val pg = mapOf(
"F1" to mapOf(
"flid" to "F1",
"weight" to "100.0",
"delay" to "0",
),
)
val legacy = mapOf(
"F1" to """{"flid":"F1","weight":100,"delay":0.0,"extra":null}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Recognizes known deviation 1 - Cross-day migrated flights protected in PG`() {
// PG 中包含跨日迁移未被误删的航班,Legacy Redis 中已被误删
val pg = mapOf(
"F_STABLE" to mapOf("flid" to "F_STABLE"),
"F_MIGRATED" to mapOf("flid" to "F_MIGRATED", "FDAY" to "2026-09-08"),
)
val legacy = mapOf(
"F_STABLE" to """{"flid":"F_STABLE"}""",
// F_MIGRATED 在 legacy 中被按旧代差删误删
)
val report = tool.diff(pg, legacy, crossDayMigratedFlids = setOf("F_MIGRATED"))
assertTrue(report.isGreen) // 属于已知合法偏差,红绿灯依然为 GREEN
assertEquals(1, report.matchedCount)
assertEquals(1, report.knownDeviations.size)
assertEquals(FlightStoreDiffTool.DeviationKind.CROSS_DAY_PROTECTED, report.knownDeviations[0].kind)
assertEquals(0, report.unexpectedDeviations.size)
}
@Test
fun `Detects real field mismatch and missing flight as unexpected deviation`() {
val pg = mapOf(
"F1" to mapOf("flid" to "F1", "status" to "BOARDING"),
"F_EXTRA" to mapOf("flid" to "F_EXTRA"),
)
val legacy = mapOf(
"F1" to """{"flid":"F1","status":"DEPARTED"}""",
"F_MISSING" to """{"flid":"F_MISSING"}""",
)
val report = tool.diff(pg, legacy)
assertFalse(report.isGreen)
assertEquals(0, report.matchedCount)
assertEquals(3, report.unexpectedDeviations.size)
val kinds = report.unexpectedDeviations.map { it.kind }
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.FIELD_MISMATCH))
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.MISSING_IN_PG))
assertTrue(kinds.contains(FlightStoreDiffTool.DeviationKind.UNEXPECTED_EXTRA_IN_PG))
val summary = report.formatSummary()
assertTrue(summary.contains("RED (未通过)"))
assertTrue(summary.contains("F1"))
}
@Test
fun `Nested collection value is compared as parsed JSON tree`() {
val pg = mapOf(
"F1" to mapOf(
"flid" to "F1",
// 1:N 明细集合列:序列化文本(JSON 数组),按解析树与 legacy 递归比对
"GTDT" to """[{"GATE":"A1","PGOT":"07SEP260800"}]""",
),
)
val legacy = mapOf(
"F1" to """{"flid":"F1","GTDT":[{"PGOT":"07SEP260800","GATE":"A1"}]}""",
)
val report = tool.diff(pg, legacy)
assertTrue(report.isGreen)
assertEquals(1, report.matchedCount)
assertEquals(0, report.unexpectedDeviations.size)
}
}