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:
@@ -1,44 +1,31 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
/**
|
||||
* ACMA-8 流程 2:Handler 决策(纯函数)产物——状态与报文进,变更与事件出,
|
||||
* 不直接触碰数据库或 Kafka。
|
||||
*/
|
||||
data class Decision(
|
||||
val flightChanges: List<FlightChange> = emptyList(),
|
||||
val msgNotifies: List<NotifyPayload> = emptyList(), // → MSG_EVENT(KAFKA:msg)
|
||||
val schdPush: List<SchdPush> = emptyList(), // → MSG_EVENT(KAFKA:schd),PARTITION_KEY=FLID
|
||||
val outboundIntents: List<OutboundIntent> = emptyList(), // → COUTMSGS(沿用既有列语义)
|
||||
val refUpserts: List<RefUpsert> = emptyList(), // → 静态主数据(独立 PG reference 库,ACM2-11)
|
||||
)
|
||||
import com.gzzn.omms.msgexchange.domain.flight.MergeChange
|
||||
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* 航班状态变更(阶段 A 落自有库 FLIGHT_SCHD 宽表,与事件同事务原子提交;ACM2-28 定案)。
|
||||
* fields = 本报文变更的字段集(field → value,与 legacy flightInfo hash 同构),
|
||||
* 仓储按「字段级合并」落库(仅新增/覆盖,不删除缺失字段,与 legacy hmset 同语义)。
|
||||
* 处理决策(docs/flight-state.md)——解码与校验进,落库计划出,纯数据不触碰 DB/Kafka。
|
||||
* 事务边界、PIPELINE_LOCK、事件登记由处理层执行(§5.1/§6)。
|
||||
*/
|
||||
data class FlightChange(
|
||||
val flid: String,
|
||||
val fields: Map<String, String>,
|
||||
val maid: String? = null,
|
||||
)
|
||||
sealed interface Decision {
|
||||
/**
|
||||
* SCHD DNLD/RESP:已过 §5.2 报文完整性校验的记录集。
|
||||
* §5.3 归属校验与 §5.4 upsert 在事务内执行(需读既有 OPERATION_DAY)。
|
||||
*/
|
||||
data class Schedule(
|
||||
val records: List<ScheduleRecord>,
|
||||
/** 报文覆盖运营日范围(单日快照时两者相等;无法确定时为 null → 整包拒绝)。 */
|
||||
val scopeStart: LocalDate?,
|
||||
val scopeEnd: LocalDate?,
|
||||
) : Decision
|
||||
|
||||
data class NotifyPayload(val payloadJson: String)
|
||||
/** FLOP 增量合并(§6.1)/ ADFT(§2.1 语义待确认,按 MergeChange 承载)。 */
|
||||
data class Dynamic(val change: MergeChange) : Decision
|
||||
|
||||
data class SchdPush(
|
||||
val flid: String,
|
||||
val payloadJson: String, // KAFKA_SCHD 出站载荷(报文线格式,与库内展开存储无关)
|
||||
val eventSeq: Long = 0, // 由事务插入时赋 EVENT_ID 语义序,聚合取 max
|
||||
)
|
||||
/** FDEL 标记删除(§6.2)。 */
|
||||
data class Delete(val flid: String) : Decision
|
||||
|
||||
data class OutboundIntent(
|
||||
val coutmsgsXml: String,
|
||||
val ackReqd: Boolean = true,
|
||||
)
|
||||
|
||||
data class RefUpsert(
|
||||
val rtype: String,
|
||||
val rkey: String,
|
||||
val payloadJson: String,
|
||||
val source: String,
|
||||
)
|
||||
/** 幂等无变化(重复/迟到等,记成功但不推进任何状态)。 */
|
||||
data object NoOp : Decision
|
||||
}
|
||||
|
||||
@@ -11,10 +11,14 @@ data class MetaFields(
|
||||
val dttm: Long,
|
||||
)
|
||||
|
||||
/** 消息分派(sealed + 穷尽 when,ACMA-6 选型;取代 legacy 反射 get{TYPE}())。 */
|
||||
/** 消息分派(sealed + 穷尽 when;FDEL 一等公民——终止航班实例 §6.2)。 */
|
||||
sealed interface MsgKind {
|
||||
data class Schd(val subtype: SchdSubtype) : MsgKind
|
||||
data class Flop(val subtype: String) : MsgKind // 29 类 STYP,阶段 2/3 逐类翻译
|
||||
data class Flop(val subtype: String) : MsgKind // 运行动态 STYP(FDEL 除外)
|
||||
data object Fdel : MsgKind
|
||||
|
||||
/** 未支持类型(§9:FAILED(UNSUPPORTED),达阈值转 DEAD)。 */
|
||||
data class Unsupported(val tag: String) : MsgKind
|
||||
|
||||
enum class SchdSubtype { RESP, DNLD, ADFT }
|
||||
}
|
||||
@@ -30,5 +34,7 @@ data class DecodedMessage(
|
||||
get() = when (val k = kind) {
|
||||
is MsgKind.Schd -> "SCHD-${k.subtype.name}"
|
||||
is MsgKind.Flop -> "FLOP-${k.subtype}"
|
||||
MsgKind.Fdel -> "FDEL"
|
||||
is MsgKind.Unsupported -> k.tag
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
/**
|
||||
* ACMA-8 MSG_EVENT(统一投递事件 / outbox)。
|
||||
* TARGET 阶段化:KAFKA:msg、KAFKA:schd 自阶段 A;ES:flight_hts、REDIS:flightInfo 仅阶段 B 投影期。
|
||||
*/
|
||||
object Targets {
|
||||
const val KAFKA_MSG = "KAFKA:msg"
|
||||
const val KAFKA_SCHD = "KAFKA:schd"
|
||||
const val ES_FLIGHT_HTS = "ES:flight_hts"
|
||||
@Deprecated("Retired in ACM2-28: Redis projection removed")
|
||||
const val REDIS_FLIGHT_INFO = "REDIS:flightInfo"
|
||||
|
||||
/** 阶段 A 投递目标(仅 Kafka)——ACM2-28:Delivery 阶段 A/B 均不写 Redis。 */
|
||||
val phaseA: List<String> = listOf(KAFKA_MSG, KAFKA_SCHD)
|
||||
|
||||
/** 阶段 B 追加投影目标(仅 ES 历史库;Redis 投影按 ACM2-28 废弃)。 */
|
||||
val phaseB: List<String> = phaseA + listOf(ES_FLIGHT_HTS)
|
||||
}
|
||||
/** MSG_EVENT 事件形态(§7.3)。 */
|
||||
enum class EventType { UPSERT, TOMBSTONE }
|
||||
|
||||
/** MSG_EVENT 投递状态(outbox 状态机)。 */
|
||||
enum class EventStatus { PENDING, SENT, DEAD }
|
||||
|
||||
/**
|
||||
* MSG_EVENT(outbox,§3.2):状态、变更、删除通知。
|
||||
* KAFKA_SCHD 整态 + KAFKA_MSG 变化通知;TOMBSTONE 仅在 ACTIVE→DELETED 时
|
||||
* 与删除同事务登记(§7.3),投递失败持续重试。
|
||||
*/
|
||||
data class MsgEvent(
|
||||
val eventId: Long? = null,
|
||||
val target: String,
|
||||
val partitionKey: String? = null, // schd 事件恒为 FLID(v4 显式声明)
|
||||
val partitionKey: String, // 恒为 FLID
|
||||
val eventType: EventType = EventType.UPSERT,
|
||||
val stateVersion: Long = 0, // 发布时航班版本;Dispatcher 合并同 FLID 未发事件取最新(§7.3)
|
||||
val payloadJson: String,
|
||||
val state: EventStatus = EventStatus.PENDING,
|
||||
val attempts: Int = 0,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* 运营日计算(docs/flight-state.md §3.5):由计划运行时间字段 SODT(ddMMMyyHHmm)
|
||||
* 与机场时区计算;切日边界业务配置——默认 0 点为占位,待业务确认(§10)。
|
||||
*/
|
||||
class OperationDayCalculator(
|
||||
zone: ZoneId,
|
||||
cutoffHour: Int,
|
||||
) {
|
||||
private val zone: ZoneId = zone
|
||||
/** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23,越界按 0)。 */
|
||||
private val cutoffHour: Int = cutoffHour.coerceIn(0, 23)
|
||||
|
||||
/** SODT → 运营日;输入 null/空/非法返回 null(不抛异常,调用方按"运营日不可计算"处理)。 */
|
||||
fun compute(sodt: String?): LocalDate? {
|
||||
if (sodt.isNullOrBlank()) return null
|
||||
val local = parseSodt(sodt.trim()) ?: return null
|
||||
val day = local.atZone(zone).toLocalDate()
|
||||
return if (local.hour < cutoffHour) day.minusDays(1) else day
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* SIS SODT 线格式:ddMMMyyHHmm(如 15DEC031723),月份英文三字母、大小写不敏感。
|
||||
* 两位年显式按 2000 基准展开(java.time 的 yy reduced-value 解析跨实现不一致,
|
||||
* 显式展开保证 AODB 侧年份窗口唯一口径;基准年待真实报文验收确认 §10)。
|
||||
*/
|
||||
private val SODT_REGEX = Regex("(\\d{1,2})([A-Za-z]{3})(\\d{2})(\\d{2})(\\d{2})")
|
||||
|
||||
private val MONTHS = mapOf(
|
||||
"JAN" to 1, "FEB" to 2, "MAR" to 3, "APR" to 4, "MAY" to 5, "JUN" to 6,
|
||||
"JUL" to 7, "AUG" to 8, "SEP" to 9, "OCT" to 10, "NOV" to 11, "DEC" to 12,
|
||||
)
|
||||
|
||||
internal fun parseSodt(raw: String): LocalDateTime? {
|
||||
val m = SODT_REGEX.matchEntire(raw.uppercase()) ?: return null
|
||||
val (d, mon, yy, hh, mm) = m.destructured
|
||||
val month = MONTHS[mon] ?: return null
|
||||
return runCatching {
|
||||
LocalDateTime.of(2000 + yy.toInt(), month, d.toInt(), hh.toInt(), mm.toInt())
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
/**
|
||||
* ACMA-8 数据模型 / PROC_STATE 状态机(六迁移表)。
|
||||
* ACMA-8 数据模型 / PROC_STATE 状态机(docs/flight-state.md §3.2:每消息一行,
|
||||
* 处理状态与重试结果;兼作快照重放判定 §5.1)。
|
||||
*/
|
||||
enum class ProcStatus { PENDING, FAILED, SUCCEEDED, SKIPPED, DEAD }
|
||||
|
||||
enum class ErrorClass { MALFORMED, CODEC_ERROR, EXHAUSTED, INFRA, UNSUPPORTED }
|
||||
/** §9 错误分类:PROTOCOL = 整包拒绝(归属日不符等),不重试交人工。 */
|
||||
enum class ErrorClass { MALFORMED, PROTOCOL, CODEC_ERROR, EXHAUSTED, INFRA, UNSUPPORTED }
|
||||
|
||||
data class ProcState(
|
||||
val cminmsgsId: Long,
|
||||
val msgId: Long,
|
||||
val state: ProcStatus,
|
||||
val identityKey: String? = null, // decode 后首次绑定;FAILED 重试不重绑(I3)
|
||||
val identityKey: String? = null, // SNDR|TYPE|STYP|SEQN;decode 后首次绑定,FAILED 重试不重绑
|
||||
val attempts: Int = 0,
|
||||
val nextAttemptAt: java.time.Instant? = null,
|
||||
val errorClass: ErrorClass? = null,
|
||||
@@ -20,6 +22,6 @@ data class ProcState(
|
||||
val isTerminal: Boolean
|
||||
get() = state == ProcStatus.SUCCEEDED || state == ProcStatus.SKIPPED || state == ProcStatus.DEAD
|
||||
|
||||
/** 终态皆可归档(矩阵 #12:SUCCEEDED ∪ SKIPPED ∪ DEAD);PENDING/FAILED 不迁。 */
|
||||
/** 终态皆可归档;PENDING/FAILED 不迁。 */
|
||||
val archivable: Boolean get() = isTerminal
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* SCHD 快照留痕模型(docs/flight-state.md §5.5):
|
||||
* RESULT 与 FLAGS 分列(可「成功且告警」);一行 = 一次尝试,重放也记;
|
||||
* 留痕不参与决策;写失败只记指标;保留 90 天,按 (SCOPE_END, RECV_AT) 清理。
|
||||
*/
|
||||
enum class SnapshotResult { COMMITTED, REPLAY_SKIPPED, ROLLED_BACK }
|
||||
|
||||
enum class SnapshotFlag { EMPTY, RECS_DROP, SEQN_REGRESSION, DAY_MISMATCH, SCHD_REVIVE_CONFLICT }
|
||||
|
||||
data class SnapshotLogEntry(
|
||||
val msgId: Long,
|
||||
val recvAt: Instant,
|
||||
val scopeStart: LocalDate,
|
||||
val scopeEnd: LocalDate,
|
||||
val recs: Int,
|
||||
val upserted: Int,
|
||||
val durationMs: Long,
|
||||
val result: SnapshotResult,
|
||||
val flags: Set<SnapshotFlag> = emptySet(),
|
||||
val archiveKey: String? = null, // 证据层引用(尚未交付 §3.2)
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.gzzn.omms.msgexchange.domain
|
||||
|
||||
/**
|
||||
* MSG_EVENT 投递目标(§7.3:KAFKA_SCHD 发整态、KAFKA_MSG 只通知变化;
|
||||
* 两主题间不保证顺序)。ES 投影属阶段 B,暂不登记目标。
|
||||
*/
|
||||
object Targets {
|
||||
const val KAFKA_MSG = "KAFKA:msg"
|
||||
const val KAFKA_SCHD = "KAFKA:schd"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.domain.flight
|
||||
|
||||
/**
|
||||
* 显式字段命令(docs/flight-state.md §4)。
|
||||
*/
|
||||
sealed interface ScalarCommand {
|
||||
data object Unchanged : ScalarCommand
|
||||
data class Set(val value: String) : ScalarCommand
|
||||
data object Clear : ScalarCommand
|
||||
}
|
||||
|
||||
sealed interface CollectionCommand {
|
||||
data object Unchanged : CollectionCommand
|
||||
data class Replace(val items: List<Map<String, String>>) : CollectionCommand
|
||||
data object Clear : CollectionCommand
|
||||
data class Apply(val item: Map<String, String>, val sourceSeq: String) : CollectionCommand
|
||||
}
|
||||
|
||||
data class FlightFieldCommands(
|
||||
val flid: String,
|
||||
val scalars: Map<String, ScalarCommand> = emptyMap(),
|
||||
val collections: Map<String, CollectionCommand> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.gzzn.omms.msgexchange.domain.flight
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* 航班实例当前态模型(docs/flight-state.md §3)。
|
||||
*
|
||||
* 身份:FLID 唯一关联键;OPERATION_DAY 一经确定不可变(§3.5/§5.3);
|
||||
* STATE 仅 ACTIVE/DELETED(§3.1,无 ARCHIVED——物理清除只发生在历史归档成功之后 §8.2)。
|
||||
*/
|
||||
enum class FlightState { ACTIVE, DELETED }
|
||||
|
||||
/** FLIGHT_SCHD 主行的身份与追踪字段(不含标量载荷)。 */
|
||||
data class FlightMainRow(
|
||||
val flid: String,
|
||||
val operationDay: LocalDate?,
|
||||
val state: FlightState,
|
||||
val stateVersion: Long,
|
||||
val lastMsgId: Long?,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
/**
|
||||
* SCHD DNLD/RESP 单条 FLTR 记录(解码产物,§5 入口)。
|
||||
* scalars/collections 只含报文中出现的字段——完整快照语义下
|
||||
* 出现 = Set/Replace,未出现 = 清除/替换空集(§5.4)。
|
||||
*/
|
||||
data class ScheduleRecord(
|
||||
val flid: String,
|
||||
val scalars: Map<String, String>,
|
||||
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
|
||||
val seqn: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* 完整快照落库载荷(§5.1 步骤 6):按记录整体替换映射内字段,
|
||||
* 仓储层负责明细先删后插与 `OPERATION_DAY` 不可变条件更新(§7.4)。
|
||||
*/
|
||||
data class SnapshotPatch(
|
||||
val flid: String,
|
||||
val operationDay: LocalDate,
|
||||
val scalars: Map<String, String>,
|
||||
val collections: Map<String, List<Map<String, String>>>,
|
||||
)
|
||||
|
||||
/** 快照 upsert 结果(§5.1 步骤 6:DELETED 航班保持 DELETED 并告警,不恢复)。 */
|
||||
enum class UpsertOutcome { INSERTED, UPDATED, REVIVE_CONFLICT }
|
||||
|
||||
/**
|
||||
* FLOP/ADFT 增量载荷(§6.1/§2.1)。
|
||||
* scalars 出现 = Set,缺失 = 保留本地值;collections 出现 = Replace
|
||||
* (FLOP 集合语义未定案前沿用 Replace,§10 当前偏差明示)。
|
||||
*/
|
||||
data class MergeChange(
|
||||
val flid: String,
|
||||
val scalars: Map<String, String>,
|
||||
val collections: Map<String, List<Map<String, String>>> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 完整当前态(§3.3):主行 + 全部明细 = 完整当前态;
|
||||
* 读取须在一致性读事务中,且展示层过滤 STATE = ACTIVE(§9)。
|
||||
*/
|
||||
data class FlightSnapshot(
|
||||
val flid: String,
|
||||
val operationDay: LocalDate?,
|
||||
val state: FlightState,
|
||||
val stateVersion: Long,
|
||||
val scalars: Map<String, String>,
|
||||
val collections: Map<String, List<Map<String, String>>>,
|
||||
)
|
||||
|
||||
/** §8.1 历史判定窗口(按机场时区计算;窗口值业务配置)。 */
|
||||
data class HistoryRules(
|
||||
val cancelledHours: Long = 48, // CNCL 非空超过 N 小时
|
||||
val terminalHours: Long = 48, // NAAT/NEAT 终态超过 N 小时(字段含义待术语表确认 §10)
|
||||
val deletedHours: Long = 48, // STATE = DELETED 超过 N 小时
|
||||
val idleHours: Long = 24 * 7, // 无终态字段:最后更新超过兜底期限
|
||||
)
|
||||
|
||||
/** §8.1 命中历史判定的航班(归档 → 物理清除候选)。 */
|
||||
data class HistoryCandidate(
|
||||
val flid: String,
|
||||
val state: FlightState,
|
||||
val stateVersion: Long,
|
||||
/** 是否未经 FDEL 而被生命周期清除——清除前须补发一次删除事件(§7.3/§8.2)。 */
|
||||
val wasNeverFdel: Boolean,
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.gzzn.omms.msgexchange.domain.flight
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
||||
|
||||
data class FlightNextState(
|
||||
val flid: String,
|
||||
val scalars: Map<String, String>,
|
||||
val collections: Map<String, List<Map<String, String>>>,
|
||||
val stateVersion: Long,
|
||||
val lastMessageId: String,
|
||||
/** 本次迁移中被显式清除的标量/异常/文本键(仓储据此写 NULL 列;快照全量替换下无意义)。 */
|
||||
val clearedKeys: Set<String> = emptySet(),
|
||||
) {
|
||||
fun toFlightFields(mapper: ObjectMapper = ObjectMapper()): FlightFields {
|
||||
val out = linkedMapOf<String, String>()
|
||||
out["FLID"] = flid
|
||||
scalars.forEach { (k, v) -> out[k] = v }
|
||||
collections.forEach { (key, items) ->
|
||||
out[key] = mapper.writeValueAsString(items)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -1,157 +1,134 @@
|
||||
package com.gzzn.omms.msgexchange.domain.flight
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
||||
import com.gzzn.omms.msgexchange.domain.OperationDayCalculator
|
||||
import com.gzzn.omms.msgexchange.domain.SnapshotFlag
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* 从 legacy 字段视图解析/合并命令,并计算 nextState(v2 §4–§5)。
|
||||
* 航班状态引擎(docs/flight-state.md §3.3/§5.2/§5.4/§6.1)——纯函数,
|
||||
* 内存生成完整新状态再落库;不触碰 DB/Kafka。
|
||||
*/
|
||||
object FlightStateEngine {
|
||||
private val mapper = ObjectMapper()
|
||||
|
||||
private val COLLECTION_KEYS = setOf(
|
||||
/** 集合键白名单:10 类集合 ↔ 9 张明细表(ROUT/ERUT 共用 FLIGHT_ROUTE_POINT,§3.2)。 */
|
||||
val COLLECTION_KEYS: Set<String> = setOf(
|
||||
"GTDT", "CKDT", "CLDT", "PSDT", "CHDT", "DELY", "ABTM", "CHOT", "ROUT", "ERUT",
|
||||
)
|
||||
|
||||
/** 1:0..1 单值异常结构:主表前缀标量列承载;显式 null 载荷 = 清除(v2 §3.2/§9.1)。 */
|
||||
val EXCEPTION_KEYS = setOf("FDIV", "FRET", "FLAB")
|
||||
/**
|
||||
* §5.2 报文完整性五项校验:RECS 0–9999 且等于实收数;每条含合法数字型 FLID;
|
||||
* 快照内不重复;每条记录运营日可计算且在报文覆盖范围内。任一失败整包不落地。
|
||||
*/
|
||||
fun validateMessage(
|
||||
recsDeclared: Int,
|
||||
records: List<ScheduleRecord>,
|
||||
scopeStart: LocalDate?,
|
||||
scopeEnd: LocalDate?,
|
||||
opDay: OperationDayCalculator,
|
||||
): SnapshotValidation {
|
||||
val flags = linkedSetOf<SnapshotFlag>()
|
||||
if (recsDeclared !in 0..9999) return SnapshotValidation.Invalid("RECS out of range: $recsDeclared", setOf(SnapshotFlag.RECS_DROP))
|
||||
if (records.size != recsDeclared) return SnapshotValidation.Invalid(
|
||||
"RECS ($recsDeclared) != received FLTR count (${records.size})",
|
||||
setOf(SnapshotFlag.RECS_DROP),
|
||||
)
|
||||
if (records.isEmpty()) return SnapshotValidation.Ok(emptyMap(), setOf(SnapshotFlag.EMPTY))
|
||||
|
||||
/** 异常/单值载荷的显式清除形态:空串、null 字面量、空对象(Oracle 空串即 NULL 语义的显式来源)。 */
|
||||
private fun isClearPayload(value: String): Boolean {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isEmpty() || trimmed == "null") return true
|
||||
val node = runCatching { mapper.readTree(trimmed) }.getOrNull() ?: return false
|
||||
return node.isNull || (node.isObject && node.size() == 0)
|
||||
val perRecordDay = linkedMapOf<String, LocalDate>()
|
||||
val seen = linkedSetOf<String>()
|
||||
for (record in records) {
|
||||
if (!record.flid.matches(FLID_REGEX)) {
|
||||
return SnapshotValidation.Invalid("illegal FLID: '${record.flid}'", emptySet())
|
||||
}
|
||||
if (!seen.add(record.flid)) {
|
||||
return SnapshotValidation.Invalid("duplicate FLID in snapshot: ${record.flid}", emptySet())
|
||||
}
|
||||
val day = opDay.compute(record.scalars["SODT"])
|
||||
if (day == null) {
|
||||
return SnapshotValidation.Invalid("operation day not computable, flid=${record.flid}", setOf(SnapshotFlag.DAY_MISMATCH))
|
||||
}
|
||||
val inScope = (scopeStart == null || !day.isBefore(scopeStart)) &&
|
||||
(scopeEnd == null || !day.isAfter(scopeEnd))
|
||||
if (!inScope) {
|
||||
return SnapshotValidation.Invalid(
|
||||
"operation day $day outside coverage [$scopeStart, $scopeEnd], flid=${record.flid}",
|
||||
setOf(SnapshotFlag.DAY_MISMATCH),
|
||||
)
|
||||
}
|
||||
perRecordDay[record.flid] = day
|
||||
}
|
||||
return SnapshotValidation.Ok(perRecordDay, flags)
|
||||
}
|
||||
|
||||
private val SEQ_ATTR = mapOf(
|
||||
"GTDT" to "GTNO",
|
||||
"CKDT" to "CKNO",
|
||||
"CLDT" to "CLNO",
|
||||
"PSDT" to "PSNO",
|
||||
"CHDT" to "CHNO",
|
||||
"ABTM" to "ASNO",
|
||||
"CHOT" to "CSNO",
|
||||
"ROUT" to "RTNO",
|
||||
"ERUT" to "RTNO",
|
||||
)
|
||||
// 注意:DELY 无协议序号属性(DLNO 非法)→ 不支持 Apply;清除走空数组 Replace(空集)
|
||||
|
||||
/** DNLD/FLOP 字段集 → 命令(出现即 Set/Replace;未出现即 Unchanged;序号 0 条目 = 显式清除标记)。 */
|
||||
fun commandsFromFields(flid: String, fields: FlightFields): FlightFieldCommands {
|
||||
val scalars = linkedMapOf<String, ScalarCommand>()
|
||||
val collections = linkedMapOf<String, CollectionCommand>()
|
||||
fields.forEach { (key, value) ->
|
||||
if (key == "FLID") return@forEach
|
||||
when {
|
||||
key in COLLECTION_KEYS -> {
|
||||
val seqAttr = SEQ_ATTR[key]
|
||||
val parsed = parseCollection(value)
|
||||
val markers = parsed.filter { seqAttr != null && it[seqAttr] == "0" }
|
||||
collections[key] = when {
|
||||
markers.isEmpty() -> CollectionCommand.Replace(parsed)
|
||||
markers.size == parsed.size -> CollectionCommand.Clear
|
||||
else -> throw IllegalArgumentException(
|
||||
"$key mixes explicit clear marker (seq=0) with regular items; refusing to guess",
|
||||
)
|
||||
}
|
||||
}
|
||||
key in EXCEPTION_KEYS && isClearPayload(value) -> scalars[key] = ScalarCommand.Clear
|
||||
else -> scalars[key] = ScalarCommand.Set(value)
|
||||
/**
|
||||
* 完整快照语义(§5.4 DNLD/RESP):标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
|
||||
* `keepDeleted = true` 时 STATE 保持 DELETED(§5.1 步骤 6:普通 SCHD 不恢复)。
|
||||
*/
|
||||
fun snapshotState(
|
||||
current: FlightSnapshot?,
|
||||
record: ScheduleRecord,
|
||||
operationDay: LocalDate,
|
||||
keepDeleted: Boolean,
|
||||
): FlightSnapshot {
|
||||
val state = when {
|
||||
current == null -> FlightState.ACTIVE
|
||||
keepDeleted -> FlightState.DELETED
|
||||
else -> current.state
|
||||
}
|
||||
// §5.4 完整快照:标量出现 Set、缺失 Clear;集合出现 Replace、缺失 Replace 空集。
|
||||
// 完整替换 = 新状态只由记录决定,不从 current 继承任何字段。
|
||||
val scalars: Map<String, String> = record.scalars
|
||||
val collections = buildMap {
|
||||
COLLECTION_KEYS.forEach { key -> put(key, emptyList()) } // 缺失 = Replace 空集(§5.4)
|
||||
record.collections.forEach { (key, items) ->
|
||||
if (key in COLLECTION_KEYS) put(key, items)
|
||||
}
|
||||
}
|
||||
return FlightFieldCommands(flid, scalars, collections)
|
||||
}
|
||||
|
||||
fun apply(
|
||||
current: FlightNextState?,
|
||||
commands: FlightFieldCommands,
|
||||
messageId: String,
|
||||
bumpVersion: Boolean,
|
||||
): FlightNextState {
|
||||
val baseScalars = current?.scalars?.toMutableMap() ?: mutableMapOf()
|
||||
val baseCollections = current?.collections?.mapValues { it.value.toMutableList() }
|
||||
?.toMutableMap() ?: mutableMapOf()
|
||||
val baseVersion = current?.stateVersion ?: 0L
|
||||
val cleared = mutableSetOf<String>()
|
||||
|
||||
commands.scalars.forEach { (key, cmd) ->
|
||||
when (cmd) {
|
||||
ScalarCommand.Unchanged -> Unit
|
||||
is ScalarCommand.Set -> {
|
||||
baseScalars[key] = cmd.value
|
||||
cleared.remove(key)
|
||||
}
|
||||
ScalarCommand.Clear -> {
|
||||
baseScalars.remove(key)
|
||||
cleared += key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commands.collections.forEach { (key, cmd) ->
|
||||
when (cmd) {
|
||||
CollectionCommand.Unchanged -> Unit
|
||||
is CollectionCommand.Replace -> baseCollections[key] = cmd.items.toMutableList()
|
||||
CollectionCommand.Clear -> baseCollections.remove(key)
|
||||
is CollectionCommand.Apply -> {
|
||||
val seqAttr = SEQ_ATTR[key]
|
||||
?: throw IllegalArgumentException("$key does not support Apply: protocol defines no source sequence attribute")
|
||||
val list = baseCollections.getOrPut(key) { mutableListOf() }.toMutableList()
|
||||
val idx = list.indexOfFirst { it[seqAttr] == cmd.sourceSeq }
|
||||
if (idx >= 0) {
|
||||
list[idx] = cmd.item
|
||||
} else {
|
||||
list.add(cmd.item)
|
||||
}
|
||||
baseCollections[key] = list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nextVersion = if (bumpVersion) baseVersion + 1 else baseVersion
|
||||
return FlightNextState(
|
||||
flid = commands.flid,
|
||||
scalars = baseScalars,
|
||||
collections = baseCollections.mapValues { it.value.toList() },
|
||||
stateVersion = nextVersion,
|
||||
lastMessageId = messageId,
|
||||
clearedKeys = cleared.toSet(),
|
||||
return FlightSnapshot(
|
||||
flid = record.flid,
|
||||
operationDay = operationDay,
|
||||
state = state,
|
||||
stateVersion = (current?.stateVersion ?: 0L) + 1,
|
||||
scalars = scalars,
|
||||
collections = collections,
|
||||
)
|
||||
}
|
||||
|
||||
fun parseCollection(raw: String): List<Map<String, String>> {
|
||||
val node = mapper.readTree(raw)
|
||||
val items = when {
|
||||
node.isNull -> emptyList()
|
||||
node.isArray -> node.toList()
|
||||
node.isObject -> listOf(node)
|
||||
else -> throw IllegalArgumentException("collection value must be array or object")
|
||||
/**
|
||||
* FLOP 增量合并(§6.1):标量出现覆盖、缺失保留;集合出现 Replace、缺失保留
|
||||
* ——FLOP 集合语义未定案,Replace 为当前偏差明示沿用(§10)。
|
||||
*/
|
||||
fun mergedState(current: FlightSnapshot, change: MergeChange): FlightSnapshot {
|
||||
val scalars = buildMap {
|
||||
putAll(current.scalars)
|
||||
putAll(change.scalars)
|
||||
}
|
||||
return items.map { item ->
|
||||
item.properties().associate { (k, v) ->
|
||||
k to when {
|
||||
v.isNull -> ""
|
||||
v.isValueNode -> v.asText()
|
||||
else -> v.toString()
|
||||
}
|
||||
val collections = buildMap {
|
||||
putAll(current.collections)
|
||||
change.collections.forEach { (key, items) ->
|
||||
if (key in COLLECTION_KEYS) put(key, items)
|
||||
}
|
||||
}
|
||||
return current.copy(
|
||||
stateVersion = current.stateVersion + 1,
|
||||
scalars = scalars,
|
||||
collections = collections,
|
||||
)
|
||||
}
|
||||
|
||||
/** 当前库态 → FlightNextState(供增量合并)。 */
|
||||
fun fromFlightFields(flid: String, fields: FlightFields, stateVersion: Long = 0L, lastMessageId: String = ""): FlightNextState {
|
||||
val scalars = linkedMapOf<String, String>()
|
||||
val collections = linkedMapOf<String, List<Map<String, String>>>()
|
||||
fields.forEach { (key, value) ->
|
||||
if (key == "FLID") return@forEach
|
||||
if (key in COLLECTION_KEYS) {
|
||||
collections[key] = parseCollection(value)
|
||||
} else {
|
||||
scalars[key] = value
|
||||
}
|
||||
}
|
||||
return FlightNextState(flid, scalars, collections, stateVersion, lastMessageId)
|
||||
}
|
||||
/** FLID:数字型,SIS §3.16.2 Number(1-12)。 */
|
||||
private val FLID_REGEX = Regex("\\d{1,12}")
|
||||
}
|
||||
|
||||
/** §5.2 校验结果:Ok 携带每条记录的归属运营日与观测 flags;Invalid 整包拒绝。 */
|
||||
sealed interface SnapshotValidation {
|
||||
data class Ok(
|
||||
val perRecordDay: Map<String, LocalDate>,
|
||||
val flags: Set<SnapshotFlag>,
|
||||
) : SnapshotValidation
|
||||
|
||||
data class Invalid(
|
||||
val reason: String,
|
||||
val flags: Set<SnapshotFlag>,
|
||||
) : SnapshotValidation
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user