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:
@@ -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) // 删除通知只走 msg(C-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()
|
||||
|
||||
Reference in New Issue
Block a user