feat(wave4): GET /all/flights and SCHD day-plan processing (ACM2-80, ACM2-85)

Expose projection read API with MAID filtering and 503 on failure; complete schedule snapshot sweep, field clearing, batched PG via FlightCommit, and contract/PARAM docs with regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-09-21 15:40:58 +08:00
co-authored by Cursor
parent 2c3d6af309
commit b545fd2b58
22 changed files with 491 additions and 114 deletions
@@ -11,7 +11,7 @@ import java.time.ZoneId
/**
* 守着航班状态合并的几条规矩:
* - 日计划合并:报文里出现的字段覆盖本地值、没出现的保留,标量给空串等于显式清空
* - 日计划:报文带的字段写进去,没带的字段和集合清掉(`C-6`、`US-07` AC3
* - 日计划不能把已删除的航班恢复成在用;
* - FLOP/ADFT 只改报文表达的字段和集合;
* - 整包校验(条数、FLID 格式、运营日能不能算出来)有一条不过就整包拒收。
@@ -21,37 +21,31 @@ class FlightStateEngineTest {
private val opDay = OperationDayCalculator(ZoneId.of("Asia/Shanghai"), 0)
private val day = LocalDate.of(2026, 12, 15)
/** 日计划只接状态与版本,所以库里已有的航班用主行表示。 */
private fun mainRow(version: Long, state: FlightState = FlightState.ACTIVE) =
FlightMainRow("121", day, state, version, lastMsgId = null, updatedAt = java.time.Instant.EPOCH)
@Test
fun `day plan merges scalars overwriting overlaps retaining absent and clearing on explicit empty`() {
val current = FlightSnapshot(
"121", day, FlightState.ACTIVE, 5,
scalars = mapOf("FLNO" to "CA001", "REMC" to "old-note"),
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
)
fun `day plan clears fields the snapshot does not carry`() {
val next = FlightStateEngine.snapshotState(
current,
mainRow(5),
ScheduleRecord("121", scalars = mapOf("FLNO" to "CA002", "CNCL" to "")),
operationDay = day,
keepDeleted = false,
)
assertEquals("CA002", next.scalars["FLNO"]) // 报文里出现的字段覆盖本地值
assertEquals("old-note", next.scalars["REMC"]) // 报文没提的标量保留原值
assertEquals("", next.scalars["CNCL"]) // 空串表示显式清空,落库时置成 NULL
assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"]) // 报文没提的集合保留原值
assertEquals(6, next.stateVersion) // 每成功合并一次,版本号加一
assertEquals("CA002", next.scalars["FLNO"]) // 报文带的字段写进去
assertFalse(next.scalars.containsKey("REMC")) // 没带的标量清掉(AODB 已删掉该值)
assertEquals("", next.scalars["CNCL"]) // 空串同样是清空,落库时置成 NULL
assertEquals(emptyList<Map<String, String>>(), next.collections["GTDT"]) // 没带的集合清空
assertEquals(6, next.stateVersion) // 每成功写入一次,版本号加一
assertEquals(FlightState.ACTIVE, next.state)
}
@Test
fun `day plan replaces collections that are present in the record`() {
val current = FlightSnapshot(
"121", day, FlightState.ACTIVE, 3,
scalars = mapOf("FLNO" to "CA001"),
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
)
val next = FlightStateEngine.snapshotState(
current,
mainRow(3),
ScheduleRecord(
"121",
scalars = mapOf("FLNO" to "CA002"),
@@ -85,9 +79,8 @@ class FlightStateEngineTest {
@Test
fun `day plan keeps DELETED state for revive conflict handling`() {
val current = FlightSnapshot("121", day, FlightState.DELETED, 3, mapOf(), emptyMap())
val next = FlightStateEngine.snapshotState(
current, ScheduleRecord("121", mapOf("FLNO" to "CA001")), day, keepDeleted = true,
mainRow(3, FlightState.DELETED), ScheduleRecord("121", mapOf("FLNO" to "CA001")), day, keepDeleted = true,
)
assertEquals(FlightState.DELETED, next.state) // 日计划不能把已删除的航班恢复成在用
assertEquals(4, next.stateVersion)
@@ -113,13 +106,8 @@ class FlightStateEngineTest {
@Test
fun `unpersisted collections are carried but never merged until their detail tables exist`() {
val current = FlightSnapshot(
"121", day, FlightState.ACTIVE, 1,
scalars = mapOf("FLNO" to "CA001"),
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
)
val next = FlightStateEngine.snapshotState(
current,
mainRow(1),
ScheduleRecord(
"121",
scalars = mapOf("FLNO" to "CA002"),
@@ -129,10 +117,9 @@ class FlightStateEngineTest {
keepDeleted = false,
)
// [G-SRVT-VIPF]wire/domain 保留这两个集合,但合并层不落库也不因"出现"清空任何明细
// [G-SRVT-VIPF]wire/domain 保留这两个集合,但合并层不落库也不进快照
assertFalse(next.collections.containsKey("SRVT"))
assertFalse(next.collections.containsKey("VIPF"))
assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"])
}
@Test
@@ -0,0 +1,47 @@
package com.gzzn.omms.msgexchange.ingress
import com.fasterxml.jackson.databind.ObjectMapper
import com.gzzn.omms.msgexchange.infra.stub.StubFlightProjectionPort
import io.micronaut.http.HttpStatus
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
/** `GET /all/flights`:投影读、过滤共享航班、失败时 503`US-12`、`C-11`、`Q21` 暂定)。 */
class FlightQueryControllerTest {
private val mapper = ObjectMapper()
@Test
fun `returns bare JSON array of non-shared flights`() {
val projection = StubFlightProjectionPort()
projection.snapshots["1"] = """{"flid":"1","scalars":{"MAID":""}}"""
projection.snapshots["2"] = """{"flid":"2","scalars":{"MAID":"1"}}"""
val controller = FlightQueryController(projection, mapper)
val response = controller.all()
assertEquals(HttpStatus.OK, response.status)
val root = mapper.readTree(response.body()!!)
assertTrue(root.isArray)
assertEquals(1, root.size())
assertEquals("1", root[0].path("flid").asText())
}
@Test
fun `projection failure returns 503 with error body not empty array`() {
val projection = StubFlightProjectionPort().apply {
failWith = IllegalStateException("redis-down")
}
val controller = FlightQueryController(projection, mapper)
val response = controller.all()
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, response.status)
val body = mapper.readTree(response.body()!!)
assertEquals("FLIGHT_PROJECTION_UNAVAILABLE", body.path("error").asText())
assertTrue(body.path("reason").asText().contains("redis-down"))
assertFalse(body.isArray)
}
}
@@ -201,7 +201,7 @@ class FlightCommitTest {
inbox = inbox,
procState = f.procState,
codec = codecReturning(msg()),
scheduleProcessor = ScheduleProcessor(commit, f.procState, f.flights, f.events, StubSnapshotLog(), opDay, mapper, clock),
scheduleProcessor = ScheduleProcessor(commit, f.procState, f.flights, f.events, StubSnapshotLog(), opDay, props, mapper, clock),
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),
@@ -259,5 +259,7 @@ class FlightCommitTest {
override fun write(writes: List<FlightProjectionWrite>) {
trace += "projection"
}
override fun readAll(): List<String> = throw UnsupportedOperationException("查询侧不参与本用例")
}
}
@@ -68,7 +68,7 @@ class IgnoreBranchTest {
inbox = inbox,
procState = proc,
codec = codec,
scheduleProcessor = ScheduleProcessor(commit, proc, flights, events, log, opDay, ObjectMapper(), clock),
scheduleProcessor = ScheduleProcessor(commit, proc, flights, events, log, 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),
@@ -3,6 +3,7 @@ package com.gzzn.omms.msgexchange.processing
import com.fasterxml.jackson.databind.ObjectMapper
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
@@ -16,11 +17,13 @@ import com.gzzn.omms.msgexchange.infra.persistence.FlightStateRepository
import com.gzzn.omms.msgexchange.infra.persistence.MsgEventRepository
import com.gzzn.omms.msgexchange.infra.persistence.PersistOutcome
import com.gzzn.omms.msgexchange.infra.persistence.PipelineTransactionManager
import com.gzzn.omms.msgexchange.infra.stub.StubFlightProjectionPort
import com.gzzn.omms.msgexchange.infra.stub.StubFlightState
import com.gzzn.omms.msgexchange.infra.stub.StubMsgEvents
import com.gzzn.omms.msgexchange.infra.stub.StubProcState
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
@@ -67,17 +70,20 @@ class ScheduleProcessorTest {
events: StubMsgEvents = StubMsgEvents(),
log: StubSnapshotLog = StubSnapshotLog(),
tx: PipelineTransactionManager? = null,
projection: FlightProjectionPort = StubFlightProjectionPort(),
snapshotBatch: Int = 200,
): ScheduleProcessor {
val txRunner = tx ?: object : PipelineTransactionManager {
override fun <T> inTransaction(block: () -> T): T = block()
}
return ScheduleProcessor(
commit = testCommit(procState = proc, msgEvents = events, txManager = txRunner),
commit = testCommit(procState = proc, msgEvents = events, projection = projection, txManager = txRunner),
procState = proc,
flightState = flights,
msgEvents = events,
snapshotLog = log,
operationDayProps = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 },
props = PipelineProps().apply { schd.snapshotBatch = snapshotBatch },
mapper = ObjectMapper(),
clock = java.time.Clock.systemUTC(),
)
@@ -197,6 +203,144 @@ class ScheduleProcessorTest {
assertTrue(log.entries.single().flags.contains(SnapshotFlag.SCHD_REVIVE_CONFLICT))
}
/** 覆盖范围内缺席 = AODB 已删掉这个航班(`INV-7`、`US-07` AC2/AC5)。 */
@Test
fun `absent flights inside the coverage range are deleted, notified and dropped from the projection`() {
val proc = StubProcState()
proc.insertIfAbsent(msgId, null)
val flights = StubFlightState()
val events = StubMsgEvents()
val log = StubSnapshotLog()
val projection = StubFlightProjectionPort()
seed(flights, "121", LocalDate.of(2026, 12, 15))
seed(flights, "122", LocalDate.of(2026, 12, 15)) // 覆盖范围内,但快照里没带
seed(flights, "133", LocalDate.of(2026, 12, 14)) // 覆盖范围外:前一日的航班不受本报文影响
projection.snapshots["122"] = """{"flid":"122"}"""
projection.snapshots["133"] = """{"flid":"133"}"""
val result = processor(proc = proc, flights = flights, events = events, log = log, projection = projection)
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
assertEquals(ApplyResult.Succeeded, result)
assertEquals(FlightState.DELETED, flights.findMainRow("122")!!.state)
assertEquals(2L, flights.findMainRow("122")!!.stateVersion) // 标删推进版本
assertFalse(projection.snapshots.containsKey("122")) // 投影按快照刷掉
val delete = events.rows.values.single { it.partitionKey == "122" }
assertEquals(Targets.KAFKA_MSG, delete.target) // 删除通知只走 msgC-9
assertTrue(delete.payloadJson!!.contains("\"deleted\":true"))
assertEquals(FlightState.ACTIVE, flights.findMainRow("133")!!.state)
assertTrue(projection.snapshots.containsKey("133"))
assertTrue(log.entries.single().flags.contains(SnapshotFlag.SCHD_ABSENT_DELETED))
}
/** 还没被日计划收录的航班(运营日为空)不属于任何覆盖范围,缺席清扫碰不到它。 */
@Test
fun `flights without an operation day are out of every coverage range`() {
val flights = StubFlightState()
val events = StubMsgEvents()
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"144", null, FlightState.ACTIVE, 1, mapOf("FLNO" to "CA144"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
processor(flights = flights, events = events)
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
assertEquals(FlightState.ACTIVE, flights.findMainRow("144")!!.state)
assertEquals(1L, flights.findMainRow("144")!!.stateVersion)
}
/** `US-07` AC4:分批写,每批一个事务;终态在整份写完、投影刷完之后才落(`INV-9`)。 */
@Test
fun `each batch commits in its own transaction and the terminal state comes last`() {
val proc = StubProcState()
proc.insertIfAbsent(msgId, null)
val flights = StubFlightState()
val tx = TxRunner { false }
val body = makeBody("121" to "15DEC261723", "122" to "15DEC261823", "123" to "15DEC261923")
val result = processor(proc = proc, flights = flights, tx = tx, snapshotBatch = 1)
.applyScheduleRecords(head(), message(body))
assertEquals(ApplyResult.Succeeded, result)
assertEquals(4, tx.entered) // 3 批领域事务 + 1 个终态事务
assertEquals(3, flights.mains.size)
assertEquals(ProcStatus.SUCCEEDED, proc.find(msgId)!!.state)
}
/** `INV-10`:投影刷不成功就不算处理完成;已提交的批次不回滚(`US-03` AC3)。 */
@Test
fun `projection failure keeps committed batches and leaves the message unfinished`() {
val proc = StubProcState()
proc.insertIfAbsent(msgId, null)
val flights = StubFlightState()
val events = StubMsgEvents()
val projection = StubFlightProjectionPort().apply { failWith = IllegalStateException("redis-down") }
val body = makeBody("121" to "15DEC261723", "122" to "15DEC261823")
org.junit.jupiter.api.Assertions.assertThrows(FlightProjectionFailure::class.java) {
processor(proc = proc, flights = flights, events = events, projection = projection, snapshotBatch = 1)
.applyScheduleRecords(head(), message(body))
}
assertEquals(2, flights.mains.size) // 已提交的批次不撤销
assertEquals(ProcStatus.PENDING, proc.find(msgId)!!.state)
assertNull(proc.find(msgId)!!.backfillNextAt)
assertTrue(events.rows.values.all { it.state == com.gzzn.omms.msgexchange.domain.EventStatus.HELD })
}
/** 运营日冲突在任何一批写入之前就查出来:整包拒绝、一条都不落(`INV-4`)。 */
@Test
fun `operation day conflict rejects the package before any batch is written`() {
val flights = StubFlightState()
seed(flights, "122", LocalDate.of(2026, 12, 16)) // 库里 122 属于 12-16
val body = makeBody("121" to "15DEC261723", "122" to "15DEC261823")
val result = processor(flights = flights, snapshotBatch = 1)
.applyScheduleRecords(head(), message(body))
assertTrue(result is ApplyResult.DeadProtocol)
assertNull(flights.findMainRow("121")) // 第一批也没写
assertEquals(LocalDate.of(2026, 12, 16), flights.findMainRow("122")!!.operationDay)
assertEquals(1L, flights.findMainRow("122")!!.stateVersion)
}
/** `C-6`、`US-07` AC3:日计划没带的字段按"AODB 已删掉该值"清空。 */
@Test
fun `fields absent from the day plan snapshot are cleared`() {
val proc = StubProcState()
proc.insertIfAbsent(msgId, null)
val flights = StubFlightState()
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1,
mapOf("SODT" to "15DEC261723", "REMC" to "old-note"),
mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
),
msgId = 1,
now = java.time.Instant.now(),
)
processor(proc = proc, flights = flights).applyScheduleRecords(head(), message(makeBody("121" to "15DEC261723")))
val snapshot = flights.loadFullSnapshot("121")!!
assertFalse(snapshot.scalars.containsKey("REMC"))
assertEquals(emptyList<Map<String, String>>(), snapshot.collections["GTDT"])
}
private fun seed(flights: StubFlightState, flid: String, day: LocalDate) {
flights.persistFullState(
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
flid, day, FlightState.ACTIVE, 1, mapOf("SODT" to "15DEC261723"), emptyMap(),
),
msgId = 1,
now = java.time.Instant.now(),
)
}
@Test
fun `infra failure inside transaction throws and leaves nothing persisted`() {
val proc = StubProcState()