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:
+16
-17
@@ -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 待发事件 → 投递 / 重试 │
|
||||||
└─────────────────────────┬──────────────────────────────┘
|
└─────────────────────────┬──────────────────────────────┘
|
||||||
├─ Kafka:msg / schd
|
├─ Kafka:msg / 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/RESP(FLTR 记录集,§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,47 +39,42 @@ 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(
|
|
||||||
DecodeFailure(ErrorClass.MALFORMED, "missing-root"),
|
|
||||||
)
|
|
||||||
if (!root.tagName.equals("MSG", ignoreCase = true)) {
|
|
||||||
return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "root-not-msg:${root.tagName}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val metaEl = firstChildElement(root, "META")
|
|
||||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-meta"))
|
|
||||||
val sndr = childText(metaEl, "SNDR")
|
|
||||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-sndr"))
|
|
||||||
val seqn = childText(metaEl, "SEQN")?.toLongOrNull()
|
|
||||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-seqn"))
|
|
||||||
val dttm = childText(metaEl, "DTTM")?.toLongOrNull()
|
|
||||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-dttm"))
|
|
||||||
val type = childText(metaEl, "TYPE")?.uppercase()
|
|
||||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
|
|
||||||
val styp = childText(metaEl, "STYP")?.uppercase()
|
|
||||||
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-styp"))
|
|
||||||
|
|
||||||
val kind = kindOf(type, styp)
|
|
||||||
val body = when (kind) {
|
|
||||||
is MsgKind.Flop -> parseFlopBody(root, type)
|
|
||||||
is MsgKind.Schd -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
DecodeResult.Ok(
|
|
||||||
DecodedMessage(
|
|
||||||
meta = MetaFields(sndr = sndr, type = type, styp = styp, seqn = seqn, dttm = dttm),
|
|
||||||
kind = kind,
|
|
||||||
rawXml = trimmed,
|
|
||||||
body = body,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} catch (e: NumberFormatException) {
|
|
||||||
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "invalid-number:${e.message}"))
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message ?: e.javaClass.simpleName}"))
|
return DecodeResult.Err(
|
||||||
|
DecodeFailure(ErrorClass.MALFORMED, "xml-parse:${e.message?.take(200) ?: e.javaClass.simpleName}"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
val meta = msg.meta
|
||||||
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-meta"))
|
||||||
|
val sndr = meta.sndr?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-sndr"))
|
||||||
|
val seqn = meta.seqn
|
||||||
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-seqn"))
|
||||||
|
val dttm = meta.dttm
|
||||||
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-or-invalid-dttm"))
|
||||||
|
val type = meta.type?.uppercase()
|
||||||
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-type"))
|
||||||
|
val styp = meta.styp?.uppercase()
|
||||||
|
?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-styp"))
|
||||||
|
|
||||||
|
val kind = kindOf(type, styp)
|
||||||
|
val body: Any? = when (kind) {
|
||||||
|
is MsgKind.Schd -> parseScheduleBody(msg)
|
||||||
|
is MsgKind.Flop -> parseFlightSection(msg.flop)
|
||||||
|
MsgKind.Fdel -> parseFlightSection(msg.fdel)
|
||||||
|
is MsgKind.Unsupported -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecodeResult.Ok(
|
||||||
|
DecodedMessage(
|
||||||
|
meta = MetaFields(sndr = sndr, type = type, styp = styp, seqn = seqn, dttm = dttm),
|
||||||
|
kind = kind,
|
||||||
|
rawXml = trimmed,
|
||||||
|
body = body,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun encodeRqrd(kind: String, rangeJson: String): String =
|
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/ADFT:RECS 缺失/非法 → -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)
|
when {
|
||||||
if (node.nodeType != Node.ELEMENT_NODE) continue
|
tag == "FLID" -> flid = value.toString().trim()
|
||||||
val el = node as Element
|
tag in COLLECTION_TAGS -> value.toCollectionItems()?.let { items ->
|
||||||
val tag = el.tagName.uppercase()
|
collections.getOrPut(tag) { mutableListOf() }.addAll(items)
|
||||||
when {
|
|
||||||
tag in COLLECTION_TAGS -> {
|
|
||||||
collections.getOrPut(tag) { mutableListOf() }.add(parseCollectionItem(el))
|
|
||||||
}
|
|
||||||
tag == "FLID" -> scalars["FLID"] = el.textContent.trim()
|
|
||||||
hasElementChildren(el) -> Unit
|
|
||||||
else -> scalars[tag] = el.textContent.trim()
|
|
||||||
}
|
}
|
||||||
|
value is String -> scalars[tag] = value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return flid?.takeIf { it.isNotEmpty() }?.let {
|
||||||
val flid = scalars.remove("FLID") ?: return null
|
FlopPayload(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() })
|
||||||
return FlopPayload(flid = flid, scalars = scalars, collections = collections.mapValues { it.value.toList() })
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseCollectionItem(el: Element): Map<String, String> {
|
/** FLTR Map → ScheduleRecord:标量/集合分离,集合项含源序号属性。 */
|
||||||
val item = linkedMapOf<String, String>()
|
private fun recordOf(fltr: Map<String, Any>): ScheduleRecord? {
|
||||||
if (el.hasAttributes()) {
|
val scalars = linkedMapOf<String, String>()
|
||||||
for (i in 0 until el.attributes.length) {
|
val collections = linkedMapOf<String, MutableList<Map<String, String>>>()
|
||||||
val attr = el.attributes.item(i)
|
var flid: String? = null
|
||||||
item[attr.nodeName.uppercase()] = attr.nodeValue.trim()
|
var seqn = 0L
|
||||||
}
|
fltr.forEach { (key, value) ->
|
||||||
}
|
val tag = key.uppercase()
|
||||||
el.childNodes.let { nodes ->
|
when {
|
||||||
for (i in 0 until nodes.length) {
|
tag == "FLID" -> flid = value.toString().trim()
|
||||||
val node = nodes.item(i)
|
tag == "SEQN" -> seqn = (value as? Number)?.toLong() ?: value.toString().trim().toLongOrNull() ?: 0L
|
||||||
if (node.nodeType != Node.ELEMENT_NODE) continue
|
tag in COLLECTION_TAGS -> value.toCollectionItems()?.let { items ->
|
||||||
val child = node as Element
|
collections.getOrPut(tag) { mutableListOf() }.addAll(items)
|
||||||
item[child.tagName.uppercase()] = child.textContent.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun firstChildElement(parent: Element, tag: String): Element? {
|
|
||||||
parent.childNodes.let { nodes ->
|
|
||||||
for (i in 0 until nodes.length) {
|
|
||||||
val node = nodes.item(i)
|
|
||||||
if (node.nodeType == Node.ELEMENT_NODE && (node as Element).tagName.equals(tag, ignoreCase = true)) {
|
|
||||||
return node
|
|
||||||
}
|
}
|
||||||
|
value is String -> scalars[tag] = value.trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return flid?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
ScheduleRecord(flid = it, scalars = scalars, collections = collections.mapValues { m -> m.value.toList() }, seqn = seqn)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun childText(parent: Element, tag: String): String? =
|
/**
|
||||||
firstChildElement(parent, tag)?.textContent?.trim()?.takeIf { it.isNotEmpty() }
|
* 集合值归一:重复元素 → List<Map>(属性与子元素展平);单元素 → 单项列表;
|
||||||
|
* 混合文本(如 DELY 备注文本)落到 REMC 键(XmlMapper 绑定的已知弱项,尽力保留)。
|
||||||
|
*/
|
||||||
|
private fun Any?.toCollectionItems(): List<Map<String, String>>? = when (this) {
|
||||||
|
is List<*> -> mapNotNull { it?.toCollectionItem() }
|
||||||
|
is Map<*, *> -> listOfNotNull(toCollectionItem(this))
|
||||||
|
is String -> listOf(mapOf("REMC" to trim()))
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasElementChildren(el: Element): Boolean {
|
private fun toCollectionItem(map: Map<*, *>): Map<String, String> {
|
||||||
el.childNodes.let { nodes ->
|
val out = linkedMapOf<String, String>()
|
||||||
for (i in 0 until nodes.length) {
|
map.forEach { (k, v) ->
|
||||||
if (nodes.item(i).nodeType == Node.ELEMENT_NODE) return true
|
val key = k.toString().uppercase()
|
||||||
|
when (v) {
|
||||||
|
is String -> out[key] = v.trim()
|
||||||
|
is Number -> out[key] = v.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Any?.toCollectionItem(): Map<String, String>? = when (this) {
|
||||||
|
is Map<*, *> -> toCollectionItem(this)
|
||||||
|
is String -> mapOf("REMC" to trim())
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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)。
|
||||||
* scalars:FLID/FFID 等标量;collections:GTDT/CKDT 等重复集合(含源序号属性)。
|
* scalars:FLID 以外的标量;collections:GTDT/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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHD(DNLD/RESP/ADFT)解析体(docs/flight-state.md §5)。
|
||||||
|
* RECS 声明数与实收 FLTR 记录分离承载,完整性校验(§5.2)在处理层执行;
|
||||||
|
* 协议无报文级覆盖日字段——scope 由处理层按记录 SODT 推导。
|
||||||
|
*/
|
||||||
|
data class ScheduleBody(
|
||||||
|
val recsDeclared: Int,
|
||||||
|
val records: List<ScheduleRecord>,
|
||||||
|
val scopeStart: LocalDate? = null,
|
||||||
|
val scopeEnd: LocalDate? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// XML 直接映射(jackson-dataformat-xml 数据绑定,不再手写 DOM 遍历)。
|
||||||
|
// 信封强类型(META/段结构),FLTR/航班段内标签为开放集 → Map<String, Any>
|
||||||
|
// 泛型承载(45+ 标签逐一建模不成比例);集合项 = 属性 + 子元素展平的 Map。
|
||||||
|
// 已知弱项:DELY 的混合文本(元素文本)在 XmlMapper 绑定下不保证保留,
|
||||||
|
// 属性 CODE/STRT/DURA 保留;备注文本待真实报文验收(§10)。
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class SisMessage(
|
||||||
|
@JacksonXmlProperty(localName = "META")
|
||||||
|
val meta: SisMeta? = null,
|
||||||
|
@JacksonXmlProperty(localName = "SCHD")
|
||||||
|
val schd: SchdSection? = null,
|
||||||
|
@JacksonXmlProperty(localName = "FLOP")
|
||||||
|
val flop: Map<String, Any>? = null,
|
||||||
|
@JacksonXmlProperty(localName = "FDEL")
|
||||||
|
val fdel: Map<String, Any>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class SisMeta(
|
||||||
|
@JacksonXmlProperty(localName = "SNDR")
|
||||||
|
val sndr: String? = null,
|
||||||
|
@JacksonXmlProperty(localName = "SEQN")
|
||||||
|
val seqn: Long? = null,
|
||||||
|
@JacksonXmlProperty(localName = "DTTM")
|
||||||
|
val dttm: Long? = null,
|
||||||
|
@JacksonXmlProperty(localName = "TYPE")
|
||||||
|
val type: String? = null,
|
||||||
|
@JacksonXmlProperty(localName = "STYP")
|
||||||
|
val styp: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** SCHD 段:RECS 声明数 + FLTR 记录集(SIS §3.16 样例结构)。 */
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class SchdSection(
|
||||||
|
@JacksonXmlProperty(localName = "RECS")
|
||||||
|
val recs: Int? = null,
|
||||||
|
@JacksonXmlElementWrapper(useWrapping = false)
|
||||||
|
@JacksonXmlProperty(localName = "FLTR")
|
||||||
|
val fltr: List<Map<String, Any>> = emptyList(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.config
|
||||||
|
|
||||||
|
import io.micronaut.context.annotation.ConfigurationProperties
|
||||||
|
|
||||||
|
/** 生命周期与保留期(docs/flight-state.md §8)。窗口按机场时区计算。 */
|
||||||
|
@ConfigurationProperties("msgx.history")
|
||||||
|
class HistoryProps {
|
||||||
|
/** 已取消(CNCL 非空)超过 N 小时。 */
|
||||||
|
var cancelledHours: Long = 48
|
||||||
|
|
||||||
|
/** 到港/离港终态(NAAT/NEAT,含义待术语表确认 §10)超过 N 小时。 */
|
||||||
|
var terminalHours: Long = 48
|
||||||
|
|
||||||
|
/** STATE = DELETED 超过 N 小时。 */
|
||||||
|
var deletedHours: Long = 48
|
||||||
|
|
||||||
|
/** 无终态字段:最后有效更新超过兜底期限(默认 7 天)。 */
|
||||||
|
var idleHours: Long = 24 * 7
|
||||||
|
|
||||||
|
/** 留痕 SCHD_SNAP_LOG 保留天数(§8.3,按 (SCOPE_END, RECV_AT) 清理)。 */
|
||||||
|
var snapLogRetentionDays: Long = 90
|
||||||
|
|
||||||
|
/** 是否接通历史存储——未接通时 HISTORY_SWEEP 必须删 0 条(§8.2)。 */
|
||||||
|
var historyStoreEnabled: Boolean = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.config
|
||||||
|
|
||||||
|
import io.micronaut.context.annotation.ConfigurationProperties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营日计算(docs/flight-state.md §3.5):由 SODT(ddMMMyyHHmm)与机场时区计算;
|
||||||
|
* 切日边界业务配置——不得假设等于接收日期或自然日零点(默认 0 点为占位,待业务确认 §10)。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties("msgx.operation-day")
|
||||||
|
class OperationDayProps {
|
||||||
|
/** 机场时区(IANA)。 */
|
||||||
|
var zone: String = "Asia/Shanghai"
|
||||||
|
|
||||||
|
/** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23)。 */
|
||||||
|
var cutoffHour: Int = 0
|
||||||
|
}
|
||||||
@@ -5,20 +5,16 @@ import java.time.Duration
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* ACMA-8 参数表(v4)初值;阶段 0 现网基线校准。
|
* ACMA-8 参数表(v4)初值;阶段 0 现网基线校准。
|
||||||
* U03(N02):Micronaut 要求嵌套配置类同样标注 @ConfigurationProperties,否则 msgx.pipeline/schd/
|
* U03(N02):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)
|
||||||
|
|
||||||
/** 阶段 B:ES flight_hts 写入。 */
|
/**
|
||||||
fun indexFlightHts(payloadJson: String)
|
* KAFKA_SCHD 整态(key=FLID)。KAFKA_MSG 与 KAFKA_SCHD 映射同一 topic 语义由适配层定;
|
||||||
|
* target→topic:KAFKA:msg→"msg",KAFKA:schd→"schd"。
|
||||||
|
*/
|
||||||
|
fun sendKafkaSchd(topic: String, key: String, payloadJson: String)
|
||||||
|
|
||||||
|
/** TOMBSTONE:key=FLID、value=null——整态键缺失表示删除旧值(§7.3)。 */
|
||||||
|
fun sendKafkaNull(topic: String, key: String)
|
||||||
|
|
||||||
/** 连通性探测(健康检查用);默认 true,真实 Kafka 实装时覆写为 producer metadata 校验。 */
|
/** 连通性探测(健康检查用);默认 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;
|
||||||
* N03(U06):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 // N03:schd 唯一出口 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)(DLQ,attempts 落库审计),否则退避重试。 */
|
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(到期再试)
|
try {
|
||||||
|
when (e.eventType) {
|
||||||
|
EventType.TOMBSTONE -> port.sendKafkaNull("schd", e.partitionKey)
|
||||||
|
EventType.UPSERT -> port.sendKafkaSchd("schd", e.partitionKey, e.payloadJson)
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
failures.add(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val payload = SchdAggregation.latestPerFlight(pending).joinToString(",", "[", "]")
|
val sentIds = batch.mapNotNull { it.eventId }.toSet() - failures.mapNotNull { it.eventId }.toSet()
|
||||||
log.debug("flushSchd batch size={} payloadLen={}", pending.size, payload.length)
|
if (sentIds.isNotEmpty()) msgEvents.markAllSent(sentIds.toList())
|
||||||
try {
|
// 被最新版本合并压掉的未发事件同样关闭(§7.3:同 FLID 只按最新 STATE_VERSION 输出一次)
|
||||||
port.sendKafka("schd", payload)
|
val sentVersions = batch.associate { it.partitionKey to it.stateVersion }
|
||||||
} catch (e: Exception) {
|
runCatching {
|
||||||
log.warn("flushSchd send failed batch={} -> batch retryOrDead: {}", pending.size, e.message)
|
while (true) {
|
||||||
pending.forEach { retryOrDead(it, "schd-send: ${e.message ?: "unknown"}") }
|
val superseded = msgEvents.mergePendingSchd(props.schd.flushLimit)
|
||||||
return // 不推进 lastFlush:整批退避(含 DEAD 出队)后到期重试
|
.filter { sentVersions[it.partitionKey]?.let { v -> it.stateVersion < v } == true }
|
||||||
}
|
if (superseded.isEmpty()) break
|
||||||
msgEvents.markAllSent(pending.mapNotNull { it.eventId })
|
msgEvents.markAllSent(superseded.mapNotNull { it.eventId })
|
||||||
log.info("flushSchd sent batch={}", pending.size)
|
}
|
||||||
|
}.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)(DLQ,attempts 落库审计),否则退避重试。 */
|
||||||
|
private fun retryOrDead(e: MsgEvent, lastError: String) {
|
||||||
|
val eventId = e.eventId ?: return
|
||||||
|
val attempts = e.attempts + 1
|
||||||
|
if (scheduler.exhausted(attempts)) {
|
||||||
|
msgEvents.markDead(eventId, ErrorClass.EXHAUSTED, lastError, attempts)
|
||||||
|
} else {
|
||||||
|
msgEvents.scheduleRetry(eventId, scheduler.nextAttemptAt(attempts), attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun sleepQuietly(d: Duration) {
|
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 流程 2:Handler 决策(纯函数)产物——状态与报文进,变更与事件出,
|
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 + 穷尽 when,ACMA-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 // 运行动态 STYP(FDEL 除外)
|
||||||
|
data object Fdel : MsgKind
|
||||||
|
|
||||||
|
/** 未支持类型(§9:FAILED(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 自阶段 A;ES:flight_hts、REDIS:flightInfo 仅阶段 B 投影期。
|
|
||||||
*/
|
|
||||||
object Targets {
|
|
||||||
const val KAFKA_MSG = "KAFKA:msg"
|
|
||||||
const val KAFKA_SCHD = "KAFKA:schd"
|
|
||||||
const val ES_FLIGHT_HTS = "ES:flight_hts"
|
|
||||||
@Deprecated("Retired in ACM2-28: Redis projection removed")
|
|
||||||
const val REDIS_FLIGHT_INFO = "REDIS:flightInfo"
|
|
||||||
|
|
||||||
/** 阶段 A 投递目标(仅 Kafka)——ACM2-28:Delivery 阶段 A/B 均不写 Redis。 */
|
|
||||||
val phaseA: List<String> = listOf(KAFKA_MSG, KAFKA_SCHD)
|
|
||||||
|
|
||||||
/** 阶段 B 追加投影目标(仅 ES 历史库;Redis 投影按 ACM2-28 废弃)。 */
|
|
||||||
val phaseB: List<String> = phaseA + listOf(ES_FLIGHT_HTS)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** MSG_EVENT 投递状态(outbox 状态机)。 */
|
||||||
enum class EventStatus { PENDING, SENT, DEAD }
|
enum class EventStatus { PENDING, SENT, DEAD }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSG_EVENT(outbox,§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 事件恒为 FLID(v4 显式声明)
|
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):由计划运行时间字段 SODT(ddMMMyyHHmm)
|
||||||
|
* 与机场时区计算;切日边界业务配置——默认 0 点为占位,待业务确认(§10)。
|
||||||
|
*/
|
||||||
|
class OperationDayCalculator(
|
||||||
|
zone: ZoneId,
|
||||||
|
cutoffHour: Int,
|
||||||
|
) {
|
||||||
|
private val zone: ZoneId = zone
|
||||||
|
/** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23,越界按 0)。 */
|
||||||
|
private val cutoffHour: Int = cutoffHour.coerceIn(0, 23)
|
||||||
|
|
||||||
|
/** SODT → 运营日;输入 null/空/非法返回 null(不抛异常,调用方按"运营日不可计算"处理)。 */
|
||||||
|
fun compute(sodt: String?): LocalDate? {
|
||||||
|
if (sodt.isNullOrBlank()) return null
|
||||||
|
val local = parseSodt(sodt.trim()) ?: return null
|
||||||
|
val day = local.atZone(zone).toLocalDate()
|
||||||
|
return if (local.hour < cutoffHour) day.minusDays(1) else day
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* SIS SODT 线格式:ddMMMyyHHmm(如 15DEC031723),月份英文三字母、大小写不敏感。
|
||||||
|
* 两位年显式按 2000 基准展开(java.time 的 yy reduced-value 解析跨实现不一致,
|
||||||
|
* 显式展开保证 AODB 侧年份窗口唯一口径;基准年待真实报文验收确认 §10)。
|
||||||
|
*/
|
||||||
|
private val SODT_REGEX = Regex("(\\d{1,2})([A-Za-z]{3})(\\d{2})(\\d{2})(\\d{2})")
|
||||||
|
|
||||||
|
private val MONTHS = mapOf(
|
||||||
|
"JAN" to 1, "FEB" to 2, "MAR" to 3, "APR" to 4, "MAY" to 5, "JUN" to 6,
|
||||||
|
"JUL" to 7, "AUG" to 8, "SEP" to 9, "OCT" to 10, "NOV" to 11, "DEC" to 12,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun parseSodt(raw: String): LocalDateTime? {
|
||||||
|
val m = SODT_REGEX.matchEntire(raw.uppercase()) ?: return null
|
||||||
|
val (d, mon, yy, hh, mm) = m.destructured
|
||||||
|
val month = MONTHS[mon] ?: return null
|
||||||
|
return runCatching {
|
||||||
|
LocalDateTime.of(2000 + yy.toInt(), month, d.toInt(), hh.toInt(), mm.toInt())
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
package com.gzzn.omms.msgexchange.domain
|
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|SEQN;decode 后首次绑定,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
|
||||||
|
|
||||||
/** 终态皆可归档(矩阵 #12:SUCCEEDED ∪ SKIPPED ∪ DEAD);PENDING/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.3:KAFKA_SCHD 发整态、KAFKA_MSG 只通知变化;
|
||||||
|
* 两主题间不保证顺序)。ES 投影属阶段 B,暂不登记目标。
|
||||||
|
*/
|
||||||
|
object Targets {
|
||||||
|
const val KAFKA_MSG = "KAFKA:msg"
|
||||||
|
const val KAFKA_SCHD = "KAFKA:schd"
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.domain.flight
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显式字段命令(docs/flight-state.md §4)。
|
|
||||||
*/
|
|
||||||
sealed interface ScalarCommand {
|
|
||||||
data object Unchanged : ScalarCommand
|
|
||||||
data class Set(val value: String) : ScalarCommand
|
|
||||||
data object Clear : ScalarCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface CollectionCommand {
|
|
||||||
data object Unchanged : CollectionCommand
|
|
||||||
data class Replace(val items: List<Map<String, String>>) : CollectionCommand
|
|
||||||
data object Clear : CollectionCommand
|
|
||||||
data class Apply(val item: Map<String, String>, val sourceSeq: String) : CollectionCommand
|
|
||||||
}
|
|
||||||
|
|
||||||
data class FlightFieldCommands(
|
|
||||||
val flid: String,
|
|
||||||
val scalars: Map<String, ScalarCommand> = emptyMap(),
|
|
||||||
val collections: Map<String, CollectionCommand> = emptyMap(),
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.domain.flight
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 航班实例当前态模型(docs/flight-state.md §3)。
|
||||||
|
*
|
||||||
|
* 身份:FLID 唯一关联键;OPERATION_DAY 一经确定不可变(§3.5/§5.3);
|
||||||
|
* STATE 仅 ACTIVE/DELETED(§3.1,无 ARCHIVED——物理清除只发生在历史归档成功之后 §8.2)。
|
||||||
|
*/
|
||||||
|
enum class FlightState { ACTIVE, DELETED }
|
||||||
|
|
||||||
|
/** FLIGHT_SCHD 主行的身份与追踪字段(不含标量载荷)。 */
|
||||||
|
data class FlightMainRow(
|
||||||
|
val flid: String,
|
||||||
|
val operationDay: LocalDate?,
|
||||||
|
val state: FlightState,
|
||||||
|
val stateVersion: Long,
|
||||||
|
val lastMsgId: Long?,
|
||||||
|
val updatedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHD DNLD/RESP 单条 FLTR 记录(解码产物,§5 入口)。
|
||||||
|
* scalars/collections 只含报文中出现的字段——完整快照语义下
|
||||||
|
* 出现 = Set/Replace,未出现 = 清除/替换空集(§5.4)。
|
||||||
|
*/
|
||||||
|
data class ScheduleRecord(
|
||||||
|
val flid: String,
|
||||||
|
val scalars: Map<String, String>,
|
||||||
|
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
|
||||||
|
val seqn: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整快照落库载荷(§5.1 步骤 6):按记录整体替换映射内字段,
|
||||||
|
* 仓储层负责明细先删后插与 `OPERATION_DAY` 不可变条件更新(§7.4)。
|
||||||
|
*/
|
||||||
|
data class SnapshotPatch(
|
||||||
|
val flid: String,
|
||||||
|
val operationDay: LocalDate,
|
||||||
|
val scalars: Map<String, String>,
|
||||||
|
val collections: Map<String, List<Map<String, String>>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 快照 upsert 结果(§5.1 步骤 6:DELETED 航班保持 DELETED 并告警,不恢复)。 */
|
||||||
|
enum class UpsertOutcome { INSERTED, UPDATED, REVIVE_CONFLICT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FLOP/ADFT 增量载荷(§6.1/§2.1)。
|
||||||
|
* scalars 出现 = Set,缺失 = 保留本地值;collections 出现 = Replace
|
||||||
|
* (FLOP 集合语义未定案前沿用 Replace,§10 当前偏差明示)。
|
||||||
|
*/
|
||||||
|
data class MergeChange(
|
||||||
|
val flid: String,
|
||||||
|
val scalars: Map<String, String>,
|
||||||
|
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整当前态(§3.3):主行 + 全部明细 = 完整当前态;
|
||||||
|
* 读取须在一致性读事务中,且展示层过滤 STATE = ACTIVE(§9)。
|
||||||
|
*/
|
||||||
|
data class FlightSnapshot(
|
||||||
|
val flid: String,
|
||||||
|
val operationDay: LocalDate?,
|
||||||
|
val state: FlightState,
|
||||||
|
val stateVersion: Long,
|
||||||
|
val scalars: Map<String, String>,
|
||||||
|
val collections: Map<String, List<Map<String, String>>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** §8.1 历史判定窗口(按机场时区计算;窗口值业务配置)。 */
|
||||||
|
data class HistoryRules(
|
||||||
|
val cancelledHours: Long = 48, // CNCL 非空超过 N 小时
|
||||||
|
val terminalHours: Long = 48, // NAAT/NEAT 终态超过 N 小时(字段含义待术语表确认 §10)
|
||||||
|
val deletedHours: Long = 48, // STATE = DELETED 超过 N 小时
|
||||||
|
val idleHours: Long = 24 * 7, // 无终态字段:最后更新超过兜底期限
|
||||||
|
)
|
||||||
|
|
||||||
|
/** §8.1 命中历史判定的航班(归档 → 物理清除候选)。 */
|
||||||
|
data class HistoryCandidate(
|
||||||
|
val flid: String,
|
||||||
|
val state: FlightState,
|
||||||
|
val stateVersion: Long,
|
||||||
|
/** 是否未经 FDEL 而被生命周期清除——清除前须补发一次删除事件(§7.3/§8.2)。 */
|
||||||
|
val wasNeverFdel: Boolean,
|
||||||
|
)
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.domain.flight
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
|
||||||
|
|
||||||
data class FlightNextState(
|
|
||||||
val flid: String,
|
|
||||||
val scalars: Map<String, String>,
|
|
||||||
val collections: Map<String, List<Map<String, String>>>,
|
|
||||||
val stateVersion: Long,
|
|
||||||
val lastMessageId: String,
|
|
||||||
/** 本次迁移中被显式清除的标量/异常/文本键(仓储据此写 NULL 列;快照全量替换下无意义)。 */
|
|
||||||
val clearedKeys: Set<String> = emptySet(),
|
|
||||||
) {
|
|
||||||
fun toFlightFields(mapper: ObjectMapper = ObjectMapper()): FlightFields {
|
|
||||||
val out = linkedMapOf<String, String>()
|
|
||||||
out["FLID"] = flid
|
|
||||||
scalars.forEach { (k, v) -> out[k] = v }
|
|
||||||
collections.forEach { (key, items) ->
|
|
||||||
out[key] = mapper.writeValueAsString(items)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +1,134 @@
|
|||||||
package com.gzzn.omms.msgexchange.domain.flight
|
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;
|
||||||
|
* 快照内不重复;每条记录运营日可计算且在报文覆盖范围内。任一失败整包不落地。
|
||||||
|
*/
|
||||||
|
fun validateMessage(
|
||||||
|
recsDeclared: Int,
|
||||||
|
records: List<ScheduleRecord>,
|
||||||
|
scopeStart: LocalDate?,
|
||||||
|
scopeEnd: LocalDate?,
|
||||||
|
opDay: OperationDayCalculator,
|
||||||
|
): SnapshotValidation {
|
||||||
|
val flags = linkedSetOf<SnapshotFlag>()
|
||||||
|
if (recsDeclared !in 0..9999) return SnapshotValidation.Invalid("RECS out of range: $recsDeclared", setOf(SnapshotFlag.RECS_DROP))
|
||||||
|
if (records.size != recsDeclared) return SnapshotValidation.Invalid(
|
||||||
|
"RECS ($recsDeclared) != received FLTR count (${records.size})",
|
||||||
|
setOf(SnapshotFlag.RECS_DROP),
|
||||||
|
)
|
||||||
|
if (records.isEmpty()) return SnapshotValidation.Ok(emptyMap(), setOf(SnapshotFlag.EMPTY))
|
||||||
|
|
||||||
/** 异常/单值载荷的显式清除形态:空串、null 字面量、空对象(Oracle 空串即 NULL 语义的显式来源)。 */
|
val perRecordDay = linkedMapOf<String, LocalDate>()
|
||||||
private fun isClearPayload(value: String): Boolean {
|
val seen = linkedSetOf<String>()
|
||||||
val trimmed = value.trim()
|
for (record in records) {
|
||||||
if (trimmed.isEmpty() || trimmed == "null") return true
|
if (!record.flid.matches(FLID_REGEX)) {
|
||||||
val node = runCatching { mapper.readTree(trimmed) }.getOrNull() ?: return false
|
return SnapshotValidation.Invalid("illegal FLID: '${record.flid}'", emptySet())
|
||||||
return node.isNull || (node.isObject && node.size() == 0)
|
}
|
||||||
|
if (!seen.add(record.flid)) {
|
||||||
|
return SnapshotValidation.Invalid("duplicate FLID in snapshot: ${record.flid}", emptySet())
|
||||||
|
}
|
||||||
|
val day = opDay.compute(record.scalars["SODT"])
|
||||||
|
if (day == null) {
|
||||||
|
return SnapshotValidation.Invalid("operation day not computable, flid=${record.flid}", setOf(SnapshotFlag.DAY_MISMATCH))
|
||||||
|
}
|
||||||
|
val inScope = (scopeStart == null || !day.isBefore(scopeStart)) &&
|
||||||
|
(scopeEnd == null || !day.isAfter(scopeEnd))
|
||||||
|
if (!inScope) {
|
||||||
|
return SnapshotValidation.Invalid(
|
||||||
|
"operation day $day outside coverage [$scopeStart, $scopeEnd], flid=${record.flid}",
|
||||||
|
setOf(SnapshotFlag.DAY_MISMATCH),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
perRecordDay[record.flid] = day
|
||||||
|
}
|
||||||
|
return SnapshotValidation.Ok(perRecordDay, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val SEQ_ATTR = mapOf(
|
/**
|
||||||
"GTDT" to "GTNO",
|
* 完整快照语义(§5.4 DNLD/RESP):标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
|
||||||
"CKDT" to "CKNO",
|
* `keepDeleted = true` 时 STATE 保持 DELETED(§5.1 步骤 6:普通 SCHD 不恢复)。
|
||||||
"CLDT" to "CLNO",
|
*/
|
||||||
"PSDT" to "PSNO",
|
fun snapshotState(
|
||||||
"CHDT" to "CHNO",
|
current: FlightSnapshot?,
|
||||||
"ABTM" to "ASNO",
|
record: ScheduleRecord,
|
||||||
"CHOT" to "CSNO",
|
operationDay: LocalDate,
|
||||||
"ROUT" to "RTNO",
|
keepDeleted: Boolean,
|
||||||
"ERUT" to "RTNO",
|
): FlightSnapshot {
|
||||||
)
|
val state = when {
|
||||||
// 注意:DELY 无协议序号属性(DLNO 非法)→ 不支持 Apply;清除走空数组 Replace(空集)
|
current == null -> FlightState.ACTIVE
|
||||||
|
keepDeleted -> FlightState.DELETED
|
||||||
/** DNLD/FLOP 字段集 → 命令(出现即 Set/Replace;未出现即 Unchanged;序号 0 条目 = 显式清除标记)。 */
|
else -> current.state
|
||||||
fun commandsFromFields(flid: String, fields: FlightFields): FlightFieldCommands {
|
}
|
||||||
val scalars = linkedMapOf<String, ScalarCommand>()
|
// §5.4 完整快照:标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
|
||||||
val collections = linkedMapOf<String, CollectionCommand>()
|
// 完整替换 = 新状态只由记录决定,不从 current 继承任何字段。
|
||||||
fields.forEach { (key, value) ->
|
val scalars: Map<String, String> = record.scalars
|
||||||
if (key == "FLID") return@forEach
|
val collections = buildMap {
|
||||||
when {
|
COLLECTION_KEYS.forEach { key -> put(key, emptyList()) } // 缺失 = Replace 空集(§5.4)
|
||||||
key in COLLECTION_KEYS -> {
|
record.collections.forEach { (key, items) ->
|
||||||
val seqAttr = SEQ_ATTR[key]
|
if (key in COLLECTION_KEYS) put(key, items)
|
||||||
val parsed = parseCollection(value)
|
|
||||||
val markers = parsed.filter { seqAttr != null && it[seqAttr] == "0" }
|
|
||||||
collections[key] = when {
|
|
||||||
markers.isEmpty() -> CollectionCommand.Replace(parsed)
|
|
||||||
markers.size == parsed.size -> CollectionCommand.Clear
|
|
||||||
else -> throw IllegalArgumentException(
|
|
||||||
"$key mixes explicit clear marker (seq=0) with regular items; refusing to guess",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
key in EXCEPTION_KEYS && isClearPayload(value) -> scalars[key] = ScalarCommand.Clear
|
|
||||||
else -> scalars[key] = ScalarCommand.Set(value)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return FlightFieldCommands(flid, scalars, collections)
|
return FlightSnapshot(
|
||||||
}
|
flid = record.flid,
|
||||||
|
operationDay = operationDay,
|
||||||
fun apply(
|
state = state,
|
||||||
current: FlightNextState?,
|
stateVersion = (current?.stateVersion ?: 0L) + 1,
|
||||||
commands: FlightFieldCommands,
|
scalars = scalars,
|
||||||
messageId: String,
|
collections = collections,
|
||||||
bumpVersion: Boolean,
|
|
||||||
): FlightNextState {
|
|
||||||
val baseScalars = current?.scalars?.toMutableMap() ?: mutableMapOf()
|
|
||||||
val baseCollections = current?.collections?.mapValues { it.value.toMutableList() }
|
|
||||||
?.toMutableMap() ?: mutableMapOf()
|
|
||||||
val baseVersion = current?.stateVersion ?: 0L
|
|
||||||
val cleared = mutableSetOf<String>()
|
|
||||||
|
|
||||||
commands.scalars.forEach { (key, cmd) ->
|
|
||||||
when (cmd) {
|
|
||||||
ScalarCommand.Unchanged -> Unit
|
|
||||||
is ScalarCommand.Set -> {
|
|
||||||
baseScalars[key] = cmd.value
|
|
||||||
cleared.remove(key)
|
|
||||||
}
|
|
||||||
ScalarCommand.Clear -> {
|
|
||||||
baseScalars.remove(key)
|
|
||||||
cleared += key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commands.collections.forEach { (key, cmd) ->
|
|
||||||
when (cmd) {
|
|
||||||
CollectionCommand.Unchanged -> Unit
|
|
||||||
is CollectionCommand.Replace -> baseCollections[key] = cmd.items.toMutableList()
|
|
||||||
CollectionCommand.Clear -> baseCollections.remove(key)
|
|
||||||
is CollectionCommand.Apply -> {
|
|
||||||
val seqAttr = SEQ_ATTR[key]
|
|
||||||
?: throw IllegalArgumentException("$key does not support Apply: protocol defines no source sequence attribute")
|
|
||||||
val list = baseCollections.getOrPut(key) { mutableListOf() }.toMutableList()
|
|
||||||
val idx = list.indexOfFirst { it[seqAttr] == cmd.sourceSeq }
|
|
||||||
if (idx >= 0) {
|
|
||||||
list[idx] = cmd.item
|
|
||||||
} else {
|
|
||||||
list.add(cmd.item)
|
|
||||||
}
|
|
||||||
baseCollections[key] = list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val nextVersion = if (bumpVersion) baseVersion + 1 else baseVersion
|
|
||||||
return FlightNextState(
|
|
||||||
flid = commands.flid,
|
|
||||||
scalars = baseScalars,
|
|
||||||
collections = baseCollections.mapValues { it.value.toList() },
|
|
||||||
stateVersion = nextVersion,
|
|
||||||
lastMessageId = messageId,
|
|
||||||
clearedKeys = cleared.toSet(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ->
|
val collections = buildMap {
|
||||||
item.properties().associate { (k, v) ->
|
putAll(current.collections)
|
||||||
k to when {
|
change.collections.forEach { (key, items) ->
|
||||||
v.isNull -> ""
|
if (key in COLLECTION_KEYS) put(key, items)
|
||||||
v.isValueNode -> v.asText()
|
|
||||||
else -> v.toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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) ->
|
/** §5.2 校验结果:Ok 携带每条记录的归属运营日与观测 flags;Invalid 整包拒绝。 */
|
||||||
if (key == "FLID") return@forEach
|
sealed interface SnapshotValidation {
|
||||||
if (key in COLLECTION_KEYS) {
|
data class Ok(
|
||||||
collections[key] = parseCollection(value)
|
val perRecordDay: Map<String, LocalDate>,
|
||||||
} else {
|
val flags: Set<SnapshotFlag>,
|
||||||
scalars[key] = value
|
) : SnapshotValidation
|
||||||
}
|
|
||||||
}
|
data class Invalid(
|
||||||
return FlightNextState(flid, scalars, collections, stateVersion, lastMessageId)
|
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 表职责)。
|
||||||
* 存储边界:自有 PostgreSQL(datasources.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?
|
||||||
|
|
||||||
/** I3:identity 首次绑定;返回 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 置回 PENDING(ATTEMPTS=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 删除事件。 */
|
|
||||||
fun insertSync(events: List<MsgEvent>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 完整态落库结果:DAY_GUARD_VIOLATION = OPERATION_DAY 不可变条件更新未命中(§7.4)。 */
|
||||||
|
enum class PersistOutcome { INSERTED, UPDATED, DAY_GUARD_VIOLATION }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 阶段 A 运营航班权威与日计划代(ACM2-28 定案 + ACM2-29 全宽表·零子表定案):
|
* 航班当前态权威(§3.2 FLIGHT_SCHD + 9 张明细表)。
|
||||||
* - 表 FLIGHT_SCHD:一行一航班的运营航班宽表(FLID 主键 + FDAY 可空所属代 + SCHD.FLTR 标量字段列
|
* 唯一写路径 = persistFullState:写入前在内存生成完整新状态(引擎产物)再落库,
|
||||||
* + 集合平铺标量列/紧凑 VARCHAR 字串列),零子表、零 CLOB,字段即列、天然可索引可直查,
|
* 明细集合按组先删后插(§3.3)。
|
||||||
* PG/Oracle 11g 方言一致;读侧视图由平铺列重建集合键,与 legacy flightInfo hash 同构;
|
|
||||||
* - 运营资源消息语义 = 单资源集合级全量快照替换;外层航班增量仍为字段级合并(仅新增/覆盖);
|
|
||||||
* - 表 PIPELINE_LOCK:单写者行级互斥(SELECT ... FOR UPDATE,PG/11g 同构,无 DBMS_LOCK 特权依赖);
|
|
||||||
* - 表 SCHD_GEN:各日代版本(SQL CAS);表 SCHD_GEN_FLID:当前代有效航班 FLID 集合(差删依据);
|
|
||||||
* - Redis 退出动态权威与全部写路径;事务 2 与快照发布全在自有库内单事务原子提交;
|
|
||||||
* - 增量更新(FLOP/ADFT):新插 FDAY=NULL,已有行保留原 FDAY,按字段列更新
|
|
||||||
* (与 legacy flightInfo hash「仅新增/覆盖、不删除缺失字段」同语义);
|
|
||||||
* - 快照(DNLD):声明/更新 FDAY 归属,航班字段整体替换;
|
|
||||||
* - 按代差删域化:DELETE FROM FLIGHT_SCHD WHERE FDAY = :day AND FLID IN (:diffSet)
|
|
||||||
* 仅删除仍属旧代的行,ADFT(FDAY=NULL)与已迁移至新代的同 FLID 行天然存活。
|
|
||||||
*/
|
*/
|
||||||
interface FlightSchdRepository {
|
interface FlightStateRepository {
|
||||||
data class FlightRecord(
|
/** 主行点查(身份/版本判定;批量用于快照归属校验 §5.3)。 */
|
||||||
val flid: String,
|
fun findMainRow(flid: String): FlightMainRow?
|
||||||
val fday: String?,
|
|
||||||
val fields: FlightFields,
|
|
||||||
val createdAt: Instant,
|
|
||||||
val updatedAt: Instant,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class GenMeta(
|
fun findMainRows(flids: Collection<String>): Map<String, FlightMainRow>
|
||||||
val fday: String,
|
|
||||||
val version: Long,
|
|
||||||
val flids: Set<String>,
|
|
||||||
val lastMessageId: String? = null,
|
|
||||||
val updatedAt: Instant = Instant.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** v2(P4 退场):legacy upsertSnapshotBatch/upsertIncremental 已删除——唯一写路径是 persistNextStates。 */
|
/** 完整当前态:主行 + 全部明细(一致性读边界由调用方事务保证,§9)。 */
|
||||||
|
fun loadFullSnapshot(flid: String): FlightSnapshot?
|
||||||
/** 按代差删域化:仅删除 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 集合;空集合不执行;分批参数化删除。
|
* 完整当前态落库(§3.3):主行 upsert + 全部明细按组先删后插;
|
||||||
* 返回实际删除行数(允许重放时为 0)。
|
* STATE_VERSION 以 snapshot.stateVersion 落库。
|
||||||
|
* SCHD(整体替换/清除)、FLOP/ADFT(合并后全量写)共用此唯一写路径。
|
||||||
|
* 条件更新带 `WHERE operation_day IS NULL OR operation_day = :day`(§7.4 不可变强化)。
|
||||||
*/
|
*/
|
||||||
fun deleteByFlids(flids: Set<String>): Int
|
fun persistFullState(snapshot: FlightSnapshot, msgId: Long, now: Instant): PersistOutcome
|
||||||
|
|
||||||
/** 读计划代(SCHD_GEN)。 */
|
|
||||||
fun getGen(day: String): GenMeta?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计划代 SQL 版本 CAS:仅当 expected 与数据库中当前版本一致(或不存在且 expected=0)时写入/推进新版本。
|
* FDEL(§6.2):ACTIVE → 置 DELETED、推进版本、明细保留、返回 true(发布删除事件);
|
||||||
* 返回 true 表示推进成功,false 表示发生 CAS 冲突。
|
* 已 DELETED 或不存在 → 返回 false(幂等成功,不推进版本不重复发布)。
|
||||||
*/
|
*/
|
||||||
fun putGenIfVersion(day: String, expected: Long, newGen: GenMeta, now: Instant = Instant.now()): Boolean
|
fun markDeleted(flid: String, msgId: Long, now: Instant): Boolean
|
||||||
|
|
||||||
|
/** ADFT 生命周期重激活(§6.3):DELETED → ACTIVE,推进版本;非 DELETED 返回 false。 */
|
||||||
|
fun revive(flid: String, msgId: Long, now: Instant): Boolean
|
||||||
|
|
||||||
|
/** §8.1:按窗口规则选出历史候选(含 DELETED;候选时间按机场时区折算)。 */
|
||||||
|
fun findHistoryCandidates(rules: HistoryRules, zone: ZoneId, now: Instant): List<HistoryCandidate>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 历史代清理:清理 cutoffDay 之前的历史代记录(与 FLIGHT_SCHD 历史清场生命周期对齐)。
|
* §8.2 步骤 3:物理删除主行与明细(仅历史存储确认成功后调用;
|
||||||
|
* 历史存储未接通时调用方必须传空集合——删 0 条)。
|
||||||
*/
|
*/
|
||||||
fun deleteGenBefore(cutoffDay: String): Int
|
fun purgeArchived(flids: Collection<String>): Int
|
||||||
|
|
||||||
/**
|
/** §8.3 观测:OPERATION_DAY 仍为 NULL 的航班数(只增不删,终止规则未定 §10)。 */
|
||||||
* v2 无损明细:将 nextState 写入宽表 + 明细表,并更新追踪字段。
|
fun countOperationDayNull(): Int
|
||||||
* 必须在 PipelineTransactionManager 事务内调用。
|
|
||||||
*/
|
|
||||||
fun persistNextStates(
|
|
||||||
day: String?,
|
|
||||||
states: List<com.gzzn.omms.msgexchange.domain.flight.FlightNextState>,
|
|
||||||
snapshotReplace: Boolean,
|
|
||||||
now: Instant = Instant.now(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 事务管理器抽象:自有 PG 单事务原子保障。 */
|
/** §5.5 SCHD_SNAP_LOG:事务外追加留痕,不参与决策;一行 = 一次尝试(重放也记)。 */
|
||||||
interface PipelineTransactionManager {
|
interface SnapshotLogRepository {
|
||||||
fun <T> inTransaction(block: () -> T): T
|
fun append(entry: SnapshotLogEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求状态机(§7.1):只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)
|
||||||
|
* 匹配最新一条 PENDING;同类请求只留一条有效,新请求置旧为 EXPIRED。
|
||||||
|
*/
|
||||||
|
interface ReqTrackRepository {
|
||||||
|
enum class ReqState { PENDING, SENT, DONE, EXPIRED }
|
||||||
|
|
||||||
|
data class Req(
|
||||||
|
val reqId: Long,
|
||||||
|
val reqType: String,
|
||||||
|
val operationDay: LocalDate,
|
||||||
|
val sender: String,
|
||||||
|
val state: ReqState,
|
||||||
|
val coutmsgsId: Long? = null,
|
||||||
|
val sentAt: Instant? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 登记新请求:同类(类型+运营日+发送方)旧有效请求先置 EXPIRED。 */
|
||||||
|
fun insert(reqType: String, operationDay: LocalDate, sender: String): Long
|
||||||
|
|
||||||
|
fun findLatest(reqType: String, operationDay: LocalDate, sender: String, states: List<ReqState>): Req?
|
||||||
|
|
||||||
|
/** RESP 完成请求(§7.1):匹配最新一条 PENDING/SENT;无匹配返回 false(迟到不报错)。 */
|
||||||
|
fun completeLatest(reqType: String, operationDay: LocalDate, sender: String): Boolean
|
||||||
|
|
||||||
|
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
|
||||||
|
|
||||||
|
fun markSent(reqId: Long, sentAt: Instant)
|
||||||
|
|
||||||
|
fun expire(reqId: Long)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v2 §5(ACM2-29 P2-3):提交后共享信箱回填的持久补偿待办。
|
* 共享信箱回填补偿待办(§7.2):业务事务内预登记(消除崩溃窗口 §10 偏差),
|
||||||
* 业务事务已提交(PROC_STATE=SUCCEEDED)后 backfillOnSuccess 失败 → 落本表重试;
|
* 提交后由 BackfillSweepJob 重试;回填失败不得把 SUCCEEDED 改回 FAILED。
|
||||||
* 回填失败不得把已成功的业务事务重新标记为失败,也不得重放业务变更。
|
|
||||||
*/
|
*/
|
||||||
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/PIPELINE,N19 对齐)。
|
* 主路径 JDBC 轮询读 + 处理回填;compat HTTP 写;出站写 COUTMSGS 由出站适配层承担。
|
||||||
* 与主链弱事务耦合:写入者为 ReferenceService(21 类同步)与请求应答路径。
|
|
||||||
*/
|
|
||||||
interface StaticRefRepository {
|
|
||||||
fun upsertAll(refs: List<RefUpsert>)
|
|
||||||
|
|
||||||
fun findByType(type: String): List<RefUpsert>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 15 类请求状态机——自有 PG `REQ_TRACK`(ACM2-12;Reference & Query 域)。
|
|
||||||
* 与共享库 COUTMSGS(出站信箱)跨库:先 COUTMSGS 落库成功 → 再 markSent,补偿重扫,最终一致。
|
|
||||||
*/
|
|
||||||
interface ReqTrackRepository {
|
|
||||||
data class Req(
|
|
||||||
val reqId: Long,
|
|
||||||
val reqType: String,
|
|
||||||
val state: String, // REGISTERED/SENT/WAITING/DONE/EXPIRED
|
|
||||||
val sentAt: Instant? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun findOpenByKind(kind: String): Req?
|
|
||||||
|
|
||||||
fun forceExpireOpenOf(kind: String)
|
|
||||||
|
|
||||||
fun insert(kind: String, paramsJson: String): Long
|
|
||||||
|
|
||||||
fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
|
|
||||||
|
|
||||||
fun markSent(reqId: Long, sentAt: Instant)
|
|
||||||
|
|
||||||
fun expireIfWaiting(reqId: Long)
|
|
||||||
|
|
||||||
fun markDone(reqId: Long)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 泵作业调度记录——自有 PG `PUMP_JOB`(ACM2-12)。
|
|
||||||
* 决策 1 修订:作业不插队,仅在消息队头空闲/退避窗口由主泵执行(跨库/异队列无全序);
|
|
||||||
* 作业动作本身(归档写共享库 CMINMSGS_HST、清场删 ES+PG 等)仍在各自目标存储。
|
|
||||||
*/
|
|
||||||
interface PumpJobRepository {
|
|
||||||
data class Job(val jobId: Long, val kind: String) // ARCHIVE/HISTORY_SWEEP/PROJECTION_REBUILD
|
|
||||||
|
|
||||||
fun enqueue(kind: String)
|
|
||||||
|
|
||||||
fun headQueued(): Job?
|
|
||||||
|
|
||||||
fun markRunning(jobId: Long)
|
|
||||||
|
|
||||||
fun markDone(jobId: Long)
|
|
||||||
|
|
||||||
fun markFailed(jobId: Long, lastError: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 共享信箱 CMINMSGS 访问(ACM2-12:库属他人系统,本系统不建表)。
|
|
||||||
*
|
|
||||||
* **主路径(生产)**:`pollNew` / `rawOf` — JDBC 轮询/读取上游外部写入的报文(U05 InboxPoller)。
|
|
||||||
* **compat 路径**:`insertRaw` — HTTP `POST /cminmsgs/send` 辅助写(手工/对拍,非主拓扑)。
|
|
||||||
*
|
|
||||||
* 入队模型:发现或 compat 写得到 CMINMSGS_ID → 自有 PG 建 PROC_STATE(PENDING);
|
|
||||||
* PG 建行失败以共享库 DATE_PROCESSED IS NULL 重扫补建。
|
|
||||||
* `backfillOnSuccess` = 处理成功后的外部回填(DATE_PROCESSED/STATUS,最终一致)。
|
|
||||||
*/
|
*/
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
-162
@@ -1,162 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightNextState
|
|
||||||
import java.time.Instant
|
|
||||||
import javax.sql.DataSource
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v2 明细表读写(docs/flight-state.md §3.2)。
|
|
||||||
* 主表保存标量,明细表保存重复集合;不存在旧槽位读写回退。
|
|
||||||
*/
|
|
||||||
internal object FlightDetailTables {
|
|
||||||
private data class TableSpec(
|
|
||||||
val table: String,
|
|
||||||
val collectionKey: String,
|
|
||||||
val seqAttr: String,
|
|
||||||
val columns: List<Pair<String, String>>, // json attr -> db column
|
|
||||||
)
|
|
||||||
|
|
||||||
private val SPECS = listOf(
|
|
||||||
TableSpec("flight_gate", "GTDT", "GTNO", listOf(
|
|
||||||
"GATE" to "gate", "PGOT" to "pgot", "PGCT" to "pgct", "GOTM" to "gotm", "GCTM" to "gctm", "GTYP" to "gtyp",
|
|
||||||
)),
|
|
||||||
TableSpec("flight_checkin", "CKDT", "CKNO", listOf(
|
|
||||||
"CHKC" to "chkc", "CCLS" to "ccls", "PCOT" to "pcot", "PCCT" to "pcct", "COTM" to "cotm", "CCTM" to "cctm", "CTYP" to "ctyp",
|
|
||||||
)),
|
|
||||||
TableSpec("flight_belt", "CLDT", "CLNO", listOf(
|
|
||||||
"BELT" to "belt", "BCLS" to "bcls", "PCOT" to "pcot", "PCCT" to "pcct", "FBAG" to "fbag", "LBAG" to "lbag", "BTYP" to "btyp",
|
|
||||||
)),
|
|
||||||
TableSpec("flight_stand_plan", "PSDT", "PSNO", listOf(
|
|
||||||
"PSST" to "psst", "STST" to "stst", "STET" to "stet",
|
|
||||||
)),
|
|
||||||
TableSpec("flight_chute", "CHDT", "CHNO", listOf(
|
|
||||||
"CHUT" to "chut", "CHCLS" to "chcls", "PCBT" to "pcbt", "PCET" to "pcet", "CBTM" to "cbtm", "CETM" to "cetm", "CHTYP" to "chtyp",
|
|
||||||
)),
|
|
||||||
TableSpec("flight_delay", "DELY", "DLNO", listOf(
|
|
||||||
"CODE" to "code", "STRT" to "strt", "DURA" to "dura", "REMC" to "remc",
|
|
||||||
)),
|
|
||||||
TableSpec("flight_bridge_op", "ABTM", "ASNO", listOf(
|
|
||||||
"ABDG" to "abdg", "ABOP" to "abop", "AOTM" to "aotm",
|
|
||||||
)),
|
|
||||||
TableSpec("flight_chock_op", "CHOT", "CSNO", listOf(
|
|
||||||
"CHID" to "chid", "CHST" to "chst", "CHTM" to "chtm",
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun replaceAll(ds: DataSource, state: FlightNextState, now: Instant) {
|
|
||||||
val ts = now.toSqlTimestamp()
|
|
||||||
val conn = ds.obtainConnection()
|
|
||||||
try {
|
|
||||||
for (spec in SPECS) {
|
|
||||||
ds.update("DELETE FROM ${spec.table} WHERE flid = ?") { ps -> ps.setString(1, state.flid) }
|
|
||||||
val items = state.collections[spec.collectionKey] ?: continue
|
|
||||||
if (items.isEmpty()) continue
|
|
||||||
val colNames = listOf("flid", "ordinal", "source_seq", "record_version") +
|
|
||||||
spec.columns.map { it.second } + listOf("created_at", "updated_at")
|
|
||||||
val sql = "INSERT INTO ${spec.table} (${colNames.joinToString(", ")}) VALUES (${
|
|
||||||
colNames.joinToString(", ") { "?" }
|
|
||||||
})"
|
|
||||||
conn.prepareStatement(sql).use { ps ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
var i = 1
|
|
||||||
ps.setString(i++, state.flid)
|
|
||||||
ps.setInt(i++, index + 1)
|
|
||||||
ps.setString(i++, item[spec.seqAttr])
|
|
||||||
ps.setLong(i++, state.stateVersion)
|
|
||||||
spec.columns.forEach { (attr, _) -> ps.setString(i++, item[attr]) }
|
|
||||||
ps.setTimestamp(i++, ts)
|
|
||||||
ps.setTimestamp(i++, ts)
|
|
||||||
ps.addBatch()
|
|
||||||
}
|
|
||||||
ps.executeBatch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
replaceRoutes(ds, state, now)
|
|
||||||
} finally {
|
|
||||||
conn.releaseIfNotInTransaction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun replaceRoutes(ds: DataSource, state: FlightNextState, now: Instant) {
|
|
||||||
val ts = now.toSqlTimestamp()
|
|
||||||
ds.update("DELETE FROM flight_route_point WHERE flid = ?") { ps -> ps.setString(1, state.flid) }
|
|
||||||
listOf("ROUT" to "ROUT", "ERUT" to "ERUT").forEach { (key, kind) ->
|
|
||||||
val items = state.collections[key] ?: return@forEach
|
|
||||||
if (items.isEmpty()) return@forEach
|
|
||||||
val conn = ds.obtainConnection()
|
|
||||||
try {
|
|
||||||
conn.prepareStatement(
|
|
||||||
"INSERT INTO flight_route_point (flid, ordinal, source_seq, record_version, route_kind, apcd, scat, scdt, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
).use { ps ->
|
|
||||||
items.forEachIndexed { index, item ->
|
|
||||||
ps.setString(1, state.flid)
|
|
||||||
ps.setInt(2, index + 1)
|
|
||||||
ps.setString(3, item["RTNO"])
|
|
||||||
ps.setLong(4, state.stateVersion)
|
|
||||||
ps.setString(5, kind)
|
|
||||||
ps.setString(6, item["APCD"])
|
|
||||||
ps.setString(7, item["SCAT"])
|
|
||||||
ps.setString(8, item["SCDT"])
|
|
||||||
ps.setTimestamp(9, ts)
|
|
||||||
ps.setTimestamp(10, ts)
|
|
||||||
ps.addBatch()
|
|
||||||
}
|
|
||||||
ps.executeBatch()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
conn.releaseIfNotInTransaction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteForFlids(ds: DataSource, flids: Collection<String>) {
|
|
||||||
if (flids.isEmpty()) return
|
|
||||||
val tables = SPECS.map { it.table } + "flight_route_point"
|
|
||||||
for (table in tables) {
|
|
||||||
for (chunk in flids.chunked(200)) {
|
|
||||||
val placeholders = chunk.joinToString(",") { "?" }
|
|
||||||
ds.update("DELETE FROM $table WHERE flid IN ($placeholders)") { ps ->
|
|
||||||
chunk.forEachIndexed { i, flid -> ps.setString(i + 1, flid) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadCollections(ds: DataSource, flid: String): Map<String, String> {
|
|
||||||
val out = linkedMapOf<String, String>()
|
|
||||||
val mapper = com.fasterxml.jackson.databind.ObjectMapper()
|
|
||||||
for (spec in SPECS) {
|
|
||||||
val rows = ds.query(
|
|
||||||
"SELECT * FROM ${spec.table} WHERE flid = ? ORDER BY ordinal ASC",
|
|
||||||
{ ps -> ps.setString(1, flid) },
|
|
||||||
) { rs ->
|
|
||||||
// 只输出非空属性(缺失键语义),保证 write→read 线格式一致(评审 F5)
|
|
||||||
spec.columns.associate { (attr, col) -> attr to rs.getString(col) }
|
|
||||||
.filterValues { it != null }
|
|
||||||
.toMutableMap()
|
|
||||||
.apply { rs.getString("source_seq")?.let { put(spec.seqAttr, it) } }
|
|
||||||
}
|
|
||||||
if (rows.isNotEmpty()) {
|
|
||||||
out[spec.collectionKey] = mapper.writeValueAsString(rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listOf("ROUT" to "ROUT", "ERUT" to "ERUT").forEach { (key, kind) ->
|
|
||||||
val rows = ds.query(
|
|
||||||
"SELECT source_seq, apcd, scat, scdt FROM flight_route_point WHERE flid = ? AND route_kind = ? ORDER BY ordinal ASC",
|
|
||||||
{ ps -> ps.setString(1, flid); ps.setString(2, kind) },
|
|
||||||
) { rs ->
|
|
||||||
buildMap {
|
|
||||||
rs.getString("source_seq")?.let { put("RTNO", it) }
|
|
||||||
rs.getString("apcd")?.let { put("APCD", it) }
|
|
||||||
rs.getString("scat")?.let { put("SCAT", it) }
|
|
||||||
rs.getString("scdt")?.let { put("SCDT", it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rows.isNotEmpty()) {
|
|
||||||
out[key] = mapper.writeValueAsString(rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FLIGHT_SCHD 读侧视图组装(v2 权威读,P4 退场后唯一读路径)。
|
|
||||||
*
|
|
||||||
* - 标量列直读 + 无界集合文本列(SRVT/VIPF/MAFL)
|
|
||||||
* - FDIV/FRET/FLAB 由主表前缀标量列重建对象
|
|
||||||
* - 10 类重复集合由明细表重建(保序、保源序号),legacy 槽位/里程碑列已随 V1.4.0 退场
|
|
||||||
*
|
|
||||||
* 过渡期三条读路径(assembleMerged/assembleLegacySlotsOnly)与 FS7 双读对拍
|
|
||||||
* 已随回退窗口关闭一并移除(V1.4.0,ACM2-29 P4)。
|
|
||||||
*/
|
|
||||||
object FlightSchdReadAssembler {
|
|
||||||
|
|
||||||
/** SCHD.FLTR 标量列 + legacy 派生列(与 V1.1.0 迁移一致)。 */
|
|
||||||
val SCALAR_COLUMNS: List<String> = listOf(
|
|
||||||
"ALCD", "ALSC", "FLNO", "MVIN", "SODT", "FLTY", "FLIN", "ACFT", "RENO",
|
|
||||||
"TAOP", "TAFL", "TAID", "TRML", "MAXP", "CSOP", "CSFT", "MAID",
|
|
||||||
"ESTT", "ACTT", "STND", "PHAG", "CNCL", "REMC", "BOTM", "LACL", "FINT",
|
|
||||||
"APPT", "EGSR", "EGST", "FHAG", "MHAG", "VIPP", "VIPR", "LBNO", "LBWT",
|
|
||||||
"PAXC", "EXSC", "EXSR", "FTSS", "PEDT", "NEAT", "PADT", "NAAT",
|
|
||||||
"ABDG", "LPSDT", "ABN",
|
|
||||||
)
|
|
||||||
|
|
||||||
val EXCEPTION_COLUMNS: List<String> = listOf(
|
|
||||||
"FDIV_DDES", "FDIV_DDIR", "FDIV_REMC", "FRET_REID", "FRET_RSN",
|
|
||||||
"FLAB_ARES", "FLAB_RSN",
|
|
||||||
)
|
|
||||||
|
|
||||||
val TEXT_COLUMNS: List<String> = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT")
|
|
||||||
|
|
||||||
/** 库列名全集(读侧 SELECT 稳定顺序;与 V1.4.0 后表结构一致)。 */
|
|
||||||
val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS
|
|
||||||
|
|
||||||
private val mapper = ObjectMapper()
|
|
||||||
|
|
||||||
/** v2 权威读:标量 + 异常 + 文本列 + 明细表集合。 */
|
|
||||||
fun assembleDetailOnly(
|
|
||||||
flid: String,
|
|
||||||
row: Map<String, String?>,
|
|
||||||
detailCollections: Map<String, String>,
|
|
||||||
): FlightFields {
|
|
||||||
val fields = assembleScalarsAndExceptions(flid, row)
|
|
||||||
detailCollections.forEach { (key, value) -> fields[key] = value }
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assembleScalarsAndExceptions(flid: String, row: Map<String, String?>): LinkedHashMap<String, String> {
|
|
||||||
val fields = linkedMapOf<String, String>()
|
|
||||||
fields["FLID"] = flid
|
|
||||||
SCALAR_COLUMNS.forEach { column -> row[column]?.let { fields[column] = it } }
|
|
||||||
TEXT_COLUMNS.forEach { column -> row[column]?.let { fields[column.removeSuffix("_TEXT")] = it } }
|
|
||||||
exceptionView("FDIV", listOf("DDES" to "FDIV_DDES", "DDIR" to "FDIV_DDIR", "REMC" to "FDIV_REMC"), row)
|
|
||||||
?.let { fields["FDIV"] = it }
|
|
||||||
exceptionView("FRET", listOf("REID" to "FRET_REID", "RSN" to "FRET_RSN"), row)
|
|
||||||
?.let { fields["FRET"] = it }
|
|
||||||
exceptionView("FLAB", listOf("ARES" to "FLAB_ARES", "RSN" to "FLAB_RSN"), row)
|
|
||||||
?.let { fields["FLAB"] = it }
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun exceptionView(key: String, attrs: List<Pair<String, String>>, row: Map<String, String?>): String? {
|
|
||||||
val node = mapper.createObjectNode()
|
|
||||||
attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
|
|
||||||
return if (node.size() == 0) null else mapper.writeValueAsString(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-4
@@ -27,10 +27,10 @@ class JdbcCminmsgInboxRepository(
|
|||||||
ps.setString(1, rawXml)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+568
-700
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.infra.pipeline
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
|
||||||
import com.gzzn.omms.msgexchange.processing.CodecHolder
|
|
||||||
import com.gzzn.omms.msgexchange.processing.Handler
|
|
||||||
import com.gzzn.omms.msgexchange.processing.HandlerHolder
|
|
||||||
import com.gzzn.omms.msgexchange.processing.HandlerRegistry
|
|
||||||
import io.micronaut.context.annotation.Bean
|
|
||||||
import io.micronaut.context.annotation.Factory
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
/** 主泵解码与 Handler 装配(stub / 实装模式共用真实 XmlCodec + 已注册 Handler)。 */
|
|
||||||
@Factory
|
|
||||||
class PipelineHolderFactory {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@Singleton
|
|
||||||
fun codecHolder(codec: JacksonXmlCodec): CodecHolder = CodecHolder(codec)
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@Singleton
|
|
||||||
fun handlerHolder(handlers: List<Handler>): HandlerHolder = HandlerHolder(HandlerRegistry(handlers))
|
|
||||||
}
|
|
||||||
@@ -20,14 +20,14 @@ class ProcFailure(
|
|||||||
val attempts = head.attempts + 1
|
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* U07:stub 适配层——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
|
||||||
|
@Requires(property = "msgx.stubs", value = "true")
|
||||||
|
class StubPipelineTx : PipelineTransactionManager {
|
||||||
|
override fun <T> inTransaction(block: () -> T): T = block()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Requires(property = "msgx.stubs", value = "true")
|
||||||
|
class StubPipelineLock : PipelineLockRepository {
|
||||||
|
override fun lock() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Requires(property = "msgx.stubs", value = "true")
|
||||||
class StubProcState : ProcStateRepository {
|
class StubProcState : ProcStateRepository {
|
||||||
private val rows = linkedMapOf<Long, ProcState>()
|
val rows = linkedMapOf<Long, ProcState>()
|
||||||
private val bound = mutableMapOf<String, Long>()
|
val bound = linkedMapOf<String, Long>()
|
||||||
|
|
||||||
fun clear() { rows.clear(); bound.clear() }
|
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)
|
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()
|
||||||
|
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()
|
val id = ids.incrementAndGet()
|
||||||
raws[id] = rawXml
|
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/T07:saveSync 接线前判史为空集——禁止“全量可删”默认
|
|
||||||
val success = esArchiver?.invoke(history) ?: emptySet() // 仅收集已确认写入 ES 的成功 FLID 集合
|
|
||||||
if (success.isNotEmpty()) {
|
|
||||||
flightSchd.deleteByFlids(success) // FS2/FS4:仅删除已确认写入 ES 的成功集合,分批参数化删除
|
|
||||||
}
|
|
||||||
val cutoff = cutoffProvider?.invoke()
|
|
||||||
if (cutoff != null) {
|
|
||||||
flightSchd.deleteGenBefore(cutoff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** U10/T07(修订):接入 ES success 集之前的门禁——占位默认返回空集;
|
|
||||||
* 现役五条判史规则(SODT 3 天 / CNCL 1 小时 / 备降 / 离港 / 到港)golden 通过后才允许接线。 */
|
|
||||||
private fun pickHistory(all: Map<String, FlightFields>): Map<String, FlightFields> =
|
|
||||||
historyPicker?.invoke(all) ?: emptyMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 流程 5 runJob(ARCHIVE):3:00 归档——1 天前且仅终态可迁(矩阵 #12)。 */
|
|
||||||
@Singleton
|
|
||||||
class ArchiveJob(
|
|
||||||
// TODO(阶段1后续): CMINMSGS⇆CMINMSGS_HST 迁移 SQL:JOIN PROC_STATE,STATE ∈ {SUCCEEDED,DEAD,SKIPPED}
|
|
||||||
) {
|
|
||||||
fun run() {
|
|
||||||
// TODO: 迁移(DEAD 带 ERROR_CLASS、SKIPPED 带 duplicate-of 审计随行保留)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 流程 7:阶段 B 投影重建——切入阶段 B 时全量重建一次,此后增量走事件。 */
|
|
||||||
@Singleton
|
|
||||||
class ProjectionRebuildJob(
|
|
||||||
private val flightSchd: FlightSchdRepository,
|
|
||||||
private val msgEvents: MsgEventRepository,
|
|
||||||
) {
|
|
||||||
fun run() {
|
|
||||||
for (day in activeDays()) {
|
|
||||||
val flights = flightSchd.findByDay(day)
|
|
||||||
flights.forEach { (flid, fields) ->
|
|
||||||
msgEvents.insertSync(
|
|
||||||
listOf(
|
|
||||||
MsgEvent(
|
|
||||||
target = Targets.KAFKA_SCHD,
|
|
||||||
partitionKey = flid,
|
|
||||||
payloadJson = FlightFieldsJson.toJson(fields),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun activeDays(): List<String> = emptyList() // TODO(阶段B)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.jobs
|
||||||
|
|
||||||
|
import com.gzzn.omms.msgexchange.config.HistoryProps
|
||||||
|
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 维护作业调度(docs/flight-state.md §8):单 daemon 线程,独立于主泵——
|
||||||
|
* 旧「作业不插队 PUMP_JOB 队列」机制随审计口径移除(§3.2 表清单无 PUMP_JOB);
|
||||||
|
* 历史归档/留痕清理均为内部清理路径,不参与 FIFO 消息序。
|
||||||
|
* 触发:回填补偿 30s 固定间隔;历史归档/留痕清理每日 03:30(机场时区)后首个 tick。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class JobRunner(
|
||||||
|
private val backfillSweep: BackfillSweepJob,
|
||||||
|
private val historySweep: HistorySweepJob,
|
||||||
|
@Suppress("unused") private val pipelineProps: PipelineProps,
|
||||||
|
@Suppress("unused") private val historyProps: HistoryProps,
|
||||||
|
) {
|
||||||
|
private val log = org.slf4j.LoggerFactory.getLogger(JobRunner::class.java)
|
||||||
|
private val zone: ZoneId = ZoneId.of("Asia/Shanghai")
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var running = true
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastHistoryDay: LocalDate? = null
|
||||||
|
|
||||||
|
private var thread: Thread? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (thread != null) return
|
||||||
|
running = true
|
||||||
|
thread = Thread.ofPlatform().name("msgx-jobs").daemon(true).start { loop() }
|
||||||
|
log.info("job runner started (backfill 30s, history daily 03:30 {})", zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
thread?.interrupt()
|
||||||
|
thread = null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loop() {
|
||||||
|
while (running) {
|
||||||
|
try {
|
||||||
|
backfillSweep.sweep()
|
||||||
|
maybeHistorySweep()
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("job tick failed: {}", e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
if (running) runCatching { Thread.sleep(Duration.ofSeconds(30).toMillis()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maybeHistorySweep() {
|
||||||
|
val today = LocalDate.now(zone)
|
||||||
|
if (lastHistoryDay == today) return
|
||||||
|
val now = java.time.LocalTime.now(zone)
|
||||||
|
if ((now.hour == 3 && now.minute >= 30) || now.hour > 3) {
|
||||||
|
val outcome = historySweep.run()
|
||||||
|
lastHistoryDay = today
|
||||||
|
if (outcome.selected > 0 || outcome.snapLogPurged > 0) {
|
||||||
|
log.info("history sweep: {}", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.processing
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||||
|
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||||
|
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||||
|
import com.gzzn.omms.msgexchange.domain.EventType
|
||||||
|
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||||
|
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||||
|
import com.gzzn.omms.msgexchange.domain.Targets
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.MergeChange
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FLOP(§6.1):读取完整当前态 → 合并变化 → 保留运营日 → STATE_VERSION+1 →
|
||||||
|
* 同事务登记 KAFKA_MSG / KAFKA_SCHD 与处理终态。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FlopProcessor(
|
||||||
|
private val txManager: PipelineTransactionManager,
|
||||||
|
private val lock: PipelineLockRepository,
|
||||||
|
private val flightState: FlightStateRepository,
|
||||||
|
private val msgEvents: MsgEventRepository,
|
||||||
|
private val backfillTodo: BackfillTodoRepository?,
|
||||||
|
private val mapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
|
||||||
|
lock.lock()
|
||||||
|
val current = flightState.loadFullSnapshot(payload.flid)
|
||||||
|
?: return@inTransaction idempotentAbsent(head, msg) // 迟到/未知航班:幂等成功,不创建
|
||||||
|
|
||||||
|
val change = MergeChange(flid = payload.flid, scalars = payload.scalars, collections = payload.collections)
|
||||||
|
val next = FlightStateEngine.mergedState(current, change)
|
||||||
|
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
|
||||||
|
msgEvents.insertAll(eventsFor(next, mapper))
|
||||||
|
preRegisterBackfill(head, msg, backfillTodo)
|
||||||
|
ApplyResult.Succeeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FDEL(§6.2):ACTIVE → 置 DELETED、推进版本、明细保留、发布 tombstone;
|
||||||
|
* 已 DELETED / 不存在 → 幂等成功,不推进版本、不重复发布。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FdelProcessor(
|
||||||
|
private val txManager: PipelineTransactionManager,
|
||||||
|
private val lock: PipelineLockRepository,
|
||||||
|
private val flightState: FlightStateRepository,
|
||||||
|
private val msgEvents: MsgEventRepository,
|
||||||
|
private val backfillTodo: BackfillTodoRepository?,
|
||||||
|
private val mapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
fun apply(head: ProcState, msg: DecodedMessage, payload: FlopPayload): ApplyResult = txManager.inTransaction {
|
||||||
|
lock.lock()
|
||||||
|
val deleted = flightState.markDeleted(payload.flid, msgId = head.msgId, now = Instant.now())
|
||||||
|
if (deleted) {
|
||||||
|
val current = flightState.loadFullSnapshot(payload.flid)
|
||||||
|
// tombstone 仅在 ACTIVE→DELETED 时登记(§7.3),与删除同事务
|
||||||
|
msgEvents.insertAll(
|
||||||
|
listOf(
|
||||||
|
MsgEvent(
|
||||||
|
target = Targets.KAFKA_SCHD,
|
||||||
|
partitionKey = payload.flid,
|
||||||
|
eventType = EventType.TOMBSTONE,
|
||||||
|
stateVersion = current?.stateVersion ?: 0L,
|
||||||
|
payloadJson = mapper.writeValueAsString(
|
||||||
|
mapOf(
|
||||||
|
"flid" to payload.flid,
|
||||||
|
"stateVersion" to (current?.stateVersion ?: 0L),
|
||||||
|
"deleted" to true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MsgEvent(
|
||||||
|
target = Targets.KAFKA_MSG,
|
||||||
|
partitionKey = payload.flid,
|
||||||
|
stateVersion = current?.stateVersion ?: 0L,
|
||||||
|
payloadJson = mapper.writeValueAsString(
|
||||||
|
mapOf("flid" to payload.flid, "stateVersion" to (current?.stateVersion ?: 0L), "deleted" to true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
preRegisterBackfill(head, msg, backfillTodo)
|
||||||
|
}
|
||||||
|
ApplyResult.Succeeded // 未命中 = 迟到/重复,幂等成功(§6.2 步骤 3/4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADFT(§6.3 + §2.1):字段缺失语义待确认——确认前按保守 Set-only 处理
|
||||||
|
* (出现字段覆盖,缺失不 Clear,不沿用 FLOP 全量合并规则)。
|
||||||
|
* FLID 已存在且 DELETED → 生命周期重激活;不存在 → 新实例建立(含运营日计算 §3.5)。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AdftProcessor(
|
||||||
|
private val txManager: PipelineTransactionManager,
|
||||||
|
private val lock: PipelineLockRepository,
|
||||||
|
private val flightState: FlightStateRepository,
|
||||||
|
private val msgEvents: MsgEventRepository,
|
||||||
|
private val backfillTodo: BackfillTodoRepository?,
|
||||||
|
operationDayProps: OperationDayProps,
|
||||||
|
private val mapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
private val opDay = OperationDayCalculator(
|
||||||
|
zone = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") },
|
||||||
|
cutoffHour = operationDayProps.cutoffHour,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun apply(head: ProcState, msg: DecodedMessage, record: com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord): ApplyResult =
|
||||||
|
txManager.inTransaction {
|
||||||
|
lock.lock()
|
||||||
|
val main = flightState.findMainRow(record.flid)
|
||||||
|
if (main != null && main.state == com.gzzn.omms.msgexchange.domain.flight.FlightState.DELETED) {
|
||||||
|
// §6.3 重激活:DELETED → ACTIVE,推进版本,登记状态事件
|
||||||
|
if (flightState.revive(record.flid, msgId = head.msgId, now = Instant.now())) {
|
||||||
|
val current = flightState.loadFullSnapshot(record.flid)
|
||||||
|
if (current != null) {
|
||||||
|
val next = FlightStateEngine.mergedState(current, setOnly(record))
|
||||||
|
flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
|
||||||
|
msgEvents.insertAll(eventsFor(next, mapper))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preRegisterBackfill(head, msg, backfillTodo)
|
||||||
|
return@inTransaction ApplyResult.Succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
val current = flightState.loadFullSnapshot(record.flid)
|
||||||
|
val next: FlightSnapshot = if (current == null) {
|
||||||
|
// 新实例建立:ADFT 含 SODT 时直接计算运营日(§2.1),不可算则置 null 待快照收录
|
||||||
|
val day = opDay.compute(record.scalars["SODT"])
|
||||||
|
FlightSnapshot(
|
||||||
|
flid = record.flid,
|
||||||
|
operationDay = day, // 待确认项 §2.1:不可算时不得默认写接收日
|
||||||
|
state = com.gzzn.omms.msgexchange.domain.flight.FlightState.ACTIVE,
|
||||||
|
stateVersion = 1L,
|
||||||
|
scalars = record.scalars,
|
||||||
|
collections = com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine.COLLECTION_KEYS.associateWith { key ->
|
||||||
|
record.collections[key] ?: emptyList()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FlightStateEngine.mergedState(current, setOnly(record))
|
||||||
|
}
|
||||||
|
val outcome = flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())
|
||||||
|
check(outcome != com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome.DAY_GUARD_VIOLATION) {
|
||||||
|
"operation-day guard violated flid=${record.flid}"
|
||||||
|
}
|
||||||
|
msgEvents.insertAll(eventsFor(next, mapper))
|
||||||
|
preRegisterBackfill(head, msg, backfillTodo)
|
||||||
|
ApplyResult.Succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §2.1 保守语义:仅出现字段 Set;集合出现 Replace、缺失保留。 */
|
||||||
|
private fun setOnly(record: com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord) = MergeChange(
|
||||||
|
flid = record.flid,
|
||||||
|
scalars = record.scalars,
|
||||||
|
collections = record.collections,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// 共享小工具(处理器层私有约定)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/** 航班不存在/迟到:幂等成功(§9 队头不阻塞;不创建实例——创建入口只有 SCHD/ADFT)。 */
|
||||||
|
private fun idempotentAbsent(head: ProcState, msg: DecodedMessage): ApplyResult = ApplyResult.Succeeded
|
||||||
|
|
||||||
|
/** KAFKA_SCHD 整态 + KAFKA_MSG 变化通知(§7.3)。 */
|
||||||
|
internal fun eventsFor(next: FlightSnapshot, mapper: ObjectMapper): List<MsgEvent> {
|
||||||
|
val payload = linkedMapOf<String, Any>(
|
||||||
|
"flid" to next.flid,
|
||||||
|
"stateVersion" to next.stateVersion,
|
||||||
|
"scalars" to next.scalars,
|
||||||
|
"collections" to next.collections,
|
||||||
|
)
|
||||||
|
return listOf(
|
||||||
|
MsgEvent(
|
||||||
|
target = Targets.KAFKA_SCHD,
|
||||||
|
partitionKey = next.flid,
|
||||||
|
stateVersion = next.stateVersion,
|
||||||
|
payloadJson = mapper.writeValueAsString(payload),
|
||||||
|
),
|
||||||
|
MsgEvent(
|
||||||
|
target = Targets.KAFKA_MSG,
|
||||||
|
partitionKey = next.flid,
|
||||||
|
stateVersion = next.stateVersion,
|
||||||
|
payloadJson = mapper.writeValueAsString(mapOf("flid" to next.flid, "stateVersion" to next.stateVersion)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §7.2 业务事务内预登记回填待办(与终态同事务)。 */
|
||||||
|
internal fun preRegisterBackfill(head: ProcState, msg: DecodedMessage, backfillTodo: BackfillTodoRepository?) {
|
||||||
|
backfillTodo?.record(
|
||||||
|
BackfillTodoRepository.BackfillTask(
|
||||||
|
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
|
||||||
|
styp = msg.meta.styp, seqn = msg.meta.seqn,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.processing
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
|
||||||
import com.gzzn.omms.msgexchange.domain.Decision
|
|
||||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACMA-8 流程 2:Handler = 纯函数(状态与报文进,Decision 出,不碰 Redis/Kafka)。
|
|
||||||
* 32 个 Handler(flop 29 + schd 3)翻译属阶段 2/3,逐条对照 ACMA-4 基线与兼容矩阵 KEEP/FIX。
|
|
||||||
*/
|
|
||||||
interface Handler {
|
|
||||||
val kind: MsgKind
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在线状态以只读视图传入:FLID → 字段集(field → value,与 legacy flightInfo hash /
|
|
||||||
* hgetAllFlightInfo 同构;阶段 A 由调用方点查自有库 FLIGHT_SCHD 宽表装配,ACM2-28 定案)。
|
|
||||||
*/
|
|
||||||
fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* sealed 穷尽分派(ACMA-6 选型:取代 legacy 反射 get{TYPE}())。
|
|
||||||
*/
|
|
||||||
class HandlerRegistry(handlers: List<Handler>) {
|
|
||||||
private val byKind: Map<String, Handler> = handlers.associateBy { keyOf(it.kind) }
|
|
||||||
|
|
||||||
fun dispatcherFor(msg: DecodedMessage): Handler? = byKind[keyOf(msg.kind)]
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun keyOf(kind: MsgKind): String = when (kind) {
|
|
||||||
is MsgKind.Schd -> "SCHD-${kind.subtype.name}"
|
|
||||||
is MsgKind.Flop -> "FLOP-${kind.subtype}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(阶段2/3): 注册 3+29 Handler;本阶段仅含骨架(阶段 2 最小纵向链路先做 1 SCHD(DNLD) + 1 FLOP)。
|
|
||||||
@@ -1,37 +1,32 @@
|
|||||||
package com.gzzn.omms.msgexchange.processing
|
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/I5,ACM2-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) {
|
||||||
// U08:loop 只作最后防线——失败状态迁移已在 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):任何意外异常归于本条 head,FAILED(INFRA)+退避,不穿出杀泵;
|
||||||
* 任何意外异常 → FAILED(INFRA)+退避(ProcFailure);attempts 达上限 → DEAD(EXHAUSTED);
|
* MALFORMED / PROTOCOL 直接 DEAD 不重试(§9)。
|
||||||
* MALFORMED 直接 DEAD;InterruptedException 恢复中断位并上抛(不被普通恢复逻辑吞掉)。
|
|
||||||
*/
|
*/
|
||||||
@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 -> {
|
||||||
// T06(U11):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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// I3:identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑(N25:入参传递 head,不再二次查询)
|
// I3:identity 仅首次绑定(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)
|
|
||||||
if (handler == null) {
|
|
||||||
// U10(N21):未注册 ≠ 报文非法——写 FAILED(可重放),绝不写终态
|
|
||||||
log.warn("no-handler:{} -> FAILED(UNSUPPORTED) id={}", decoded.typeTag, head.cminmsgsId)
|
|
||||||
procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${decoded.typeTag}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val schdKind = decoded.kind as? com.gzzn.omms.msgexchange.domain.MsgKind.Schd
|
|
||||||
if (schdKind != null &&
|
|
||||||
schdKind.subtype == com.gzzn.omms.msgexchange.domain.MsgKind.SchdSubtype.DNLD
|
|
||||||
) {
|
|
||||||
snapshotFlow.publishSnapshot(head, decoded)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val candidateFlids = extractCandidateFlids(decoded)
|
// 处理器分派:SCHD 快照主链路(§5.1)/ FLOP / FDEL / ADFT;缺载荷按 MALFORMED 终态
|
||||||
val flightView = if (candidateFlids.isNotEmpty()) {
|
val result: ApplyResult = when (val kind = decoded.kind) {
|
||||||
flightSchd.findByFlids(candidateFlids)
|
is MsgKind.Schd -> {
|
||||||
} else {
|
val body = decoded.body as? ScheduleBody
|
||||||
emptyMap()
|
if (body == null) {
|
||||||
}
|
deadMalformed(head, "missing-schd-body")
|
||||||
|
return
|
||||||
val decision = handler.decide(flightView, decoded) // 纯函数
|
}
|
||||||
|
when (kind.subtype) {
|
||||||
// 事务 2(自有 PG 单事务原子):
|
MsgKind.SchdSubtype.DNLD, MsgKind.SchdSubtype.RESP ->
|
||||||
// upsert FLIGHT_SCHD(decision.flightChanges) + insert MSG_EVENT + PROC_STATE → SUCCEEDED
|
scheduleProcessor.applyScheduleRecords(head, decoded)
|
||||||
val events = buildList {
|
MsgKind.SchdSubtype.ADFT -> {
|
||||||
decision.msgNotifies.forEach { add(MsgEvent(target = Targets.KAFKA_MSG, payloadJson = it.payloadJson)) }
|
val record = body.records.singleOrNull()
|
||||||
decision.schdPush.forEach { add(MsgEvent(target = Targets.KAFKA_SCHD, partitionKey = it.flid, payloadJson = it.payloadJson)) }
|
if (record == null) {
|
||||||
}
|
deadMalformed(head, "adft-needs-single-fltr")
|
||||||
|
return
|
||||||
val messageId = head.cminmsgsId.toString()
|
}
|
||||||
txManager.inTransaction {
|
adftProcessor.apply(head, decoded, record)
|
||||||
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()) {
|
MsgKind.Fdel -> {
|
||||||
msgEvents.insertAll(events)
|
val payload = decoded.body as? FlopPayload
|
||||||
|
if (payload == null) {
|
||||||
|
deadMalformed(head, "missing-fdel-flid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fdelProcessor.apply(head, decoded, payload)
|
||||||
|
}
|
||||||
|
is MsgKind.Flop -> {
|
||||||
|
val payload = decoded.body as? FlopPayload
|
||||||
|
if (payload == null) {
|
||||||
|
deadMalformed(head, "missing-flop-body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flopProcessor.apply(head, decoded, payload)
|
||||||
|
}
|
||||||
|
is MsgKind.Unsupported -> {
|
||||||
|
// §9:未支持类型 → FAILED(UNSUPPORTED) 退避重试,达阈值转 DEAD;绝不写终态
|
||||||
|
log.warn("unsupported type -> FAILED(UNSUPPORTED) msgId={} tag={}", head.msgId, kind.tag)
|
||||||
|
procFailure.fail(head, ErrorClass.UNSUPPORTED, "no-handler:${kind.tag}")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
when (result) {
|
||||||
inbox.backfillOnSuccess(head.cminmsgsId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
|
is ApplyResult.Succeeded, ApplyResult.ReplaySkipped ->
|
||||||
} catch (e: Exception) {
|
procState.update(head.msgId, ProcStatus.SUCCEEDED)
|
||||||
log.error("backfill failed after SUCCEEDED id={} (compensation required)", head.cminmsgsId, e)
|
is ApplyResult.DeadProtocol -> {
|
||||||
recordBackfillTodo(head.cminmsgsId, decoded, e)
|
// §9:整包拒绝 DEAD(PROTOCOL),立即释放队头,交人工确认
|
||||||
|
log.error("DEAD(PROTOCOL) msgId={} reason={} flags={}", head.msgId, result.reason, result.flags)
|
||||||
|
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.PROTOCOL, lastError = result.reason.take(1000))
|
||||||
|
compensateBackfill(head, decoded) // 拒绝包同样要回填信箱,防止反复轮询
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log.info("SUCCEEDED id={} events={} flightChanges={}", head.cminmsgsId, events.size, decision.flightChanges.size)
|
|
||||||
|
backfill(head, decoded)
|
||||||
|
log.info("SUCCEEDED msgId={} kind={}", head.msgId, decoded.typeTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** v2 §5:回填失败只落补偿待办(成功终态不降级,业务不重放);由 BackfillSweepJob 到期重试。 */
|
/** §7.2:提交后回填共享信箱;失败不得把 SUCCEEDED 改回 FAILED,待办已事务内预登记。 */
|
||||||
|
private fun backfill(head: ProcState, decoded: DecodedMessage) {
|
||||||
|
try {
|
||||||
|
inbox.backfillOnSuccess(head.msgId, decoded.meta.sndr, decoded.meta.type, decoded.meta.styp, decoded.meta.seqn)
|
||||||
|
backfillTodo?.delete(head.msgId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("backfill failed after SUCCEEDED msgId={} (todo pre-registered, sweep will retry)", head.msgId, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SKIPPED/DEAD(PROTOCOL) 包的回填(无预登记待办):失败落补偿待办。 */
|
||||||
private fun compensateBackfill(head: ProcState, decoded: DecodedMessage) {
|
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)
|
backfillTodo?.record(
|
||||||
|
BackfillTodoRepository.BackfillTask(
|
||||||
|
msgId = head.msgId,
|
||||||
|
sndr = decoded.meta.sndr,
|
||||||
|
type = decoded.meta.type,
|
||||||
|
styp = decoded.meta.styp,
|
||||||
|
seqn = decoded.meta.seqn,
|
||||||
|
),
|
||||||
|
e.message ?: e.javaClass.simpleName,
|
||||||
|
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted msgId={}", head.msgId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun recordBackfillTodo(cminmsgsId: Long, decoded: DecodedMessage, e: Exception) {
|
private fun deadMalformed(head: ProcState, detail: String) {
|
||||||
backfillTodo?.record(
|
procState.update(head.msgId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED, lastError = detail)
|
||||||
BackfillTodoRepository.BackfillTask(
|
|
||||||
cminmsgsId = cminmsgsId,
|
|
||||||
sndr = decoded.meta.sndr,
|
|
||||||
type = decoded.meta.type,
|
|
||||||
styp = decoded.meta.styp,
|
|
||||||
seqn = decoded.meta.seqn,
|
|
||||||
),
|
|
||||||
e.message ?: e.javaClass.simpleName,
|
|
||||||
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted id={}", cminmsgsId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 延迟装配占位(阶段 1 后续以 Micronaut Bean 替换直连构造)。 */
|
|
||||||
class CodecHolder(val codec: com.gzzn.omms.msgexchange.codec.XmlCodec)
|
|
||||||
class HandlerHolder(val registry: HandlerRegistry)
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package com.gzzn.omms.msgexchange.processing
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.gzzn.omms.msgexchange.codec.ScheduleBody
|
||||||
|
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||||
|
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||||
|
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||||
|
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||||
|
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
||||||
|
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
|
||||||
|
import com.gzzn.omms.msgexchange.domain.SnapshotResult
|
||||||
|
import com.gzzn.omms.msgexchange.domain.Targets
|
||||||
|
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
|
||||||
|
import com.gzzn.omms.msgexchange.domain.flight.SnapshotValidation
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||||
|
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
|
||||||
|
import jakarta.inject.Singleton
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/** 处理器执行结果——终态迁移由 MessageProcessor 统一落库。 */
|
||||||
|
sealed interface ApplyResult {
|
||||||
|
/** 业务成功(含幂等成功)。 */
|
||||||
|
data object Succeeded : ApplyResult
|
||||||
|
|
||||||
|
/** §5.1 步骤 2:MSG_ID 已有成功终态 → 重放,直接记幂等成功。 */
|
||||||
|
data object ReplaySkipped : ApplyResult
|
||||||
|
|
||||||
|
/** §9:整包拒绝 DEAD(PROTOCOL),不重试,交人工确认。 */
|
||||||
|
data class DeadProtocol(val reason: String, val flags: Set<SnapshotFlag> = emptySet()) : ApplyResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §5.3 第四行:归属日不符 = 串日/错发/污染,整包拒绝。 */
|
||||||
|
class ProtocolViolation(message: String) : RuntimeException(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHD 快照主链路(docs/flight-state.md §5.1 applyScheduleRecords,同一事务):
|
||||||
|
* 对单日快照与滚动窗口统一适用,不做名单层面的处理。
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ScheduleProcessor(
|
||||||
|
private val txManager: PipelineTransactionManager,
|
||||||
|
private val lock: PipelineLockRepository,
|
||||||
|
private val procState: ProcStateRepository,
|
||||||
|
private val flightState: FlightStateRepository,
|
||||||
|
private val msgEvents: MsgEventRepository,
|
||||||
|
private val snapshotLog: SnapshotLogRepository,
|
||||||
|
private val backfillTodo: BackfillTodoRepository?,
|
||||||
|
operationDayProps: OperationDayProps,
|
||||||
|
private val mapper: ObjectMapper,
|
||||||
|
) {
|
||||||
|
private val opDay = OperationDayCalculator(
|
||||||
|
zone = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") },
|
||||||
|
cutoffHour = operationDayProps.cutoffHour,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun applyScheduleRecords(head: ProcState, msg: DecodedMessage): ApplyResult {
|
||||||
|
val body = msg.body as? ScheduleBody ?: return ApplyResult.DeadProtocol("missing-schd-body")
|
||||||
|
val started = System.nanoTime()
|
||||||
|
|
||||||
|
// ② 重放判定:MSG_ID 已有成功终态 → 幂等成功(§5.1 步骤 2,重放也记留痕)
|
||||||
|
if (procState.findSuccessTerminal(head.msgId)) {
|
||||||
|
logSnapshot(head, body, SnapshotResult.REPLAY_SKIPPED, upserted = 0, flags = emptySet(), started)
|
||||||
|
return ApplyResult.ReplaySkipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 报文完整性(§5.2 五项):任一失败整包不落地 → DEAD(PROTOCOL)
|
||||||
|
val validation = FlightStateEngine.validateMessage(
|
||||||
|
recsDeclared = body.recsDeclared,
|
||||||
|
records = body.records,
|
||||||
|
scopeStart = body.scopeStart,
|
||||||
|
scopeEnd = body.scopeEnd,
|
||||||
|
opDay = opDay,
|
||||||
|
)
|
||||||
|
if (validation is SnapshotValidation.Invalid) {
|
||||||
|
logSnapshot(head, body, SnapshotResult.ROLLED_BACK, upserted = 0, validation.flags, started)
|
||||||
|
return ApplyResult.DeadProtocol(validation.reason, validation.flags)
|
||||||
|
}
|
||||||
|
val ok = validation as SnapshotValidation.Ok
|
||||||
|
|
||||||
|
if (ok.perRecordDay.isEmpty()) {
|
||||||
|
// 空快照:合法但无写入(§5.5 EMPTY),仍算成功终态
|
||||||
|
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted = 0, setOf(SnapshotFlag.EMPTY), started)
|
||||||
|
return ApplyResult.Succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
val flags = linkedSetOf<SnapshotFlag>()
|
||||||
|
return try {
|
||||||
|
// ①④⑤⑥⑦ 同一事务:锁 → 归属校验 → upsert → 版本/事件/待办预登记/终态
|
||||||
|
val upserted = txManager.inTransaction {
|
||||||
|
lock.lock()
|
||||||
|
|
||||||
|
// ⑤ 归属校验(§5.3):OPERATION_DAY 不可变,批量点查避免逐航班往返
|
||||||
|
val mains = flightState.findMainRows(ok.perRecordDay.keys)
|
||||||
|
ok.perRecordDay.forEach { (flid, day) ->
|
||||||
|
val existing = mains[flid] ?: return@forEach
|
||||||
|
if (existing.operationDay != null && existing.operationDay != day) {
|
||||||
|
throw ProtocolViolation(
|
||||||
|
"SAME_FLID_ACROSS_OPERATION_DAYS flid=$flid existing=${existing.operationDay} incoming=$day",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var written = 0
|
||||||
|
val events = mutableListOf<MsgEvent>()
|
||||||
|
ok.perRecordDay.forEach { (flid, day) ->
|
||||||
|
val record = body.records.first { it.flid == flid }
|
||||||
|
val existingMain = mains[flid]
|
||||||
|
val keepDeleted = existingMain?.state == FlightState.DELETED
|
||||||
|
if (keepDeleted) flags.add(SnapshotFlag.SCHD_REVIVE_CONFLICT) // §5.1 步骤 6:不恢复
|
||||||
|
val current = if (existingMain != null) flightState.loadFullSnapshot(flid) else null
|
||||||
|
val next = FlightStateEngine.snapshotState(
|
||||||
|
current = current,
|
||||||
|
record = record,
|
||||||
|
operationDay = day,
|
||||||
|
keepDeleted = keepDeleted,
|
||||||
|
)
|
||||||
|
when (flightState.persistFullState(next, msgId = head.msgId, now = Instant.now())) {
|
||||||
|
PersistOutcome.DAY_GUARD_VIOLATION ->
|
||||||
|
throw ProtocolViolation("operation-day guard violated flid=$flid")
|
||||||
|
else -> written++
|
||||||
|
}
|
||||||
|
events += snapshotEvents(next)
|
||||||
|
}
|
||||||
|
if (events.isNotEmpty()) msgEvents.insertAll(events)
|
||||||
|
// §7.2 目标形态:业务事务内预登记回填待办(提交后由 MessageProcessor 回填并删待办)
|
||||||
|
backfillTodo?.record(
|
||||||
|
BackfillTodoRepository.BackfillTask(
|
||||||
|
msgId = head.msgId, sndr = msg.meta.sndr, type = msg.meta.type,
|
||||||
|
styp = msg.meta.styp, seqn = msg.meta.seqn,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
written
|
||||||
|
}
|
||||||
|
logSnapshot(head, body, SnapshotResult.COMMITTED, upserted, flags, started)
|
||||||
|
ApplyResult.Succeeded
|
||||||
|
} catch (e: ProtocolViolation) {
|
||||||
|
logSnapshot(head, body, SnapshotResult.ROLLED_BACK, upserted = 0, flags, started)
|
||||||
|
ApplyResult.DeadProtocol(e.message ?: "protocol-violation", flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态事件:整态出站(§7.3 KAFKA_SCHD)+ 变化通知(KAFKA_MSG)。 */
|
||||||
|
private fun snapshotEvents(next: FlightSnapshot): List<MsgEvent> {
|
||||||
|
// KAFKA_SCHD 整态:缺失集合输出空集 → 消费者删除旧值(§5.4/§7.3)
|
||||||
|
val payload = linkedMapOf<String, Any>(
|
||||||
|
"flid" to next.flid,
|
||||||
|
"stateVersion" to next.stateVersion,
|
||||||
|
"scalars" to next.scalars,
|
||||||
|
"collections" to next.collections,
|
||||||
|
)
|
||||||
|
val notify = mapper.writeValueAsString(mapOf("flid" to next.flid, "stateVersion" to next.stateVersion))
|
||||||
|
return listOf(
|
||||||
|
MsgEvent(
|
||||||
|
target = Targets.KAFKA_SCHD,
|
||||||
|
partitionKey = next.flid,
|
||||||
|
stateVersion = next.stateVersion,
|
||||||
|
payloadJson = mapper.writeValueAsString(payload),
|
||||||
|
),
|
||||||
|
MsgEvent(target = Targets.KAFKA_MSG, partitionKey = next.flid, stateVersion = next.stateVersion, payloadJson = notify),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §5.5 留痕:事务外追加,失败只记 error 不阻塞;scope 按记录归属运营日推导。 */
|
||||||
|
private fun logSnapshot(
|
||||||
|
head: ProcState,
|
||||||
|
body: ScheduleBody,
|
||||||
|
result: SnapshotResult,
|
||||||
|
upserted: Int,
|
||||||
|
flags: Set<SnapshotFlag>,
|
||||||
|
startedNanos: Long,
|
||||||
|
) {
|
||||||
|
runCatching {
|
||||||
|
val days = body.records.mapNotNull { opDay.compute(it.scalars["SODT"]) }
|
||||||
|
snapshotLog.append(
|
||||||
|
SnapshotLogEntry(
|
||||||
|
msgId = head.msgId,
|
||||||
|
recvAt = Instant.now(),
|
||||||
|
scopeStart = days.minOrNull() ?: LocalDate.now(),
|
||||||
|
scopeEnd = days.maxOrNull() ?: LocalDate.now(),
|
||||||
|
recs = body.records.size,
|
||||||
|
upserted = upserted,
|
||||||
|
durationMs = (System.nanoTime() - startedNanos) / 1_000_000,
|
||||||
|
result = result,
|
||||||
|
flags = flags,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.onFailure { log.error("snap-log write failed (metric only) msgId={}", head.msgId, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private val log = org.slf4j.LoggerFactory.getLogger(ScheduleProcessor::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.processing
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
|
||||||
import com.gzzn.omms.msgexchange.domain.ErrorClass
|
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
|
||||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
|
||||||
import com.gzzn.omms.msgexchange.domain.MsgEvent
|
|
||||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
|
||||||
import com.gzzn.omms.msgexchange.domain.Targets
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.BackfillTodoRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightSchdRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* staging(内存瞬态,崩溃从 raw 整包重放)→ PG 单事务原子“覆盖+按代差删+SQL CAS 推进+事件入队+SUCCEEDED”(I4/I5,ACM2-28 定案)。
|
|
||||||
* U10/T07 占位安全化 + U08 统一失败迁移(ProcFailure):staging 未实装 → FAILED(UNSUPPORTED)+退避
|
|
||||||
* (可重放,绝不写终态);CAS 冲突 → FAILED(INFRA)+退避;达上限统一 DEAD(EXHAUSTED)。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class SnapshotFlow(
|
|
||||||
private val procState: ProcStateRepository,
|
|
||||||
private val flightSchd: FlightSchdRepository,
|
|
||||||
private val msgEvents: MsgEventRepository,
|
|
||||||
private val reqTrack: ReqTrackRepository,
|
|
||||||
private val procFailure: ProcFailure,
|
|
||||||
private val txManager: PipelineTransactionManager,
|
|
||||||
private val inbox: CminmsgInboxRepository,
|
|
||||||
private val backfillTodo: BackfillTodoRepository? = null,
|
|
||||||
) {
|
|
||||||
private val log = org.slf4j.LoggerFactory.getLogger(SnapshotFlow::class.java)
|
|
||||||
|
|
||||||
fun publishSnapshot(head: ProcState, msg: DecodedMessage) {
|
|
||||||
val messageId = head.cminmsgsId.toString()
|
|
||||||
val staged = StageResult.stagingOf(msg)
|
|
||||||
if (staged is StageResult.Invalid) {
|
|
||||||
log.warn("staging not implemented -> FAILED(UNSUPPORTED) id={} reason={}", head.cminmsgsId, staged.reason)
|
|
||||||
procFailure.fail(head, ErrorClass.UNSUPPORTED, staged.reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val ok = staged as StageResult.Ok
|
|
||||||
val normalized = ok.flights
|
|
||||||
val day = ok.day
|
|
||||||
|
|
||||||
if (normalized.size > MAX_FLIGHTS_PER_SNAPSHOT) {
|
|
||||||
log.error("snapshot flights exceed limit -> DEAD(MALFORMED) id={} size={}", head.cminmsgsId, normalized.size)
|
|
||||||
procState.update(head.cminmsgsId, ProcStatus.DEAD, errorClass = ErrorClass.MALFORMED,
|
|
||||||
lastError = "flights-exceed-limit:${normalized.size}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// v2 §5 事务流程:锁外只做解析与整包校验;取得 PIPELINE_LOCK 后在锁内
|
|
||||||
// 复核快照身份 → 读取当前代与当前航班态 → 计算 nextState → 写入 → 提交。
|
|
||||||
var replayNoOp = false
|
|
||||||
try {
|
|
||||||
txManager.inTransaction {
|
|
||||||
// 锁内复核快照身份:同一消息已成功提交 → 重放短路(不加版本、不重复发事件)
|
|
||||||
val gen = flightSchd.getGen(day)
|
|
||||||
if (gen?.lastMessageId == messageId) {
|
|
||||||
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
|
||||||
replayNoOp = true
|
|
||||||
return@inTransaction
|
|
||||||
}
|
|
||||||
|
|
||||||
val expected = gen?.version ?: 0L
|
|
||||||
val newFlids = normalized.map { it.first }.toSet()
|
|
||||||
val delFields = gen?.flids?.minus(newFlids) ?: emptySet()
|
|
||||||
val newVersion = expected + 1L
|
|
||||||
|
|
||||||
// 锁内读取当前航班态,计算 nextState(单写者互斥下读到的一定是已提交最新态)
|
|
||||||
val nextStates = normalized.map { (flid, fields) ->
|
|
||||||
val current = flightSchd.findNextStateByFlid(flid)
|
|
||||||
val commands = FlightStateEngine.commandsFromFields(flid, fields)
|
|
||||||
FlightStateEngine.apply(current, commands, messageId, bumpVersion = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
flightSchd.persistNextStates(day, nextStates, snapshotReplace = true)
|
|
||||||
|
|
||||||
if (delFields.isNotEmpty()) {
|
|
||||||
flightSchd.deleteDiffByDay(day, delFields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 锁内 CAS:expected 即锁内刚读到的最新版本,仍失败属数据异常——
|
|
||||||
// 一律回滚进入可重试 FAILED(INFRA),绝不凭版本号推断“已经是我的提交”(v2 §5)
|
|
||||||
val casSuccess = flightSchd.putGenIfVersion(
|
|
||||||
day = day,
|
|
||||||
expected = expected,
|
|
||||||
newGen = FlightSchdRepository.GenMeta(
|
|
||||||
fday = day,
|
|
||||||
version = newVersion,
|
|
||||||
flids = newFlids,
|
|
||||||
lastMessageId = messageId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!casSuccess) {
|
|
||||||
throw CasConflictException(
|
|
||||||
"gen-cas-conflict: expected=$expected msg=$messageId (lock-held CAS must not fail)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val schdKind = msg.kind as? MsgKind.Schd
|
|
||||||
if (schdKind?.subtype == MsgKind.SchdSubtype.RESP) {
|
|
||||||
reqTrack.findOpenByKind("SCHD")?.let { req ->
|
|
||||||
reqTrack.markDone(req.reqId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val events = nextStates.map { state ->
|
|
||||||
MsgEvent(
|
|
||||||
target = Targets.KAFKA_SCHD,
|
|
||||||
partitionKey = state.flid,
|
|
||||||
payloadJson = FlightFieldsJson.toJson(state.toFlightFields()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (events.isNotEmpty()) {
|
|
||||||
msgEvents.insertAll(events)
|
|
||||||
}
|
|
||||||
|
|
||||||
procState.update(head.cminmsgsId, ProcStatus.SUCCEEDED)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交后补偿:重放短路同样补做回填(其待办可能仍在重试中)
|
|
||||||
safeBackfill(head, msg)
|
|
||||||
if (replayNoOp) {
|
|
||||||
log.info("snapshot replay no-op id={} day={}", head.cminmsgsId, day)
|
|
||||||
} else {
|
|
||||||
log.info("snapshot SUCCEEDED id={} day={} flights={}", head.cminmsgsId, day, normalized.size)
|
|
||||||
}
|
|
||||||
} catch (e: CasConflictException) {
|
|
||||||
log.warn("gen CAS conflict -> FAILED(INFRA) id={} msg={}", head.cminmsgsId, e.message)
|
|
||||||
procFailure.fail(head, ErrorClass.INFRA, e.message ?: "gen-cas-conflict")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun safeBackfill(head: ProcState, msg: DecodedMessage) {
|
|
||||||
try {
|
|
||||||
inbox.backfillOnSuccess(head.cminmsgsId, msg.meta.sndr, msg.meta.type, msg.meta.styp, msg.meta.seqn)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error("backfill failed after SUCCEEDED id={} (compensation required)", head.cminmsgsId, e)
|
|
||||||
backfillTodo?.record(
|
|
||||||
BackfillTodoRepository.BackfillTask(
|
|
||||||
cminmsgsId = head.cminmsgsId,
|
|
||||||
sndr = msg.meta.sndr,
|
|
||||||
type = msg.meta.type,
|
|
||||||
styp = msg.meta.styp,
|
|
||||||
seqn = msg.meta.seqn,
|
|
||||||
),
|
|
||||||
e.message ?: e.javaClass.simpleName,
|
|
||||||
) ?: log.warn("no backfill-todo repository bound; compensation NOT persisted id={}", head.cminmsgsId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** staging 结果(骨架)。 */
|
|
||||||
sealed interface StageResult {
|
|
||||||
data class Ok(val day: String, val flights: List<Pair<String, FlightFields>>) : StageResult
|
|
||||||
data class Invalid(val reason: String) : StageResult
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
var parser: ((DecodedMessage) -> StageResult)? = null
|
|
||||||
|
|
||||||
fun stagingOf(msg: DecodedMessage): StageResult =
|
|
||||||
parser?.invoke(msg) ?: Invalid("staging-not-implemented(${msg.typeTag})") // TODO(阶段2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class CasConflictException(message: String) : RuntimeException(message)
|
|
||||||
const val MAX_FLIGHTS_PER_SNAPSHOT = 10000
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.processing.handlers
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
|
||||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
|
||||||
import com.gzzn.omms.msgexchange.domain.Decision
|
|
||||||
import com.gzzn.omms.msgexchange.domain.FlightChange
|
|
||||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
|
||||||
import com.gzzn.omms.msgexchange.domain.SchdPush
|
|
||||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFieldsJson
|
|
||||||
import com.gzzn.omms.msgexchange.processing.Handler
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FLOP-GTDT:登机门集合级快照替换(SIS §3.34;GTNO=0 清除)。
|
|
||||||
* 产出 FlightChange.fields["GTDT"] 为 JSON 数组,供 FlightStateEngine → persistNextStates 写入明细表。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class GtdtHandler(
|
|
||||||
private val mapper: ObjectMapper = ObjectMapper(),
|
|
||||||
) : Handler {
|
|
||||||
override val kind: MsgKind = MsgKind.Flop("GTDT")
|
|
||||||
|
|
||||||
override fun decide(flightView: Map<String, Map<String, String>>, msg: DecodedMessage): Decision {
|
|
||||||
val body = msg.body as? FlopPayload
|
|
||||||
?: throw IllegalArgumentException("GTDT handler requires FlopPayload body")
|
|
||||||
val gtdtItems = body.collections["GTDT"] ?: emptyList()
|
|
||||||
val fields = linkedMapOf<String, String>()
|
|
||||||
fields["FLID"] = body.flid
|
|
||||||
body.scalars.forEach { (k, v) -> fields[k] = v }
|
|
||||||
fields["GTDT"] = mapper.writeValueAsString(gtdtItems)
|
|
||||||
|
|
||||||
val current = flightView[body.flid]?.let { FlightStateEngine.fromFlightFields(body.flid, it) }
|
|
||||||
val commands = FlightStateEngine.commandsFromFields(body.flid, fields)
|
|
||||||
val preview = FlightStateEngine.apply(current, commands, messageId = "", bumpVersion = false)
|
|
||||||
val payloadJson = FlightFieldsJson.toJson(preview.toFlightFields(mapper))
|
|
||||||
|
|
||||||
return Decision(
|
|
||||||
flightChanges = listOf(FlightChange(body.flid, fields)),
|
|
||||||
schdPush = listOf(SchdPush(body.flid, payloadJson)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.reference
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACMA-8 决策 6:21 类 admin-api 基础数据同步(阶段 6)。
|
|
||||||
* 落点 = 21 类静态主数据独立 PostgreSQL 参考库(StaticRefRepository,ACM2-11);
|
|
||||||
* 只产静态主数据行(权威写唯一入口);影子实例不运行本模块(v4 影子二分)。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class ReferenceService(
|
|
||||||
private val staticRef: StaticRefRepository,
|
|
||||||
// TODO(阶段6): @Client(id="ADMINAPI") 声明式客户端 + 21 类端点配置化清单
|
|
||||||
) {
|
|
||||||
fun refresh(type: String) {
|
|
||||||
// TODO(阶段6): 拉取 → staticRef.upsertAll(rows, source=ADMINAPI);失败旧数据可用(参考库保留旧值)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.reference
|
|
||||||
|
|
||||||
import com.gzzn.omms.msgexchange.domain.RefUpsert
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.StaticRefRepository
|
|
||||||
import jakarta.inject.Singleton
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACMA-8 流程 6:请求式查询状态机(15 类,决策树版 v4)。
|
|
||||||
* 同类并发=1(注册新请求先强制旧请求 EXPIRED,终态不再被匹配);
|
|
||||||
* 应答匹配:回显字段 CONFIRM(矩阵 #13)→ 精确匹配;否则退化模式(DTTM ≥ SENT 才应用 + 审计)。
|
|
||||||
* 应答落库目标 = 21 类静态主数据(独立 PG 参考库,StaticRefRepository,ACM2-11);
|
|
||||||
* 注:N04 量纲缺陷(dttm vs epochMillis)与 N16 死分支未修,接线阶段 6 前处理(U19–U21)。
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class RequestCoordinator(
|
|
||||||
private val reqTrack: ReqTrackRepository,
|
|
||||||
private val staticRef: StaticRefRepository,
|
|
||||||
) {
|
|
||||||
fun request(kind: String, rangeJson: String): Long {
|
|
||||||
reqTrack.forceExpireOpenOf(kind) // 防护①
|
|
||||||
val reqId = reqTrack.insert(kind, rangeJson)
|
|
||||||
// 出站走既有 COUTMSGS outbox(TODO(阶段6): codec.encodeRqrd + coutmsgs 落库 + link)
|
|
||||||
reqTrack.markSent(reqId, Instant.now())
|
|
||||||
return reqId
|
|
||||||
// TODO(阶段6): timer.at(kind.timeout) { expireIfWaiting(reqId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 应答处理(决策树):msg.dttm 与回显字段由 codec 解出后传入。 */
|
|
||||||
fun onResp(
|
|
||||||
kind: String,
|
|
||||||
dttm: Long,
|
|
||||||
echoSeqn: Long?,
|
|
||||||
records: List<RefUpsert>, // 应答载荷 → 静态主数据(source=AODB)
|
|
||||||
): String {
|
|
||||||
val req = reqTrack.findOpenByKind(kind) ?: return audit("late/unknown resp kind=$kind")
|
|
||||||
val echoConfirmed = false // CONFIRM(矩阵 #13)待机场方结论
|
|
||||||
return when {
|
|
||||||
echoConfirmed && echoSeqn != null -> { // ① 精确匹配(TODO: matchBy echoSeqn)
|
|
||||||
applyResp(req.reqId, records); "matched"
|
|
||||||
}
|
|
||||||
dttm < epochMillis(req.sentAt) -> audit("stale resp kind=$kind") // ② DTTM < SENT → 丢弃
|
|
||||||
else -> { // ③ 退化模式应用;残余跨代错配风险明示接受并审计
|
|
||||||
applyResp(req.reqId, records); "applied-degraded"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun applyResp(reqId: Long, records: List<RefUpsert>) {
|
|
||||||
staticRef.upsertAll(records) // 独立 PG 参考库(阶段 6 接线)
|
|
||||||
reqTrack.markDone(reqId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun audit(reason: String): String = reason.also {
|
|
||||||
// TODO(阶段1后续): 审计表/日志(跨代错配残余风险可观测)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun epochMillis(i: Instant?): Long = i?.toEpochMilli() ?: 0
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
package com.gzzn.omms.msgexchange.tools
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import com.fasterxml.jackson.databind.node.ArrayNode
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
|
||||||
import com.fasterxml.jackson.databind.node.ValueNode
|
|
||||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
|
||||||
import java.math.BigDecimal
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACM2-28 FS7:影子对拍跨存储 Diff 工具。
|
|
||||||
*
|
|
||||||
* 核心目标:
|
|
||||||
* 对齐 CMINMSGS_ID 水位后,比对 nextgen 自有库 FLIGHT_SCHD(11g 修订:宽表列值)
|
|
||||||
* vs legacy 现役 Redis (flightInfo)。
|
|
||||||
*
|
|
||||||
* 字段归一化规则(ACM2-28 评论 4 项 4,随宽表存储形态修订):
|
|
||||||
* 1. PG 侧为宽表列值映射(字段名 → 字符串值),legacy 侧解析 FLTR JSON 后按字段逐一比对,
|
|
||||||
* 天然规避 JSONB/文本的 key 排序与序列化形态差异,绝不裸字符串比对;
|
|
||||||
* 2. 数值精度归一化(PG "100.0" vs legacy 100 视为等价;PG 值不可解析为数值则判不一致);
|
|
||||||
* 3. 空值规范化(legacy JSON null/字段缺失 与 PG 列 NULL 视为等价,规避无语义偏离);
|
|
||||||
* 4. 嵌套/集合值(legacy 为对象或数组):PG 侧序列化文本按 JSON 解析后递归比对。
|
|
||||||
*
|
|
||||||
* 已知合法偏离声明(ACM2-28 方案六):
|
|
||||||
* ① FDAY 域化差删对跨代迁移行的保护(legacy 误删、nextgen 不删,属于修正性偏离)。
|
|
||||||
*/
|
|
||||||
class FlightStoreDiffTool(
|
|
||||||
private val mapper: ObjectMapper = ObjectMapper(),
|
|
||||||
) {
|
|
||||||
|
|
||||||
enum class DeviationKind {
|
|
||||||
/** 已知合法偏离 ①:跨日迁移航班在 nextgen PG 中受 FDAY 保护未被误删,而 legacy 误删 */
|
|
||||||
CROSS_DAY_PROTECTED,
|
|
||||||
/** 非预期偏离:字段值不匹配 */
|
|
||||||
FIELD_MISMATCH,
|
|
||||||
/** 非预期偏离:PG 缺失该航班 */
|
|
||||||
MISSING_IN_PG,
|
|
||||||
/** 非预期偏离:Redis 缺失且非已知迁移保护行 */
|
|
||||||
UNEXPECTED_EXTRA_IN_PG,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Deviation(
|
|
||||||
val flid: String,
|
|
||||||
val kind: DeviationKind,
|
|
||||||
val path: String? = null,
|
|
||||||
val pgValue: Any? = null,
|
|
||||||
val legacyValue: Any? = null,
|
|
||||||
val detail: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DiffReport(
|
|
||||||
val totalPg: Int,
|
|
||||||
val totalLegacy: Int,
|
|
||||||
val matchedCount: Int,
|
|
||||||
val knownDeviations: List<Deviation>,
|
|
||||||
val unexpectedDeviations: List<Deviation>,
|
|
||||||
) {
|
|
||||||
val isGreen: Boolean
|
|
||||||
get() = unexpectedDeviations.isEmpty()
|
|
||||||
|
|
||||||
fun formatSummary(): String {
|
|
||||||
val sb = StringBuilder()
|
|
||||||
sb.appendLine("========== 影子对拍跨存储 Diff 报告 ==========")
|
|
||||||
sb.appendLine("Nextgen PG 航班总数 : $totalPg")
|
|
||||||
sb.appendLine("Legacy Redis 航班总数 : $totalLegacy")
|
|
||||||
sb.appendLine("完全一致航班数 : $matchedCount")
|
|
||||||
sb.appendLine("已知合法偏离数 (偏差①) : ${knownDeviations.size}")
|
|
||||||
sb.appendLine("非预期异常数 : ${unexpectedDeviations.size}")
|
|
||||||
sb.appendLine("验收红绿灯状态 : ${if (isGreen) "GREEN (通过)" else "RED (未通过)"}")
|
|
||||||
sb.appendLine("----------------------------------------------")
|
|
||||||
if (knownDeviations.isNotEmpty()) {
|
|
||||||
sb.appendLine("[已知合法偏离]")
|
|
||||||
knownDeviations.forEach { d ->
|
|
||||||
sb.appendLine(" FLID=${d.flid}: ${d.detail}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (unexpectedDeviations.isNotEmpty()) {
|
|
||||||
sb.appendLine("[非预期异常偏离]")
|
|
||||||
unexpectedDeviations.forEach { d ->
|
|
||||||
sb.appendLine(" FLID=${d.flid} [${d.kind}] path=${d.path}: PG=${d.pgValue}, Legacy=${d.legacyValue} (${d.detail})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.appendLine("==============================================")
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FS7 v2 双读对拍已随回退窗口关闭移除(V1.4.0,ACM2-29 P4):
|
|
||||||
* legacy 槽位/里程碑列退场后无对比基准,损失性差异已由 P2-1 对拍记录留档。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 单航班 FlightFields 对称比对(集合值为 JSON 文本时递归解析)。 */
|
|
||||||
internal fun compareFlightFields(flid: String, left: FlightFields, right: FlightFields): List<Deviation> {
|
|
||||||
val mismatches = mutableListOf<Deviation>()
|
|
||||||
val allKeys = (left.keys + right.keys).toSortedSet()
|
|
||||||
for (key in allKeys) {
|
|
||||||
compareFieldValues(flid, key, left[key], right[key], mismatches)
|
|
||||||
}
|
|
||||||
return mismatches
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compareFieldValues(flid: String, path: String, left: String?, right: String?, acc: MutableList<Deviation>) {
|
|
||||||
val leftNode = left?.let { parseFieldValue(it) }
|
|
||||||
val rightNode = right?.let { parseFieldValue(it) }
|
|
||||||
compareNodes(flid, path, leftNode, rightNode, acc)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseFieldValue(value: String): JsonNode =
|
|
||||||
runCatching { mapper.readTree(value) }.getOrNull()
|
|
||||||
?: mapper.nodeFactory.textNode(value)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行全量比对。
|
|
||||||
* @param pgFlights nextgen FLIGHT_SCHD 行(flid -> 宽表列值映射)
|
|
||||||
* @param legacyFlights legacy Redis flightInfo 行(flid -> value string)
|
|
||||||
* @param crossDayMigratedFlids 已知在多日之间迁移的航班 FLID 集合(用于识别合法偏差 ①)
|
|
||||||
*/
|
|
||||||
fun diff(
|
|
||||||
pgFlights: Map<String, FlightFields>,
|
|
||||||
legacyFlights: Map<String, String>,
|
|
||||||
crossDayMigratedFlids: Set<String> = emptySet(),
|
|
||||||
): DiffReport {
|
|
||||||
val allFlids = (pgFlights.keys + legacyFlights.keys).toSortedSet()
|
|
||||||
var matched = 0
|
|
||||||
val known = mutableListOf<Deviation>()
|
|
||||||
val unexpected = mutableListOf<Deviation>()
|
|
||||||
|
|
||||||
for (flid in allFlids) {
|
|
||||||
val pgFields = pgFlights[flid]
|
|
||||||
val legacyRaw = legacyFlights[flid]
|
|
||||||
|
|
||||||
if (pgFields == null) {
|
|
||||||
unexpected += Deviation(
|
|
||||||
flid = flid,
|
|
||||||
kind = DeviationKind.MISSING_IN_PG,
|
|
||||||
legacyValue = legacyRaw,
|
|
||||||
detail = "Flight present in Redis but missing in PG",
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (legacyRaw == null) {
|
|
||||||
if (flid in crossDayMigratedFlids) {
|
|
||||||
known += Deviation(
|
|
||||||
flid = flid,
|
|
||||||
kind = DeviationKind.CROSS_DAY_PROTECTED,
|
|
||||||
pgValue = pgFields,
|
|
||||||
detail = "Known Deviation 1: Cross-day migrated flight protected by FDAY domain-scoped delete in nextgen PG, erroneously deleted in legacy Redis",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
unexpected += Deviation(
|
|
||||||
flid = flid,
|
|
||||||
kind = DeviationKind.UNEXPECTED_EXTRA_IN_PG,
|
|
||||||
pgValue = pgFields,
|
|
||||||
detail = "Flight present in PG but missing in Redis without cross-day migration justification",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 两侧均存在:逐字段归一化比对(PG 列值 vs legacy JSON 解析树)
|
|
||||||
val mismatches = compareFlight(flid, pgFields, legacyRaw)
|
|
||||||
if (mismatches.isEmpty()) {
|
|
||||||
matched++
|
|
||||||
} else {
|
|
||||||
unexpected.addAll(mismatches)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return DiffReport(
|
|
||||||
totalPg = pgFlights.size,
|
|
||||||
totalLegacy = legacyFlights.size,
|
|
||||||
matchedCount = matched,
|
|
||||||
knownDeviations = known,
|
|
||||||
unexpectedDeviations = unexpected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单航班比对:PG 宽表列值映射 vs legacy FLTR JSON 文本。
|
|
||||||
* 遍历两侧字段名并集,按归一化规则逐字段判定。
|
|
||||||
*/
|
|
||||||
internal fun compareFlight(flid: String, pgFields: FlightFields, legacyJson: String): List<Deviation> {
|
|
||||||
val legacyNode = try {
|
|
||||||
mapper.readTree(legacyJson)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "Legacy JSON parse failed: ${e.message}"))
|
|
||||||
}
|
|
||||||
if (!legacyNode.isObject) {
|
|
||||||
return listOf(Deviation(flid, DeviationKind.FIELD_MISMATCH, detail = "Legacy JSON is not an object"))
|
|
||||||
}
|
|
||||||
val legacyObj = legacyNode as ObjectNode
|
|
||||||
|
|
||||||
val mismatches = mutableListOf<Deviation>()
|
|
||||||
val allKeys = (pgFields.keys + legacyObj.fieldNames().asSequence().toSet()).toSortedSet()
|
|
||||||
for (key in allKeys) {
|
|
||||||
val pgValue: String? = pgFields[key]
|
|
||||||
val legacyChild = legacyObj.get(key)
|
|
||||||
compareFieldNode(flid, key, pgValue, legacyChild, mismatches)
|
|
||||||
}
|
|
||||||
return mismatches
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单字段判定:PG 列值(字符串或缺失)vs legacy JSON 节点。 */
|
|
||||||
private fun compareFieldNode(flid: String, path: String, pgValue: String?, legacyNode: JsonNode?, acc: MutableList<Deviation>) {
|
|
||||||
fun mismatch(pg: Any?, legacy: Any?, detail: String) {
|
|
||||||
acc += Deviation(flid, DeviationKind.FIELD_MISMATCH, path = path, pgValue = pg, legacyValue = legacy, detail = detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
val legacyAbsent = legacyNode == null || legacyNode.isNull
|
|
||||||
val pgAbsent = pgValue == null
|
|
||||||
|
|
||||||
// 规范化:legacy JSON null/缺失 与 PG 列 NULL 等价
|
|
||||||
if (legacyAbsent && pgAbsent) return
|
|
||||||
if (legacyAbsent != pgAbsent) {
|
|
||||||
mismatch(pgValue, legacyNode?.asText(), "One side is null/absent while other is present")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val legacy = legacyNode!!
|
|
||||||
|
|
||||||
// 数值归一化(PG 列值可解析为数值时按数值比较)
|
|
||||||
if (legacy.isNumber) {
|
|
||||||
val pgNumber = pgValue!!.trim().toBigDecimalOrNull()
|
|
||||||
val legacyNumber = BigDecimal(legacy.asText()).stripTrailingZeros()
|
|
||||||
if (pgNumber == null || pgNumber.stripTrailingZeros().compareTo(legacyNumber) != 0) {
|
|
||||||
mismatch(pgValue, legacy.asText(), "Numeric value mismatch: $pgValue != ${legacy.asText()}")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 嵌套/集合值:PG 序列化文本按 JSON 解析后递归比对
|
|
||||||
if (legacy.isObject || legacy.isArray) {
|
|
||||||
val pgNode = try {
|
|
||||||
mapper.readTree(pgValue)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (pgNode == null) {
|
|
||||||
mismatch(pgValue, "<${legacy.nodeType}>", "PG value is not parseable as JSON while legacy is ${legacy.nodeType}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
compareNodes(flid, path, pgNode, legacy, acc)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 基本类型:文本值比较
|
|
||||||
if (pgValue != legacy.asText()) {
|
|
||||||
mismatch(pgValue, legacy.asText(), "Value mismatch: $pgValue != ${legacy.asText()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun compareNodes(flid: String, path: String, n1: JsonNode?, n2: JsonNode?, acc: MutableList<Deviation>) {
|
|
||||||
if (n1 == null && n2 == null) return
|
|
||||||
|
|
||||||
// 规范化:null 节点与缺失节点视为等价
|
|
||||||
val isN1Empty = n1 == null || n1.isNull
|
|
||||||
val isN2Empty = n2 == null || n2.isNull
|
|
||||||
if (isN1Empty && isN2Empty) return
|
|
||||||
|
|
||||||
if (isN1Empty != isN2Empty) {
|
|
||||||
acc += Deviation(
|
|
||||||
flid = flid,
|
|
||||||
kind = DeviationKind.FIELD_MISMATCH,
|
|
||||||
path = path,
|
|
||||||
pgValue = n1?.asText(),
|
|
||||||
legacyValue = n2?.asText(),
|
|
||||||
detail = "One side is null/absent while other is present",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val nonNull1 = n1!!
|
|
||||||
val nonNull2 = n2!!
|
|
||||||
|
|
||||||
// 数值归一化比较
|
|
||||||
if (nonNull1.isNumber && nonNull2.isNumber) {
|
|
||||||
val d1 = BigDecimal(nonNull1.asText()).stripTrailingZeros()
|
|
||||||
val d2 = BigDecimal(nonNull2.asText()).stripTrailingZeros()
|
|
||||||
if (d1.compareTo(d2) != 0) {
|
|
||||||
acc += Deviation(
|
|
||||||
flid = flid,
|
|
||||||
kind = DeviationKind.FIELD_MISMATCH,
|
|
||||||
path = path,
|
|
||||||
pgValue = nonNull1.numberValue(),
|
|
||||||
legacyValue = nonNull2.numberValue(),
|
|
||||||
detail = "Numeric value mismatch: $d1 != $d2",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对象类型:递归属性比较(忽略 key 顺序)
|
|
||||||
if (nonNull1.isObject && nonNull2.isObject) {
|
|
||||||
val obj1 = nonNull1 as ObjectNode
|
|
||||||
val obj2 = nonNull2 as ObjectNode
|
|
||||||
val allKeys = (obj1.fieldNames().asSequence().toSet() + obj2.fieldNames().asSequence().toSet()).toSortedSet()
|
|
||||||
|
|
||||||
for (key in allKeys) {
|
|
||||||
val nextPath = if (path.isEmpty()) key else "$path.$key"
|
|
||||||
compareNodes(flid, nextPath, obj1.get(key), obj2.get(key), acc)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 数组类型:逐项比较
|
|
||||||
if (nonNull1.isArray && nonNull2.isArray) {
|
|
||||||
val arr1 = nonNull1 as ArrayNode
|
|
||||||
val arr2 = nonNull2 as ArrayNode
|
|
||||||
if (arr1.size() != arr2.size()) {
|
|
||||||
acc += Deviation(
|
|
||||||
flid = flid,
|
|
||||||
kind = DeviationKind.FIELD_MISMATCH,
|
|
||||||
path = path,
|
|
||||||
pgValue = "size=${arr1.size()}",
|
|
||||||
legacyValue = "size=${arr2.size()}",
|
|
||||||
detail = "Array size mismatch",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (i in 0 until arr1.size()) {
|
|
||||||
compareNodes(flid, "$path[$i]", arr1.get(i), arr2.get(i), acc)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 基本类型:文本值比较
|
|
||||||
if (nonNull1.asText() != nonNull2.asText()) {
|
|
||||||
acc += Deviation(
|
|
||||||
flid = flid,
|
|
||||||
kind = DeviationKind.FIELD_MISMATCH,
|
|
||||||
path = path,
|
|
||||||
pgValue = nonNull1.asText(),
|
|
||||||
legacyValue = nonNull2.asText(),
|
|
||||||
detail = "Value mismatch: ${nonNull1.asText()} != ${nonNull2.asText()}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,11 @@
|
|||||||
# 口令/端点全部环境变量外置——零入库。
|
# 口令/端点全部环境变量外置——零入库。
|
||||||
# 存储边界:自有 PostgreSQL(datasources.default,全部内部状态与运营航班权威 FLIGHT_SCHD/SCHD_GEN)+
|
# 存储边界:自有 PostgreSQL(datasources.default,全部内部状态与运营航班权威 FLIGHT_SCHD/SCHD_GEN)+
|
||||||
# 共享 MySQL 信箱(mailbox.shared-mysql,仅 DML,不建表)+
|
# 共享 MySQL 信箱(mailbox.shared-mysql,仅 DML,不建表)+
|
||||||
# Kafka + Eureka/logstash(见 ACM2-28 与 docs;Redis 彻底退出阶段 A 权威与写路径)。
|
# Kafka + Eureka/logstash。权威设计:docs/flight-state.md。
|
||||||
# 键位口径:Micronaut 5.1(U03):datasources.* / flyway.datasources.* /
|
# 键位口径:Micronaut 5.1(U03):datasources.* / flyway.datasources.* /
|
||||||
# kafka.producers.<name>.* / eureka.client.*(Micronaut 原生键)。
|
# kafka.producers.<name>.* / eureka.client.*(Micronaut 原生键)。
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
msgx:
|
msgx:
|
||||||
phase: A # 航班动态权威落自有 PG(FLIGHT_SCHD,主泵单线程写,I5;ACM2-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/migration,ACM2-28)。数据层实装前 enabled=false。
|
# FLIGHT_SCHD 及明细表/SCHD_SNAP_LOG;迁移 db/migration/V1__flight_state_baseline.sql)。
|
||||||
|
# 数据层实装前 enabled=false(stub 模式不建连)。
|
||||||
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)。本地事务只在此库。
|
|
||||||
-- · 共享 MySQL(cdairport,他人系统库):仅信箱 DML —— 上游外部写 CMINMSGS;
|
|
||||||
-- 本系统 JDBC 轮询读 + 处理回填;出站写 COUTMSGS(他人读取发送);
|
|
||||||
-- 本系统不在此建表/schema(迁移不覆盖)。
|
|
||||||
-- · Redis:航班动态 flightInfo + 快照 SCHD_GEN(Lua 原子,另行脚本)。
|
|
||||||
-- · FLIGHT_STATE(阶段 B 权威):缓做,不落表(Redis 永续动态权威)。
|
|
||||||
-- 21 类静态表 REF_MASTER 为通用 rtype/rkey 形态(SOURCE 审计;gen/SCHD_GEN
|
|
||||||
-- 已迁 Redis,不再需要 VERSION/CAS 列)。
|
|
||||||
-- 影子对拍:本库开独立 schema/实例;共享信箱只读水位/回放(见 ACM2-12 影响面)。
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
-- ① 处理伴生状态(取消息侧;与共享库 CMINMSGS 一一对应,CMINMSGS_ID 沿用其主键;
|
|
||||||
-- 入队由 JDBC 轮询发现新信后写入,非本系统创建 CMINMSGS 原文)
|
|
||||||
CREATE TABLE PROC_STATE (
|
|
||||||
CMINMSGS_ID BIGINT PRIMARY KEY,
|
|
||||||
STATE VARCHAR(16) NOT NULL, -- PENDING/FAILED/SUCCEEDED/SKIPPED/DEAD
|
|
||||||
IDENTITY_KEY VARCHAR(200), -- SNDR|TYPE|STYP|SEQN,decode 后首次绑定(I3)
|
|
||||||
ATTEMPTS INT NOT NULL DEFAULT 0,
|
|
||||||
NEXT_ATTEMPT_AT TIMESTAMP,
|
|
||||||
ERROR_CLASS VARCHAR(20), -- MALFORMED/CODEC_ERROR/EXHAUSTED/INFRA/UNSUPPORTED
|
|
||||||
LAST_ERROR VARCHAR(1000),
|
|
||||||
UPDATED_AT TIMESTAMP NOT NULL,
|
|
||||||
CONSTRAINT uk_proc_identity UNIQUE (IDENTITY_KEY)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_proc_head ON PROC_STATE (STATE, CMINMSGS_ID); -- 主泵队头查询(I1)
|
|
||||||
|
|
||||||
-- ② 统一投递事件 outbox(发消息侧)
|
|
||||||
CREATE TABLE MSG_EVENT (
|
|
||||||
EVENT_ID BIGSERIAL PRIMARY KEY,
|
|
||||||
TARGET VARCHAR(30) NOT NULL, -- KAFKA:msg / KAFKA:schd(A起);ES:flight_hts / REDIS:flightInfo(B起)
|
|
||||||
PARTITION_KEY VARCHAR(64), -- schd 事件恒为 FLID
|
|
||||||
PAYLOAD_JSON TEXT NOT NULL,
|
|
||||||
STATE VARCHAR(16) NOT NULL, -- PENDING/SENT/DEAD
|
|
||||||
ATTEMPTS INT NOT NULL DEFAULT 0,
|
|
||||||
NEXT_ATTEMPT_AT TIMESTAMP,
|
|
||||||
ERROR_CLASS VARCHAR(20),
|
|
||||||
LAST_ERROR VARCHAR(1000),
|
|
||||||
CREATED_AT TIMESTAMP NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_evt_head ON MSG_EVENT (TARGET, STATE, EVENT_ID); -- 每 target 队头(I1 双层)
|
|
||||||
|
|
||||||
-- ③ 泵作业调度记录(作业不插队:仅在消息队头空闲/退避窗口由主泵执行,决策 1 修订)
|
|
||||||
CREATE TABLE PUMP_JOB (
|
|
||||||
JOB_ID BIGSERIAL PRIMARY KEY,
|
|
||||||
KIND VARCHAR(20) NOT NULL, -- ARCHIVE / HISTORY_SWEEP / PROJECTION_REBUILD
|
|
||||||
STATE VARCHAR(16) NOT NULL, -- QUEUED/RUNNING/DONE/FAILED
|
|
||||||
CREATED_AT TIMESTAMP NOT NULL,
|
|
||||||
UPDATED_AT TIMESTAMP NOT NULL,
|
|
||||||
LAST_ERROR VARCHAR(1000)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ④ 15 类请求状态机(Reference & Query 域;与共享库 COUTMSGS 跨库:
|
|
||||||
-- 先落 COUTMSGS 成功 → 再标 SENT,补偿重扫,最终一致)
|
|
||||||
CREATE TABLE REQ_TRACK (
|
|
||||||
REQ_ID BIGSERIAL PRIMARY KEY,
|
|
||||||
REQ_TYPE VARCHAR(20) NOT NULL,
|
|
||||||
PARAMS_JSON TEXT NOT NULL,
|
|
||||||
STATE VARCHAR(16) NOT NULL, -- REGISTERED/SENT/WAITING/DONE/EXPIRED
|
|
||||||
COUTMSGS_ID BIGINT, -- U18 修正:BIGINT(原 MySQL 版 INT)
|
|
||||||
SENT_AT TIMESTAMP,
|
|
||||||
COMPLETED_AT TIMESTAMP
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_req_open ON REQ_TRACK (REQ_TYPE, STATE);
|
|
||||||
|
|
||||||
-- ⑤ 21 类静态主数据(SOURCE=ADMINAPI/AODB/PIPELINE 审计;弱事务 upsert)
|
|
||||||
CREATE TABLE REF_MASTER (
|
|
||||||
RTYPE VARCHAR(20) NOT NULL, -- AIRL/ARPT/...
|
|
||||||
RKEY VARCHAR(64) NOT NULL,
|
|
||||||
PAYLOAD_JSON TEXT NOT NULL,
|
|
||||||
SOURCE VARCHAR(20) NOT NULL,
|
|
||||||
REFRESHED_AT TIMESTAMP NOT NULL,
|
|
||||||
PRIMARY KEY (RTYPE, RKEY)
|
|
||||||
);
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
-- =====================================================================
|
|
||||||
-- 自有库 · 阶段 A 运营航班表与计划代迁移 · ACM2-28 定案(选项 C)· 11g 修订
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- 存储边界(ACM2-28 定案,取代 ACM2-12「FLIGHT_STATE 缓做不落表、Redis 永续权威」口径):
|
|
||||||
-- · FLIGHT_SCHD:SCHD 日计划快照 + FLOP/ADFT 增量合并后的当前运营航班权威态,一行一航班。
|
|
||||||
-- · SCHD_GEN / SCHD_GEN_FLID:各日代版本(SQL CAS)与当前代航班全量集合(差删依据)。
|
|
||||||
-- · Redis:彻底退出动态权威与全部写路径;阶段 A 读写事务全在自有库原子完成。
|
|
||||||
-- · 增量 Upsert 语义:
|
|
||||||
-- - 快照全量写入(DNLD):声明/更新 FDAY 归属,航班字段整体替换;
|
|
||||||
-- - 增量写入(FLOP/ADFT):新插置 NULL FDAY,已有行保留原 FDAY,按字段列更新
|
|
||||||
-- (与 legacy flightInfo hash「仅新增/覆盖 field」同语义);
|
|
||||||
-- - 按代差删域化:DELETE FROM FLIGHT_SCHD WHERE FDAY = :day AND FLID IN (:diffSet)
|
|
||||||
-- 仅删除仍属旧代的行,ADFT(FDAY=NULL)与已迁移至新代的同 FLID 行天然存活。
|
|
||||||
-- · 时间列规范:统一使用 TIMESTAMP(6) WITH TIME ZONE。
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- 11g 修订(现场数据库版本 11g,废除 FLTR_JSON JSONB 整文档存储):
|
|
||||||
-- · 表形态 = 运营航班宽表:一行一航班,字段即列,天然可索引、可直查、可加约束;
|
|
||||||
-- · 列清单锚定 legacy SIS 契约(unisysaodbsis.xsd SCHD.FLTR 记录,ACMA-4 基线,
|
|
||||||
-- 32 个 Handler 的 KEEP/FIX 翻译即以此为闭集):
|
|
||||||
-- - 标量字段 → 真实列(约 45 列 + legacy 派生列 ABDG/LPSDT/ABN);
|
|
||||||
-- - 1:N 明细集合(登机口/柜台/转盘/桥/延误/航线等,单航班可达 99 条)各占一列
|
|
||||||
-- 存序列化文本(TEXT;11g 移植为 CLOB),阶段 2/3 语义钉死后按需升独立子表;
|
|
||||||
-- · 值语义保持 legacy 字符串原样(不做库端类型转换),保证影子对拍逐字段保真;
|
|
||||||
-- 范围查询需要的类型化列(如 SODT→DATE)按查询需求逐列后置提升。
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
-- ① 运营航班表(当前运营航班动态权威,FLID 与 legacy flightInfo 同键;一行一航班)
|
|
||||||
CREATE TABLE FLIGHT_SCHD (
|
|
||||||
FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 唯一航班标识
|
|
||||||
FDAY DATE NULL, -- 所属日计划代;NULL = 非代所有(ADFT/增量增建)
|
|
||||||
-- ==== SCHD.FLTR 标量字段(unisysaodbsis.xsd,值保持 legacy 字符串原样)====
|
|
||||||
ALCD VARCHAR(64) NULL, -- 航空公司代码
|
|
||||||
ALSC VARCHAR(64) NULL, -- 航空公司简称
|
|
||||||
FLNO VARCHAR(64) NULL, -- 航班号
|
|
||||||
MVIN VARCHAR(64) NULL, -- 进离港标识(A=到港 D=离港)
|
|
||||||
SODT VARCHAR(64) NULL, -- 计划时间(ddMMMyyHHmm)
|
|
||||||
FLTY VARCHAR(64) NULL, -- 航班类型
|
|
||||||
FLIN VARCHAR(64) NULL, -- 国内/国际/混合属性
|
|
||||||
ACFT VARCHAR(64) NULL, -- 机型
|
|
||||||
RENO VARCHAR(64) NULL, -- 机尾号
|
|
||||||
TAOP VARCHAR(64) NULL, -- 实际承运人代码
|
|
||||||
TAFL VARCHAR(64) NULL, -- 实际承运航班号
|
|
||||||
TAID VARCHAR(64) NULL, -- 实际承运航班 FLID
|
|
||||||
TRML VARCHAR(64) NULL, -- 航站楼
|
|
||||||
MAXP VARCHAR(64) NULL, -- 最大旅客数
|
|
||||||
CSOP VARCHAR(64) NULL, -- 共享承运人代码
|
|
||||||
CSFT VARCHAR(64) NULL, -- 共享航班号
|
|
||||||
MAID VARCHAR(32) NULL, -- 共享主航班 FLID
|
|
||||||
ESTT VARCHAR(64) NULL, -- 预计时间
|
|
||||||
ACTT VARCHAR(64) NULL, -- 实际时间
|
|
||||||
STND VARCHAR(64) NULL, -- 备降站
|
|
||||||
PHAG VARCHAR(64) NULL, -- 地服代理(值机)
|
|
||||||
CNCL VARCHAR(64) NULL, -- 取消时间
|
|
||||||
REMC VARCHAR(80) NULL, -- 备注
|
|
||||||
BOTM VARCHAR(64) NULL, -- 摆渡车时间
|
|
||||||
LACL VARCHAR(64) NULL, -- 行李确认时间
|
|
||||||
FINT VARCHAR(64) NULL, -- 完成时间
|
|
||||||
APPT VARCHAR(64) NULL, -- 旅客到达时间
|
|
||||||
EGSR VARCHAR(64) NULL, -- 保障开始时间
|
|
||||||
EGST VARCHAR(64) NULL, -- 保障结束时间
|
|
||||||
FHAG VARCHAR(64) NULL, -- 地服代理(货运)
|
|
||||||
MHAG VARCHAR(64) NULL, -- 地服代理(机务)
|
|
||||||
VIPP VARCHAR(64) NULL, -- VIP 旅客数
|
|
||||||
VIPR VARCHAR(64) NULL, -- VIP 等级
|
|
||||||
LBNO VARCHAR(64) NULL, -- 行李件数
|
|
||||||
LBWT VARCHAR(64) NULL, -- 行李重量
|
|
||||||
PAXC VARCHAR(64) NULL, -- 旅客计数
|
|
||||||
EXSC VARCHAR(64) NULL, -- 例外代码
|
|
||||||
EXSR VARCHAR(256) NULL, -- 例外原因
|
|
||||||
FTSS VARCHAR(64) NULL, -- 航班状态
|
|
||||||
PEDT VARCHAR(64) NULL, -- 前序估计时间
|
|
||||||
NEAT VARCHAR(64) NULL, -- 下机完成时间
|
|
||||||
PADT VARCHAR(64) NULL, -- 旅客登机时间
|
|
||||||
NAAT VARCHAR(64) NULL, -- 关门时间
|
|
||||||
-- ==== legacy flightInfo 派生标量字段(user-stories §SCHD.FLTR)====
|
|
||||||
ABDG VARCHAR(64) NULL, -- 当前登机桥(派生)
|
|
||||||
LPSDT VARCHAR(64) NULL, -- 历史计划机位(派生)
|
|
||||||
ABN VARCHAR(64) NULL, -- 异常标识(派生)
|
|
||||||
-- ==== 1:N 明细集合(序列化文本,一列一集合;阶段 2/3 按需升独立子表)====
|
|
||||||
ROUT_TXT TEXT NULL, -- 航线(ROUTEDAILY ×6)
|
|
||||||
ERUT_TXT TEXT NULL, -- 扩展航线(ROUTEDAILY ×2..7)
|
|
||||||
CHDT_TXT TEXT NULL, -- 廊桥/ chute 数据(×99)
|
|
||||||
GTDT_TXT TEXT NULL, -- 登机口(×99)
|
|
||||||
PSDT_TXT TEXT NULL, -- 机位(×9)
|
|
||||||
CKDT_TXT TEXT NULL, -- 值机柜台(×99)
|
|
||||||
CLDT_TXT TEXT NULL, -- 行李转盘(×99)
|
|
||||||
DELY_TXT TEXT NULL, -- 延误(不限)
|
|
||||||
CHOT_TXT TEXT NULL, -- 客桥桥载(×99)
|
|
||||||
ABTM_TXT TEXT NULL, -- 登机桥(×99)
|
|
||||||
SRVT_TXT TEXT NULL, -- 服务(不限)
|
|
||||||
VIPF_TXT TEXT NULL, -- VIP(不限)
|
|
||||||
MAFL_TXT TEXT NULL, -- 共享航班列表(legacy 派生集合)
|
|
||||||
FDIV_TXT TEXT NULL, -- 备降明细
|
|
||||||
FRET_TXT TEXT NULL, -- 返航明细
|
|
||||||
FLAB_TXT TEXT NULL, -- 中止明细
|
|
||||||
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_flight_schd_fday ON FLIGHT_SCHD (FDAY); -- 日代扫描与影子对拍分片
|
|
||||||
-- 运营查询索引按需逐列追加(11g/PG 均为普通 B-tree,零方言成本)
|
|
||||||
|
|
||||||
-- ② 日计划代与差删元数据表(代版本推进与版本 CAS)
|
|
||||||
CREATE TABLE SCHD_GEN (
|
|
||||||
FDAY DATE NOT NULL PRIMARY KEY, -- 计划日(yyyy-MM-dd)
|
|
||||||
VERSION BIGINT NOT NULL, -- 代版本号(单调递增)
|
|
||||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ③ 当前代有效航班 FLID 集合(差删依据)
|
|
||||||
CREATE TABLE SCHD_GEN_FLID (
|
|
||||||
FDAY DATE NOT NULL, -- 所属计划日(FK 级联删除)
|
|
||||||
FLID VARCHAR(32) NOT NULL, -- 当前代有效航班
|
|
||||||
CONSTRAINT pk_schd_gen_flid PRIMARY KEY (FDAY, FLID),
|
|
||||||
CONSTRAINT fk_schd_gen_flid FOREIGN KEY (FDAY) REFERENCES SCHD_GEN (FDAY) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
-- =====================================================================
|
|
||||||
-- ACM2-29 · 全宽表·零子表(Zero-Subtable Wide Table)定案落地
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- 废除「16 个明细集合保留 *_TXT 并逐个升独立子表」的原规划(对 XSD
|
|
||||||
-- maxOccurs=99 的过度设计)。结合 docs/legacy/SIS_AODB_RMS-V0.1.md 报文
|
|
||||||
-- 样例与成都现场(双流/天府)地服实际规律——登机门 1~2 个、值机柜台专属
|
|
||||||
-- 1~3 个、转盘 1~2 个、延误单有效、靠撤桥/轮挡各 1 次——全部集合收敛为
|
|
||||||
-- FLIGHT_SCHD 宽表的标准 VARCHAR 标量列或紧凑格式化字串,零子表、零 CLOB:
|
|
||||||
-- · 平铺标量列(属性名 + 出现槽位序号,可按需直接追加普通 B-tree,
|
|
||||||
-- 11g/PG 零方言成本):
|
|
||||||
-- GTDT → GATE/PGOT/PGCT/GOTM/GCTM/GTYP ×2 组(登机门 1~2 个)
|
|
||||||
-- CKDT → CHKC/CCLS/PCOT/PCCT/COTM/CCTM/CTYP ×3 组
|
|
||||||
-- (值机柜台 1~3 个;同柜台分舱复用条目在写侧抹平去重)
|
|
||||||
-- CLDT → BELT/BCLS/BPCOT/BPCCT/FBAG/LBAG/BTYP ×2 组(转盘 1~2 个;
|
|
||||||
-- B 前缀时刻 = BELT 专属,避免与值机 PCOT/PCCT 同名)
|
|
||||||
-- PSDT → PSST/STST/STET ×2 组(计划机位 1~2 个)
|
|
||||||
-- CHDT → CHUT/CHCLS/PCBT/PCET/CBTM/CETM/CHTYP ×2 组(离港通道 1~2 个;
|
|
||||||
-- CH 前缀 = CHUTE 专属,避免与值机 CCLS/CTYP 同名)
|
|
||||||
-- DELY → DELY_CODE/DELY_STRT/DELY_DURA/DELY_REMC(单有效覆盖语义)
|
|
||||||
-- ABTM → ABTM_A/ABTM_D(靠桥/撤桥时刻;桥号沿用派生列 ABDG)
|
|
||||||
-- CHOT → CHOT_ON/CHOT_OFF(上/下轮挡时刻;机位沿用派生列 STND)
|
|
||||||
-- FDIV/FRET/FLAB → 前缀标量列(规范 1:0..1 单值异常)
|
|
||||||
-- 超出现场定界的条目按定案丢弃(不再为 XSD 理论上限设计)。
|
|
||||||
-- · 紧凑 VARCHAR 字串列(无界/航路型集合,11g VARCHAR2 内联存储,非 CLOB):
|
|
||||||
-- ROUT/ERUT → ROUT_PATH/ERUT_PATH(≤7 站紧凑航路 "APCD/SCAT/SCDT,...")
|
|
||||||
-- SRVT/VIPF/MAFL → *_TEXT(服务/VIP/共享列表条数无 XSD 上限,存 JSON 数组;
|
|
||||||
-- 超长写侧 fail fast,不允许静默截断)
|
|
||||||
-- · 值语义保持 legacy 字符串原样;读侧视图由标量列重建集合键,
|
|
||||||
-- 与 legacy flightInfo hash 同构(KAFKA_SCHD 线格式与 FS7 对拍不变)。
|
|
||||||
-- · 运营资源消息语义 = 单资源集合级全量快照替换;外层航班字段仍为
|
|
||||||
-- 字段级增量合并(集合键出现即整集合覆盖,缺槽位置 NULL)。
|
|
||||||
-- ---------------------------------------------------------------------
|
|
||||||
-- 交叉实例单写者互斥(I5 修正定案):PIPELINE_LOCK 行级锁。主泵写事务
|
|
||||||
-- 持有 FLIGHT_SCHD_WRITER 行 SELECT ... FOR UPDATE;连接断开、提交或回滚
|
|
||||||
-- 时由数据库释放,PG 与 Oracle 11g 同构,无 DBMS_LOCK DBA 特权依赖。
|
|
||||||
-- =====================================================================
|
|
||||||
|
|
||||||
ALTER TABLE FLIGHT_SCHD
|
|
||||||
-- 登机门 GTDT(现场 1~2 个)
|
|
||||||
ADD COLUMN GATE1 VARCHAR(64), ADD COLUMN PGOT1 VARCHAR(64), ADD COLUMN PGCT1 VARCHAR(64),
|
|
||||||
ADD COLUMN GOTM1 VARCHAR(64), ADD COLUMN GCTM1 VARCHAR(64), ADD COLUMN GTYP1 VARCHAR(64),
|
|
||||||
ADD COLUMN GATE2 VARCHAR(64), ADD COLUMN PGOT2 VARCHAR(64), ADD COLUMN PGCT2 VARCHAR(64),
|
|
||||||
ADD COLUMN GOTM2 VARCHAR(64), ADD COLUMN GCTM2 VARCHAR(64), ADD COLUMN GTYP2 VARCHAR(64),
|
|
||||||
-- 值机柜台 CKDT(现场专属 1~3 个,分舱复用条目写侧抹平)
|
|
||||||
ADD COLUMN CHKC1 VARCHAR(64), ADD COLUMN CCLS1 VARCHAR(64),
|
|
||||||
ADD COLUMN PCOT1 VARCHAR(64), ADD COLUMN PCCT1 VARCHAR(64),
|
|
||||||
ADD COLUMN COTM1 VARCHAR(64), ADD COLUMN CCTM1 VARCHAR(64), ADD COLUMN CTYP1 VARCHAR(64),
|
|
||||||
ADD COLUMN CHKC2 VARCHAR(64), ADD COLUMN CCLS2 VARCHAR(64),
|
|
||||||
ADD COLUMN PCOT2 VARCHAR(64), ADD COLUMN PCCT2 VARCHAR(64),
|
|
||||||
ADD COLUMN COTM2 VARCHAR(64), ADD COLUMN CCTM2 VARCHAR(64), ADD COLUMN CTYP2 VARCHAR(64),
|
|
||||||
ADD COLUMN CHKC3 VARCHAR(64), ADD COLUMN CCLS3 VARCHAR(64),
|
|
||||||
ADD COLUMN PCOT3 VARCHAR(64), ADD COLUMN PCCT3 VARCHAR(64),
|
|
||||||
ADD COLUMN COTM3 VARCHAR(64), ADD COLUMN CCTM3 VARCHAR(64), ADD COLUMN CTYP3 VARCHAR(64),
|
|
||||||
-- 行李转盘 CLDT(现场 1~2 个;B 前缀时刻 = BELT 专属)
|
|
||||||
ADD COLUMN BELT1 VARCHAR(64), ADD COLUMN BCLS1 VARCHAR(64),
|
|
||||||
ADD COLUMN BPCOT1 VARCHAR(64), ADD COLUMN BPCCT1 VARCHAR(64),
|
|
||||||
ADD COLUMN FBAG1 VARCHAR(64), ADD COLUMN LBAG1 VARCHAR(64), ADD COLUMN BTYP1 VARCHAR(64),
|
|
||||||
ADD COLUMN BELT2 VARCHAR(64), ADD COLUMN BCLS2 VARCHAR(64),
|
|
||||||
ADD COLUMN BPCOT2 VARCHAR(64), ADD COLUMN BPCCT2 VARCHAR(64),
|
|
||||||
ADD COLUMN FBAG2 VARCHAR(64), ADD COLUMN LBAG2 VARCHAR(64), ADD COLUMN BTYP2 VARCHAR(64),
|
|
||||||
-- 计划机位 PSDT(现场 1~2 个)
|
|
||||||
ADD COLUMN PSST1 VARCHAR(64), ADD COLUMN STST1 VARCHAR(64), ADD COLUMN STET1 VARCHAR(64),
|
|
||||||
ADD COLUMN PSST2 VARCHAR(64), ADD COLUMN STST2 VARCHAR(64), ADD COLUMN STET2 VARCHAR(64),
|
|
||||||
-- 离港行李通道 CHDT(现场 1~2 个;CH 前缀 = CHUTE 专属)
|
|
||||||
ADD COLUMN CHUT1 VARCHAR(64), ADD COLUMN CHCLS1 VARCHAR(64),
|
|
||||||
ADD COLUMN PCBT1 VARCHAR(64), ADD COLUMN PCET1 VARCHAR(64),
|
|
||||||
ADD COLUMN CBTM1 VARCHAR(64), ADD COLUMN CETM1 VARCHAR(64), ADD COLUMN CHTYP1 VARCHAR(64),
|
|
||||||
ADD COLUMN CHUT2 VARCHAR(64), ADD COLUMN CHCLS2 VARCHAR(64),
|
|
||||||
ADD COLUMN PCBT2 VARCHAR(64), ADD COLUMN PCET2 VARCHAR(64),
|
|
||||||
ADD COLUMN CBTM2 VARCHAR(64), ADD COLUMN CETM2 VARCHAR(64), ADD COLUMN CHTYP2 VARCHAR(64),
|
|
||||||
-- 延误 DELY(单有效覆盖语义)
|
|
||||||
ADD COLUMN DELY_CODE VARCHAR(64),
|
|
||||||
ADD COLUMN DELY_STRT VARCHAR(64),
|
|
||||||
ADD COLUMN DELY_DURA VARCHAR(64),
|
|
||||||
ADD COLUMN DELY_REMC VARCHAR(80),
|
|
||||||
-- 靠撤桥 ABTM / 轮挡 CHOT(保障里程碑时刻)
|
|
||||||
ADD COLUMN ABTM_A VARCHAR(64),
|
|
||||||
ADD COLUMN ABTM_D VARCHAR(64),
|
|
||||||
ADD COLUMN CHOT_ON VARCHAR(64),
|
|
||||||
ADD COLUMN CHOT_OFF VARCHAR(64),
|
|
||||||
-- 异常明细 FDIV/FRET/FLAB(规范 1:0..1 单值异常)
|
|
||||||
ADD COLUMN FDIV_DDES VARCHAR(64),
|
|
||||||
ADD COLUMN FDIV_DDIR VARCHAR(64),
|
|
||||||
ADD COLUMN FDIV_REMC VARCHAR(80),
|
|
||||||
ADD COLUMN FRET_REID VARCHAR(64),
|
|
||||||
ADD COLUMN FRET_RSN VARCHAR(80),
|
|
||||||
ADD COLUMN FLAB_ARES VARCHAR(64),
|
|
||||||
ADD COLUMN FLAB_RSN VARCHAR(80),
|
|
||||||
-- 紧凑航路字串(≤7 站,免除多表关联)
|
|
||||||
ADD COLUMN ROUT_PATH VARCHAR(256),
|
|
||||||
ADD COLUMN ERUT_PATH VARCHAR(256),
|
|
||||||
-- 无界集合紧凑 JSON 数组字串(11g VARCHAR2 内联,非 CLOB;超长写侧 fail fast)
|
|
||||||
ADD COLUMN SRVT_TEXT VARCHAR(2000),
|
|
||||||
ADD COLUMN VIPF_TEXT VARCHAR(512),
|
|
||||||
ADD COLUMN MAFL_TEXT VARCHAR(512);
|
|
||||||
|
|
||||||
-- 16 个集合的过渡 *_TXT 文本列全清场
|
|
||||||
ALTER TABLE FLIGHT_SCHD
|
|
||||||
DROP COLUMN ROUT_TXT, DROP COLUMN ERUT_TXT, DROP COLUMN CHDT_TXT,
|
|
||||||
DROP COLUMN GTDT_TXT, DROP COLUMN PSDT_TXT, DROP COLUMN CKDT_TXT,
|
|
||||||
DROP COLUMN CLDT_TXT, DROP COLUMN DELY_TXT, DROP COLUMN CHOT_TXT,
|
|
||||||
DROP COLUMN ABTM_TXT, DROP COLUMN SRVT_TXT, DROP COLUMN VIPF_TXT,
|
|
||||||
DROP COLUMN MAFL_TXT, DROP COLUMN FDIV_TXT, DROP COLUMN FRET_TXT,
|
|
||||||
DROP COLUMN FLAB_TXT;
|
|
||||||
|
|
||||||
-- 运营查询索引按需逐列追加示例(11g/PG 均为普通 B-tree,零方言成本):
|
|
||||||
-- CREATE INDEX idx_flight_schd_gate1 ON FLIGHT_SCHD (GATE1);
|
|
||||||
-- CREATE INDEX idx_flight_schd_chkc1 ON FLIGHT_SCHD (CHKC1);
|
|
||||||
-- CREATE INDEX idx_flight_schd_dely_code ON FLIGHT_SCHD (DELY_CODE);
|
|
||||||
|
|
||||||
-- 交叉实例单写者互斥行(I5:SELECT ... FOR UPDATE 行级锁)
|
|
||||||
CREATE TABLE PIPELINE_LOCK (
|
|
||||||
LOCK_KEY VARCHAR(32) NOT NULL PRIMARY KEY,
|
|
||||||
OWNER_INFO VARCHAR(128),
|
|
||||||
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO PIPELINE_LOCK (LOCK_KEY, OWNER_INFO, UPDATED_AT)
|
|
||||||
VALUES ('FLIGHT_SCHD_WRITER', NULL, CURRENT_TIMESTAMP);
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
-- docs/flight-state.md (ACM2-29): lossless detail tables + tracking columns
|
|
||||||
|
|
||||||
ALTER TABLE flight_schd
|
|
||||||
ADD COLUMN IF NOT EXISTS last_message_id VARCHAR(64),
|
|
||||||
ADD COLUMN IF NOT EXISTS state_version BIGINT NOT NULL DEFAULT 0;
|
|
||||||
|
|
||||||
ALTER TABLE schd_gen
|
|
||||||
ADD COLUMN IF NOT EXISTS last_message_id VARCHAR(64);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_gate (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
gate VARCHAR(64),
|
|
||||||
pgot VARCHAR(64),
|
|
||||||
pgct VARCHAR(64),
|
|
||||||
gotm VARCHAR(64),
|
|
||||||
gctm VARCHAR(64),
|
|
||||||
gtyp VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_checkin (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
chkc VARCHAR(64),
|
|
||||||
ccls VARCHAR(64),
|
|
||||||
pcot VARCHAR(64),
|
|
||||||
pcct VARCHAR(64),
|
|
||||||
cotm VARCHAR(64),
|
|
||||||
cctm VARCHAR(64),
|
|
||||||
ctyp VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_belt (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
belt VARCHAR(64),
|
|
||||||
bcls VARCHAR(64),
|
|
||||||
pcot VARCHAR(64),
|
|
||||||
pcct VARCHAR(64),
|
|
||||||
fbag VARCHAR(64),
|
|
||||||
lbag VARCHAR(64),
|
|
||||||
btyp VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_stand_plan (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
psst VARCHAR(64),
|
|
||||||
stst VARCHAR(64),
|
|
||||||
stet VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_chute (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
chut VARCHAR(64),
|
|
||||||
chcls VARCHAR(64),
|
|
||||||
pcbt VARCHAR(64),
|
|
||||||
pcet VARCHAR(64),
|
|
||||||
cbtm VARCHAR(64),
|
|
||||||
cetm VARCHAR(64),
|
|
||||||
chtyp VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_delay (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
code VARCHAR(64),
|
|
||||||
strt VARCHAR(64),
|
|
||||||
dura VARCHAR(64),
|
|
||||||
remc VARCHAR(80),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_bridge_op (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
abdg VARCHAR(64),
|
|
||||||
abop VARCHAR(64),
|
|
||||||
aotm VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_chock_op (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
chid VARCHAR(64),
|
|
||||||
chst VARCHAR(64),
|
|
||||||
chtm VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS flight_route_point (
|
|
||||||
flid VARCHAR(32) NOT NULL,
|
|
||||||
ordinal INTEGER NOT NULL,
|
|
||||||
source_seq VARCHAR(64),
|
|
||||||
record_version BIGINT NOT NULL DEFAULT 0,
|
|
||||||
route_kind VARCHAR(8) NOT NULL,
|
|
||||||
apcd VARCHAR(64),
|
|
||||||
scat VARCHAR(64),
|
|
||||||
scdt VARCHAR(64),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
PRIMARY KEY (flid, ordinal)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_flight_gate_flid ON flight_gate (flid);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_flight_checkin_flid ON flight_checkin (flid);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_flight_belt_flid ON flight_belt (flid);
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
-- docs/flight-state.md §8 (ACM2-29 P2-2): compatibility read view.
|
|
||||||
-- 提供「首个/第二个资源 + 总数」的运营查询投影;完整明细仍在各明细表可查。
|
|
||||||
-- PG 专属语法(LATERAL);Oracle 11g 方言随 P3-B 单独提供 location。
|
|
||||||
|
|
||||||
CREATE OR REPLACE VIEW flight_schd_display AS
|
|
||||||
SELECT
|
|
||||||
f.flid,
|
|
||||||
f.fday,
|
|
||||||
f.flno,
|
|
||||||
f.mvin,
|
|
||||||
f.sodt,
|
|
||||||
f.stnd,
|
|
||||||
f.abdg,
|
|
||||||
f.state_version,
|
|
||||||
f.last_message_id,
|
|
||||||
|
|
||||||
g1.gate AS gate1,
|
|
||||||
g2.gate AS gate2,
|
|
||||||
COALESCE(g_total.cnt, 0) AS gate_total,
|
|
||||||
|
|
||||||
c1.chkc AS chkc1,
|
|
||||||
c2.chkc AS chkc2,
|
|
||||||
COALESCE(c_total.cnt, 0) AS checkin_total,
|
|
||||||
|
|
||||||
b1.belt AS belt1,
|
|
||||||
COALESCE(b_total.cnt, 0) AS belt_total,
|
|
||||||
|
|
||||||
dly.code AS dely_code,
|
|
||||||
dly.strt AS dely_strt
|
|
||||||
FROM flight_schd f
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT gate FROM flight_gate WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 0
|
|
||||||
) g1 ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT gate FROM flight_gate WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 1
|
|
||||||
) g2 ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT count(*) AS cnt FROM flight_gate WHERE flid = f.flid
|
|
||||||
) g_total ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT chkc FROM flight_checkin WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 0
|
|
||||||
) c1 ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT chkc FROM flight_checkin WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 1
|
|
||||||
) c2 ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT count(*) AS cnt FROM flight_checkin WHERE flid = f.flid
|
|
||||||
) c_total ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT belt FROM flight_belt WHERE flid = f.flid ORDER BY ordinal LIMIT 1 OFFSET 0
|
|
||||||
) b1 ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT count(*) AS cnt FROM flight_belt WHERE flid = f.flid
|
|
||||||
) b_total ON TRUE
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT code, strt FROM flight_delay WHERE flid = f.flid ORDER BY ordinal LIMIT 1
|
|
||||||
) dly ON TRUE;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- docs/flight-state.md §5 (ACM2-29 P2-3): 提交后共享信箱回填的持久补偿待办。
|
|
||||||
-- 业务事务已提交(PROC_STATE=SUCCEEDED)后回填失败 → 落本表重试;
|
|
||||||
-- 回填失败不得把已成功的业务事务重新标记为失败,也不得重放业务变更。
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS backfill_todo (
|
|
||||||
cminmsgs_id BIGINT PRIMARY KEY,
|
|
||||||
sndr VARCHAR(64) NOT NULL,
|
|
||||||
type VARCHAR(32) NOT NULL,
|
|
||||||
styp VARCHAR(32),
|
|
||||||
seqn BIGINT,
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
next_attempt_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
last_error VARCHAR(512),
|
|
||||||
created_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
|
||||||
updated_at TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_backfill_todo_due ON backfill_todo (next_attempt_at);
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
-- docs/flight-state.md §7 步骤 5 (ACM2-29 P4): 旧损失性结构退场。
|
|
||||||
-- v2 无损明细表已是唯一权威存储(V1.3.0 起),槽位/里程碑/紧凑航路列自 V1.3.0 起零写入、
|
|
||||||
-- P2-2 起零读取;对拍(FS7 v2 双读 + 全结构回环矩阵)与升级链验证通过后关闭回退窗口,
|
|
||||||
-- 本迁移物理删除这些列与其冗余索引。SRVT/VIPF/MAFL_TEXT 与异常前缀列为无损承载,保留。
|
|
||||||
|
|
||||||
ALTER TABLE flight_schd
|
|
||||||
DROP COLUMN IF EXISTS gate1, DROP COLUMN IF EXISTS pgot1, DROP COLUMN IF EXISTS pgct1,
|
|
||||||
DROP COLUMN IF EXISTS gotm1, DROP COLUMN IF EXISTS gctm1, DROP COLUMN IF EXISTS gtyp1,
|
|
||||||
DROP COLUMN IF EXISTS gate2, DROP COLUMN IF EXISTS pgot2, DROP COLUMN IF EXISTS pgct2,
|
|
||||||
DROP COLUMN IF EXISTS gotm2, DROP COLUMN IF EXISTS gctm2, DROP COLUMN IF EXISTS gtyp2,
|
|
||||||
|
|
||||||
DROP COLUMN IF EXISTS chkc1, DROP COLUMN IF EXISTS ccls1, DROP COLUMN IF EXISTS pcot1,
|
|
||||||
DROP COLUMN IF EXISTS pcct1, DROP COLUMN IF EXISTS cotm1, DROP COLUMN IF EXISTS cctm1,
|
|
||||||
DROP COLUMN IF EXISTS ctyp1, DROP COLUMN IF EXISTS chkc2, DROP COLUMN IF EXISTS ccls2,
|
|
||||||
DROP COLUMN IF EXISTS pcot2, DROP COLUMN IF EXISTS pcct2, DROP COLUMN IF EXISTS cotm2,
|
|
||||||
DROP COLUMN IF EXISTS cctm2, DROP COLUMN IF EXISTS ctyp2, DROP COLUMN IF EXISTS chkc3,
|
|
||||||
DROP COLUMN IF EXISTS ccls3, DROP COLUMN IF EXISTS pcot3, DROP COLUMN IF EXISTS pcct3,
|
|
||||||
DROP COLUMN IF EXISTS cotm3, DROP COLUMN IF EXISTS cctm3, DROP COLUMN IF EXISTS ctyp3,
|
|
||||||
|
|
||||||
DROP COLUMN IF EXISTS belt1, DROP COLUMN IF EXISTS bcls1, DROP COLUMN IF EXISTS bpcot1,
|
|
||||||
DROP COLUMN IF EXISTS bpcct1, DROP COLUMN IF EXISTS fbag1, DROP COLUMN IF EXISTS lbag1,
|
|
||||||
DROP COLUMN IF EXISTS btyp1, DROP COLUMN IF EXISTS belt2, DROP COLUMN IF EXISTS bcls2,
|
|
||||||
DROP COLUMN IF EXISTS bpcot2, DROP COLUMN IF EXISTS bpcct2, DROP COLUMN IF EXISTS fbag2,
|
|
||||||
DROP COLUMN IF EXISTS lbag2, DROP COLUMN IF EXISTS btyp2,
|
|
||||||
|
|
||||||
DROP COLUMN IF EXISTS psst1, DROP COLUMN IF EXISTS stst1, DROP COLUMN IF EXISTS stet1,
|
|
||||||
DROP COLUMN IF EXISTS psst2, DROP COLUMN IF EXISTS stst2, DROP COLUMN IF EXISTS stet2,
|
|
||||||
|
|
||||||
DROP COLUMN IF EXISTS chut1, DROP COLUMN IF EXISTS chcls1, DROP COLUMN IF EXISTS pcbt1,
|
|
||||||
DROP COLUMN IF EXISTS pcet1, DROP COLUMN IF EXISTS cbtm1, DROP COLUMN IF EXISTS cetm1,
|
|
||||||
DROP COLUMN IF EXISTS chtyp1, DROP COLUMN IF EXISTS chut2, DROP COLUMN IF EXISTS chcls2,
|
|
||||||
DROP COLUMN IF EXISTS pcbt2, DROP COLUMN IF EXISTS pcet2, DROP COLUMN IF EXISTS cbtm2,
|
|
||||||
DROP COLUMN IF EXISTS cetm2, DROP COLUMN IF EXISTS chtyp2,
|
|
||||||
|
|
||||||
DROP COLUMN IF EXISTS dely_code, DROP COLUMN IF EXISTS dely_strt,
|
|
||||||
DROP COLUMN IF EXISTS dely_dura, DROP COLUMN IF EXISTS dely_remc,
|
|
||||||
DROP COLUMN IF EXISTS abtm_a, DROP COLUMN IF EXISTS abtm_d,
|
|
||||||
DROP COLUMN IF EXISTS chot_on, DROP COLUMN IF EXISTS chot_off,
|
|
||||||
|
|
||||||
DROP COLUMN IF EXISTS rout_path, DROP COLUMN IF EXISTS erut_path;
|
|
||||||
|
|
||||||
-- V1.3.0 的三个 (flid) 单列索引与 PK (flid, ordinal) 前缀冗余(L2),一并清理
|
|
||||||
DROP INDEX IF EXISTS idx_flight_gate_flid;
|
|
||||||
DROP INDEX IF EXISTS idx_flight_checkin_flid;
|
|
||||||
DROP INDEX IF EXISTS idx_flight_belt_flid;
|
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 航班运行数据接入与当前状态管理 · 全量基线
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 权威设计:docs/flight-state.md(审计定稿)。本脚本整体取代历史迁移
|
||||||
|
-- (V1.0.0–V1.4.0 已删除),按 §3.2 表职责建立全部分层:
|
||||||
|
-- · 决策层:FLIGHT_SCHD + 8 张资源明细表 + FLIGHT_ROUTE_POINT —— 承担正确性;
|
||||||
|
-- · 管道层:PIPELINE_LOCK / PROC_STATE / MSG_EVENT(outbox) / REQ_TRACK / BACKFILL_TODO;
|
||||||
|
-- · 留痕层:SCHD_SNAP_LOG —— 只追加、不参与决策、可重建;
|
||||||
|
-- · 证据层:报文原文归档 —— 冷路径,尚未交付(ARCHIVE_KEY 仅留引用位)。
|
||||||
|
-- 核心定案(对照文档章节):
|
||||||
|
-- · 身份:FLID 主键;OPERATION_DAY 一经确定不可变(§3.1/§5.3,应用层校验);
|
||||||
|
-- STATE ∈ {ACTIVE, DELETED},无 ARCHIVED —— 物理清除只发生在历史归档成功之后(§8.2);
|
||||||
|
-- · 无名单差删:主链路无集合删除;删除入口只有 FDEL(§6.2)与历史归档(§8.2);
|
||||||
|
-- · 版本:每次成功写入推进 STATE_VERSION;Kafka 判旧 = (FLID, STATE_VERSION, UPDATED_AT)(§7.3);
|
||||||
|
-- · 出站:COUTMSGS 是共享 MySQL 信箱(他人系统库,本系统不建表,迁移不覆盖)。
|
||||||
|
-- 类型口径:TIMESTAMP(6) WITH TIME ZONE 统一 UTC 语义;无 JSONB/CLOB 依赖;
|
||||||
|
-- BIGSERIAL 为 PostgreSQL 方言(Oracle 适配见 §10,未定前不预设)。
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ① 单行锁:串行化状态写事务(§1 边界:只串行化 DB 事务,不代替消息认领/选主/故障切换)
|
||||||
|
CREATE TABLE PIPELINE_LOCK (
|
||||||
|
LOCK_ID INT NOT NULL PRIMARY KEY, -- 恒为 1
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO PIPELINE_LOCK (LOCK_ID, UPDATED_AT) VALUES (1, now());
|
||||||
|
|
||||||
|
-- ② 处理伴生状态:与共享信箱 CMINMSGS 一一对应(MSG_ID = CMINMSGS_ID)。
|
||||||
|
-- 兼作快照重放判定(§5.1 步骤 2):MSG_ID 已有成功终态 → 重放,直接记幂等成功。
|
||||||
|
CREATE TABLE PROC_STATE (
|
||||||
|
MSG_ID BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
STATE VARCHAR(16) NOT NULL, -- PENDING/FAILED/SUCCEEDED/SKIPPED/DEAD
|
||||||
|
IDENTITY_KEY VARCHAR(200), -- SNDR|TYPE|STYP|SEQN(decode 后首次绑定;SEQN 同会话顺序观测 §5.5)
|
||||||
|
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||||
|
NEXT_ATTEMPT_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||||
|
ERROR_CLASS VARCHAR(20), -- MALFORMED/PROTOCOL/INFRA/UNSUPPORTED/EXHAUSTED(§9)
|
||||||
|
LAST_ERROR VARCHAR(1000),
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
CONSTRAINT uk_proc_identity UNIQUE (IDENTITY_KEY)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_proc_head ON PROC_STATE (STATE, MSG_ID); -- 主泵队头查询(严格 FIFO)
|
||||||
|
|
||||||
|
-- ③ 航班当前态主行:一行一 FLID(决策层权威;展示视图是投影,不是权威 §3.3)
|
||||||
|
CREATE TABLE FLIGHT_SCHD (
|
||||||
|
FLID VARCHAR(32) NOT NULL PRIMARY KEY, -- AODB 实例 ID,Number(1-12)(SIS §3.16.2)
|
||||||
|
OPERATION_DAY DATE NULL, -- 运营保障日;一经确定不可变(§3.5);NULL=尚未被快照收录
|
||||||
|
STATE VARCHAR(8) NOT NULL, -- ACTIVE/DELETED(§3.1,无第三态)
|
||||||
|
STATE_VERSION BIGINT NOT NULL DEFAULT 0, -- 每次成功写入 +1(§4)
|
||||||
|
LAST_MSG_ID BIGINT NULL, -- 最近一次成功写入的消息 ID
|
||||||
|
-- ==== SCHD.FLTR 标量字段(值保持 AODB 字符串原样;缺失语义由处理层决定,§5.4)====
|
||||||
|
ALCD VARCHAR(64) NULL, -- 航空公司代码
|
||||||
|
ALSC VARCHAR(64) NULL, -- 航空公司简称
|
||||||
|
FLNO VARCHAR(64) NULL, -- 航班号(展示用,不参与身份判定 §3.1)
|
||||||
|
MVIN VARCHAR(64) NULL, -- 进离港标识(A/D)
|
||||||
|
SODT VARCHAR(64) NULL, -- 计划运行时间(ddMMMyyHHmm;OPERATION_DAY 计算源 §3.5)
|
||||||
|
FLTY VARCHAR(64) NULL, -- 航班类型
|
||||||
|
FLIN VARCHAR(64) NULL, -- 国内/国际/混合
|
||||||
|
ACFT VARCHAR(64) NULL, -- 机型
|
||||||
|
RENO VARCHAR(64) NULL, -- 机尾号
|
||||||
|
TAOP VARCHAR(64) NULL, -- 过站实际承运人代码(§3.4:过站关联,非代码共享)
|
||||||
|
TAFL VARCHAR(64) NULL, -- 过站实际承运航班号
|
||||||
|
TAID VARCHAR(64) NULL, -- 过站关联航班 FLID
|
||||||
|
TRML VARCHAR(64) NULL, -- 航站楼
|
||||||
|
MAXP VARCHAR(64) NULL, -- 最大旅客数
|
||||||
|
CSOP VARCHAR(64) NULL, -- 共享承运人代码(§3.4)
|
||||||
|
CSFT VARCHAR(64) NULL, -- 共享航班号
|
||||||
|
MAID VARCHAR(32) NULL, -- 共享主航班 FLID
|
||||||
|
ESTT VARCHAR(64) NULL, -- 预计时间
|
||||||
|
ACTT VARCHAR(64) NULL, -- 实际时间
|
||||||
|
STND VARCHAR(64) NULL, -- 备降站/机位
|
||||||
|
PHAG VARCHAR(64) NULL, -- 地服代理(值机)
|
||||||
|
CNCL VARCHAR(64) NULL, -- 取消时间(§8.1 历史判定依据)
|
||||||
|
REMC VARCHAR(256) NULL, -- 备注
|
||||||
|
BOTM VARCHAR(64) NULL, -- 摆渡车时间
|
||||||
|
LACL VARCHAR(64) NULL, -- 行李确认时间
|
||||||
|
FINT VARCHAR(64) NULL, -- 完成时间
|
||||||
|
APPT VARCHAR(64) NULL, -- 旅客到达时间
|
||||||
|
EGSR VARCHAR(64) NULL, -- 保障开始时间
|
||||||
|
EGST VARCHAR(64) NULL, -- 保障结束时间
|
||||||
|
FHAG VARCHAR(64) NULL, -- 地服代理(货运)
|
||||||
|
MHAG VARCHAR(64) NULL, -- 地服代理(机务)
|
||||||
|
VIPP VARCHAR(64) NULL, -- VIP 旅客数
|
||||||
|
VIPR VARCHAR(64) NULL, -- VIP 等级
|
||||||
|
LBNO VARCHAR(64) NULL, -- 行李件数
|
||||||
|
LBWT VARCHAR(64) NULL, -- 行李重量
|
||||||
|
PAXC VARCHAR(64) NULL, -- 旅客计数
|
||||||
|
EXSC VARCHAR(64) NULL, -- 例外代码
|
||||||
|
EXSR VARCHAR(256) NULL, -- 例外原因
|
||||||
|
FTSS VARCHAR(64) NULL, -- 航班状态
|
||||||
|
PEDT VARCHAR(64) NULL, -- 前序估计时间
|
||||||
|
NAAT VARCHAR(64) NULL, -- 到港终态时间(§8.1;业务含义待术语表确认 §10)
|
||||||
|
NEAT VARCHAR(64) NULL, -- 离港终态时间(§8.1;业务含义待术语表确认 §10)
|
||||||
|
PADT VARCHAR(64) NULL, -- 旅客登机时间
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_flight_schd_opday ON FLIGHT_SCHD (OPERATION_DAY);
|
||||||
|
CREATE INDEX idx_flight_schd_state ON FLIGHT_SCHD (STATE);
|
||||||
|
-- OPERATION_DAY 不可变的数据库层强化(§7.4):快照 upsert 一律带
|
||||||
|
-- WHERE OPERATION_DAY IS NULL OR OPERATION_DAY = :day,行级条件更新即满足;
|
||||||
|
-- 不使用触发器(Oracle/PG 双方言成本)。
|
||||||
|
|
||||||
|
-- ④ 资源明细表 ×8 + 路线点表(每 FLID 多行;明细集合按组先删后插 §3.3;
|
||||||
|
-- ORDINAL 保留输入顺序,SOURCE_SEQ 只存上游序号 §3.2;
|
||||||
|
-- 删除一律标记在主行 STATE,明细物理清除只发生在历史归档 §8.2)
|
||||||
|
-- ④-1 登机门 GTDT
|
||||||
|
CREATE TABLE FLIGHT_GATE (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
GATE VARCHAR(64),
|
||||||
|
PGOT VARCHAR(64), -- 计划登机开始
|
||||||
|
PGCT VARCHAR(64), -- 计划登机结束
|
||||||
|
GOTM VARCHAR(64), -- 实际登机开始
|
||||||
|
GCTM VARCHAR(64), -- 实际登机结束
|
||||||
|
GTYP VARCHAR(64),
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-2 值机柜台 CKDT
|
||||||
|
CREATE TABLE FLIGHT_CHECKIN (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
CHKC VARCHAR(64),
|
||||||
|
CCLS VARCHAR(64),
|
||||||
|
PCOT VARCHAR(64), -- 计划开柜
|
||||||
|
PCCT VARCHAR(64), -- 计划关柜
|
||||||
|
COTM VARCHAR(64), -- 实际开柜
|
||||||
|
CCTM VARCHAR(64), -- 实际关柜
|
||||||
|
CTYP VARCHAR(64),
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-3 行李转盘 CLDT
|
||||||
|
CREATE TABLE FLIGHT_BELT (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
BELT VARCHAR(64),
|
||||||
|
BCLS VARCHAR(64),
|
||||||
|
PCOT VARCHAR(64), -- 计划开转盘(转盘专属前缀,避免与柜台同名)
|
||||||
|
PCCT VARCHAR(64), -- 计划关转盘
|
||||||
|
FBAG VARCHAR(64), -- 首件行李
|
||||||
|
LBAG VARCHAR(64), -- 末件行李
|
||||||
|
BTYP VARCHAR(64),
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-4 计划机位 PSDT
|
||||||
|
CREATE TABLE FLIGHT_STAND_PLAN (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
PSST VARCHAR(64),
|
||||||
|
STST VARCHAR(64), -- 计划开始
|
||||||
|
STET VARCHAR(64), -- 计划结束
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-5 滑槽 CHDT
|
||||||
|
CREATE TABLE FLIGHT_CHUTE (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
CHUT VARCHAR(64),
|
||||||
|
CHCLS VARCHAR(64),
|
||||||
|
PCBT VARCHAR(64), -- 计划开始(SIS 线格式 PCBT)
|
||||||
|
PCET VARCHAR(64), -- 计划结束(PCET)
|
||||||
|
CBTM VARCHAR(64), -- 实际开始(CBTM)
|
||||||
|
CETM VARCHAR(64), -- 实际结束(CETM)
|
||||||
|
CHTYP VARCHAR(64),
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-6 延误 DELY(业务上任意时刻仅 1 个有效延误,保留多行能力以无损承接)
|
||||||
|
CREATE TABLE FLIGHT_DELAY (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
CODE VARCHAR(64),
|
||||||
|
STRT VARCHAR(64),
|
||||||
|
DURA VARCHAR(64),
|
||||||
|
REMC VARCHAR(256),
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-7 靠撤桥 ABTM(桥号 ABDG;A/D 各一条)
|
||||||
|
CREATE TABLE FLIGHT_BRIDGE_OP (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
ABDG VARCHAR(64),
|
||||||
|
ABOP VARCHAR(64), -- A=靠桥 D=撤桥
|
||||||
|
AOTM VARCHAR(64), -- 动作时间
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-8 轮挡 CHOT(机位 CHID;ON/OFF 各一条)
|
||||||
|
CREATE TABLE FLIGHT_CHOCK_OP (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
CHID VARCHAR(64),
|
||||||
|
CHST VARCHAR(64), -- ON/OFF
|
||||||
|
CHTM VARCHAR(64), -- 动作时间
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ORDINAL)
|
||||||
|
);
|
||||||
|
-- ④-9 路线点 ROUT/ERUT 共用(ROUTE_KIND 区分 §3.2:10 类集合 9 张表)
|
||||||
|
CREATE TABLE FLIGHT_ROUTE_POINT (
|
||||||
|
FLID VARCHAR(32) NOT NULL,
|
||||||
|
ORDINAL INT NOT NULL,
|
||||||
|
SOURCE_SEQ VARCHAR(64),
|
||||||
|
ROUTE_KIND VARCHAR(8) NOT NULL, -- ROUT/ERUT
|
||||||
|
APCD VARCHAR(64), -- 机场代码
|
||||||
|
SCAT VARCHAR(64), -- 计划到达时间
|
||||||
|
SCDT VARCHAR(64), -- 计划出发时间
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
PRIMARY KEY (FLID, ROUTE_KIND, ORDINAL)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_flight_gate_flid ON FLIGHT_GATE (FLID);
|
||||||
|
CREATE INDEX idx_flight_checkin_flid ON FLIGHT_CHECKIN (FLID);
|
||||||
|
CREATE INDEX idx_flight_belt_flid ON FLIGHT_BELT (FLID);
|
||||||
|
CREATE INDEX idx_flight_stand_flid ON FLIGHT_STAND_PLAN (FLID);
|
||||||
|
CREATE INDEX idx_flight_chute_flid ON FLIGHT_CHUTE (FLID);
|
||||||
|
CREATE INDEX idx_flight_delay_flid ON FLIGHT_DELAY (FLID);
|
||||||
|
CREATE INDEX idx_flight_bridge_flid ON FLIGHT_BRIDGE_OP (FLID);
|
||||||
|
CREATE INDEX idx_flight_chock_flid ON FLIGHT_CHOCK_OP (FLID);
|
||||||
|
|
||||||
|
-- ⑤ 统一投递事件 outbox(§7.3:KAFKA_MSG 只通知变化;KAFKA_SCHD 发整态;
|
||||||
|
-- tombstone 仅在 ACTIVE→DELETED 时与删除同事务登记,投递失败持续重试)
|
||||||
|
CREATE TABLE MSG_EVENT (
|
||||||
|
EVENT_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
TARGET VARCHAR(30) NOT NULL, -- KAFKA_MSG / KAFKA_SCHD
|
||||||
|
PARTITION_KEY VARCHAR(32) NOT NULL, -- 恒为 FLID
|
||||||
|
EVENT_TYPE VARCHAR(16) NOT NULL DEFAULT 'UPSERT', -- UPSERT / TOMBSTONE
|
||||||
|
STATE_VERSION BIGINT NOT NULL, -- 发布时航班版本;聚合按 FLID 取最新(§7.3)
|
||||||
|
PAYLOAD_JSON TEXT NOT NULL, -- TOMBSTONE 时至少含 FLID/STATE_VERSION/DELETED
|
||||||
|
STATE VARCHAR(16) NOT NULL, -- PENDING/SENT/DEAD
|
||||||
|
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||||
|
NEXT_ATTEMPT_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||||
|
ERROR_CLASS VARCHAR(20),
|
||||||
|
LAST_ERROR VARCHAR(1000),
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_evt_head ON MSG_EVENT (TARGET, STATE, EVENT_ID); -- 每 target 队头
|
||||||
|
CREATE INDEX idx_evt_flid ON MSG_EVENT (PARTITION_KEY, STATE_VERSION); -- 同 FLID 未发事件合并
|
||||||
|
|
||||||
|
-- ⑥ 请求状态机(§7.1:只有 RESP 完成 RQFD 请求,按(运营日、发送方、请求类型)
|
||||||
|
-- 匹配最新一条 PENDING;同类请求只留一条有效,新请求置旧为 EXPIRED)
|
||||||
|
CREATE TABLE REQ_TRACK (
|
||||||
|
REQ_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
REQ_TYPE VARCHAR(20) NOT NULL,
|
||||||
|
OPERATION_DAY DATE NOT NULL, -- 请求覆盖运营日
|
||||||
|
SENDER VARCHAR(64) NOT NULL, -- 请求发送方(匹配键之一)
|
||||||
|
STATE VARCHAR(16) NOT NULL, -- PENDING/SENT/DONE/EXPIRED
|
||||||
|
COUTMSGS_ID BIGINT,
|
||||||
|
SENT_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||||
|
COMPLETED_AT TIMESTAMP(6) WITH TIME ZONE,
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_req_open ON REQ_TRACK (REQ_TYPE, OPERATION_DAY, SENDER, STATE);
|
||||||
|
|
||||||
|
-- ⑦ 共享信箱回填补偿待办(§7.2:提交后回填;回填失败不得把 SUCCEEDED 改回 FAILED;
|
||||||
|
-- 目标形态 = 业务事务内预登记,消除「提交后写待办」的崩溃窗口,§10 当前偏差)
|
||||||
|
CREATE TABLE BACKFILL_TODO (
|
||||||
|
MSG_ID BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
SNDR VARCHAR(64) NOT NULL,
|
||||||
|
TYPE VARCHAR(32) NOT NULL,
|
||||||
|
STYP VARCHAR(32),
|
||||||
|
SEQN BIGINT,
|
||||||
|
ATTEMPTS INT NOT NULL DEFAULT 0,
|
||||||
|
NEXT_ATTEMPT_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
LAST_ERROR VARCHAR(512),
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||||
|
UPDATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_backfill_todo_due ON BACKFILL_TODO (NEXT_ATTEMPT_AT);
|
||||||
|
|
||||||
|
-- ⑧ SCHD 快照留痕(§5.5):事务外追加,只追加留痕、不参与决策;一行=一次尝试,重放也记;
|
||||||
|
-- RESULT 与 FLAGS 分列(可「成功且告警」);写失败只记指标;保留 90 天,按 (SCOPE_END, RECV_AT) 清理
|
||||||
|
CREATE TABLE SCHD_SNAP_LOG (
|
||||||
|
LOG_ID BIGSERIAL PRIMARY KEY,
|
||||||
|
MSG_ID BIGINT NOT NULL,
|
||||||
|
RECV_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL, -- 接收时刻(UTC)
|
||||||
|
SCOPE_START DATE NOT NULL, -- 报文覆盖运营日范围(单日时两者相等)
|
||||||
|
SCOPE_END DATE NOT NULL,
|
||||||
|
RECS INT NOT NULL, -- 报文声明记录数
|
||||||
|
UPSERTED INT NOT NULL, -- 实际写入数
|
||||||
|
DURATION_MS BIGINT NOT NULL,
|
||||||
|
RESULT VARCHAR(20) NOT NULL, -- COMMITTED/REPLAY_SKIPPED/ROLLED_BACK
|
||||||
|
FLAGS VARCHAR(200) NOT NULL DEFAULT '', -- 逗号分隔:EMPTY/RECS_DROP/SEQN_REGRESSION/DAY_MISMATCH/SCHD_REVIVE_CONFLICT
|
||||||
|
ARCHIVE_KEY VARCHAR(200), -- 原文归档引用(证据层,尚未交付)
|
||||||
|
CREATED_AT TIMESTAMP(6) WITH TIME ZONE NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_snaplog_cleanup ON SCHD_SNAP_LOG (SCOPE_END, RECV_AT);
|
||||||
|
CREATE INDEX idx_snaplog_msg ON SCHD_SNAP_LOG (MSG_ID);
|
||||||
@@ -13,12 +13,11 @@ Flyway 配置**;PG 路径使用 `classpath:db/migration`,两者互不混用
|
|||||||
|
|
||||||
## 计划内容
|
## 计划内容
|
||||||
|
|
||||||
- V1.0.0 起:管道表(PROC_STATE/MSG_EVENT/PUMP_JOB/REQ_TRACK/REF_MASTER/PIPELINE_LOCK)
|
- 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` 改 MERGE(OPERATION_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
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* U03(N02)验收:msgx.* 嵌套键「改值—回读」离线化。
|
* U03(N02)验收: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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* U06(N03)+ U08 闭环:schd 出口只走 flushSchd;
|
* 投递调度(docs/flight-state.md §7.3):
|
||||||
* 批发送失败 → 整批 attempts 递增 + 指数退避(队首未到期不 claim),达上限整批 DEAD/DLQ;
|
* KAFKA_MSG 逐条 FIFO;KAFKA_SCHD 唯一出口 flushSchd——同 FLID 未发事件按最新
|
||||||
* 时间经可注入 Clock(MutableClock),不依赖真实睡眠。
|
* 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() // 失败#1:attempts=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" }) // N03:schd 唯一出口 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() // 失败#2:attempts=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 正常
|
/** schd 发送恒失败的端口(退避/终态闭环验证用)。 */
|
||||||
assertEquals(1, repo.events.first { it.eventId == 1L }.attempts) // schd 进退避
|
private class FailingSchdPort : DeliveryPort {
|
||||||
assertEquals(EventStatus.PENDING, repo.events.first { it.eventId == 1L }.state)
|
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):SODT(ddMMMyyHHmm)+ 机场时区 + 业务切日边界。
|
||||||
|
*/
|
||||||
|
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"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
"m2",
|
day, day, opDay,
|
||||||
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(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 "[]"))
|
assertTrue(tooLong is SnapshotValidation.Invalid) // SIS §3.16.2 Number(1-12)
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-815
@@ -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-29:PostgreSQL 真实方言集成测试(JdbcFlightSchdRepository)
|
|
||||||
* 验证:
|
|
||||||
* 1. DNLD 快照批处理写入(JDBC batch,200 批次);
|
|
||||||
* 2. 增量 Upsert FDAY 保留策略(ON CONFLICT 保留原代,新插置 NULL);
|
|
||||||
* 3. 域化差删(按 FDAY 严格隔离);
|
|
||||||
* 4. SCHD_GEN SQL CAS(防并发断言);
|
|
||||||
* 5. 单事务原子回滚(崩溃无残留);
|
|
||||||
* 6. ES 历史清场 deleteByFlids 幂等删除;
|
|
||||||
* 7. ACM2-29 零子表:集合平铺槽位列写读(集合级全量替换/序号 0 清除/里程碑与航路紧凑列),
|
|
||||||
* 读侧视图重建 legacy 同构集合键;
|
|
||||||
* 8. I5:PIPELINE_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_01,FDAY 为 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 → 版本 2,last_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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+39
-113
@@ -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 列
|
||||||
|
stmt.executeQuery(
|
||||||
|
"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 ->
|
||||||
|
val cols = mutableSetOf<String>()
|
||||||
|
while (rs.next()) cols.add(rs.getString("column_name"))
|
||||||
|
assertEquals(setOf("flid", "operation_day", "state", "state_version", "last_msg_id"), cols)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIPELINE_LOCK 单行种子(§3.2)
|
||||||
|
stmt.executeQuery("SELECT count(*) FROM pipeline_lock WHERE lock_id = 1").use { rs ->
|
||||||
|
assertTrue(rs.next())
|
||||||
|
assertEquals(1, rs.getInt(1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
|
||||||
"SELECT flno, remc, state_version, last_message_id FROM $schema.flight_schd WHERE flid = 'UPG_FL_1'",
|
|
||||||
).use { rs ->
|
|
||||||
assertTrue(rs.next())
|
|
||||||
assertEquals("CA_UPG", rs.getString("flno"))
|
|
||||||
assertEquals("legacy-note", rs.getString("remc"))
|
|
||||||
assertEquals(0L, rs.getLong("state_version"))
|
|
||||||
assertNull(rs.getString("last_message_id"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 已知损失边界:*_TXT 集合列已不存在(升级前须导出/重放,评审 F6)
|
|
||||||
stmt.executeQuery(
|
|
||||||
"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())
|
|
||||||
assertEquals("CA_UPG", rs.getString("flno"))
|
|
||||||
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 全部为历史候选
|
|
||||||
HistorySweepJob.historyPicker = { it }
|
|
||||||
// 模拟 ES 写入:全部成功返回
|
|
||||||
HistorySweepJob.esArchiver = { it.keys }
|
|
||||||
// 模拟历史代清理:清理 2026-09-02 之前的代
|
|
||||||
HistorySweepJob.cutoffProvider = { "2026-09-02" }
|
|
||||||
|
|
||||||
sweepJob.run()
|
|
||||||
|
|
||||||
// 断言:PG 中 F1, F2, F3 全部被删除
|
|
||||||
assertNull(flightSchd.findByFlid("F1"))
|
|
||||||
assertNull(flightSchd.findByFlid("F2"))
|
|
||||||
assertNull(flightSchd.findByFlid("F3"))
|
|
||||||
// 断言:过期代元数据被清理
|
|
||||||
assertNull(flightSchd.getGen("2026-09-01"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 场景二:ES 部分成功
|
private class RecordingHistoryStore : HistorySweepJob.HistoryStore {
|
||||||
@Test
|
val archivedBatches = mutableListOf<List<HistoryCandidate>>()
|
||||||
fun `Scenario 2 - ES Partial Success deletes only successful flights and keeps failed ones`() {
|
val confirmed = mutableSetOf<String>()
|
||||||
val flights = listOf(
|
var fail = false
|
||||||
"F1" to mapOf("FLID" to "F1"),
|
|
||||||
"F2" to mapOf("FLID" to "F2"),
|
|
||||||
"F3" to mapOf("FLID" to "F3"),
|
|
||||||
)
|
|
||||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
|
||||||
|
|
||||||
HistorySweepJob.historyPicker = { it }
|
override fun archive(candidates: List<HistoryCandidate>): Set<String> {
|
||||||
// 模拟 ES 写入:F1 和 F2 成功,F3 失败
|
archivedBatches.add(candidates)
|
||||||
HistorySweepJob.esArchiver = { setOf("F1", "F2") }
|
if (fail) return emptySet()
|
||||||
|
return candidates.map { it.flid }.toSet().intersect(confirmed.ifEmpty { candidates.map { it.flid }.toSet() })
|
||||||
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 }
|
|
||||||
// 模拟 ES 全部写入失败
|
|
||||||
HistorySweepJob.esArchiver = { emptySet() }
|
|
||||||
|
|
||||||
sweepJob.run()
|
|
||||||
|
|
||||||
// 断言:PG 未执行任何删除,全部候选均安全保留
|
|
||||||
assertNotNull(flightSchd.findByFlid("F1"))
|
|
||||||
assertNotNull(flightSchd.findByFlid("F2"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 场景四:ES 成功后 PG 删除前崩溃重放
|
|
||||||
@Test
|
|
||||||
fun `Scenario 4 - Crash after ES success before PG delete recovers idempotently on next run`() {
|
|
||||||
val flights = listOf(
|
|
||||||
"F1" to mapOf("FLID" to "F1"),
|
|
||||||
)
|
|
||||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
|
||||||
|
|
||||||
// 第一次运行模拟:ES 写入成功,但在调用 deleteByFlids 前崩溃(PG 依然保留 F1)
|
|
||||||
val esUpserted = mutableSetOf<String>()
|
|
||||||
var crashed = false
|
|
||||||
|
|
||||||
HistorySweepJob.historyPicker = { it }
|
|
||||||
HistorySweepJob.esArchiver = {
|
|
||||||
esUpserted.addAll(it.keys)
|
|
||||||
if (!crashed) {
|
|
||||||
crashed = true
|
|
||||||
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
|
@Test
|
||||||
fun `Scenario 5 - Replaying deleteByFlids is completely idempotent with no side effects`() {
|
fun `history store not connected must delete zero rows`() {
|
||||||
val flights = listOf("F1" to mapOf("FLID" to "F1"))
|
val f = seededFlight("F1", deleted = true, idleDays = 30)
|
||||||
seedSnapshot(flightSchd, "2026-09-01", flights)
|
val events = StubMsgEvents()
|
||||||
|
val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false })
|
||||||
|
|
||||||
// 首次删除
|
val outcome = job.run(now)
|
||||||
val deletedCount1 = flightSchd.deleteByFlids(setOf("F1"))
|
|
||||||
assertEquals(1, deletedCount1)
|
|
||||||
assertNull(flightSchd.findByFlid("F1"))
|
|
||||||
|
|
||||||
// 重复执行删除(重放)
|
assertEquals(0, outcome.purged) // §8.2 红线:先删历史再补当前态绝不允许
|
||||||
val deletedCount2 = flightSchd.deleteByFlids(setOf("F1"))
|
assertTrue(f.findMainRow("F1") != null)
|
||||||
// 断言:允许重放时影响行数为 0,不抛出异常,无任何副作用
|
assertEquals(0, events.rows.size)
|
||||||
assertEquals(0, deletedCount2)
|
}
|
||||||
assertNull(flightSchd.findByFlid("F1"))
|
|
||||||
|
@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),
|
||||||
|
)
|
||||||
|
val events = StubMsgEvents()
|
||||||
|
val store = RecordingHistoryStore().apply { confirmed.add("F1") } // 只有 F1 归档确认成功
|
||||||
|
val job = HistorySweepJob(
|
||||||
|
f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
|
||||||
|
)
|
||||||
|
|
||||||
|
val outcome = job.run(now)
|
||||||
|
|
||||||
|
assertEquals(2, outcome.selected)
|
||||||
|
assertEquals(1, outcome.archived)
|
||||||
|
assertEquals(1, outcome.purged)
|
||||||
|
assertEquals(null, f.findMainRow("F1")) // §8.2 步骤 3:确认成功 → 物理删除
|
||||||
|
assertTrue(f.findMainRow("F2") != null) // 失败或不明确的保留重试(步骤 5)
|
||||||
|
assertEquals(0, events.rows.size) // DELETED 航班清除不再发业务删除事件
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `never-fdel lifecycle purge emits tombstone before deletion`() {
|
||||||
|
val f = seededFlight("F3", deleted = false, idleDays = 30) // ACTIVE 且超兜底窗(§8.1)
|
||||||
|
val events = StubMsgEvents()
|
||||||
|
val store = RecordingHistoryStore()
|
||||||
|
val job = HistorySweepJob(
|
||||||
|
f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store,
|
||||||
|
)
|
||||||
|
|
||||||
|
job.run(now)
|
||||||
|
|
||||||
|
// §7.3/§8.2 步骤 4:未经 FDEL 的航班,清除前补发一次删除事件
|
||||||
|
val tombstone = events.rows.values.single { it.eventType == EventType.TOMBSTONE }
|
||||||
|
assertEquals("F3", tombstone.partitionKey)
|
||||||
|
assertTrue(tombstone.payloadJson.contains("\"deleted\":true"))
|
||||||
|
assertEquals(null, f.findMainRow("F3"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. 门槛 3(ADFT 存活保障):增量 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 门槛 3:ADFT 存活保障(增量 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_OLD(FDAY 为 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-08(FDAY 更新为 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-1:GTDT 报文端到端 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/U11:processOne 失败闭环——
|
|
||||||
* ① 意外异常在边界内写 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))
|
|
||||||
}
|
|
||||||
// 未被普通恢复逻辑改写成 FAILED(Error 不落入 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-111
@@ -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 upsert(P4)。 */
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user