fix(config): fail fast on invalid operation-day zone

运营日时区只认 PARAM:msgx.operation-day.zone:JobRunner、HistorySweepJob、JdbcSnapshotLogRepository.purgeBefore 改走注入的 OperationDayProps;ScheduleProcessor/AdftProcessor 删除「非法值回退字面量」;新增 @Context 启动自检 OperationDayZoneCheck,配置错位在启动时暴露。

验证:./gradlew test 126 tests / 0 fail(新增 OperationDayPropsTest 覆盖合法解析与非法即失败)。
This commit is contained in:
windyboy
2026-09-12 20:34:56 +08:00
parent 3f2a1be75b
commit 9554b1aae8
8 changed files with 68 additions and 10 deletions
@@ -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()
}
@@ -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())
},
@@ -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)
@@ -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
@@ -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,
)
@@ -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,
@@ -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"))
}
}
@@ -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)