feat(codec): 观测未落库的 SRVT/VIPF 集合
SRVT/VIPF 只保留在 wire/domain 并计数告警,不落明细表、不参与合并: 出现事实不再被静默丢弃,为 Q13 定案提供真实流量证据([G-SRVT-VIPF])。 - wire DTO:SRVT/SERVICEDATA、VIPF/VIPDATA 与嵌套 VIPT(OPER 为元素属性) - 出现即留键:缺席与"出现但为空"不再等价;已落库 10 类集合语义不变 - 新增 msgx.pipeline.codec.srvt_seen.total / vipf_seen.total(reference.md 已登记) - 一并提交此前的 MAFL 文档改动(INV-21/INV-22、flight-state §2.3、G-MAFL 措辞)
This commit is contained in:
@@ -19,6 +19,32 @@ data class ScheduleBody(
|
||||
val records: List<ScheduleRecord>,
|
||||
)
|
||||
|
||||
/**
|
||||
* 只保留在 wire/domain、尚未映射到持久化明细的集合键(`[G-SRVT-VIPF]`)。
|
||||
*
|
||||
* 它们不参与合并、不进快照、不落库;保留的目的是不让入站事实在解码层被静默抹平,并让真实
|
||||
* 流量里的出现情况可观测——决定"缺席是否等于删除"的 `Q13` 需要真实报文才能定案。
|
||||
*/
|
||||
private val UNPERSISTED_COLLECTION_KEYS: Set<String> = setOf("SRVT", "VIPF")
|
||||
|
||||
/**
|
||||
* 一条解码载荷里各未落库集合命中的**记录数**(`[G-SRVT-VIPF]`)。
|
||||
*
|
||||
* 按记录计(一条记录带该段即算 1),不按段内元素计;只返回命中项。载荷类型不在
|
||||
* [ScheduleBody] / [FlopPayload] 之内时返回空——调用方不得据此改变处理结果。
|
||||
*/
|
||||
fun unpersistedCollectionHits(body: Any?): Map<String, Int> {
|
||||
val perRecord = when (body) {
|
||||
is ScheduleBody -> body.records.map { it.collections }
|
||||
is FlopPayload -> listOf(body.collections)
|
||||
else -> return emptyMap()
|
||||
}
|
||||
if (perRecord.isEmpty()) return emptyMap()
|
||||
return UNPERSISTED_COLLECTION_KEYS
|
||||
.mapNotNull { key -> perRecord.count { key in it }.takeIf { it > 0 }?.let { key to it } }
|
||||
.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire DTOs for SIS in docs/legacy/SIS_AODB_RMS-V0.1.md.
|
||||
* SIS permits a subsystem to ignore standard fields it does not use; those fields are
|
||||
@@ -105,6 +131,10 @@ data class FlightRecordXml(
|
||||
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "CHOT") val chot: List<ChotXml> = emptyList(),
|
||||
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "ROUT") val rout: List<RoutXml> = emptyList(),
|
||||
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "ERUT") val erut: List<RoutXml> = emptyList(),
|
||||
// SRVT/VIPF 用可空表达"段是否出现":null = 未出现;出现即为列表(空元素得到一行空行)。
|
||||
// 两者都不落明细表、不参与合并,清空语义待 `Q13`(`[G-SRVT-VIPF]`)。
|
||||
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "SRVT") val srvt: List<SrvtXml>? = null,
|
||||
@param:JacksonXmlElementWrapper(useWrapping = false) @param:JacksonXmlProperty(localName = "VIPF") val vipf: List<VipfXml>? = null,
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -198,3 +228,44 @@ data class RoutXml(
|
||||
@param:JacksonXmlProperty(localName = "SCAT") val scat: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SCDT") val scdt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* SIS `SRVT`/`SERVICEDATA`(SCHD)与 `OPT_SERVICEDATA`(FLOP):一次服务明细,可重复出现。
|
||||
*
|
||||
* 字段形态以 `docs/legacy/unisysaodbsis.xsd` 为准。`OPER` 是元素属性,SIS 正文未记载其值域;
|
||||
* `SANR`/`SARR` 只出现在 FLOP 的 `OPT_SERVICEDATA`。协议未给序号属性,故没有源序号。
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class SrvtXml(
|
||||
@param:JacksonXmlProperty(isAttribute = true, localName = "OPER") val oper: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SRTC") val srtc: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SRQT") val srqt: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SRST") val srst: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SRET") val sret: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SRPR") val srpr: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SANR") val sanr: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "SARR") val sarr: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* SIS `VIPF`/`VIPDATA`(SCHD)与 `OPT_VIPDATA`(FLOP):一位 VIP,可重复出现。
|
||||
*
|
||||
* `VIPT` 按 XSD 至多一个;SIS 正文"每个 VIP 重复"的说法与之冲突,形态以 XSD 为准。
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class VipfXml(
|
||||
@param:JacksonXmlProperty(isAttribute = true, localName = "OPER") val oper: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "VPCD") val vpcd: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "VFES") val vfes: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "VIPT") val vipt: ViptXml? = null,
|
||||
)
|
||||
|
||||
/** SIS `VIPT`/`VIPTXNDATA`:VIP 关联的服务交易明细,嵌套在 [VipfXml] 内。 */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ViptXml(
|
||||
@param:JacksonXmlProperty(isAttribute = true, localName = "OPER") val oper: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "VSCD") val vscd: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "VTQY") val vtqy: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "VTST") val vtst: String? = null,
|
||||
@param:JacksonXmlProperty(localName = "VTET") val vtet: String? = null,
|
||||
)
|
||||
|
||||
@@ -33,18 +33,42 @@ internal object SisWireMapper {
|
||||
).forEach { (key, value) -> value?.let { put(key, it.trim()) } }
|
||||
}
|
||||
|
||||
private fun FlightRecordXml.collections(): Map<String, List<Map<String, String>>> = linkedMapOf(
|
||||
"GTDT" to gtdt.map { it.toMap("GTNO" to it.gtno, "GATE" to it.gate, "PGOT" to it.pgot, "PGCT" to it.pgct, "GOTM" to it.gotm, "GCTM" to it.gctm, "GTYP" to it.gtyp) },
|
||||
"CKDT" to ckdt.map { it.toMap("CKNO" to it.ckno, "CHKC" to it.chkc, "CCLS" to it.ccls, "PCOT" to it.pcot, "PCCT" to it.pcct, "COTM" to it.cotm, "CCTM" to it.cctm, "CTYP" to it.ctyp) },
|
||||
"CLDT" to cldt.map { it.toMap("CLNO" to it.clno, "BELT" to it.belt, "BCLS" to it.bcls, "PCOT" to it.pcot, "PCCT" to it.pcct, "FBAG" to it.fbag, "LBAG" to it.lbag, "BTYP" to it.btyp) },
|
||||
"PSDT" to psdt.map { it.toMap("PSNO" to it.psno, "PSST" to it.psst, "STST" to it.stst, "STET" to it.stet) },
|
||||
"CHDT" to chdt.map { it.toMap("CHNO" to it.chno, "CHUT" to it.chut, "CHCLS" to it.chcls, "PCBT" to it.pcbt, "PCET" to it.pcet, "CBTM" to it.cbtm, "CETM" to it.cetm, "CHTYP" to it.chtyp) },
|
||||
"DELY" to dely.map { it.toMap("CODE" to it.code, "STRT" to it.strt, "DURA" to it.dura, "REMC" to it.text) },
|
||||
"ABTM" to abtm.map { it.toMap("ASNO" to it.asno, "ABDG" to it.abdg, "ABOP" to it.abop, "AOTM" to it.aotm) },
|
||||
"CHOT" to chot.map { it.toMap("CSNO" to it.csno, "CHID" to it.chid, "CHST" to it.chst, "CHTM" to it.chtm) },
|
||||
"ROUT" to rout.map { it.toMap("RTNO" to it.rtno, "APCD" to it.apcd, "SCAT" to it.scat, "SCDT" to it.scdt) },
|
||||
"ERUT" to erut.map { it.toMap("RTNO" to it.rtno, "APCD" to it.apcd, "SCAT" to it.scat, "SCDT" to it.scdt) },
|
||||
).filterValues { it.isNotEmpty() }
|
||||
/**
|
||||
* 已落库的 10 类集合:缺席(字段默认空列表)不产生键,出现但为空的元素得到一行空行 `[{}]`;
|
||||
* `filterValues` 去掉的正是"缺席",避免整包凭空清空本地明细(合并语义见 flight-state.md §3.1)。
|
||||
*
|
||||
* `SRVT`/`VIPF` 尚未有明细表(`[G-SRVT-VIPF]`):只用"键是否存在"表达段是否出现,保留原始
|
||||
* 内容与顺序,不参与合并、不判断清空语义(`Q13`)——出现(哪怕为空)与缺席不再被抹平。
|
||||
*/
|
||||
private fun FlightRecordXml.collections(): Map<String, List<Map<String, String>>> {
|
||||
val mapped = linkedMapOf(
|
||||
"GTDT" to gtdt.map { it.toMap("GTNO" to it.gtno, "GATE" to it.gate, "PGOT" to it.pgot, "PGCT" to it.pgct, "GOTM" to it.gotm, "GCTM" to it.gctm, "GTYP" to it.gtyp) },
|
||||
"CKDT" to ckdt.map { it.toMap("CKNO" to it.ckno, "CHKC" to it.chkc, "CCLS" to it.ccls, "PCOT" to it.pcot, "PCCT" to it.pcct, "COTM" to it.cotm, "CCTM" to it.cctm, "CTYP" to it.ctyp) },
|
||||
"CLDT" to cldt.map { it.toMap("CLNO" to it.clno, "BELT" to it.belt, "BCLS" to it.bcls, "PCOT" to it.pcot, "PCCT" to it.pcct, "FBAG" to it.fbag, "LBAG" to it.lbag, "BTYP" to it.btyp) },
|
||||
"PSDT" to psdt.map { it.toMap("PSNO" to it.psno, "PSST" to it.psst, "STST" to it.stst, "STET" to it.stet) },
|
||||
"CHDT" to chdt.map { it.toMap("CHNO" to it.chno, "CHUT" to it.chut, "CHCLS" to it.chcls, "PCBT" to it.pcbt, "PCET" to it.pcet, "CBTM" to it.cbtm, "CETM" to it.cetm, "CHTYP" to it.chtyp) },
|
||||
"DELY" to dely.map { it.toMap("CODE" to it.code, "STRT" to it.strt, "DURA" to it.dura, "REMC" to it.text) },
|
||||
"ABTM" to abtm.map { it.toMap("ASNO" to it.asno, "ABDG" to it.abdg, "ABOP" to it.abop, "AOTM" to it.aotm) },
|
||||
"CHOT" to chot.map { it.toMap("CSNO" to it.csno, "CHID" to it.chid, "CHST" to it.chst, "CHTM" to it.chtm) },
|
||||
"ROUT" to rout.map { it.toMap("RTNO" to it.rtno, "APCD" to it.apcd, "SCAT" to it.scat, "SCDT" to it.scdt) },
|
||||
"ERUT" to erut.map { it.toMap("RTNO" to it.rtno, "APCD" to it.apcd, "SCAT" to it.scat, "SCDT" to it.scdt) },
|
||||
).filterValues { it.isNotEmpty() }.toMutableMap()
|
||||
srvt?.let { mapped["SRVT"] = it.map(::srvtRow) }
|
||||
vipf?.let { mapped["VIPF"] = it.map(::vipfRow) }
|
||||
return mapped
|
||||
}
|
||||
|
||||
/** `SRVT` 一行:`OPER` 取自属性;`VIPT_*` 前缀供嵌套字段使用,避免与外层 `OPER` 撞键。 */
|
||||
private fun srvtRow(x: SrvtXml): Map<String, String> = x.toMap(
|
||||
"OPER" to x.oper, "SRTC" to x.srtc, "SRQT" to x.srqt, "SRST" to x.srst,
|
||||
"SRET" to x.sret, "SRPR" to x.srpr, "SANR" to x.sanr, "SARR" to x.sarr,
|
||||
)
|
||||
|
||||
private fun vipfRow(x: VipfXml): Map<String, String> = x.toMap(
|
||||
"OPER" to x.oper, "VPCD" to x.vpcd, "VFES" to x.vfes,
|
||||
"VIPT_OPER" to x.vipt?.oper, "VIPT_VSCD" to x.vipt?.vscd, "VIPT_VTQY" to x.vipt?.vtqy,
|
||||
"VIPT_VTST" to x.vipt?.vtst, "VIPT_VTET" to x.vipt?.vtet,
|
||||
)
|
||||
|
||||
private fun Any.toMap(vararg values: Pair<String, String?>): Map<String, String> = values.mapNotNull { (key, value) ->
|
||||
value?.trim()?.let { key to it }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.gzzn.omms.msgexchange.infra.metrics
|
||||
|
||||
import jakarta.inject.Singleton
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* 管道运行期的**进程内**计数。
|
||||
@@ -11,4 +12,22 @@ import jakarta.inject.Singleton
|
||||
* 注意:计数在重启后归零。需要跨重启的累计值应由指标后端聚合,不在这里做持久化。
|
||||
*/
|
||||
@Singleton
|
||||
class PipelineCounters
|
||||
class PipelineCounters {
|
||||
private val srvtSeen = AtomicLong(0)
|
||||
private val vipfSeen = AtomicLong(0)
|
||||
|
||||
/**
|
||||
* 入站记录里出现 `SRVT`/`VIPF` 段的条数(`[G-SRVT-VIPF]`)。
|
||||
*
|
||||
* 这两个集合目前只保留在 wire/domain,不落明细表、不参与合并;计数是"真实报文有没
|
||||
* 有在用"的唯一取证渠道(清空语义 `Q13` 需要真实样例才能定案)。> 0 表示确有流量携带该段。
|
||||
*/
|
||||
fun unpersistedCollectionSeenAdd(hits: Map<String, Int>) {
|
||||
hits["SRVT"]?.let { srvtSeen.addAndGet(it.toLong()) }
|
||||
hits["VIPF"]?.let { vipfSeen.addAndGet(it.toLong()) }
|
||||
}
|
||||
|
||||
fun srvtSeenCount(): Long = srvtSeen.get()
|
||||
|
||||
fun vipfSeenCount(): Long = vipfSeen.get()
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import java.time.Duration
|
||||
* - `msgx.pipeline.job.last_failure_age_seconds`:距最近一次作业 tick 失败的秒数(从未失败为 -1)
|
||||
* - `msgx.pipeline.job.ticks.total` / `msgx.pipeline.job.failures.total`:作业 tick 完成/抛错次数
|
||||
* - `msgx.pipeline.job.last_sweep_selected`:上一轮回填扫描选中的待办条数(扫描积压)
|
||||
* - `msgx.pipeline.codec.srvt_seen.total` / `msgx.pipeline.codec.vipf_seen.total`:入站记录里出现
|
||||
* `SRVT`/`VIPF` 段的条数(尚未落明细表,`[G-SRVT-VIPF]`;> 0 表示真实流量确有该段)
|
||||
*
|
||||
* 取数统一走 [BacklogSnapshotProvider](30 秒 TTL),因此指标抓取不会打穿数据库。
|
||||
* 无法取数时以 `NaN` 上报(Micrometer 的惯例表示"本次无值"),而不是伪造 0。
|
||||
@@ -45,6 +47,7 @@ class PipelineMetrics(
|
||||
private val mailbox: BeanProvider<CminmsgInboxRepository>,
|
||||
private val activity: JobActivity,
|
||||
private val clock: Clock,
|
||||
private val counters: PipelineCounters,
|
||||
) {
|
||||
|
||||
@PostConstruct
|
||||
@@ -89,6 +92,15 @@ class PipelineMetrics(
|
||||
Gauge.builder("msgx.pipeline.job.last_sweep_selected", activity) { it.snapshot().lastSweepSelected.toDouble() }
|
||||
.strongReference(true)
|
||||
.register(registry)
|
||||
|
||||
// SRVT/VIPF 尚未落明细表([G-SRVT-VIPF]):计数替代静默丢弃,为 Q13 提供真实流量证据。
|
||||
Gauge.builder("msgx.pipeline.codec.srvt_seen.total", counters) { it.srvtSeenCount().toDouble() }
|
||||
.strongReference(true)
|
||||
.register(registry)
|
||||
|
||||
Gauge.builder("msgx.pipeline.codec.vipf_seen.total", counters) { it.vipfSeenCount().toDouble() }
|
||||
.strongReference(true)
|
||||
.register(registry)
|
||||
}
|
||||
|
||||
private fun backlogGauge(name: String, value: (com.gzzn.omms.msgexchange.infra.persistence.Backlog) -> Double) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.gzzn.omms.msgexchange.processing
|
||||
import com.gzzn.omms.msgexchange.codec.FlopPayload
|
||||
import com.gzzn.omms.msgexchange.codec.ScheduleBody
|
||||
import com.gzzn.omms.msgexchange.codec.XmlCodec
|
||||
import com.gzzn.omms.msgexchange.codec.unpersistedCollectionHits
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
@@ -11,6 +12,7 @@ import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.infra.log.TraceLog
|
||||
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CminmsgInboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.InboxCursorRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ProcStateRepository
|
||||
@@ -150,6 +152,7 @@ class MessageProcessor(
|
||||
private val props: PipelineProps,
|
||||
private val clock: Clock,
|
||||
private val operationDayProps: OperationDayProps,
|
||||
private val counters: PipelineCounters,
|
||||
) {
|
||||
private val log = org.slf4j.LoggerFactory.getLogger(MessageProcessor::class.java)
|
||||
|
||||
@@ -187,6 +190,14 @@ class MessageProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
// [G-SRVT-VIPF]:SRVT/VIPF 段只保留在解码载荷里,尚未落明细表(清空语义待 Q13)。
|
||||
// 计数 + 告警替代此前的静默丢弃;出现即证明真实报文携带该段,可作为定案依据。
|
||||
val unpersisted = unpersistedCollectionHits(decoded.body)
|
||||
if (unpersisted.isNotEmpty()) {
|
||||
counters.unpersistedCollectionSeenAdd(unpersisted)
|
||||
log.warn("unpersisted collection(s) {} present msgId={} [G-SRVT-VIPF]", unpersisted, head.msgId)
|
||||
}
|
||||
|
||||
// I3:identity 仅首次绑定(head.identityKey == null);FAILED 重试不重绑
|
||||
if (head.identityKey == null) {
|
||||
val identity = Identity.of(
|
||||
|
||||
Reference in New Issue
Block a user