diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/ErorPayload.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/ErorPayload.kt
new file mode 100644
index 0000000..7f33b43
--- /dev/null
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/ErorPayload.kt
@@ -0,0 +1,9 @@
+package com.gzzn.omms.msgexchange.codec
+
+/** AODB EROR 回报体(SIS 3.44):指向触发错误的源出站 META。 */
+data class ErorPayload(
+ val seqs: Long,
+ val typs: String,
+ val stys: String,
+ val etex: String,
+)
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt
index 1fb9ddc..b18f411 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodec.kt
@@ -9,6 +9,7 @@ import com.gzzn.omms.msgexchange.domain.DecodedMessage
import com.gzzn.omms.msgexchange.domain.ErrorClass
import com.gzzn.omms.msgexchange.domain.MetaFields
import com.gzzn.omms.msgexchange.domain.MsgKind
+import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
import jakarta.inject.Singleton
import javax.xml.stream.XMLInputFactory
@@ -68,7 +69,14 @@ class JacksonXmlCodec : XmlCodec {
MsgKind.Fdel -> msg.flop?.let(SisWireMapper::flopPayload)
// 参考数据类别报文的正文结构随 13 类各异(ACM2-93 实装 ReferenceDataProcessor 时绑定)
is MsgKind.RefData -> null
- MsgKind.Eror -> null
+ MsgKind.Eror -> msg.eror?.let {
+ val seqs = it.seqs ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-eror-seqs"))
+ val typs = it.typs?.trim()?.takeIf { t -> t.isNotEmpty() }
+ ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-eror-typs"))
+ val stys = it.stys?.trim()?.takeIf { t -> t.isNotEmpty() }
+ ?: return DecodeResult.Err(DecodeFailure(ErrorClass.MALFORMED, "missing-eror-stys"))
+ ErorPayload(seqs, typs.uppercase(), stys.uppercase(), it.etex?.trim().orEmpty())
+ }
is MsgKind.Unsupported -> null
}
@@ -82,8 +90,36 @@ class JacksonXmlCodec : XmlCodec {
)
}
- override fun encodeRqrd(kind: String, rangeJson: String): String =
- """RQFD"""
+ override fun encodeOutboundRqfd(seqn: Long, dttm: Long): String =
+ writeOutbound(
+ SisOutboundMessageXml(
+ meta = outboundMeta("RQFD", "NONE", seqn, dttm),
+ rqfd = RqfdXml(),
+ ),
+ )
+
+ override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?): String {
+ val body = if (rtyp != null) RqrdXml(rtyp = rtyp) else RqrdXml()
+ return writeOutbound(
+ SisOutboundMessageXml(
+ meta = outboundMeta("RQRD", styp.uppercase(), seqn, dttm),
+ rqrd = body,
+ ),
+ )
+ }
+
+ private fun outboundMeta(type: String, styp: String, seqn: Long, dttm: Long) = SisMetaXml(
+ sndr = OutboundRequestKeys.SENDER,
+ seqn = seqn,
+ dttm = dttm,
+ type = type,
+ styp = styp,
+ )
+
+ private fun writeOutbound(msg: SisOutboundMessageXml): String =
+ mapper.writeValueAsString(msg).let { xml ->
+ if (xml.startsWith("$xml"""
+ }
/**
* 子类型分派必须是**白名单**:未知的 SCHD 子类型不能静默当成全量日计划(DNLD)——
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt
index 6b8a7fe..a000a87 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/SisMessageBody.kt
@@ -56,6 +56,34 @@ data class SisMessageXml(
@param:JacksonXmlProperty(localName = "META") val meta: SisMetaXml? = null,
@param:JacksonXmlProperty(localName = "SCHD") val schd: SchdXml? = null,
@param:JacksonXmlProperty(localName = "FLOP") val flop: FlightRecordXml? = null,
+ @param:JacksonXmlProperty(localName = "EROR") val eror: ErorXml? = null,
+ @param:JacksonXmlProperty(localName = "RQFD") val rqfd: RqfdXml? = null,
+ @param:JacksonXmlProperty(localName = "RQRD") val rqrd: RqrdXml? = null,
+)
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class ErorXml(
+ @param:JacksonXmlProperty(localName = "SEQS") val seqs: Long? = null,
+ @param:JacksonXmlProperty(localName = "TYPS") val typs: String? = null,
+ @param:JacksonXmlProperty(localName = "STYS") val stys: String? = null,
+ @param:JacksonXmlProperty(localName = "ETEX") val etex: String? = null,
+)
+
+/** 出站 RQFD 空体占位(C-4 全量不带 STDB/STDE)。 */
+@JsonIgnoreProperties(ignoreUnknown = true)
+class RqfdXml
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class RqrdXml(
+ @param:JacksonXmlProperty(localName = "RTYP") val rtyp: String? = null,
+)
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JacksonXmlRootElement(localName = "MSG")
+data class SisOutboundMessageXml(
+ @param:JacksonXmlProperty(localName = "META") val meta: SisMetaXml,
+ @param:JacksonXmlProperty(localName = "RQFD") val rqfd: RqfdXml? = null,
+ @param:JacksonXmlProperty(localName = "RQRD") val rqrd: RqrdXml? = null,
)
@JsonIgnoreProperties(ignoreUnknown = true)
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 156ecee..e55fc9a 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/codec/XmlCodec.kt
@@ -21,5 +21,10 @@ sealed interface DecodeResult {
*/
interface XmlCodec {
fun decode(rawXml: String): DecodeResult
- fun encodeRqrd(kind: String, rangeJson: String): String
+
+ /** C-4:RQFD STYP=NONE,空 RQFD 体。 */
+ fun encodeOutboundRqfd(seqn: Long, dttm: Long): String
+
+ /** C-4:RQRD,STYP 为参考数据子类型;RSTA 时可带 RTYP。 */
+ fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String? = null): String
}
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 556829e..ab7a5a8 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/config/PipelineProps.kt
@@ -15,6 +15,7 @@ class PipelineProps {
var registerEureka: Boolean = true
var pipeline: Pipeline = Pipeline()
var schd: Schd = Schd()
+ var outbound: Outbound = Outbound()
var identity: Identity = Identity()
@ConfigurationProperties("pipeline")
@@ -115,6 +116,15 @@ class PipelineProps {
var snapshotBatch: Int = 200
}
+ @ConfigurationProperties("outbound")
+ class Outbound {
+ /** 出站请求落信后等待应答的最长时限(`US-09`;具体取值待 Q)。 */
+ var responseTimeout: Duration = Duration.ofMinutes(30)
+
+ var routingRqfd: String = "OMMSRQFD"
+ var routingRqrd: String = "OMMSRQRD"
+ }
+
@ConfigurationProperties("identity")
class Identity {
/** CONFIRM(矩阵 #11):SEQN 重置作用域确认前保持 false,计算集中此处(I3)。 */
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/domain/OutboundRequestKeys.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/OutboundRequestKeys.kt
new file mode 100644
index 0000000..b1fba6f
--- /dev/null
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/domain/OutboundRequestKeys.kt
@@ -0,0 +1,15 @@
+package com.gzzn.omms.msgexchange.domain
+
+/** 出站请求在 REQ_TRACK 与 C-4 编码中的稳定键。 */
+object OutboundRequestKeys {
+ const val SENDER = "OMMS"
+ const val RQFD_REQ_TYPE = "RQFD-NONE"
+ const val RQRD_REQ_TYPE = "RQRD"
+
+ /** EROR 体 TYPS/STYS → REQ_TRACK.req_type;无法配对时 null。 */
+ fun reqTypeFromEror(typs: String, stys: String): String? = when (typs.uppercase()) {
+ "RQFD" -> if (stys.uppercase() == "NONE") RQFD_REQ_TYPE else null
+ "RQRD" -> RQRD_REQ_TYPE
+ else -> null
+ }
+}
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 399c094..f27f0d4 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
@@ -306,7 +306,7 @@ interface SnapshotLogRepository {
* 同一类请求同时只保留一条有效,发新请求时把旧的置为已过期。
*/
interface ReqTrackRepository {
- enum class ReqState { PENDING, SENT, DONE, EXPIRED }
+ enum class ReqState { PENDING, SENT, DONE, EXPIRED, FAILED }
data class Req(
val reqId: Long,
@@ -315,7 +315,10 @@ interface ReqTrackRepository {
val sender: String,
val state: ReqState,
val coutmsgsId: Long? = null,
+ val outboundSeqn: Long? = null,
+ val writeUncertain: Boolean = false,
val sentAt: Instant? = null,
+ val createdAt: Instant? = null,
)
/** 登记新请求:同类(类型+运营日+发送方)旧有效请求先置 EXPIRED。 */
@@ -329,11 +332,35 @@ interface ReqTrackRepository {
*/
fun completeLatest(reqType: String, operationDay: LocalDate, sender: String): Boolean
- fun linkCoutmsgs(reqId: Long, coutmsgsId: Long)
+ fun linkCoutmsgs(reqId: Long, coutmsgsId: Long, outboundSeqn: Long)
fun markSent(reqId: Long, sentAt: Instant)
fun expire(reqId: Long)
+
+ fun markFailed(reqId: Long)
+
+ fun markWriteUncertain(reqId: Long)
+
+ /** 待落信:PENDING 且未标记 write_uncertain。 */
+ fun listPendingDispatch(limit: Int): List
+
+ /** 已落信等待应答且超过时限:标记 EXPIRED 并返回条数。 */
+ fun expireSentOlderThan(cutoff: Instant): Int
+
+ /** EROR:按出站 SEQN + 请求族匹配开放 SENT。 */
+ fun findOpenSentBySeqn(reqType: String, operationDay: LocalDate, sender: String, outboundSeqn: Long): Req?
+}
+
+/** 共享 MySQL COUTMSGS 出站信箱(只写、不改表)。 */
+interface CoutmsgOutboxRepository {
+ enum class InsertOutcome { CONFIRMED, AMBIGUOUS }
+
+ data class InsertResult(val outcome: InsertOutcome, val coutmsgsId: Long?)
+
+ fun nextOutboundSeqn(): Long
+
+ fun insertMessage(rawXml: String, routingId: String): InsertResult
}
/**
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcCoutmsgOutboxRepository.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcCoutmsgOutboxRepository.kt
new file mode 100644
index 0000000..2c57405
--- /dev/null
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcCoutmsgOutboxRepository.kt
@@ -0,0 +1,42 @@
+package com.gzzn.omms.msgexchange.infra.persistence.jdbc
+
+import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
+import io.micronaut.context.annotation.Requires
+import jakarta.inject.Named
+import jakarta.inject.Singleton
+import javax.sql.DataSource
+
+@Singleton
+@Requires(property = "msgx.stubs", notEquals = "true")
+@Requires(property = "mailbox.shared-mysql.enabled", value = "true")
+class JdbcCoutmsgOutboxRepository(
+ @Named("mailbox") private val ds: DataSource,
+) : CoutmsgOutboxRepository {
+
+ override fun nextOutboundSeqn(): Long =
+ ds.queryOne("SELECT COALESCE(MAX(COUTMSGS_ID), 0) + 1 FROM coutmsgs", { _ -> }) { rs ->
+ rs.getLong(1)
+ } ?: 1L
+
+ override fun insertMessage(rawXml: String, routingId: String): CoutmsgOutboxRepository.InsertResult =
+ try {
+ val id = ds.updateReturningLong(
+ """
+ INSERT INTO coutmsgs (COUTMSGS_CLOB_MSG, COUTMSGS_DATE_INSERTED, ROUTINGID)
+ VALUES (?, CURRENT_TIMESTAMP, ?)
+ """.trimIndent(),
+ ) { ps ->
+ ps.setString(1, rawXml)
+ ps.setString(2, routingId)
+ }
+ CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.CONFIRMED, id)
+ } catch (e: IllegalStateException) {
+ CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS, null)
+ } catch (e: Exception) {
+ if (e.cause is IllegalStateException) {
+ CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS, null)
+ } else {
+ throw e
+ }
+ }
+}
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt
index eb5b017..29e91cd 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/JdbcPgRepositories.kt
@@ -961,8 +961,18 @@ class JdbcReqTrackRepository(
private val ds: DataSource,
private val clock: Clock,
) : ReqTrackRepository {
- override fun insert(reqType: String, operationDay: LocalDate, sender: String): Long =
- ds.updateReturningLong(
+ override fun insert(reqType: String, operationDay: LocalDate, sender: String): Long {
+ ds.update(
+ """
+ UPDATE req_track SET state = 'EXPIRED'
+ WHERE req_type = ? AND operation_day = ? AND sender = ? AND state IN ('PENDING', 'SENT')
+ """.trimIndent(),
+ ) { ps ->
+ ps.setString(1, reqType)
+ ps.setDate(2, java.sql.Date.valueOf(operationDay))
+ ps.setString(3, sender)
+ }
+ return ds.updateReturningLong(
"INSERT INTO req_track (req_type, operation_day, sender, state, created_at) VALUES (?, ?, ?, 'PENDING', ?) RETURNING req_id",
{ ps ->
ps.setString(1, reqType)
@@ -971,6 +981,7 @@ class JdbcReqTrackRepository(
ps.setTimestamp(4, clock.instant().toSqlTimestamp())
},
)
+ }
override fun findLatest(
reqType: String,
@@ -1015,8 +1026,15 @@ class JdbcReqTrackRepository(
},
) == 1
- override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) {
- ds.update("UPDATE req_track SET coutmsgs_id = ? WHERE req_id = ?", { ps -> ps.setLong(1, coutmsgsId); ps.setLong(2, reqId) })
+ override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long, outboundSeqn: Long) {
+ ds.update(
+ "UPDATE req_track SET coutmsgs_id = ?, outbound_seqn = ? WHERE req_id = ?",
+ { ps ->
+ ps.setLong(1, coutmsgsId)
+ ps.setLong(2, outboundSeqn)
+ ps.setLong(3, reqId)
+ },
+ )
}
override fun markSent(reqId: Long, sentAt: Instant) {
@@ -1030,6 +1048,62 @@ class JdbcReqTrackRepository(
ds.update("UPDATE req_track SET state = 'EXPIRED' WHERE req_id = ?", { ps -> ps.setLong(1, reqId) })
}
+ override fun markFailed(reqId: Long) {
+ ds.update(
+ "UPDATE req_track SET state = 'FAILED', completed_at = ? WHERE req_id = ?",
+ { ps ->
+ ps.setTimestamp(1, clock.instant().toSqlTimestamp())
+ ps.setLong(2, reqId)
+ },
+ )
+ }
+
+ override fun markWriteUncertain(reqId: Long) {
+ ds.update("UPDATE req_track SET write_uncertain = TRUE WHERE req_id = ?", { ps -> ps.setLong(1, reqId) })
+ }
+
+ override fun listPendingDispatch(limit: Int): List =
+ ds.query(
+ """
+ SELECT * FROM req_track
+ WHERE state = 'PENDING' AND write_uncertain = FALSE
+ ORDER BY created_at ASC, req_id ASC
+ LIMIT ?
+ """.trimIndent(),
+ { ps -> ps.setInt(1, limit) },
+ ::mapReq,
+ )
+
+ override fun expireSentOlderThan(cutoff: Instant): Int =
+ ds.update(
+ """
+ UPDATE req_track SET state = 'EXPIRED'
+ WHERE state = 'SENT' AND sent_at IS NOT NULL AND sent_at < ?
+ """.trimIndent(),
+ ) { ps -> ps.setTimestamp(1, cutoff.toSqlTimestamp()) }
+
+ override fun findOpenSentBySeqn(
+ reqType: String,
+ operationDay: LocalDate,
+ sender: String,
+ outboundSeqn: Long,
+ ): ReqTrackRepository.Req? =
+ ds.queryOne(
+ """
+ SELECT * FROM req_track
+ WHERE req_type = ? AND operation_day = ? AND sender = ?
+ AND state = 'SENT' AND outbound_seqn = ?
+ ORDER BY created_at DESC, req_id DESC LIMIT 1
+ """.trimIndent(),
+ { ps ->
+ ps.setString(1, reqType)
+ ps.setDate(2, java.sql.Date.valueOf(operationDay))
+ ps.setString(3, sender)
+ ps.setLong(4, outboundSeqn)
+ },
+ ::mapReq,
+ )
+
private fun mapReq(rs: ResultSet) = ReqTrackRepository.Req(
reqId = rs.getLong("req_id"),
reqType = rs.getString("req_type"),
@@ -1037,6 +1111,9 @@ class JdbcReqTrackRepository(
sender = rs.getString("sender"),
state = ReqTrackRepository.ReqState.valueOf(rs.getString("state")),
coutmsgsId = rs.getLong("coutmsgs_id").takeIf { !rs.wasNull() },
+ outboundSeqn = rs.getLong("outbound_seqn").takeIf { !rs.wasNull() },
+ writeUncertain = rs.getBoolean("write_uncertain"),
sentAt = rs.getInstant("sent_at"),
+ createdAt = rs.getInstant("created_at"),
)
}
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubRepositories.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubRepositories.kt
index a9b7dfd..b211210 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubRepositories.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/infra/stub/StubRepositories.kt
@@ -24,6 +24,7 @@ import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineLockRepository
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
+import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
import com.gzzn.omms.msgexchange.domain.SnapshotLogEntry
import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogRepository
@@ -464,7 +465,7 @@ class StubSnapshotLog : SnapshotLogRepository, SnapshotLogPurge {
@Singleton
@Requires(property = "msgx.stubs", value = "true")
-class StubReqTrack : ReqTrackRepository {
+class StubReqTrack(private val clock: Clock = Clock.systemUTC()) : ReqTrackRepository {
val rows = linkedMapOf()
private val ids = AtomicLong(0)
@@ -477,7 +478,10 @@ class StubReqTrack : ReqTrackRepository {
(it.state == ReqTrackRepository.ReqState.PENDING || it.state == ReqTrackRepository.ReqState.SENT)
}.forEach { rows[it.reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED) }
val id = ids.incrementAndGet()
- rows[id] = ReqTrackRepository.Req(id, reqType, operationDay, sender, ReqTrackRepository.ReqState.PENDING)
+ rows[id] = ReqTrackRepository.Req(
+ id, reqType, operationDay, sender, ReqTrackRepository.ReqState.PENDING,
+ createdAt = clock.instant(),
+ )
return id
}
@@ -498,8 +502,8 @@ class StubReqTrack : ReqTrackRepository {
return true
}
- override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long) {
- rows[reqId]?.let { rows[reqId] = it.copy(coutmsgsId = coutmsgsId) }
+ override fun linkCoutmsgs(reqId: Long, coutmsgsId: Long, outboundSeqn: Long) {
+ rows[reqId]?.let { rows[reqId] = it.copy(coutmsgsId = coutmsgsId, outboundSeqn = outboundSeqn) }
}
override fun markSent(reqId: Long, sentAt: Instant) {
@@ -509,6 +513,65 @@ class StubReqTrack : ReqTrackRepository {
override fun expire(reqId: Long) {
rows[reqId]?.let { rows[reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED) }
}
+
+ override fun markFailed(reqId: Long) {
+ rows[reqId]?.let { rows[reqId] = it.copy(state = ReqTrackRepository.ReqState.FAILED) }
+ }
+
+ override fun markWriteUncertain(reqId: Long) {
+ rows[reqId]?.let { rows[reqId] = it.copy(writeUncertain = true) }
+ }
+
+ override fun listPendingDispatch(limit: Int): List =
+ rows.values.filter { it.state == ReqTrackRepository.ReqState.PENDING && !it.writeUncertain }
+ .sortedWith(compareBy({ it.createdAt }, { it.reqId }))
+ .take(limit)
+
+ override fun expireSentOlderThan(cutoff: Instant): Int {
+ var n = 0
+ rows.values.filter { it.state == ReqTrackRepository.ReqState.SENT && it.sentAt != null && it.sentAt!! < cutoff }
+ .forEach {
+ rows[it.reqId] = it.copy(state = ReqTrackRepository.ReqState.EXPIRED)
+ n++
+ }
+ return n
+ }
+
+ override fun findOpenSentBySeqn(
+ reqType: String,
+ operationDay: LocalDate,
+ sender: String,
+ outboundSeqn: Long,
+ ): ReqTrackRepository.Req? =
+ rows.values.filter {
+ it.reqType == reqType && it.operationDay == operationDay && it.sender == sender &&
+ it.state == ReqTrackRepository.ReqState.SENT && it.outboundSeqn == outboundSeqn
+ }.maxByOrNull { it.reqId }
+}
+
+@Singleton
+@Requires(property = "msgx.stubs", value = "true")
+class StubCoutmsgOutbox : CoutmsgOutboxRepository {
+ val messages = linkedMapOf()
+ private val ids = AtomicLong(0)
+
+ fun clear() {
+ messages.clear()
+ ids.set(0)
+ }
+
+ var nextInsertOutcome: CoutmsgOutboxRepository.InsertOutcome = CoutmsgOutboxRepository.InsertOutcome.CONFIRMED
+
+ override fun nextOutboundSeqn(): Long = (messages.keys.maxOrNull() ?: 0L) + 1L
+
+ override fun insertMessage(rawXml: String, routingId: String): CoutmsgOutboxRepository.InsertResult {
+ if (nextInsertOutcome == CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS) {
+ return CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS, null)
+ }
+ val id = ids.incrementAndGet()
+ messages[id] = rawXml
+ return CoutmsgOutboxRepository.InsertResult(CoutmsgOutboxRepository.InsertOutcome.CONFIRMED, id)
+ }
}
/** 内存版共享信箱:可以模拟上游写入、库方清除,并记录哪些行被打上了处理标记。 */
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 8fc7588..3bb0e88 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxController.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/InboxController.kt
@@ -13,7 +13,10 @@ import io.micronaut.http.annotation.Produces
* 生产上收报走的是轮询共享信箱表 CMINMSGS(InboxPoller),不是这个 Controller。
*/
@Controller
-class InboxController(private val inbox: InboxService) {
+class InboxController(
+ private val inbox: InboxService,
+ private val schdSync: SchdSyncService,
+) {
@Post("/cminmsgs/send")
@Produces(MediaType.TEXT_PLAIN)
@@ -22,5 +25,14 @@ class InboxController(private val inbox: InboxService) {
return HttpResponse.ok(receipt.msgId.toString()) // TODO: 先返回消息 ID 文本,等跟现役响应体逐字对拍过再定稿
}
- // TODO(阶段2): 按契约清单补齐 /schd/sync、/kafka/topics/{name}/msgs 这两个接口。
+ /** C-8:登记 RQFD 出站请求;开放槽占用时 409,不重复登记。 */
+ @Post("/schd/sync")
+ @Produces(MediaType.TEXT_PLAIN)
+ fun schdSync(): HttpResponse =
+ when (val outcome = schdSync.trigger()) {
+ is SchdSyncService.Outcome.Registered ->
+ HttpResponse.ok(outcome.reqId.toString())
+ SchdSyncService.Outcome.OpenExists ->
+ HttpResponse.status(io.micronaut.http.HttpStatus.CONFLICT).body("open-request-exists")
+ }
}
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/SchdSyncService.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/SchdSyncService.kt
new file mode 100644
index 0000000..e323c0d
--- /dev/null
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/ingress/SchdSyncService.kt
@@ -0,0 +1,18 @@
+package com.gzzn.omms.msgexchange.ingress
+
+import com.gzzn.omms.msgexchange.processing.OutboundRequestService
+import jakarta.inject.Singleton
+
+@Singleton
+class SchdSyncService(private val outbound: OutboundRequestService) {
+ sealed interface Outcome {
+ data class Registered(val reqId: Long) : Outcome
+ data object OpenExists : Outcome
+ }
+
+ fun trigger(): Outcome =
+ when (val r = outbound.registerRqfdSync()) {
+ is OutboundRequestService.RegisterOutcome.Registered -> Outcome.Registered(r.reqId)
+ OutboundRequestService.RegisterOutcome.OpenExists -> Outcome.OpenExists
+ }
+}
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunner.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunner.kt
index 181f4ca..df42681 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunner.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunner.kt
@@ -26,6 +26,7 @@ class JobRunner(
private val historySweep: HistorySweepJob,
private val eventCleanup: EventCleanupJob,
private val procStateCleanup: ProcStateCleanupJob,
+ private val reqTrackMaintenance: ReqTrackMaintenanceJob,
private val clock: Clock,
private val activity: JobActivity,
private val props: PipelineProps,
@@ -87,6 +88,10 @@ class JobRunner(
if (procCleanup.deleted > 0) {
log.info("proc_state cleanup: deleted {} expired terminal rows", procCleanup.deleted)
}
+ val reqTrackOutcome = reqTrackMaintenance.run()
+ if (reqTrackOutcome.dispatched > 0 || reqTrackOutcome.expired > 0) {
+ log.info("req_track maintenance: dispatched={} expired={}", reqTrackOutcome.dispatched, reqTrackOutcome.expired)
+ }
activity.tickFinished(startedAt, (System.nanoTime() - startedNanos) / 1_000_000, selected)
} catch (e: InterruptedException) {
throw e
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/ReqTrackMaintenanceJob.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/ReqTrackMaintenanceJob.kt
new file mode 100644
index 0000000..b9d625e
--- /dev/null
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/ReqTrackMaintenanceJob.kt
@@ -0,0 +1,15 @@
+package com.gzzn.omms.msgexchange.jobs
+
+import com.gzzn.omms.msgexchange.processing.OutboundRequestService
+import jakarta.inject.Singleton
+
+/** 出站请求:补偿落信与应答超时(`G-REQ-TRACK`)。 */
+@Singleton
+class ReqTrackMaintenanceJob(private val outbound: OutboundRequestService) {
+ data class Outcome(val dispatched: Int, val expired: Int)
+
+ fun run(): Outcome = Outcome(
+ dispatched = outbound.dispatchPending(limit = 20),
+ expired = outbound.expireTimedOut(),
+ )
+}
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/OutboundRequestService.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/OutboundRequestService.kt
new file mode 100644
index 0000000..3a246ec
--- /dev/null
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/OutboundRequestService.kt
@@ -0,0 +1,137 @@
+package com.gzzn.omms.msgexchange.processing
+
+import com.gzzn.omms.msgexchange.codec.XmlCodec
+import com.gzzn.omms.msgexchange.config.OperationDayProps
+import com.gzzn.omms.msgexchange.config.PipelineProps
+import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
+import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
+import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
+import jakarta.inject.Singleton
+import org.slf4j.LoggerFactory
+import java.time.Clock
+import java.time.LocalDate
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+/**
+ * 出站 REQ_TRACK 协调:登记、COUTMSGS 落信、超时与 EROR 失败(`G-REQ-TRACK`、`C-4`)。
+ */
+@Singleton
+class OutboundRequestService(
+ private val reqTrack: ReqTrackRepository,
+ private val outbox: CoutmsgOutboxRepository,
+ private val codec: XmlCodec,
+ private val props: PipelineProps,
+ operationDayProps: OperationDayProps,
+ private val clock: Clock,
+) {
+ private val log = LoggerFactory.getLogger(OutboundRequestService::class.java)
+ private val zone: ZoneId = operationDayProps.zoneId()
+ private val cutoffHour = operationDayProps.cutoffHour.coerceIn(0, 23)
+ private val dttmFmt = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
+
+ sealed interface RegisterOutcome {
+ data class Registered(val reqId: Long) : RegisterOutcome
+ data object OpenExists : RegisterOutcome
+ }
+
+ fun currentOperationDay(): LocalDate {
+ val airport = clock.withZone(zone)
+ val now = java.time.LocalDateTime.now(airport)
+ val day = now.toLocalDate()
+ return if (now.hour < cutoffHour) day.minusDays(1) else day
+ }
+
+ fun hasOpenRequest(reqType: String, operationDay: LocalDate = currentOperationDay()): Boolean =
+ reqTrack.findLatest(
+ reqType,
+ operationDay,
+ OutboundRequestKeys.SENDER,
+ listOf(ReqTrackRepository.ReqState.PENDING, ReqTrackRepository.ReqState.SENT),
+ ) != null
+
+ fun registerRqfdSync(): RegisterOutcome {
+ val day = currentOperationDay()
+ if (hasOpenRequest(OutboundRequestKeys.RQFD_REQ_TYPE, day)) return RegisterOutcome.OpenExists
+ val reqId = reqTrack.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
+ dispatchPending(limit = 1)
+ return RegisterOutcome.Registered(reqId)
+ }
+
+ fun hasOpenSentRqfd(operationDay: LocalDate = currentOperationDay()): Boolean =
+ reqTrack.findLatest(
+ OutboundRequestKeys.RQFD_REQ_TYPE,
+ operationDay,
+ OutboundRequestKeys.SENDER,
+ listOf(ReqTrackRepository.ReqState.SENT),
+ ) != null
+
+ fun completeRqfdResponse(operationDay: LocalDate = currentOperationDay()): Boolean =
+ reqTrack.completeLatest(OutboundRequestKeys.RQFD_REQ_TYPE, operationDay, OutboundRequestKeys.SENDER)
+
+ fun dispatchPending(limit: Int = 10): Int {
+ var sent = 0
+ reqTrack.listPendingDispatch(limit).forEach { req ->
+ if (dispatchOne(req)) sent++
+ }
+ return sent
+ }
+
+ fun expireTimedOut(): Int {
+ val cutoff = clock.instant().minus(props.outbound.responseTimeout)
+ return reqTrack.expireSentOlderThan(cutoff)
+ }
+
+ fun failFromEror(seqs: Long, typs: String, stys: String): Boolean {
+ val reqType = OutboundRequestKeys.reqTypeFromEror(typs, stys) ?: return false
+ val day = currentOperationDay()
+ val open = reqTrack.findOpenSentBySeqn(reqType, day, OutboundRequestKeys.SENDER, seqs) ?: return false
+ reqTrack.markFailed(open.reqId)
+ log.warn(
+ "outbound request FAILED from EROR reqId={} type={} seqn={} typs={} stys={}",
+ open.reqId, reqType, seqs, typs, stys,
+ )
+ return true
+ }
+
+ private fun dispatchOne(req: ReqTrackRepository.Req): Boolean {
+ if (req.state != ReqTrackRepository.ReqState.PENDING || req.writeUncertain) return false
+ val seqn = outbox.nextOutboundSeqn()
+ val dttm = beijingMetaDttm()
+ val xml = when (req.reqType) {
+ OutboundRequestKeys.RQFD_REQ_TYPE -> codec.encodeOutboundRqfd(seqn, dttm)
+ OutboundRequestKeys.RQRD_REQ_TYPE -> codec.encodeOutboundRqrd("AIRL", seqn, dttm)
+ else -> {
+ log.warn("unknown req_type for dispatch reqId={} type={}", req.reqId, req.reqType)
+ return false
+ }
+ }
+ val routing = when (req.reqType) {
+ OutboundRequestKeys.RQFD_REQ_TYPE -> props.outbound.routingRqfd
+ else -> props.outbound.routingRqrd
+ }
+ val inserted = outbox.insertMessage(xml, routing)
+ return when (inserted.outcome) {
+ CoutmsgOutboxRepository.InsertOutcome.CONFIRMED -> {
+ val id = inserted.coutmsgsId ?: return false
+ reqTrack.linkCoutmsgs(req.reqId, id, seqn)
+ reqTrack.markSent(req.reqId, clock.instant())
+ log.info("outbound COUTMSGS SENT reqId={} coutmsgsId={} seqn={}", req.reqId, id, seqn)
+ true
+ }
+ CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS -> {
+ reqTrack.markWriteUncertain(req.reqId)
+ log.error(
+ "outbound COUTMSGS write AMBIGUOUS reqId={} seqn={} — recorded, no blind resend",
+ req.reqId, seqn,
+ )
+ false
+ }
+ }
+ }
+
+ private fun beijingMetaDttm(): Long {
+ val ldt = java.time.LocalDateTime.ofInstant(clock.instant(), zone)
+ return dttmFmt.format(ldt).toLong()
+ }
+}
diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt
index 6de640b..9a45f0d 100644
--- a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt
+++ b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/Pump.kt
@@ -1,5 +1,6 @@
package com.gzzn.omms.msgexchange.processing
+import com.gzzn.omms.msgexchange.codec.ErorPayload
import com.gzzn.omms.msgexchange.codec.FlopPayload
import com.gzzn.omms.msgexchange.codec.ScheduleBody
import com.gzzn.omms.msgexchange.codec.XmlCodec
@@ -123,6 +124,7 @@ class MessageProcessor(
private val flopProcessor: FlopProcessor,
private val fdelProcessor: FdelProcessor,
private val adftProcessor: AdftProcessor,
+ private val outbound: OutboundRequestService,
private val procFailure: ProcFailure,
private val props: PipelineProps,
private val clock: Clock,
@@ -201,8 +203,24 @@ class MessageProcessor(
val body = decoded.body as? ScheduleBody
if (body == null) return deadMalformed(head, "missing-schd-body")
when (kind.subtype) {
- MsgKind.SchdSubtype.DNLD, MsgKind.SchdSubtype.RESP ->
+ MsgKind.SchdSubtype.DNLD ->
scheduleProcessor.applyScheduleRecords(head, decoded)
+ MsgKind.SchdSubtype.RESP -> {
+ if (!outbound.hasOpenSentRqfd()) {
+ log.info("SCHD-RESP without open RQFD -> SKIPPED msgId={} [G-RESP-GUARD]", head.msgId)
+ procState.markTerminal(
+ head.msgId, ProcStatus.SKIPPED,
+ lastError = "resp-guard:no-open-req",
+ now = clock.instant(),
+ )
+ return
+ }
+ val respResult = scheduleProcessor.applyScheduleRecords(head, decoded)
+ if (respResult is ApplyResult.Succeeded || respResult is ApplyResult.ReplaySkipped) {
+ outbound.completeRqfdResponse()
+ }
+ respResult
+ }
MsgKind.SchdSubtype.ADFT -> {
val record = body.records.singleOrNull()
if (record == null) return deadMalformed(head, "adft-needs-single-fltr")
@@ -227,9 +245,20 @@ class MessageProcessor(
return procFailure.fail(head, ErrorClass.UNSUPPORTED, "refdata-pending:${kind.type}")
}
MsgKind.Eror -> {
- // US-09 AC3:须匹配出站请求;REQ_TRACK 协调器落地前(ACM2-92)先按可重试失败留队。
- log.warn("eror without handler -> FAILED(UNSUPPORTED) msgId={}", head.msgId)
- return procFailure.fail(head, ErrorClass.UNSUPPORTED, "eror-pending")
+ val payload = decoded.body as? ErorPayload
+ ?: return deadMalformed(head, "missing-eror-body")
+ val matched = outbound.failFromEror(payload.seqs, payload.typs, payload.stys)
+ if (!matched) {
+ log.info("EROR without matching open outbound -> SKIPPED msgId={}", head.msgId)
+ procState.markTerminal(
+ head.msgId, ProcStatus.SKIPPED,
+ lastError = "eror:no-matching-req",
+ now = clock.instant(),
+ )
+ } else {
+ procState.markTerminal(head.msgId, ProcStatus.SUCCEEDED, now = clock.instant())
+ }
+ return
}
is MsgKind.Unsupported -> {
// 合法但不支持的类型:跳过留档记 SKIPPED(US-03 AC2;markTerminal 同一条 UPDATE
diff --git a/src/main/resources/db/migration/V4__req_track_outbound_seqn.sql b/src/main/resources/db/migration/V4__req_track_outbound_seqn.sql
new file mode 100644
index 0000000..2fce20a
--- /dev/null
+++ b/src/main/resources/db/migration/V4__req_track_outbound_seqn.sql
@@ -0,0 +1,3 @@
+-- REQ_TRACK:出站 SEQN(EROR 配对)与落信结果不明标记(不盲重发)。
+ALTER TABLE req_track ADD COLUMN IF NOT EXISTS outbound_seqn BIGINT;
+ALTER TABLE req_track ADD COLUMN IF NOT EXISTS write_uncertain BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt
index 9a8b54a..c6fe0f5 100644
--- a/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt
+++ b/src/test/kotlin/com/gzzn/omms/msgexchange/codec/JacksonXmlCodecTest.kt
@@ -16,6 +16,22 @@ class JacksonXmlCodecTest {
private val codec = JacksonXmlCodec()
+ @Test
+ fun `encode outbound RQFD uses OMMS meta and empty body per C-4`() {
+ val xml = codec.encodeOutboundRqfd(1243L, 20021010090311L)
+ assertTrue(xml.contains("OMMS"))
+ assertTrue(xml.contains("RQFD"))
+ assertTrue(xml.contains("NONE"))
+ assertTrue(xml.contains("RQRD"))
+ assertTrue(xml.contains("AIRL"))
+ }
+
@Test
fun `decode AODBGTDT sample extracts META and three gates`() {
val raw = """
diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunnerTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunnerTest.kt
index 57dabca..7d4f9d0 100644
--- a/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunnerTest.kt
+++ b/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunnerTest.kt
@@ -14,8 +14,12 @@ import com.gzzn.omms.msgexchange.infra.stub.StubInbox
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineLock
import com.gzzn.omms.msgexchange.infra.stub.StubPipelineTx
+import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
+import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
+import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.processing.BackfillService
+import com.gzzn.omms.msgexchange.processing.OutboundRequestService
import com.gzzn.omms.msgexchange.processing.MessageLifecycleGate
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
@@ -36,10 +40,17 @@ class JobRunnerTest {
OperationDayProps(), StubPipelineTx(), StubPipelineLock(),
)
+ private fun reqTrackJob() = ReqTrackMaintenanceJob(
+ OutboundRequestService(
+ StubReqTrack(clock), StubCoutmsgOutbox(), JacksonXmlCodec(), props, OperationDayProps(), clock,
+ ),
+ )
+
private fun runner(proc: ProcStateRepository, activity: JobActivity) = JobRunner(
BackfillService(proc, StubInbox(), MailboxProps(), props, clock, MessageLifecycleGate()),
historySweep(), EventCleanupJob(StubMsgEvents(), props),
ProcStateCleanupJob(proc, MessageLifecycleGate(), props),
+ reqTrackJob(),
clock, activity, props, OperationDayProps(),
)
diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/FlightCommitTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/FlightCommitTest.kt
index b33898c..c0c2734 100644
--- a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/FlightCommitTest.kt
+++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/FlightCommitTest.kt
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.MutableClock
import com.gzzn.omms.msgexchange.codec.DecodeResult
import com.gzzn.omms.msgexchange.codec.FlopPayload
+import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
import com.gzzn.omms.msgexchange.codec.XmlCodec
import com.gzzn.omms.msgexchange.config.OperationDayProps
import com.gzzn.omms.msgexchange.config.PipelineProps
@@ -205,6 +206,14 @@ class FlightCommitTest {
flopProcessor = FlopProcessor(commit, f.flights, f.events, mapper, clock),
fdelProcessor = FdelProcessor(commit, f.flights, f.events, mapper, clock),
adftProcessor = AdftProcessor(commit, f.flights, f.events, opDay, mapper, clock),
+ outbound = OutboundRequestService(
+ com.gzzn.omms.msgexchange.infra.stub.StubReqTrack(clock),
+ com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox(),
+ JacksonXmlCodec(),
+ props,
+ opDay,
+ clock,
+ ),
procFailure = ProcFailure(f.procState, FailureScheduler(props, clock)),
props = props,
clock = clock,
@@ -228,7 +237,8 @@ class FlightCommitTest {
private fun codecReturning(decoded: DecodedMessage) = object : XmlCodec {
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(decoded)
- override fun encodeRqrd(kind: String, rangeJson: String): String = ""
+ override fun encodeOutboundRqfd(seqn: Long, dttm: Long): String = ""
+ override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?): String = ""
}
private class TracingTx(private val trace: MutableList) : PipelineTransactionManager {
diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IgnoreBranchTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IgnoreBranchTest.kt
index 3f5c5cf..0db742d 100644
--- a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IgnoreBranchTest.kt
+++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/IgnoreBranchTest.kt
@@ -16,7 +16,12 @@ import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
+import com.gzzn.omms.msgexchange.codec.ErorPayload
+import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
+import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
import com.gzzn.omms.msgexchange.infra.stub.StubInbox
+import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
+import com.gzzn.omms.msgexchange.processing.OutboundRequestService
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
@@ -47,7 +52,8 @@ class IgnoreBranchTest {
private fun codecReturning(msg: DecodedMessage) = object : XmlCodec {
override fun decode(rawXml: String): DecodeResult = DecodeResult.Ok(msg)
- override fun encodeRqrd(kind: String, rangeJson: String): String = ""
+ override fun encodeOutboundRqfd(seqn: Long, dttm: Long): String = ""
+ override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?): String = ""
}
private fun processor(
@@ -72,6 +78,9 @@ class IgnoreBranchTest {
flopProcessor = FlopProcessor(commit, flights, events, ObjectMapper(), clock),
fdelProcessor = FdelProcessor(commit, flights, events, ObjectMapper(), clock),
adftProcessor = AdftProcessor(commit, flights, events, opDay, ObjectMapper(), clock),
+ outbound = OutboundRequestService(
+ StubReqTrack(clock), StubCoutmsgOutbox(), JacksonXmlCodec(), props, opDay, clock,
+ ),
procFailure = procFailure,
props = props,
clock = clock,
@@ -103,15 +112,17 @@ class IgnoreBranchTest {
proc.insertIfAbsent(msgId, null)
val inbox = StubInbox()
inbox.raws[msgId] = ""
- val msg = decoded("EROR", "GEN").copy(kind = MsgKind.Eror)
+ val msg = decoded("EROR", "NONE").copy(
+ kind = MsgKind.Eror,
+ body = ErorPayload(99L, "RQFD", "NONE", "test"),
+ )
val p = processor(proc = proc, inbox = inbox, codec = codecReturning(msg))
p.processOne(head())
val row = proc.find(msgId)!!
- assertEquals(ProcStatus.FAILED, row.state)
- assertEquals(ErrorClass.UNSUPPORTED, row.errorClass)
- assertEquals("eror-pending", row.lastError)
+ assertEquals(ProcStatus.SKIPPED, row.state)
+ assertEquals("eror:no-matching-req", row.lastError)
}
@Test
diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/OutboundRequestTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/OutboundRequestTest.kt
new file mode 100644
index 0000000..856a5b8
--- /dev/null
+++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/OutboundRequestTest.kt
@@ -0,0 +1,111 @@
+package com.gzzn.omms.msgexchange.processing
+
+import com.gzzn.omms.msgexchange.MutableClock
+import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
+import com.gzzn.omms.msgexchange.config.OperationDayProps
+import com.gzzn.omms.msgexchange.config.PipelineProps
+import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
+import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
+import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
+import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
+import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.time.Duration
+import java.time.LocalDate
+
+class OutboundRequestTest {
+
+ private val clock = MutableClock(MutableClock.BASE)
+ private val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
+ private val props = PipelineProps().apply { outbound.responseTimeout = Duration.ofMinutes(5) }
+
+ private fun service(req: StubReqTrack, outbox: StubCoutmsgOutbox) =
+ OutboundRequestService(req, outbox, JacksonXmlCodec(), props, opDay, clock)
+
+ @Test
+ fun `schd sync registers PENDING then confirms COUTMSGS as SENT`() {
+ val req = StubReqTrack(clock)
+ val outbox = StubCoutmsgOutbox()
+ val svc = service(req, outbox)
+
+ val outcome = svc.registerRqfdSync()
+ assertTrue(outcome is OutboundRequestService.RegisterOutcome.Registered)
+ val open = req.findLatest(
+ OutboundRequestKeys.RQFD_REQ_TYPE,
+ LocalDate.of(2026, 9, 6),
+ OutboundRequestKeys.SENDER,
+ listOf(ReqTrackRepository.ReqState.SENT),
+ )!!
+ assertEquals(1L, open.coutmsgsId)
+ assertTrue(outbox.messages.containsValue(outbox.messages.values.first()))
+ assertTrue(outbox.messages.values.first().contains("OMMS"))
+ assertTrue(outbox.messages.values.first().contains("RQFD"))
+ }
+
+ @Test
+ fun `open slot rejects duplicate schd sync registration`() {
+ val req = StubReqTrack(clock)
+ val svc = service(req, StubCoutmsgOutbox())
+ svc.registerRqfdSync()
+ assertEquals(OutboundRequestService.RegisterOutcome.OpenExists, svc.registerRqfdSync())
+ }
+
+ @Test
+ fun `restart retries PENDING without coutmsgs after job dispatch`() {
+ val req = StubReqTrack(clock)
+ val outbox = StubCoutmsgOutbox()
+ val day = LocalDate.of(2026, 9, 6)
+ val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
+ assertEquals(ReqTrackRepository.ReqState.PENDING, req.rows[reqId]!!.state)
+
+ val svc = service(req, outbox)
+ assertEquals(1, svc.dispatchPending())
+
+ assertEquals(ReqTrackRepository.ReqState.SENT, req.rows[reqId]!!.state)
+ }
+
+ @Test
+ fun `ambiguous write marks uncertain and does not blind resend`() {
+ val req = StubReqTrack(clock)
+ val outbox = StubCoutmsgOutbox().apply { nextInsertOutcome = CoutmsgOutboxRepository.InsertOutcome.AMBIGUOUS }
+ val day = LocalDate.of(2026, 9, 6)
+ val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
+ val svc = service(req, outbox)
+
+ assertFalse(svc.dispatchPending() > 0)
+ assertTrue(req.rows[reqId]!!.writeUncertain)
+ assertEquals(ReqTrackRepository.ReqState.PENDING, req.rows[reqId]!!.state)
+ outbox.nextInsertOutcome = CoutmsgOutboxRepository.InsertOutcome.CONFIRMED
+ assertEquals(0, svc.dispatchPending())
+ }
+
+ @Test
+ fun `timeout expires SENT request`() {
+ val req = StubReqTrack(clock)
+ val day = LocalDate.of(2026, 9, 6)
+ val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
+ req.linkCoutmsgs(reqId, 10L, 99L)
+ req.markSent(reqId, clock.instant())
+ clock.advance(Duration.ofMinutes(10).toMillis())
+
+ val svc = service(req, StubCoutmsgOutbox())
+ assertEquals(1, svc.expireTimedOut())
+ assertEquals(ReqTrackRepository.ReqState.EXPIRED, req.rows[reqId]!!.state)
+ }
+
+ @Test
+ fun `EROR fails matching open outbound by seqn`() {
+ val req = StubReqTrack(clock)
+ val day = LocalDate.of(2026, 9, 6)
+ val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
+ req.linkCoutmsgs(reqId, 1L, 42L)
+ req.markSent(reqId, clock.instant())
+
+ val svc = service(req, StubCoutmsgOutbox())
+ assertTrue(svc.failFromEror(42L, "RQFD", "NONE"))
+ assertEquals(ReqTrackRepository.ReqState.FAILED, req.rows[reqId]!!.state)
+ }
+}
diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/processing/RespGuardTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/RespGuardTest.kt
new file mode 100644
index 0000000..a5c6f0c
--- /dev/null
+++ b/src/test/kotlin/com/gzzn/omms/msgexchange/processing/RespGuardTest.kt
@@ -0,0 +1,127 @@
+package com.gzzn.omms.msgexchange.processing
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
+import com.gzzn.omms.msgexchange.codec.ScheduleBody
+import com.gzzn.omms.msgexchange.config.OperationDayProps
+import com.gzzn.omms.msgexchange.config.PipelineProps
+import com.gzzn.omms.msgexchange.domain.DecodedMessage
+import com.gzzn.omms.msgexchange.domain.MetaFields
+import com.gzzn.omms.msgexchange.domain.MsgKind
+import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
+import com.gzzn.omms.msgexchange.domain.ProcState
+import com.gzzn.omms.msgexchange.domain.ProcStatus
+import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
+import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
+import com.gzzn.omms.msgexchange.infra.retry.FailureScheduler
+import com.gzzn.omms.msgexchange.infra.retry.ProcFailure
+import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
+import com.gzzn.omms.msgexchange.infra.stub.StubFlightProjectionPort
+import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
+import com.gzzn.omms.msgexchange.infra.stub.StubInbox
+import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
+import com.gzzn.omms.msgexchange.infra.stub.StubProcState
+import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
+import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
+import com.gzzn.omms.msgexchange.MutableClock
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import java.time.Instant
+import java.time.LocalDate
+
+class RespGuardTest {
+
+ private val clock = MutableClock(MutableClock.BASE)
+ private val msgId = 900L
+ private val day = LocalDate.of(2026, 9, 6)
+
+ private fun schdMsg(styp: MsgKind.SchdSubtype, body: ScheduleBody) = DecodedMessage(
+ meta = MetaFields("AODB", "SCHD", styp.name, 1L, 1L),
+ kind = MsgKind.Schd(styp),
+ rawXml = "",
+ body = body,
+ )
+
+ private fun emptyBody() = ScheduleBody(0, emptyList())
+
+ private fun processor(
+ proc: StubProcState,
+ inbox: StubInbox,
+ req: StubReqTrack,
+ decoded: DecodedMessage,
+ ): MessageProcessor {
+ val props = PipelineProps()
+ val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
+ val flights = StubFlightState()
+ val events = StubMsgEvents()
+ val commit = testCommit(procState = proc, msgEvents = events, projection = StubFlightProjectionPort(), clock = clock)
+ return MessageProcessor(
+ inbox = inbox,
+ procState = proc,
+ codec = object : com.gzzn.omms.msgexchange.codec.XmlCodec {
+ override fun decode(rawXml: String) =
+ com.gzzn.omms.msgexchange.codec.DecodeResult.Ok(decoded)
+ override fun encodeOutboundRqfd(seqn: Long, dttm: Long) = ""
+ override fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String?) = ""
+ },
+ scheduleProcessor = ScheduleProcessor(
+ commit, proc, flights, events, StubSnapshotLog(), opDay, props, ObjectMapper(), clock,
+ ),
+ flopProcessor = FlopProcessor(commit, flights, events, ObjectMapper(), clock),
+ fdelProcessor = FdelProcessor(commit, flights, events, ObjectMapper(), clock),
+ adftProcessor = AdftProcessor(commit, flights, events, opDay, ObjectMapper(), clock),
+ outbound = OutboundRequestService(req, StubCoutmsgOutbox(), JacksonXmlCodec(), props, opDay, clock),
+ procFailure = ProcFailure(proc, FailureScheduler(props, clock)),
+ props = props,
+ clock = clock,
+ operationDayProps = opDay,
+ counters = PipelineCounters(),
+ )
+ }
+
+ @Test
+ fun `SCHD-RESP without open RQFD is SKIPPED`() {
+ val proc = StubProcState()
+ proc.insertIfAbsent(msgId, null)
+ val inbox = StubInbox()
+ inbox.raws[msgId] = ""
+ val p = processor(proc, inbox, StubReqTrack(clock), schdMsg(MsgKind.SchdSubtype.RESP, emptyBody()))
+
+ p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
+
+ assertEquals(ProcStatus.SKIPPED, proc.find(msgId)!!.state)
+ assertEquals("resp-guard:no-open-req", proc.find(msgId)!!.lastError)
+ }
+
+ @Test
+ fun `SCHD-DNLD does not require open request`() {
+ val proc = StubProcState()
+ proc.insertIfAbsent(msgId, null)
+ val inbox = StubInbox()
+ inbox.raws[msgId] = ""
+ val p = processor(proc, inbox, StubReqTrack(clock), schdMsg(MsgKind.SchdSubtype.DNLD, emptyBody()))
+
+ p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
+
+ assertEquals(ProcStatus.SUCCEEDED, proc.find(msgId)!!.state)
+ }
+
+ @Test
+ fun `duplicate SCHD-RESP after completion is SKIPPED`() {
+ val req = StubReqTrack(clock)
+ val reqId = req.insert(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
+ req.linkCoutmsgs(reqId, 1L, 1L)
+ req.markSent(reqId, clock.instant())
+ req.completeLatest(OutboundRequestKeys.RQFD_REQ_TYPE, day, OutboundRequestKeys.SENDER)
+ assertEquals(ReqTrackRepository.ReqState.DONE, req.rows[reqId]!!.state)
+
+ val proc = StubProcState()
+ proc.insertIfAbsent(msgId, null)
+ val inbox = StubInbox()
+ inbox.raws[msgId] = ""
+ val p = processor(proc, inbox, req, schdMsg(MsgKind.SchdSubtype.RESP, emptyBody()))
+ p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
+
+ assertEquals(ProcStatus.SKIPPED, proc.find(msgId)!!.state)
+ }
+}