From 9554b1aae8e317e545da7317baa68f44d19cfbe0 Mon Sep 17 00:00:00 2001 From: windyboy Date: Sat, 12 Sep 2026 20:34:56 +0800 Subject: [PATCH] fix(config): fail fast on invalid operation-day zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运营日时区只认 PARAM:msgx.operation-day.zone:JobRunner、HistorySweepJob、JdbcSnapshotLogRepository.purgeBefore 改走注入的 OperationDayProps;ScheduleProcessor/AdftProcessor 删除「非法值回退字面量」;新增 @Context 启动自检 OperationDayZoneCheck,配置错位在启动时暴露。 验证:./gradlew test 126 tests / 0 fail(新增 OperationDayPropsTest 覆盖合法解析与非法即失败)。 --- .../msgexchange/config/OperationDayProps.kt | 21 +++++++++++++ .../persistence/jdbc/JdbcPgRepositories.kt | 4 ++- .../omms/msgexchange/jobs/HistorySweepJob.kt | 5 +-- .../gzzn/omms/msgexchange/jobs/JobRunner.kt | 4 ++- .../processing/DynamicProcessors.kt | 2 +- .../processing/ScheduleProcessor.kt | 2 +- .../config/OperationDayPropsTest.kt | 31 +++++++++++++++++++ .../msgexchange/jobs/HistorySweepJobTest.kt | 9 +++--- 8 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 src/test/kotlin/com/gzzn/omms/msgexchange/config/OperationDayPropsTest.kt diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt index c0682ac..c8a0ed6 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/config/OperationDayProps.kt @@ -1,6 +1,9 @@ package com.gzzn.omms.msgexchange.config import io.micronaut.context.annotation.ConfigurationProperties +import io.micronaut.context.annotation.Context +import jakarta.inject.Singleton +import java.time.ZoneId /** * 算航班运营保障日(OPERATION_DAY)要用的两个参数,对应配置里的 `msgx.operation-day.*`: @@ -16,4 +19,22 @@ class OperationDayProps { /** 切日边界小时:SODT 的本地时刻早于这个点就归属前一个运营日。取值 0–23。 */ var cutoffHour: Int = 0 + + /** + * 解析机场时区。非法值直接失败,**不回退到代码字面量**: + * `PARAM:msgx.operation-day.zone` 是运营日时区的唯一出处,回退会把配置错位藏起来。 + */ + fun zoneId(): ZoneId = + try { + ZoneId.of(zone) + } catch (e: Exception) { + throw IllegalStateException("msgx.operation-day.zone is not a valid IANA zone: '$zone'", e) + } +} + +/** 启动自检:时区非法必须在启动时失败,而不是等第一条报文进来才炸;`@Context` 使其急切创建。 */ +@Singleton +@Context +class OperationDayZoneCheck(props: OperationDayProps) { + private val zone: ZoneId = props.zoneId() } 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 15ac7de..237507e 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 @@ -1,5 +1,6 @@ package com.gzzn.omms.msgexchange.infra.persistence.jdbc +import com.gzzn.omms.msgexchange.config.OperationDayProps import com.gzzn.omms.msgexchange.domain.ErrorClass import com.gzzn.omms.msgexchange.domain.EventStatus import com.gzzn.omms.msgexchange.domain.EventType @@ -818,6 +819,7 @@ class JdbcFlightStateRepository( class JdbcSnapshotLogRepository( private val ds: DataSource, private val clock: Clock, + private val operationDayProps: OperationDayProps, ) : SnapshotLogRepository, SnapshotLogPurge { /** 只追加写;这里不抛异常,写失败由调用方捕获并记为指标。 */ override fun append(entry: SnapshotLogEntry) { @@ -847,7 +849,7 @@ class JdbcSnapshotLogRepository( ds.update( "DELETE FROM schd_snap_log WHERE scope_end < ? AND recv_at < ?", { ps -> - val cutoffDay = java.time.LocalDate.ofInstant(instant, java.time.ZoneId.of("Asia/Shanghai")) + val cutoffDay = java.time.LocalDate.ofInstant(instant, operationDayProps.zoneId()) ps.setDate(1, java.sql.Date.valueOf(cutoffDay)) ps.setTimestamp(2, instant.toSqlTimestamp()) }, diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt index 6465237..9e19310 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJob.kt @@ -1,6 +1,7 @@ package com.gzzn.omms.msgexchange.jobs import com.gzzn.omms.msgexchange.config.HistoryProps +import com.gzzn.omms.msgexchange.config.OperationDayProps import com.gzzn.omms.msgexchange.domain.EventType import com.gzzn.omms.msgexchange.domain.MsgEvent import com.gzzn.omms.msgexchange.domain.Targets @@ -12,7 +13,6 @@ import com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogPurge import jakarta.inject.Singleton import java.time.Duration import java.time.Instant -import java.time.ZoneId /** * 历史归档与物理清除:把到期航班搬进历史存储,再从当前态删掉。五步顺序不能颠倒: @@ -31,6 +31,7 @@ class HistorySweepJob( private val flightState: FlightStateRepository, private val msgEvents: MsgEventRepository, private val props: HistoryProps, + private val operationDayProps: OperationDayProps, /** 历史存储端口:返回确认归档成功的 FLID 集合;部署侧没接通时是 null。 */ private val historyStore: HistoryStore? = null, /** 留痕清理端口:删掉超过保留期(默认 90 天)的日计划留痕行;不依赖历史存储开关,没接通时为 null。 */ @@ -54,7 +55,7 @@ class HistorySweepJob( } val rules = HistoryRules(props.cancelledHours, props.terminalHours, props.deletedHours, props.idleHours) - val zone = ZoneId.of("Asia/Shanghai") // 保留期窗口按机场时区算,不用 UTC + val zone = operationDayProps.zoneId() // 保留期窗口按 PARAM:msgx.operation-day.zone 算,不用 UTC val candidates = flightState.findHistoryCandidates(rules, zone, now) if (candidates.isEmpty()) return SweepOutcome(0, 0, 0, snapLogPurged = snapLogPurged) 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 5f57ec2..9e9c038 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunner.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/jobs/JobRunner.kt @@ -1,5 +1,6 @@ package com.gzzn.omms.msgexchange.jobs +import com.gzzn.omms.msgexchange.config.OperationDayProps import com.gzzn.omms.msgexchange.processing.BackfillService import jakarta.inject.Singleton import java.time.Clock @@ -22,9 +23,10 @@ class JobRunner( private val backfill: BackfillService, private val historySweep: HistorySweepJob, private val clock: Clock, + operationDayProps: OperationDayProps, ) { private val log = org.slf4j.LoggerFactory.getLogger(JobRunner::class.java) - private val zone: ZoneId = ZoneId.of("Asia/Shanghai") + private val zone: ZoneId = operationDayProps.zoneId() @Volatile private var running = true diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt index 4048bd2..9e7d0df 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/DynamicProcessors.kt @@ -133,7 +133,7 @@ class AdftProcessor( private val clock: Clock, ) { private val opDay = OperationDayCalculator( - zone = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") }, + zone = operationDayProps.zoneId(), cutoffHour = operationDayProps.cutoffHour, ) diff --git a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/ScheduleProcessor.kt b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/ScheduleProcessor.kt index 7a2302f..57f873a 100644 --- a/src/main/kotlin/com/gzzn/omms/msgexchange/processing/ScheduleProcessor.kt +++ b/src/main/kotlin/com/gzzn/omms/msgexchange/processing/ScheduleProcessor.kt @@ -69,7 +69,7 @@ class ScheduleProcessor( private val mapper: ObjectMapper, private val clock: Clock, ) { - private val zone: ZoneId = runCatching { ZoneId.of(operationDayProps.zone) }.getOrElse { ZoneId.of("Asia/Shanghai") } + private val zone: ZoneId = operationDayProps.zoneId() private val opDay = OperationDayCalculator( zone = zone, cutoffHour = operationDayProps.cutoffHour, diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/config/OperationDayPropsTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/config/OperationDayPropsTest.kt new file mode 100644 index 0000000..ad13524 --- /dev/null +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/config/OperationDayPropsTest.kt @@ -0,0 +1,31 @@ +package com.gzzn.omms.msgexchange.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.ZoneId + +/** + * 运营日时区只有一个出处(`PARAM:msgx.operation-day.zone`):能解析就解析, + * 解析不了必须让启动失败,**不允许回退到代码字面量**——回退会把配置错位藏起来, + * 让运营日按错误的时区计算。 + */ +class OperationDayPropsTest { + + @Test + fun `configured zone resolves to the IANA zone`() { + val props = OperationDayProps().apply { zone = "Asia/Shanghai" } + + assertEquals(ZoneId.of("Asia/Shanghai"), props.zoneId()) + } + + @Test + fun `invalid zone fails fast instead of falling back to a literal`() { + val props = OperationDayProps().apply { zone = "Not/AZone" } + + val failure = assertThrows(IllegalStateException::class.java) { props.zoneId() } + + assertTrue(failure.message!!.contains("msgx.operation-day.zone")) + } +} diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt index 229dde7..0bcaabd 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/jobs/HistorySweepJobTest.kt @@ -1,6 +1,7 @@ package com.gzzn.omms.msgexchange.jobs import com.gzzn.omms.msgexchange.config.HistoryProps +import com.gzzn.omms.msgexchange.config.OperationDayProps import com.gzzn.omms.msgexchange.domain.EventType import com.gzzn.omms.msgexchange.domain.flight.FlightState import com.gzzn.omms.msgexchange.domain.flight.HistoryCandidate @@ -50,7 +51,7 @@ class HistorySweepJobTest { fun `history store not connected must delete zero rows`() { val f = seededFlight("F1", deleted = true, idleDays = 30) val events = StubMsgEvents() - val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false }) + val job = HistorySweepJob(f, events, HistoryProps().apply { historyStoreEnabled = false }, OperationDayProps()) val outcome = job.run(now) @@ -71,7 +72,7 @@ class HistorySweepJobTest { val events = StubMsgEvents() val store = RecordingHistoryStore().apply { confirmed.add("F1") } // 只有 F1 归档确认成功 val job = HistorySweepJob( - f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store, + f, events, HistoryProps().apply { historyStoreEnabled = true }, OperationDayProps(), historyStore = store, ) val outcome = job.run(now) @@ -90,7 +91,7 @@ class HistorySweepJobTest { val events = StubMsgEvents() val store = RecordingHistoryStore() val job = HistorySweepJob( - f, events, HistoryProps().apply { historyStoreEnabled = true }, historyStore = store, + f, events, HistoryProps().apply { historyStoreEnabled = true }, OperationDayProps(), historyStore = store, ) job.run(now) @@ -108,7 +109,7 @@ class HistorySweepJobTest { var cutoff: Instant? = null val purge = com.gzzn.omms.msgexchange.infra.persistence.SnapshotLogPurge { instant -> cutoff = instant; 7 } val job = HistorySweepJob( - f, StubMsgEvents(), HistoryProps().apply { historyStoreEnabled = false }, snapLogPurge = purge, + f, StubMsgEvents(), HistoryProps().apply { historyStoreEnabled = false }, OperationDayProps(), snapLogPurge = purge, ) val outcome = job.run(now)