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

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