feat(outbound): REQ_TRACK/COUTMSGS/schd-sync 与 RESP 守卫(ACM2-92/86)

出站请求状态机与编码;SCHD-RESP 须匹配未过期开放请求;EROR 结案匹配 SENT。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-09-21 15:50:14 +08:00
co-authored by Cursor
parent b545fd2b58
commit 54b383fa9d
23 changed files with 843 additions and 26 deletions
@@ -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,
)
@@ -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 =
"""<?xml version="1.0" encoding="UTF-8"?><MSG><META><TYPE>RQFD</TYPE></META></MSG>"""
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")) xml else """<?xml version="1.0" encoding="UTF-8"?>$xml"""
}
/**
* 子类型分派必须是**白名单**:未知的 SCHD 子类型不能静默当成全量日计划(DNLD)——
@@ -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)
@@ -21,5 +21,10 @@ sealed interface DecodeResult {
*/
interface XmlCodec {
fun decode(rawXml: String): DecodeResult
fun encodeRqrd(kind: String, rangeJson: String): String
/** C-4RQFD STYP=NONE,空 RQFD 体。 */
fun encodeOutboundRqfd(seqn: Long, dttm: Long): String
/** C-4RQRDSTYP 为参考数据子类型;RSTA 时可带 RTYP。 */
fun encodeOutboundRqrd(styp: String, seqn: Long, dttm: Long, rtyp: String? = null): String
}
@@ -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)。 */
@@ -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
}
}
@@ -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<Req>
/** 已落信等待应答且超过时限:标记 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
}
/**
@@ -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
}
}
}
@@ -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<ReqTrackRepository.Req> =
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"),
)
}
@@ -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<Long, ReqTrackRepository.Req>()
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<ReqTrackRepository.Req> =
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<Long, String>()
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)
}
}
/** 内存版共享信箱:可以模拟上游写入、库方清除,并记录哪些行被打上了处理标记。 */
@@ -13,7 +13,10 @@ import io.micronaut.http.annotation.Produces
* 生产上收报走的是轮询共享信箱表 CMINMSGSInboxPoller),不是这个 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<String> =
when (val outcome = schdSync.trigger()) {
is SchdSyncService.Outcome.Registered ->
HttpResponse.ok(outcome.reqId.toString())
SchdSyncService.Outcome.OpenExists ->
HttpResponse.status<String>(io.micronaut.http.HttpStatus.CONFLICT).body("open-request-exists")
}
}
@@ -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
}
}
@@ -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
@@ -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(),
)
}
@@ -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()
}
}
@@ -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 -> {
// 合法但不支持的类型:跳过留档记 SKIPPEDUS-03 AC2markTerminal 同一条 UPDATE
@@ -0,0 +1,3 @@
-- REQ_TRACK:出站 SEQNEROR 配对)与落信结果不明标记(不盲重发)。
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;
@@ -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("<SNDR>OMMS</SNDR>"))
assertTrue(xml.contains("<TYPE>RQFD</TYPE>"))
assertTrue(xml.contains("<STYP>NONE</STYP>"))
assertTrue(xml.contains("<RQFD"))
}
@Test
fun `encode outbound RQRD carries STYP`() {
val xml = codec.encodeOutboundRqrd("AIRL", 99L, 20021010090311L)
assertTrue(xml.contains("<TYPE>RQRD</TYPE>"))
assertTrue(xml.contains("<STYP>AIRL</STYP>"))
}
@Test
fun `decode AODBGTDT sample extracts META and three gates`() {
val raw = """
@@ -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(),
)
@@ -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<String>) : PipelineTransactionManager {
@@ -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] = "<RAW/>"
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
@@ -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("<SNDR>OMMS</SNDR>"))
assertTrue(outbox.messages.values.first().contains("<TYPE>RQFD</TYPE>"))
}
@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)
}
}
@@ -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 = "<MSG/>",
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] = "<x/>"
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] = "<x/>"
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] = "<x/>"
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)
}
}