package com.gzzn.omms.msgexchange.domain import java.time.Instant /** * 一条入站消息的处理记录,一行对应共享信箱 CMINMSGS 里的一条报文。 * * 这张表同时承担四件事: * 1. 防重复入队——主键是信箱 ID,同一条报文只会有一行; * 2. 防业务重复——IDENTITY_KEY 唯一,同一条业务报文只处理一次; * 3. 记录处理进度——状态、重试次数、下次重试时间和失败原因; * 4. 记录回填进度——处理完要把"已处理"标记写回共享信箱,写成功之前一直留着待办意图。 * * 第 4 条和终态写在同一条 UPDATE 里,所以不会出现"业务处理完了,却没人记得去回填"。 */ enum class ProcStatus { /** 已入队,等待处理。 */ PENDING, /** 处理失败,等退避时间到了再重试。 */ FAILED, /** 处理成功。 */ SUCCEEDED, /** 判定为业务重复,跳过不处理。 */ SKIPPED, /** 处理失败且不再重试,等人工处置。 */ DEAD, } /** 失败原因分类,决定失败后是重试还是直接进死信。 */ enum class ErrorClass { /** 报文本身不合法,重试也没用,直接进死信。 */ MALFORMED, /** 整包被拒绝(运营日冲突、声明条数不符等),整包不落地,直接进死信。 */ PROTOCOL, /** 解码逻辑的问题;修好 codec 之后可以重放。 */ CODEC_ERROR, /** 重试次数用尽(尝试上限);人工复核后可以重放。 */ EXHAUSTED, /** 数据库、网络等基础设施抖动,重试通常就能过。 */ INFRA, /** 报文类型还没有对应处理器;属于能力未实现,先退避重试等补齐。 */ UNSUPPORTED, } data class ProcState( /** 信箱 CMINMSGS_ID,也是本表主键。 */ val msgId: Long, val state: ProcStatus, /** 业务身份 SNDR|TYPE|STYP|SEQN;解码成功后绑定一次,重试不会重绑。 */ val identityKey: String? = null, /** 处理失败次数,用来算退避档位和判断是否已到上限。 */ val attempts: Int = 0, /** FAILED 状态下,下次可以重试的时刻。 */ val nextAttemptAt: Instant? = null, val errorClass: ErrorClass? = null, /** 最近一次失败的原因(截断后落库,供排查)。 */ val lastError: String? = null, /** 信箱里的接收时间,来自**库方时钟**、**可能为 NULL**;仅用于对账与展示,不作任何判据。 */ val receivedAt: Instant? = null, /** * 本地入队时间(本系统写入,非空)。超期补写期限 `R` **只比较它**:与判据用的本地 * `NOW` 同源,不受库方时钟偏斜影响(`PRE-4`)。 */ val enqueuedAt: Instant? = null, /** 非空表示已确认信箱行带上了处理标记。 */ val backfillAt: Instant? = null, /** 非空表示还欠一次回填:写终态时置为当前时间,失败后退避推后。 */ val backfillNextAt: Instant? = null, val backfillAttempts: Int = 0, val backfillError: String? = null, /** * 非空表示已判定"不必再回填":信箱行不存在,或暂时性故障持续到 `R` 仍未打标。 * * **它不等于标记已确认**:`backfillAt` 仍为空,所以不满足"边界内全部行已打标"的清除条件。 * 停止重试与"已满足清除前提"是两件事,不能互相替代。 */ val backfillAbandonedAt: Instant? = null, /** 放弃原因(`MISSING_ROW` / `TRANSIENT_DEADLINE`),供人工对账与恢复判断。 */ val backfillAbandonedReason: String? = null, val updatedAt: Instant, )