diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/PipelineLifecycle.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/PipelineLifecycle.kt index 389862c..c9c0204 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/PipelineLifecycle.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/PipelineLifecycle.kt @@ -11,9 +11,11 @@ import jakarta.annotation.PreDestroy import jakarta.inject.Singleton /** - * U07(T03+N29):管道生命周期装配——服务启动后拉起 inbox 轮询、主泵、投递三条专用单线程 - * 停机时 requestStop + interrupt + join。仅当 `msgx.pipeline.autostart=true` 时装配 - * (默认关:需要真实仓储或 msgx.stubs=true 才安全开启)。 + * 管道的启动与停机:服务起来后拉起 inbox 轮询、主泵、投递三条循环,每条各占一个专用平台线程; + * 停机时先让它们各自收尾,再中断线程解除 sleep 并等待退出。 + * + * 只有配置 msgx.pipeline.autostart=true 时才装配,默认不开:得有真实仓储或者 msgx.stubs=true + * 才能安全自启。 */ @Requires(property = "msgx.pipeline.autostart", value = "true") @Singleton @@ -53,7 +55,7 @@ class PipelineLifecycle( pump.stop() dispatcher.stop() jobRunner.stop() - threads.forEach { it.interrupt() } // 解除 Thread.sleep 阻塞,加速退出 + threads.forEach { it.interrupt() } // 打断 sleep,让循环立刻醒过来退出,而不是干等一轮间隔 threads.forEach { runCatching { it.join(3000) } } log.info("pipeline loops stopped") } diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt index bbb3552..156ecee 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt @@ -4,7 +4,8 @@ import com.gzzn.omms.msgexchange.domain.DecodedMessage import com.gzzn.omms.msgexchange.domain.ErrorClass /** - * ACMA-8 流程 2:解码失败分类(矩阵/状态机:MALFORMED 不重试;CODEC_ERROR 可一键重放)。 + * 解码失败的结果:错误分类加上一句原因。 + * MALFORMED 是报文本身不合法、重试也不会过;CODEC_ERROR 是解码逻辑的问题,修好后可以重放。 */ data class DecodeFailure(val errorClass: ErrorClass, val detail: String) @@ -14,8 +15,9 @@ sealed interface DecodeResult { } /** - * XML codec:使用 SIS 文档对应的 Jackson XML 注解 DTO,再显式转换为领域载荷; - * XMLInputFactory 禁用 DTD/外部实体,原文仍由入站层保留。 + * XML 解码入口:先用标注了 Jackson XML 注解的 DTO 接住报文体,再显式转成领域模型。 + * + * 解析时关掉 DTD 和外部实体,避免 XML 外部实体注入;报文原文由入站层另行留存,这里不管。 */ interface XmlCodec { fun decode(rawXml: String): DecodeResult diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/config/HistoryProps.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/config/HistoryProps.kt index e4c39ae..af9b838 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/config/HistoryProps.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/config/HistoryProps.kt @@ -2,24 +2,24 @@ package com.gzzn.omms.msgexchange.config import io.micronaut.context.annotation.ConfigurationProperties -/** 生命周期与保留期(docs/flight-state.md §6;design.md §6.2)。窗口按机场时区计算。 */ +/** 航班归档删除的时间窗口和留痕保留期,对应配置里的 `msgx.history.*`;窗口按机场时区算。 */ @ConfigurationProperties("msgx.history") class HistoryProps { - /** 已取消(CNCL 非空)超过 N 小时。 */ + /** 取消时间字段(CNCL)非空,且已经过去这么多小时。 */ var cancelledHours: Long = 48 - /** 到港/离港终态(NAAT/NEAT,含义待术语表确认 §6 开放项)超过 N 小时。 */ + /** 到达/离开终态字段(NAAT/NEAT)非空且已过去这么多小时;这两个字段的确切含义还要跟业务确认。 */ var terminalHours: Long = 48 - /** STATE = DELETED 超过 N 小时。 */ + /** 航班已标记删除(STATE = DELETED)并且过了这么多小时。 */ var deletedHours: Long = 48 - /** 无终态字段:最后有效更新超过兜底期限(默认 7 天)。 */ + /** 兜底期限:航班既没取消也没到终态时,最后一次有效更新超过这么多小时就算静默,可以清理。 */ var idleHours: Long = 24 * 7 - /** 留痕 SCHD_SNAP_LOG 保留天数(design.md §6.2,按 (SCOPE_END, RECV_AT) 清理)。 */ + /** 日计划留痕表 SCHD_SNAP_LOG 保留多少天,按(快照覆盖截止日, 接收时间)删旧行。 */ var snapLogRetentionDays: Long = 90 - /** 是否接通历史存储——未接通时清理必须删 0 条(flight-state.md §6 红线)。 */ + /** 有没有历史存储可用。没接通时清理必须一条都不删,这是硬性前提。 */ var historyStoreEnabled: Boolean = false } diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt index b822e6e..c0682ac 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt @@ -3,15 +3,17 @@ package com.gzzn.omms.msgexchange.config import io.micronaut.context.annotation.ConfigurationProperties /** - * 运营日计算(docs/flight-state.md §2.1):由 SODT(ddMMMyyHHmm)与机场时区按切日边界推导; - * OPERATION_DAY 不是接收日/落库日,一经确定不可变。切日边界业务配置, - * 不得假设等于自然日零点(默认 0 点为占位,业务口径待确认)。 + * 算航班运营保障日(OPERATION_DAY)要用的两个参数,对应配置里的 `msgx.operation-day.*`: + * 机场时区和切日边界。运营日由计划运行时间 SODT 加时区推出来,不是接收日也不是落库日, + * 算出来就固定不变。 + * + * 不能假设运营日等于自然日,民航的一天从几点开始由业务定。默认 0 点只是占位,待确认。 */ @ConfigurationProperties("msgx.operation-day") class OperationDayProps { - /** 机场时区(IANA)。 */ + /** 机场所在时区,用 IANA 名称,比如 Asia/Shanghai。 */ var zone: String = "Asia/Shanghai" - /** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23)。 */ + /** 切日边界小时:SODT 的本地时刻早于这个点就归属前一个运营日。取值 0–23。 */ var cutoffHour: Int = 0 } diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt index bad45dc..b90f010 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt @@ -47,7 +47,10 @@ class PipelineProps { /** 每次回填扫描最多处理多少条。 */ var backfillBatch: Int = 100 - /** U07:启动即拉起 Pump/Dispatcher 循环(默认关——需要真实仓储或 msgx.stubs=true 才可安全开启)。 */ + /** + * 服务启动后是否自动拉起收报、主泵、投递三个循环。 + * 默认关闭:只有接了真实仓储、或者明确用内存 stub 跑的时候才安全。 + */ var autostart: Boolean = false /** N28:attempt ≤ 0(如 FAILED 未递增 attempts 的行)不得抛异常,取下界=首档退避。 */ diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/delivery/Dispatcher.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/delivery/Dispatcher.kt index 9454238..5cbaf41 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/delivery/Dispatcher.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/delivery/Dispatcher.kt @@ -12,29 +12,30 @@ import jakarta.inject.Singleton import java.time.Duration import java.time.Instant -/** 对外投递端口(Kafka 同步确认,at-least-once;阶段 B 追加 ES 投影写入)。 */ +/** 对外投递的出口:同步等 Kafka 确认,语义是至少发一次(可能重复,但不会丢)。以后 ES 投影也从这里加。 */ interface DeliveryPort { - /** KAFKA_MSG 变化通知(key=FLID)。 */ + /** 发一条变化通知,消息 key 是 FLID(航班实例 ID)。 */ fun sendKafka(topic: String, key: String, payloadJson: String) - /** - * KAFKA_SCHD 整态(key=FLID)。KAFKA_MSG 与 KAFKA_SCHD 映射同一 topic 语义由适配层定; - * target→topic:KAFKA:msg→"msg",KAFKA:schd→"schd"。 - */ + /** 发一条完整状态,消息 key 是 FLID。适配层负责把 target 映射成 topic:KAFKA:msg 对应 "msg",KAFKA:schd 对应 "schd"。 */ fun sendKafkaSchd(topic: String, key: String, payloadJson: String) - /** TOMBSTONE:key=FLID、value=null——整态键缺失表示删除旧值(flight-state.md §5)。 */ + /** 发一条删除通知:key 是 FLID、value 为空;下游按"整态里这个键没了"理解成删除。 */ fun sendKafkaNull(topic: String, key: String) - /** 连通性探测(健康检查用);默认 true,真实 Kafka 实装时覆写为 producer metadata 校验。 */ + /** 给健康检查用的连通性探测;默认返回 true,真实 Kafka 实现要覆写成向 broker 拉一次 metadata 来判断。 */ fun ping(): Boolean = true } /** - * 投递调度(docs/flight-state.md §5 + design.md §5.1/§5.2):逐条 KAFKA_MSG 严格 FIFO; - * KAFKA_SCHD 走 flushSchd 批量——同一 FLID 未发事件按最新 STATE_VERSION 合并输出, - * TOMBSTONE 发 null 值消息。两主题间不保证顺序(§5)。 - * 批量闭环:队首退避未到期不 claim;发送失败整批 attempts+1 退避,达上限整批 DEAD/DLQ。 + * 投递调度:把 outbox(待发事件表)里的事件发给下游。 + * + * KAFKA_MSG 一条一条按登记顺序发,不插队。KAFKA_SCHD 走 flushSchd 批量发:同一个 FLID 攒了 + * 多条未发事件时只发版本号最新的那条,旧的自然作废;删除通知发 value 为空的 tombstone。 + * 两个主题之间不保证先后顺序。 + * + * 失败处理:队首的重试时间没到就不取;一批里有发送失败,整批重试次数加一并推后退避, + * 次数用尽整批转 DEAD 当死信。见 docs/flight-state.md §5。 */ @Singleton class Dispatcher( @@ -50,7 +51,7 @@ class Dispatcher( private var lastFlush: Instant? = null - /** 优雅停机:loop 收尾后退出;线程中断由 PipelineLifecycle 负责。 */ + /** 请求停机:置位后 loop 走完当前一轮就退出;真正中断线程由 PipelineLifecycle 负责。 */ fun stop() { running = false } @@ -91,7 +92,7 @@ class Dispatcher( } } - /** flushSchd:同 FLID 未发事件按最新 STATE_VERSION 合并(§5);TOMBSTONE 发 null。 */ + /** 批量发 KAFKA_SCHD:每个 FLID 只发版本号最新的那条未发事件,删除通知发空 value。 */ internal fun flushSchd() { val batch = try { msgEvents.mergePendingSchd(props.schd.flushLimit) @@ -116,7 +117,7 @@ class Dispatcher( } val sentIds = batch.mapNotNull { it.eventId }.toSet() - failures.mapNotNull { it.eventId }.toSet() if (sentIds.isNotEmpty()) msgEvents.markAllSent(sentIds.toList()) - // 被最新版本合并压掉的未发事件同样关闭(§5:同 FLID 只按最新 STATE_VERSION 输出一次) + // 被更新版本压掉的旧事件也要标成已发,否则它们会一直留在队里:同一个 FLID 只按最新版本输出一次 val sentVersions = batch.associate { it.partitionKey to it.stateVersion } runCatching { while (true) { @@ -130,7 +131,7 @@ class Dispatcher( lastFlush = scheduler.now() } - /** 单条事件失败迁移:attempts+1;达上限 DEAD(EXHAUSTED)(DLQ,attempts 落库审计),否则退避重试。 */ + /** 一条事件发失败之后怎么走:重试次数加一,到上限就标成 DEAD(EXHAUSTED) 留作死信(次数落库便于追查),否则按退避推到下次再发。 */ private fun retryOrDead(e: MsgEvent, lastError: String) { val eventId = e.eventId ?: return val attempts = e.attempts + 1 diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/DecodedMessage.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/DecodedMessage.kt index 28c2d4c..5fd7585 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/DecodedMessage.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/DecodedMessage.kt @@ -1,8 +1,10 @@ package com.gzzn.omms.msgexchange.domain /** - * 解码后的入站报文(docs/design.md §2.2 统一载荷)。META 字段实名 - * SNDR/SEQN/DTTM,其中 SNDR|TYPE|STYP|SEQN 是业务幂等键来源(Identity.of)。 + * 一条入站报文的元信息(报文头 META 段)。SNDR 是发送方,TYPE/STYP 是报文类型和子类型, + * SEQN 是发送方流水号,DTTM 是发送时间。 + * + * SNDR|TYPE|STYP|SEQN 拼起来就是业务幂等键,用来判断同一条报文是否已经处理过。 */ data class MetaFields( val sndr: String, @@ -12,13 +14,13 @@ data class MetaFields( val dttm: Long, ) -/** 消息分派(design.md §2.2:一等分派键,穷尽 when;FDEL 一等公民——终止航班实例)。 */ +/** 报文的种类:日计划、运行动态、FDEL(航班终止),或者还没支持的类型。分派时用穷尽 when 保证不漏分支。 */ sealed interface MsgKind { data class Schd(val subtype: SchdSubtype) : MsgKind - data class Flop(val subtype: String) : MsgKind // 运行动态 STYP(FDEL 除外) + data class Flop(val subtype: String) : MsgKind // 运行动态,subtype 是报文子类型;FDEL 单独成类,不走这里 data object Fdel : MsgKind - /** 未支持类型(design.md §2.3:FAILED(UNSUPPORTED),达阈值转 DEAD)。 */ + /** 还没有对应处理器的类型:先按 UNSUPPORTED 记失败并退避重试,重试次数用尽转 DEAD 等人工处置。 */ data class Unsupported(val tag: String) : MsgKind enum class SchdSubtype { RESP, DNLD, ADFT } @@ -28,7 +30,7 @@ data class DecodedMessage( val meta: MetaFields, val kind: MsgKind, val rawXml: String, - /** 各类型载荷(vendor POJO 或 Kotlin 模型,阶段 1 后续统一)。 */ + /** 解码出来的载荷对象:目前有的类型是厂商 DTO、有的是本地领域模型,之后会统一。 */ val body: Any? = null, ) { val typeTag: String diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/MsgEvent.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/MsgEvent.kt index 52a2bb5..d17c5ee 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/MsgEvent.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/MsgEvent.kt @@ -1,21 +1,25 @@ package com.gzzn.omms.msgexchange.domain -/** MSG_EVENT 事件形态(design.md §2.1:UPSERT 整态/通知,TOMBSTONE 删除)。 */ +/** 事件的两种形态:UPSERT 是新增或更新(可能是整态也可能是变化通知),TOMBSTONE 是删除。 */ enum class EventType { UPSERT, TOMBSTONE } -/** MSG_EVENT 投递状态机(design.md §2.3:PENDING → SENT;失败退避重试;耗尽转 DEAD 保留作 DLQ)。 */ +/** 投递状态:PENDING 待发 → SENT 已发出;发送失败按退避重试,重试次数用尽转 DEAD,留在表里当死信队列。 */ enum class EventStatus { PENDING, SENT, DEAD } /** - * MSG_EVENT(outbox,design.md §2.1):状态、变更、删除通知。 - * KAFKA_SCHD 发整态、KAFKA_MSG 只通知变化(docs/flight-state.md §5,两主题不承诺顺序); - * TOMBSTONE 仅在 ACTIVE→DELETED(§3.3)或生命周期清理前补发(§6),与删除同事务登记。 - * stateVersion:发布时的航班版本;KAFKA_SCHD 聚合按 FLID 取最新(§5)。 + * MSG_EVENT:一张待发事件表(outbox),记录航班状态变更要对外发什么。 + * + * 写业务数据时在同一个事务里往这里插一行,投递线程随后按行发出,这样业务提交和"该发的事件" + * 不会脱节。KAFKA_SCHD 发完整状态,KAFKA_MSG 只发"这个航班变了"的通知;两个主题之间不保证 + * 先后顺序。TOMBSTONE 只在两种情况下登记:航班从在用变成删除,或者被生命周期清理前补发 + * 一次删除通知。见 docs/flight-state.md §5。 + * + * stateVersion 是发布时的航班版本号;同一 FLID 攒了多条待发事件时,只发版本号最新的那条。 */ data class MsgEvent( val eventId: Long? = null, val target: String, - val partitionKey: String, // 恒为 FLID + val partitionKey: String, // 分区键,恒为 FLID(航班实例 ID) val eventType: EventType = EventType.UPSERT, val stateVersion: Long = 0, val payloadJson: String, diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/OperationDay.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/OperationDay.kt index 7836366..b1e5d3c 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/OperationDay.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/OperationDay.kt @@ -5,19 +5,21 @@ import java.time.LocalDateTime import java.time.ZoneId /** - * 运营日计算(docs/flight-state.md §2.1):由计划运行时间字段 SODT(ddMMMyyHHmm) - * 与机场时区按切日边界推导;OPERATION_DAY 不是消息接收日或落库日,一经确定不可变。 - * 切日边界由 msgx.operation-day.cutoff-hour 配置(默认 0 = 自然日零点,业务口径待确认)。 + * 算航班的运营保障日(OPERATION_DAY):拿计划运行时间 SODT 和机场时区推出来。它不是消息 + * 接收日、也不是落库日,算出来之后就固定不变。 + * + * 民航的一天不一定从零点开始,切日边界由 msgx.operation-day.cutoff-hour 配。默认 0 点只是 + * 占位,真实口径还要业务确认。见 docs/flight-state.md §2.1。 */ class OperationDayCalculator( zone: ZoneId, cutoffHour: Int, ) { private val zone: ZoneId = zone - /** 切日边界:SODT 本地时刻早于该小时的归属前一运营日(0–23,越界收敛到边界值)。 */ + /** 切日边界小时:SODT 的本地时刻早于这个点就归属前一个运营日。取值 0–23,超出范围会被夹到边界值。 */ private val cutoffHour: Int = cutoffHour.coerceIn(0, 23) - /** SODT → 运营日;输入 null/空/非法返回 null(不抛异常,调用方按"运营日不可计算"处理)。 */ + /** 把 SODT 换算成运营日;入参为空、全空白或格式不对时返回 null 而不抛异常,由调用方按"运营日算不出来"处理。 */ fun compute(sodt: String?): LocalDate? { if (sodt.isNullOrBlank()) return null val local = parseSodt(sodt.trim()) ?: return null @@ -27,9 +29,10 @@ class OperationDayCalculator( companion object { /** - * SIS SODT 线格式:ddMMMyyHHmm(如 15DEC031723),月份英文三字母、大小写不敏感。 - * 两位年显式按 2000 基准展开(java.time 的 yy reduced-value 解析跨实现不一致, - * 显式展开保证 AODB 侧年份窗口唯一口径;基准年待真实报文验收确认)。 + * SODT 的报文格式:ddMMMyyHHmm,比如 15DEC031723,月份是三字母英文缩写、大小写都认。 + * + * 两位年份显式按 2000 年展开,不交给 java.time 的 yy 解析——不同实现对两位年的处理 + * 不一致,自己展开才能保证各方对年份窗口的理解相同。基准年等真实报文到了再确认。 */ private val SODT_REGEX = Regex("(\\d{1,2})([A-Za-z]{3})(\\d{2})(\\d{2})(\\d{2})") diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/SnapshotLog.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/SnapshotLog.kt index 089a761..f22efe9 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/SnapshotLog.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/SnapshotLog.kt @@ -4,16 +4,20 @@ import java.time.Instant import java.time.LocalDate /** - * SCHD 快照留痕 SCHD_SNAP_LOG(docs/flight-state.md §2 权威模型表; - * 清理规则见 design.md §6.2):只追加、可重建、不参与状态决策,写失败只记指标; - * 一行 = 一次尝试,重放也记;保留 90 天,按 (SCOPE_END, RECV_AT) 清理。 + * SCHD 快照留痕表 SCHD_SNAP_LOG,记录每一包日计划被处理的经过。 + * + * 只追加不修改;写失败不影响主流程,只记一条指标。数据丢了可以靠重放重建,它也不参与任何 + * 状态决策。一行对应一次处理尝试,被重放的包会再记一行。默认保留 90 天,按(快照覆盖截止日, + * 接收时间)清理。见 docs/flight-state.md §2、docs/design.md §6.2。 */ enum class SnapshotResult { COMMITTED, REPLAY_SKIPPED, ROLLED_BACK } /** - * 留痕告警 flags:EMPTY(空快照合法)、RECS_DROP(声明数量不符)、 - * DAY_MISMATCH(记录运营日不可计算,整包拒绝)、 - * SCHD_REVIVE_CONFLICT(日计划命中 DELETED 航班,保持 DELETED 不恢复)。 + * 处理日计划时记下的告警标记: + * EMPTY——空快照,合法但不是常态; + * RECS_DROP——报头声明的条数和实际收到的对不上; + * DAY_MISMATCH——有记录算不出运营日,整包拒收; + * SCHD_REVIVE_CONFLICT——日计划打到了已删除的航班上,航班保持删除不恢复。 */ enum class SnapshotFlag { EMPTY, RECS_DROP, DAY_MISMATCH, SCHD_REVIVE_CONFLICT } @@ -27,5 +31,5 @@ data class SnapshotLogEntry( val durationMs: Long, val result: SnapshotResult, val flags: Set = emptySet(), - val archiveKey: String? = null, // 证据层引用(尚未交付 §2) + val archiveKey: String? = null, // 指向归档原文的引用;归档侧还没交付,暂为 null ) diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/Targets.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/Targets.kt index 2ddd75f..ace74f0 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/Targets.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/Targets.kt @@ -1,10 +1,11 @@ package com.gzzn.omms.msgexchange.domain /** - * MSG_EVENT 投递目标(docs/flight-state.md §5:KAFKA_SCHD 按 FLID 发完整状态投影、 - * KAFKA_MSG 只通知变化;两主题间不保证顺序;删除用 tombstone)。 - * 常量值即 MSG_EVENT.TARGET 落库值(design.md §2.1);topic 名由投递适配层映射 - * (KAFKA:msg → "msg",KAFKA:schd → "schd")。ES 投影属阶段 B,暂不登记目标。 + * 事件往哪儿投。KAFKA_SCHD 按 FLID(航班实例 ID)发航班的完整状态,KAFKA_MSG 只发"变化了" + * 的通知;两个主题之间不保证顺序,删除用 value 为空的 tombstone 消息表示。 + * + * 常量值就是 MSG_EVENT.TARGET 落库的值,真正的 topic 名由投递适配层映射 + * (KAFKA:msg → "msg",KAFKA:schd → "schd")。ES 投影还没做,先不登记目标。 */ object Targets { const val KAFKA_MSG = "KAFKA:msg" diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightModel.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightModel.kt index 6d0b954..1e27066 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightModel.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightModel.kt @@ -4,15 +4,16 @@ import java.time.Instant import java.time.LocalDate /** - * 航班实例当前态模型(docs/flight-state.md §2/§3)。 + * 航班实例当前态模型,规则见 docs/flight-state.md §2/§3。 * - * 身份:FLID 是唯一关联键(§2.1);OPERATION_DAY 一经确定不可变(§2.1), - * 未被日计划收录前可为 NULL。STATE 仅 ACTIVE/DELETED(§3.3,无 ARCHIVED—— - * 物理清除只发生在历史归档成功之后 §6)。 + * FLID(航班实例 ID)是航班唯一的关联键,同一航班的所有报文都靠它对上号。OPERATION_DAY + * (运营保障日)在航班首次入库时确定,之后不允许再改;日计划还没收录它之前可以是 NULL。 + * STATE 只有 ACTIVE(在用)和 DELETED(已删除)两种,没有"已归档"这种态——航班退出当前态 + * 的唯一方式是被物理删除,而删除必须发生在历史归档成功之后。 */ enum class FlightState { ACTIVE, DELETED } -/** FLIGHT_SCHD 主行的身份与追踪字段(§2 权威模型;不含标量载荷)。 */ +/** FLIGHT_SCHD 主行:航班的身份和追踪字段,标量载荷存在别的字段里。 */ data class FlightMainRow( val flid: String, val operationDay: LocalDate?, @@ -23,9 +24,11 @@ data class FlightMainRow( ) /** - * SCHD DNLD/RESP 单条 FLTR 记录(解码产物,§3.1 日计划入口)。 - * scalars/collections 只含报文中出现的字段:出现 = Set/Replace(标量空值 = 显式清空), - * 未出现 = 按合并语义保留本地值(§3.1/§7 不变量);集合按完整合并结果写入(§2.2)。 + * 日计划报文(SCHD DNLD/RESP)里的一条 FLTR 记录,是解码后的产物。 + * + * scalars 和 collections 只装报文里真正出现过的字段:出现就覆盖本地值(标量给空串表示 + * 显式清空),没出现就保留库里已有的值;集合一旦出现就按合并后的完整结果整体覆盖写入。 + * 合并规则见 docs/flight-state.md §3.1。 */ data class ScheduleRecord( val flid: String, @@ -34,8 +37,10 @@ data class ScheduleRecord( ) /** - * FLOP/ADFT 增量载荷(§3.2/§3.3):出现字段/集合更新,缺失保留本地值; - * ADFT 缺失字段语义待上游确认前按保守 Set-only 处理(§3.3),不沿用全量替换。 + * FLOP/ADFT 报文的增量载荷:只带这次要改的字段和集合,没出现的字段保留库里已有的值。 + * + * ADFT 里缺失字段到底算清空还是算保留,上游还没给准话,所以暂时保守处理成"只设不改", + * 不按整包替换来理解。见 docs/flight-state.md §3.2/§3.3。 */ data class MergeChange( val flid: String, @@ -44,9 +49,11 @@ data class MergeChange( ) /** - * 完整当前态(§2.2/§3):主行 + 全部明细 = 完整当前态。 - * 写入前在内存生成完整新状态再落库(引擎产物),读取须在主表与全部明细的 - * 一致性读边界内进行(§5),展示层过滤 STATE = ACTIVE。 + * 一个航班的完整当前态:主行字段加上它的全部明细。 + * + * 状态引擎先在内存里推出完整的新状态再落库,所以这个对象既是引擎的输出,也是落库的输入。 + * 读的时候要在主表和全部明细的一致读边界内取,否则会读到半新半旧;对外展示时只挑 + * STATE = ACTIVE 的航班。 */ data class FlightSnapshot( val flid: String, @@ -57,19 +64,22 @@ data class FlightSnapshot( val collections: Map>>, ) -/** §6 生命周期判定窗口(按机场时区计算;窗口值业务配置)。 */ +/** 判定一个航班可以归档删除的四条时间窗口,单位是小时,按机场所在时区算;具体取值走业务配置。 */ data class HistoryRules( - val cancelledHours: Long = 48, // CNCL 非空超过 N 小时 - val terminalHours: Long = 48, // NAAT/NEAT 终态超过 N 小时(字段含义待术语表确认 §6) - val deletedHours: Long = 48, // STATE = DELETED 超过 N 小时 - val idleHours: Long = 24 * 7, // 无终态字段:最后更新超过兜底期限 + val cancelledHours: Long = 48, // 取消时间字段(CNCL)非空且已过 N 小时 + val terminalHours: Long = 48, // 到达/离开终态字段(NAAT/NEAT)非空且已过 N 小时 + val deletedHours: Long = 48, // 已标记删除(STATE = DELETED)且已过 N 小时 + val idleHours: Long = 24 * 7, // 既没有终态也没有取消:最后更新距今超过这个兜底期限 ) /** - * §6 命中生命周期判定的航班(先归档 → 后物理清除;历史存储失败必须删 0 行)。 - * wasNeverFdel:是否未经 FDEL 而被生命周期清除——清除前须补发一次删除事件(§3.3/§5)。 - * 当前为推断口径(无持久化"曾 FDEL"痕迹):state=ACTIVE 视为未经 FDEL, - * FDEL→ADFT 重激活后的航班会被误判并重复补发,收敛见 §6 开放项。 + * 被生命周期判定选中、准备归档并物理删除的航班。顺序固定是"先归档、再删除", + * 归档没确认成功就一行都不能删。 + * + * wasNeverFdel 表示这个航班从没收到过 FDEL(航班终止报文),是被生命周期直接清掉的, + * 所以清除前要补发一次删除通知,否则下游不知道它已经没了。目前只能靠推断:没有地方记录 + * "曾经收过 FDEL",于是把 state = ACTIVE 当成没收到过。副作用是 FDEL 之后又被 ADFT + * 重新激活的航班会被误判、重复补发,解决办法待定(见 docs/flight-state.md §6 开放项)。 */ data class HistoryCandidate( val flid: String, diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt index ad8d634..803e603 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngine.kt @@ -5,25 +5,30 @@ import com.gzzn.omms.msgexchange.domain.SnapshotFlag import java.time.LocalDate /** - * 航班状态引擎(docs/flight-state.md §2–§4)——纯函数,内存推导完整新状态, - * 不触碰 DB/Kafka: - * - [validateMessage]:日计划整包校验(§4 步骤 2:声明数量、航班标识、运营日推导); - * - [snapshotState]:DNLD/RESP 日计划合并(§3.1)——出现 Set/Replace、缺失保留、 - * 标量显式空值清空;DELETED 航班保持 DELETED(§3.3,不承担恢复语义); - * - [mergedState]:FLOP/ADFT 增量合并(§3.2/§3.3)——只修改报文表达的字段/集合。 + * 航班状态引擎:把报文内容和库里已有的状态合成出新状态,全部在内存里算,不碰数据库和 + * Kafka,方便单独测。三个入口: + * - [validateMessage]:整包校验日计划,检查声明条数、航班标识、运营日能不能算出来; + * - [snapshotState]:把日计划(DNLD/RESP)合并进当前态——报文里出现的字段覆盖本地值, + * 没出现的保留,标量给空串表示清空;已删除的航班保持删除,日计划救不回来; + * - [mergedState]:把 FLOP/ADFT 的增量合并进当前态,只改报文明确表达的字段和集合。 + * + * 合并规则见 docs/flight-state.md §3.1/§3.2。 */ object FlightStateEngine { - /** 集合键白名单:10 类集合 ↔ 9 张明细表(8 张资源表 + FLIGHT_ROUTE_POINT, - * ROUT/ERUT 共用路线表,flight-state.md §2.2)。 */ + /** 允许落库的集合键:10 个集合对应 9 张明细表——8 张资源表,加上存经停点的 + * FLIGHT_ROUTE_POINT;其中 ROUT 和 ERUT 共用同一张路线表。 */ val COLLECTION_KEYS: Set = setOf( "GTDT", "CKDT", "CLDT", "PSDT", "CHDT", "DELY", "ABTM", "CHOT", "ROUT", "ERUT", ) /** - * 日计划整包校验(§4 步骤 2 / §3.1):RECS 0–9999 且等于实收数;每条含合法 - * 数字型 FLID(SIS Number(1-12));快照内不重复;每条记录运营日可计算。 - * 任一失败整包不落地(DEAD(PROTOCOL))。 + * 校验一包日计划:报头声明的记录数(RECS)要在 0–9999 之间、且等于实际收到的条数; + * 每条记录的 FLID 必须是 1–12 位数字,快照内不能有重复 FLID,每条记录的运营日都要 + * 能算出来。 + * + * 任何一条不合格就整包拒收、一条都不入库,记为 DEAD(PROTOCOL) 等人工处置——日计划是 + * 整包语义,落一半会让当前态自相矛盾。 */ fun validateMessage( recsDeclared: Int, @@ -63,11 +68,14 @@ object FlightStateEngine { } /** - * 日计划合并(§3.1):标量出现 Set(空值 = 显式清空)、缺失保留本地值; - * 集合出现 Replace、缺失保留。新航班(current == null)无本地值可保留, - * 集合按全键输出空集(§2.2 完整合并结果写入口径)。 - * `keepDeleted = true` 时 STATE 保持 DELETED(§3.3:日计划不承担恢复, - * 恢复入口只有 ADFT);冲突告警由调用方按 SCHD_REVIVE_CONFLICT 记录。 + * 把一条日计划记录合并进当前态:报文里出现的标量覆盖本地值(给空串表示显式清空), + * 没出现的保留;集合一旦出现就整体替换,没出现就保留。 + * + * 新航班(current == null)没有本地值可保留,所以集合按全部键输出空集,保证落库时 + * 每张明细表都有确定的行集。 + * + * keepDeleted = true 时即使收到日计划也保持 DELETED:日计划不能把删掉的航班救回来, + * 唯一的恢复入口是 ADFT。调用方负责记一条 SCHD_REVIVE_CONFLICT 告警。 */ fun snapshotState( current: FlightSnapshot?, @@ -81,32 +89,32 @@ object FlightStateEngine { else -> current.state } val scalars = buildMap { - current?.scalars?.let(::putAll) // 缺失 = 保留(§3.1) + current?.scalars?.let(::putAll) // 报文没带的标量保持原值 putAll(record.scalars) // 出现 = Set;空串 = 显式清空 } val collections = buildMap { if (current == null) { COLLECTION_KEYS.forEach { key -> put(key, emptyList()) } // 新航班全键输出 } else { - putAll(current.collections) // 缺失 = 保留(§3.1) + putAll(current.collections) // 报文没带的集合保持原值 } record.collections.forEach { (key, items) -> - if (key in COLLECTION_KEYS) put(key, items) // 出现 = Replace(§3.1) + if (key in COLLECTION_KEYS) put(key, items) // 报文带了整个集合就整体替换 } } return FlightSnapshot( flid = record.flid, operationDay = operationDay, state = state, - stateVersion = (current?.stateVersion ?: 0L) + 1, // 每次成功写入 +1(§3.1/§4) + stateVersion = (current?.stateVersion ?: 0L) + 1, // 每次成功写入版本号加一 scalars = scalars, collections = collections, ) } /** - * FLOP/ADFT 增量合并(§3.2/§3.3):标量出现覆盖、缺失保留;集合出现 Replace、 - * 缺失保留——只修改报文表达的字段/集合,其余航班状态保持不变;不推导运营日。 + * 把 FLOP/ADFT 的增量合并进当前态:出现的标量覆盖、出现的集合整体替换,没出现的都保留。 + * 只改报文明确表达的字段和集合,不重新推导运营日,也不动航班的在用/删除状态。 */ fun mergedState(current: FlightSnapshot, change: MergeChange): FlightSnapshot { val scalars = buildMap { @@ -126,11 +134,11 @@ object FlightStateEngine { ) } - /** FLID:数字型(SIS Number(1-12))。 */ + /** FLID 必须是 1–12 位纯数字。 */ private val FLID_REGEX = Regex("\\d{1,12}") } -/** 整包校验结果:Ok 携带每条记录的归属运营日与观测 flags;Invalid 整包拒绝。 */ +/** 整包校验结果:Ok 带上每条记录的运营日和观察到的告警标记,Invalid 表示整包拒收、带上原因。 */ sealed interface SnapshotValidation { data class Ok( val perRecordDay: Map, diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/health/HealthIndicators.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/health/HealthIndicators.kt index 8758ced..03f18c0 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/health/HealthIndicators.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/health/HealthIndicators.kt @@ -10,11 +10,13 @@ import jakarta.inject.Singleton import org.reactivestreams.Publisher /** - * U12(R05):自定义健康指示器—— - * Kafka(投递端口)。经 BeanProvider 可选解析: - * 缺 bean(如未用 stub 也未实装)时指示 DOWN 而非启动失败; - * UP 判据为真实 ping(false/异常 → DOWN),而非仅 bean 存在(复审 P1 修正)。 - * (ACM2-28:Redis 退出阶段 A 权威与写路径,redis-flight-store 指示器移除)。 + * 把 Kafka 投递端口的状态报给 /health。 + * + * 用 BeanProvider 可选注入:没配 stub、也还没有真实实现时,端口 bean 根本不存在,这时报 DOWN + * 而不是让服务起不来。判 UP 的依据是真的调一次 ping——返回 false 或抛异常都算 DOWN, + * 不是"bean 在就算好"。 + * + * Redis 已经不再参与航班状态的写入和判定,对应的健康指示器也一并删掉了。 */ @Singleton class KafkaDeliveryHealthIndicator( diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/log/TraceLog.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/log/TraceLog.kt index 9c61b25..a9775c1 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/log/TraceLog.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/log/TraceLog.kt @@ -3,8 +3,8 @@ package com.gzzn.omms.msgexchange.infra.log import org.slf4j.MDC /** - * U12(R05):处理路径入口写入 MDC traceId(=cminmsgsId/eventId), - * 使一条消息全链路日志可串(logback %X{traceId} + logstash includeMdcKeyName)。 + * 处理一条消息时把它的 ID(信箱 ID 或事件 ID)放进 MDC 的 traceId,用 withTrace 把整段处理 + * 逻辑包起来;日志配置里带上 %X{traceId},同一条消息的日志就能串到一起。 */ object TraceLog { fun withTrace(id: Any, body: () -> T): T { diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/Repositories.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/Repositories.kt index c78344f..896fcbb 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/Repositories.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/Repositories.kt @@ -233,7 +233,10 @@ interface ReqTrackRepository { fun findLatest(reqType: String, operationDay: LocalDate, sender: String, states: List): Req? - /** RESP 完成请求(design.md §4.2):匹配最新一条 PENDING/SENT;无匹配返回 false(迟到不报错)。 */ + /** + * 应答报文到达时把对应请求标记为已完成:匹配最近一条待应答的请求(PENDING 或 SENT)。 + * 找不到返回 false——迟到或多余的应答不算错误。 + */ fun completeLatest(reqType: String, operationDay: LocalDate, sender: String): Boolean fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/FailureScheduler.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/FailureScheduler.kt index 60d706e..9ca872c 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/FailureScheduler.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/FailureScheduler.kt @@ -7,28 +7,29 @@ import java.time.Clock import java.time.Instant /** - * 统一重试策略(ACM2-10 U08):供 MessageProcessor / SnapshotFlow(ProcState 侧)与 - * Dispatcher(MsgEvent 侧)共用——attempts 递增后按 backoff 表给 nextAttemptAt; - * exhausted 判定与两侧同源(maxAttempts)。时间一律经可注入 Clock(测试用固定钟,避免脆弱睡眠)。 + * 重试节奏的唯一出处:处理失败的入站消息和待发事件都用它算下次重试时间,保证两边口径一致。 + * + * 重试次数加一之后,按退避表算出下次可以重试的时刻;"次数是否已经用尽"也在这里判断, + * 上限取 msgx.pipeline.max-attempts。时间一律走注入的 Clock,测试可以塞固定时钟,不用 sleep。 */ @Singleton class FailureScheduler( private val props: PipelineProps, private val clock: Clock, ) { - /** 便捷构造:默认系统时钟(生产路径)。 */ + /** 生产环境用这个构造:时钟取系统 UTC 时间。 */ constructor(props: PipelineProps) : this(props, Clock.systemUTC()) fun now(): Instant = clock.instant() fun exhausted(attempts: Int): Boolean = attempts >= props.pipeline.maxAttempts - /** attempts 指递增后的值;N28:attempt ≤ 0 由 backoffFor 兜底为首档。 */ + /** 传入的 attempts 是已经加一之后的值。若传 0 或负数(比如 FAILED 行还没记过次数),退避表会兜底给第一档。 */ fun nextAttemptAt(attemptsAfterIncrement: Int): Instant = now().plusMillis(props.pipeline.backoffFor(attemptsAfterIncrement)) } -/** 提供可注入 Clock(java.time.Clock);测试可用 Clock.fixed(...) 或自定义可变钟覆盖。 */ +/** 把 Clock 注册成可注入的 bean;测试里可以换成 Clock.fixed(...) 或自己写的可推进时钟。 */ @Factory class TimeFactory { @Singleton diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ReplayService.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ReplayService.kt index 5a1a080..50dbeb7 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ReplayService.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/retry/ReplayService.kt @@ -5,19 +5,21 @@ import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository import jakarta.inject.Singleton /** - * U11 显式重放入口:只允许“可恢复”的错误类从 FAILED/DEAD 回到 PENDING(主泵重领)。 - * 不可恢复类(MALFORMED——报文非法,重放必再失败)与未知类一律不在白名单内。 + * 人工重放入口:把失败的消息从 FAILED/DEAD 拨回 PENDING,让主泵重新领走处理。 + * + * 只有"再试一次有可能成功"的错误类才放行。报文本身不合法的(MALFORMED)重放多少次都一样, + * 所以不在白名单里;没列出的错误类也一律不放行。 */ @Singleton class ReplayService( private val procState: ProcStateRepository, ) { private val log = org.slf4j.LoggerFactory.getLogger(ReplayService::class.java) - /** 可恢复错误类:codec 修复可重放 / 未实装补齐可重放 / 基础设施抖动可重放 / 重试耗尽后人工复核可重放。 */ + /** 可以重放的错误类:解码逻辑修好后能过、处理器补齐后能过、基础设施抖动已恢复、以及重试耗尽但人工复核认为还能再试的。 */ val replayableErrorClasses: Set = setOf(ErrorClass.CODEC_ERROR, ErrorClass.UNSUPPORTED, ErrorClass.INFRA, ErrorClass.EXHAUSTED) - /** 只重放白名单内的类;请求含 MALFORMED 等非法类时静默忽略该类。 */ + /** 只放行白名单内的错误类;请求里带了 MALFORMED 这类不可重放的,直接忽略不报错。 */ fun replay(requested: Collection): Int { val allowed = requested.filter { it in replayableErrorClasses } if (allowed.isEmpty()) { @@ -29,6 +31,6 @@ class ReplayService( return n } - /** 默认入口:重放全部可恢复类。 */ + /** 什么都不指定时走这个:把白名单里的错误类全部重放一遍。 */ fun replayAll(): Int = replay(replayableErrorClasses) } diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubAdapters.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubAdapters.kt index ea3ccc2..460ab97 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubAdapters.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubAdapters.kt @@ -5,8 +5,10 @@ import io.micronaut.context.annotation.Requires import jakarta.inject.Singleton /** - * stub 适配层——DeliveryPort 内存实现,仅在 msgx.stubs=true 时生效。 - * 记录 (topic, key, payload):payload = null 表示 TOMBSTONE(flight-state.md §5 键缺失=删除旧值)。 + * 投递端口的内存假实现,只有配置 msgx.stubs=true 时才装配,本地开发和测试用。 + * + * 每次发送都往 sent 里记一条(topic、key、payload);payload 为 null 的那条就是删除通知 + * (tombstone,下游按"这个键没了"理解成删除)。见 docs/flight-state.md §5。 */ @Requires(property = "msgx.stubs", value = "true") @Singleton diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxController.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxController.kt index cb8c694..4120e0e 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxController.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxController.kt @@ -8,8 +8,9 @@ import io.micronaut.http.annotation.Post import io.micronaut.http.annotation.Produces /** - * ACMA-8 契约冻结:compat HTTP 写路径 `POST /cminmsgs/send`(手工/对拍)。 - * 生产主路径 = JDBC 轮询共享 CMINMSGS(InboxPoller,U05),非本 Controller。 + * 兼容用的 HTTP 写入口 `POST /cminmsgs/send`,给手工投递和跟现役系统逐字对拍用。 + * + * 生产上收报走的是轮询共享信箱表 CMINMSGS(InboxPoller),不是这个 Controller。 */ @Controller class InboxController(private val inbox: InboxService) { @@ -18,8 +19,8 @@ class InboxController(private val inbox: InboxService) { @Produces(MediaType.TEXT_PLAIN) fun send(@Body rawXml: String): HttpResponse { val receipt = inbox.accept(rawXml) - return HttpResponse.ok(receipt.msgId.toString()) // TODO: 与现役响应体逐字对拍后固化 + return HttpResponse.ok(receipt.msgId.toString()) // TODO: 先返回消息 ID 文本,等跟现役响应体逐字对拍过再定稿 } - // TODO(阶段2): /schd/sync、/all/flights、/kafka/topics/{name}/msgs 按契约冻结清单补齐。 + // TODO(阶段2): 按契约清单补齐 /schd/sync、/all/flights、/kafka/topics/{name}/msgs 这几个接口。 } diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPoller.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPoller.kt index b4ea41f..9b447e4 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPoller.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxPoller.kt @@ -14,13 +14,14 @@ import java.time.Instant * 收报:轮询共享信箱,把新消息登记到自有 PG 的 PROC_STATE,等着主泵处理。 * * 每一轮只做三件事: - * 1. 从水位 W 之后按 ID 升序读一批行(只看 ID,不看处理标记); - * 2. 把读到的行登记成 PENDING,并把水位推到"连续"的位置; + * 1. 从"水位"之后按 ID 升序读一批行(只看 ID,不看处理标记)。 + * 水位记的是"信箱里到哪个 ID 为止已经全部读进自有库",存在自己的库里,重启不丢; + * 2. 把读到的行登记成 PENDING,并把水位往前推; * 3. 登记和水位推进写在同一个事务里——中途崩溃时水位没动,下一轮重扫即可补齐。 * - * "连续"是这里唯一需要理解的规则。如果 W 后面缺了一个 ID,说明可能有 ID 更小的消息 - * 还没提交上来。此时先停在缺口前,不把缺口后面的消息放进队列:否则那条迟到的消息 - * 会排到它们后面,破坏"先来先处理"的约定,同一航班的报文可能被乱序应用。 + * 水位一次只推到"连续"的位置,这是这里唯一需要理解的规则。如果下一个 ID 缺号, + * 说明可能有 ID 更小的消息还没提交上来。此时先停在缺口前,不把缺口后面的消息放进队列: + * 否则那条迟到的消息会排到它们后面,破坏"先来先处理"的约定,同一航班的报文可能被乱序应用。 * * 缺口等超过 `msgx.pipeline.max-commit-delay` 仍未出现,就认定它永远不会来了 * (典型情况是自增回滚留下的空位),跳过它继续推进——否则水位会卡在第一个空位上 diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt index 0dd81e3..f07e040 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt @@ -14,27 +14,30 @@ import java.time.Instant import java.time.ZoneId /** - * 历史归档与物理清除(docs/flight-state.md §6 生命周期,顺序不可颠倒;design.md §6.2): - * 1. 选出满足保留期 + 终态/静默判据的航班(含 DELETED); - * 2. 写入历史存储; - * 3. 历史存储返回成功的 FLID 集合 → 物理删除主行与明细(归档结果只记录在历史存储,当前态无 ARCHIVED 态); - * 4. 未经 FDEL 的航班在清除前补发一次删除事件(§3.3/§5);其余不发; - * 5. 失败或不明确的保留重试。 - * 前提红线:历史存储未接通时必须删 0 条(§6)。 + * 历史归档与物理清除:把到期航班搬进历史存储,再从当前态删掉。五步顺序不能颠倒: + * 1. 按保留期和终态/静默条件挑出候选航班(已删除的也算); + * 2. 交给历史存储归档; + * 3. 只对历史存储确认归档成功的 FLID(航班实例 ID),物理删除主行和明细——当前态不设 + * "已归档"状态,归档结果只留在历史存储里; + * 4. 从没收到过 FDEL(航班终止报文)、被这里直接清掉的航班,清除前补发一次删除通知;其余的不用发; + * 5. 归档失败或者结果说不清的,原样留着下次再来。 + * + * 红线:历史存储没接通时必须一条都不删。先删当前态、事后再补历史,是不允许的。 + * 见 docs/flight-state.md §6。 */ @Singleton class HistorySweepJob( private val flightState: FlightStateRepository, private val msgEvents: MsgEventRepository, private val props: HistoryProps, - /** 历史存储端口:返回归档成功的 FLID 集合;未接通时不注入(null)。 */ + /** 历史存储端口:返回确认归档成功的 FLID 集合;部署侧没接通时是 null。 */ private val historyStore: HistoryStore? = null, - /** design.md §6.2 留痕清理端口(90 天,按 (SCOPE_END, RECV_AT));未接通时不注入。 */ + /** 留痕清理端口:删掉超过保留期(默认 90 天)的日计划留痕行;没接通时是 null。 */ private val snapLogPurge: SnapshotLogPurge? = null, ) { - /** 历史存储端口(由部署侧适配实现;脚手架默认未接通)。 */ + /** 归档到历史存储的接口,由部署侧适配具体存储;默认不接通。 */ fun interface HistoryStore { - /** 归档候选航班;返回归档确认成功的 FLID 集合(部分成功允许)。 */ + /** 归档这些候选航班,返回其中确认成功的 FLID;允许部分成功,没在返回值里的留着重试。 */ fun archive(candidates: List): Set } @@ -46,12 +49,12 @@ class HistorySweepJob( fun run(now: Instant = Instant.now()): SweepOutcome { if (!props.historyStoreEnabled || historyStore == null) { - // 红线:历史存储未接通必须删 0 条;绝不允许先删当前态再补历史(§6) + // 红线:历史存储没接通就一条都不删。先删当前态、事后再补历史是不允许的。 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") // §6:窗口按机场时区计算 + val zone = ZoneId.of("Asia/Shanghai") // 保留期窗口按机场时区算,不用 UTC val candidates = flightState.findHistoryCandidates(rules, zone, now) if (candidates.isEmpty()) return SweepOutcome(0, 0, 0) @@ -59,7 +62,7 @@ class HistorySweepJob( if (archivedFlids.isEmpty()) return SweepOutcome(candidates.size, archived = 0, purged = 0) val toPurge = candidates.filter { it.flid in archivedFlids } - // §6/§3.3:未经 FDEL、由生命周期直接清除的航班,清除前补发一次删除事件 + // 从没收到过 FDEL、被这里直接清掉的航班,删除前补发一次通知,否则下游一直以为它还在 val preDelete = toPurge.filter { it.wasNeverFdel } if (preDelete.isNotEmpty()) { msgEvents.insertAll(preDelete.map { tombstone(it) }) diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt index 43a9217..6c6755d 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt @@ -176,7 +176,10 @@ class AdftProcessor( ApplyResult.Succeeded } - /** §3.3 保守语义:仅出现字段 Set;集合出现 Replace、缺失保留。 */ + /** + * 上游对"字段缺失"的含义还没确认,这里取保守做法: + * 报文里出现的字段覆盖本地值,没出现的字段保持原值不动。 + */ private fun setOnly(record: ScheduleRecord) = MergeChange( flid = record.flid, scalars = record.scalars, diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Identity.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Identity.kt index 37bd5b0..1ec0adb 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Identity.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Identity.kt @@ -5,9 +5,12 @@ import com.gzzn.omms.msgexchange.domain.DecodedMessage import java.time.LocalDate /** - * ACMA-8 I3:幂等键 = SNDR|TYPE|STYP|SEQN。 - * 计算集中此处唯一入口;“是否含日边界”可配置且默认关闭(CONFIRM 矩阵 #11, - * SEQN 重置作用域确认前不改语义——上线后不改幂等键)。 + * 幂等键的唯一算法:发送方、报文类型、子类型、流水号四段用竖线拼起来,用来判断同一条业务 + * 报文是不是已经处理过。 + * + * 也可以再拼上日期做"按天去重",由 msgx.identity.include-day-boundary 控制,默认关闭: + * 发送方的流水号到底按什么范围重置还没确认,语义定下来之前不动。幂等键上线后就不能再改, + * 改了会让已有的去重记录全部对不上。 */ object Identity { fun of( diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/config/InfraBindingStartupTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/config/InfraBindingStartupTest.kt index 1441ae4..45cf4c9 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/config/InfraBindingStartupTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/config/InfraBindingStartupTest.kt @@ -8,9 +8,11 @@ import java.sql.Connection import javax.sql.DataSource /** - * U03(N02/R09/R01a 验收的“启动级”版本):基础设施配置在真实上下文启动时绑定生效—— - * 注入 DataSource 并建立真实连接(H2 内存,application-test.yml),证明 datasources.default.* - * 键位与驱动解析正确(而非“看似配置实则未生效”)。Pump 等业务 bean 懒加载,不依赖实仓储。 + * 守着启动期配置真的生效:在完整应用上下文里注入 DataSource 并真连一次(H2 内存库,配在 + * application-test.yml),证明 datasources.default.* 的键位和驱动都能解析,而不是 + * "看着配了、其实没生效"。 + * + * 业务 bean(比如主泵)是懒加载的,所以这里不需要真实仓储。 */ @MicronautTest class InfraBindingStartupTest { @@ -33,7 +35,7 @@ class InfraBindingStartupTest { @Test fun `registration disabled by test profile`() { assertNotNull(props) - // eureka 注册在 application-test.yml 经 msgx.register-eureka=false 关闭——启动级验证绑定可达 + // 测试配置里关掉了 eureka 注册(msgx.register-eureka=false),这里验证上下文照样能起来 assertNotNull(dataSource) } } diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsBindingTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsBindingTest.kt index c2ee4c7..38b651b 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsBindingTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsBindingTest.kt @@ -11,10 +11,11 @@ import org.junit.jupiter.api.TestInstance import java.time.Duration /** - * U03(N02)验收:msgx.* 嵌套键「改值—回读」离线化。 - * 三个嵌套类(Pipeline/Schd/Identity)须标注 @ConfigurationProperties 才会被绑定; - * 未标注时下列覆盖值会静默回落 Kotlin 默认值(本测试即红)。仅注入配置 bean,不触碰仓储/DB。 - * 注:Micronaut Test 5.x 移除 @Property 注解,改以 TestPropertyProvider 注入测试属性(需 PER_CLASS)。 + * 守着配置绑定真的生效:msgx.* 下面每个嵌套配置类都要标 @ConfigurationProperties,否则键不会 + * 被绑定,测试里给的覆盖值会静默失效、用回代码里的默认值,这个测试就会红。 + * + * 只注入配置 bean,不碰仓储和数据库。另外注意 Micronaut Test 5.x 去掉了 @Property 注解, + * 改由 TestPropertyProvider 提供测试属性,所以类上要加 PER_CLASS。 */ @MicronautTest @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -41,6 +42,6 @@ class PipelinePropsBindingTest : TestPropertyProvider { @Test fun `top-level scalars still bind`() { assertEquals("msgexchangeapi", props.serviceName) - assertFalse(props.registerEureka) // application-test.yml 置 false + assertFalse(props.registerEureka) // application-test.yml 里配成 false } } diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsTest.kt index 2122031..71faa80 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/config/PipelinePropsTest.kt @@ -4,7 +4,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test /** - * U08/N28:backoffFor 必须对 attempt ≤ 0(FAILED 行未递增 attempts)给出首档退避而非抛异常。 + * 守着退避表的规矩:按档位递增、超出表长就封顶;传入 0 或负数(比如还没记过重试次数的 + * FAILED 行)要返回第一档,不能抛异常。 */ class PipelinePropsTest { diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/delivery/DispatcherTickTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/delivery/DispatcherTickTest.kt index f556abb..2da4993 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/delivery/DispatcherTickTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/delivery/DispatcherTickTest.kt @@ -16,9 +16,11 @@ import org.junit.jupiter.api.Test import java.time.Instant /** - * 投递调度(docs/flight-state.md §5 + design.md §5.1/§5.2): - * KAFKA_MSG 逐条 FIFO;KAFKA_SCHD 唯一出口 flushSchd——同 FLID 未发事件按最新 - * STATE_VERSION 合并;TOMBSTONE 发 null 值消息;失败退避重试、达上限 DEAD。 + * 守着投递的几条规矩: + * - KAFKA_MSG 一条条按顺序发,而且不会顺手把 KAFKA_SCHD 的事件发掉; + * - KAFKA_SCHD 只有 flushSchd 一个出口,同一个 FLID(航班实例 ID)只发版本号最新的那条; + * - 删除通知发成 value 为空的 tombstone 消息; + * - 发送失败按退避重试,次数用尽转 DEAD 当死信。 */ class DispatcherTickTest { @@ -45,7 +47,7 @@ class DispatcherTickTest { d.tick() assertEquals(1, port.sent.count { it.topic == "msg" }) - assertEquals(0, port.sent.count { it.topic == "schd" }) // N03:schd 唯一出口 flushSchd + assertEquals(0, port.sent.count { it.topic == "schd" }) // KAFKA_SCHD 只能由 flushSchd 发,tick 不许碰 } @Test @@ -84,7 +86,7 @@ class DispatcherTickTest { d.flushSchd() assertEquals(1, port.tombstones.size) - assertEquals("F1", port.tombstones.single().key) // §5:整态键缺失表示删除旧值 + assertEquals("F1", port.tombstones.single().key) // value 为空表示删掉这个 key 的旧值 assertNull(port.tombstones.single().payload) } @@ -117,7 +119,7 @@ class DispatcherTickTest { } } -/** schd 发送恒失败的端口(退避/终态闭环验证用)。 */ +/** KAFKA_SCHD 发送永远失败的投递端口,用来验证退避重试和最终转死信的闭环。 */ private class FailingSchdPort : DeliveryPort { var calls = 0 override fun sendKafka(topic: String, key: String, payloadJson: String) = Unit diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/domain/OperationDayTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/OperationDayTest.kt index 7d135df..ae16ade 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/domain/OperationDayTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/OperationDayTest.kt @@ -8,7 +8,8 @@ import java.time.LocalDate import java.time.ZoneId /** - * 运营日计算(docs/flight-state.md §2.1):SODT(ddMMMyyHHmm)+ 机场时区 + 业务切日边界。 + * 守着运营日的算法:用计划运行时间 SODT(ddMMMyyHHmm)加机场时区算,本地时刻早于切日边界的 + * 归到前一个运营日;越界的切日边界收敛到 23;SODT 缺失或格式不对就返回 null。 */ class OperationDayTest { diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt index 41b1da9..368c663 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/domain/flight/FlightStateEngineTest.kt @@ -10,10 +10,11 @@ import java.time.LocalDate import java.time.ZoneId /** - * 航班状态引擎(docs/flight-state.md §2–§4)不变量: - * 日计划合并(§3.1)——出现 Set/Replace、缺失保留、标量空值显式清空; - * DELETED 不被日计划恢复(§3.3);FLOP/ADFT 增量合并(§3.2/§3.3); - * 整包校验(§4 步骤 2:声明数量、航班标识、运营日推导)失败整包拒绝。 + * 守着航班状态合并的几条规矩: + * - 日计划合并:报文里出现的字段覆盖本地值、没出现的保留,标量给空串等于显式清空; + * - 日计划不能把已删除的航班恢复成在用; + * - FLOP/ADFT 只改报文表达的字段和集合; + * - 整包校验(条数、FLID 格式、运营日能不能算出来)有一条不过就整包拒收。 */ class FlightStateEngineTest { @@ -34,11 +35,11 @@ class FlightStateEngineTest { keepDeleted = false, ) - assertEquals("CA002", next.scalars["FLNO"]) // 重叠字段被覆盖(§3.1) - assertEquals("old-note", next.scalars["REMC"]) // 缺失标量保留(§3.1/§7) - assertEquals("", next.scalars["CNCL"]) // 空串 = 显式清空(落库置 NULL) - assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"]) // 缺失集合保留 - assertEquals(6, next.stateVersion) // 每次成功写入 +1 + assertEquals("CA002", next.scalars["FLNO"]) // 报文里出现的字段覆盖本地值 + assertEquals("old-note", next.scalars["REMC"]) // 报文没提的标量保留原值 + assertEquals("", next.scalars["CNCL"]) // 空串表示显式清空,落库时置成 NULL + assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"]) // 报文没提的集合保留原值 + assertEquals(6, next.stateVersion) // 每成功合并一次,版本号加一 assertEquals(FlightState.ACTIVE, next.state) } @@ -76,8 +77,8 @@ class FlightStateEngineTest { assertEquals(FlightState.ACTIVE, next.state) assertEquals(1L, next.stateVersion) assertEquals("CA001", next.scalars["FLNO"]) - assertFalse(next.scalars.containsKey("REMC")) // 无历史可保留 - assertEquals(10, next.collections.size) // 全键输出(§2.2) + assertFalse(next.scalars.containsKey("REMC")) // 新航班没有历史值可保留 + assertEquals(10, next.collections.size) // 新航班按全部集合键输出,没内容的给空集 assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"]) assertEquals(emptyList>(), next.collections["DELY"]) } @@ -88,7 +89,7 @@ class FlightStateEngineTest { val next = FlightStateEngine.snapshotState( current, ScheduleRecord("121", mapOf("FLNO" to "CA001")), day, keepDeleted = true, ) - assertEquals(FlightState.DELETED, next.state) // §3.3:日计划不恢复 DELETED + assertEquals(FlightState.DELETED, next.state) // 日计划不能把已删除的航班恢复成在用 assertEquals(4, next.stateVersion) } @@ -105,7 +106,7 @@ class FlightStateEngineTest { ) assertEquals("15DEC261900", next.scalars["ESTT"]) - assertEquals("CA001", next.scalars["FLNO"]) // 缺失 = 保留(§3.2) + assertEquals("CA001", next.scalars["FLNO"]) // 增量里没提的字段保留原值 assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"]) assertEquals(2, next.stateVersion) } @@ -153,6 +154,6 @@ class FlightStateEngineTest { val tooLong = FlightStateEngine.validateMessage( 1, listOf(ScheduleRecord("1234567890123", mapOf("SODT" to "15DEC261723"))), opDay, ) - assertTrue(tooLong is SnapshotValidation.Invalid) // FLID 数字型 Number(1-12) + assertTrue(tooLong is SnapshotValidation.Invalid) // FLID 只能是 1–12 位纯数字 } } diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt index a7f11ee..fbb18b7 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt @@ -12,9 +12,10 @@ import org.junit.jupiter.api.Test import java.time.Instant /** - * 历史归档与物理清除(docs/flight-state.md §6,顺序不可颠倒;design.md §6.2): - * 历史存储未接通必须删 0 条;先归档确认再物理清除; - * 未经 FDEL 的航班在清除前补发删除事件。 + * 守着归档清理的三条规矩: + * - 历史存储没接通就一条都不删; + * - 先拿到归档确认再物理删除,没确认的留着下次重试; + * - 从没收到过 FDEL(航班终止报文)就被清掉的航班,删除前要补发一次删除通知。 */ class HistorySweepJobTest { @@ -53,7 +54,7 @@ class HistorySweepJobTest { val outcome = job.run(now) - assertEquals(0, outcome.purged) // §6 红线:历史存储未接通必须删 0 条 + assertEquals(0, outcome.purged) // 历史存储没接通,一条都不许删 assertTrue(f.findMainRow("F1") != null) assertEquals(0, events.rows.size) } @@ -78,14 +79,14 @@ class HistorySweepJobTest { assertEquals(2, outcome.selected) assertEquals(1, outcome.archived) assertEquals(1, outcome.purged) - assertEquals(null, f.findMainRow("F1")) // §6:归档确认成功 → 物理删除 - assertTrue(f.findMainRow("F2") != null) // 失败或不明确的保留重试(步骤 5) - assertEquals(0, events.rows.size) // DELETED 航班清除不再发业务删除事件 + assertEquals(null, f.findMainRow("F1")) // 归档确认成功的才物理删除 + assertTrue(f.findMainRow("F2") != null) // 归档没确认的留着下次再试 + assertEquals(0, events.rows.size) // 早就标记删除的航班,清理时不用再发删除通知 } @Test fun `never-fdel lifecycle purge emits tombstone before deletion`() { - val f = seededFlight("F3", deleted = false, idleDays = 30) // ACTIVE 且超兜底窗(§6 静默判据) + val f = seededFlight("F3", deleted = false, idleDays = 30) // 还在用,但已经静默超过兜底期限,命中清理条件 val events = StubMsgEvents() val store = RecordingHistoryStore() val job = HistorySweepJob( @@ -94,7 +95,7 @@ class HistorySweepJobTest { job.run(now) - // §3.3/§5/§6:未经 FDEL、由生命周期直接清除的航班,清除前补发一次删除事件 + // 从没收到过 FDEL、被生命周期直接清掉的航班,删除前要补发一次删除通知 val tombstone = events.rows.values.single { it.eventType == EventType.TOMBSTONE } assertEquals("F3", tombstone.partitionKey) assertTrue(tombstone.payloadJson.contains("\"deleted\":true")) diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IdentityTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IdentityTest.kt index 5474c1e..92712be 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IdentityTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IdentityTest.kt @@ -9,7 +9,8 @@ import java.time.LocalDate import kotlin.test.assertEquals /** - * ACMA-8 I3:identity = SNDR|TYPE|STYP|SEQN;日边界含否集中可配(CONFIRM 矩阵 #11,默认关)。 + * 守着幂等键的规矩:发送方、类型、子类型、流水号四段用竖线拼起来;只有配置打开时才追加 + * 日期段用来按天去重,默认不追加。 */ class IdentityTest { diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/support/PgTestSupport.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/support/PgTestSupport.kt index a38d501..9789b57 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/support/PgTestSupport.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/support/PgTestSupport.kt @@ -3,13 +3,13 @@ package com.gzzn.omms.msgexchange.support import org.testcontainers.containers.PostgreSQLContainer /** - * JDBC 集成测试 PostgreSQL 连接解析(ACM2-29 P3-A)。 - * - * 优先级: - * 1. 显式环境变量 `MSGX_PG_URL` / `MSGX_PG_HOST` / `MSGX_PG_PORT` / `MSGX_PG_NAME`(外接库/CI 固定库); - * 2. Testcontainers 自动起 `postgres:17-alpine` 隔离容器(未配置环境变量且 docker 可用时; - * JVM 单例,首个用例启动后全程复用,容器随 JVM 退出由 Ryuk 清理); - * 3. 都不可用 → canConnect()=false,用例 assumeTrue 跳过(绝不误报通过)。 + * 给 JDBC 集成测试找一个能用的 PostgreSQL,按下面的顺序挑: + * 1. 环境变量显式指定(MSGX_PG_URL / MSGX_PG_HOST / MSGX_PG_PORT / MSGX_PG_NAME), + * 适合外接库或 CI 上的固定库; + * 2. 没配环境变量且 docker 可用时,用 Testcontainers 起一个 postgres:17-alpine 容器: + * JVM 内只起一个、全程复用,进程退出后由 Ryuk 清掉; + * 3. 两条路都走不通时 canConnect() 返回 false,用例里用 assumeTrue 跳过, + * 绝不因为连不上就当成通过。 * * | 变量 | 默认 | * |---|---|