diff --git a/src/main/resources/db/migration/V8__req_track_open_unique.sql b/src/main/resources/db/migration/V8__req_track_open_unique.sql new file mode 100644 index 0000000..46a44aa --- /dev/null +++ b/src/main/resources/db/migration/V8__req_track_open_unique.sql @@ -0,0 +1,34 @@ +-- ===================================================================== +-- V8:REQ_TRACK 开放态部分唯一约束 +-- --------------------------------------------------------------------- +-- 只动自有 PostgreSQL;共享 MySQL 不建表、不改结构。 +-- +-- design「记录模型」:同一 (REQ_TYPE, OPERATION_DAY, SENDER) 只允许一个开放请求, +-- 且仅对开放状态(PENDING / SENT)生效。这里只建立数据库防线;「新请求原子过期旧请求 +-- 并登记自身」的运行时流程仍归 US-08 协调器([G-REQ-TRACK])。 +-- +-- 存量若已有重复开放组,不静默删数据:带业务键的诊断直接让迁移失败,由人工处置。 +-- 保留既有普通索引 idx_req_open(仍服务含关闭态的查询)。 +-- ===================================================================== + +DO $$ +DECLARE offenders text; +BEGIN + SELECT string_agg(format('%s/%s/%s', req_type, operation_day, sender), ', ') + INTO offenders + FROM ( + SELECT req_type, operation_day, sender + FROM req_track + WHERE state IN ('PENDING', 'SENT') + GROUP BY req_type, operation_day, sender + HAVING count(*) > 1 + LIMIT 20 + ) dup; + IF offenders IS NOT NULL THEN + RAISE EXCEPTION 'V8 aborted: duplicate open REQ_TRACK rows for (req_type/operation_day/sender): %', offenders; + END IF; +END $$; + +CREATE UNIQUE INDEX uq_req_open + ON req_track (req_type, operation_day, sender) + WHERE state IN ('PENDING', 'SENT'); diff --git a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt index cda37e8..9d5c67d 100644 --- a/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt +++ b/src/test/kotlin/com/gzzn/omms/msgexchange/infra/persistence/jdbc/FlywayMigrationTest.kt @@ -3,6 +3,7 @@ package com.gzzn.omms.msgexchange.infra.persistence.jdbc import com.gzzn.omms.msgexchange.support.PgTestSupport import org.flywaydb.core.Flyway 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.Assumptions.assumeTrue import org.junit.jupiter.api.Test @@ -48,7 +49,7 @@ class FlywayMigrationTest { while (rs.next()) { records.add(Triple(rs.getString("version"), rs.getString("script"), rs.getBoolean("success"))) } - assertTrue(records.size >= 7, "flyway_schema_history must record all migrations") + assertTrue(records.size >= 8, "flyway_schema_history must record all migrations") assertEquals("1", records[0].first) assertEquals("V1__flight_state_baseline.sql", records[0].second) assertEquals("2", records[1].first) @@ -63,6 +64,8 @@ class FlywayMigrationTest { assertEquals("V6__enqueued_at.sql", records[5].second) assertEquals("7", records[6].first) assertEquals("V7__drop_processing_started_at.sql", records[6].second) + assertEquals("8", records[7].first) + assertEquals("V8__req_track_open_unique.sql", records[7].second) assertTrue(records.all { it.third }) } @@ -114,6 +117,37 @@ class FlywayMigrationTest { assertEquals(0, rs.getInt(1), "V7 必须已删除 PROCESSING_STARTED_AT") } + // V8:REQ_TRACK 开放态部分唯一约束(仅 PENDING/SENT;关闭态可并存) + stmt.executeQuery( + "SELECT indexdef FROM pg_indexes WHERE tablename = 'req_track' AND indexname = 'uq_req_open'", + ).use { rs -> + assertTrue(rs.next(), "V8 必须建立开放态唯一索引 uq_req_open") + val indexDef = rs.getString(1) + assertTrue(indexDef.contains("UNIQUE"), "uq_req_open 必须是唯一索引:$indexDef") + assertTrue(indexDef.contains("WHERE"), "uq_req_open 必须是仅约束开放态的部分索引:$indexDef") + } + stmt.executeUpdate( + "INSERT INTO req_track (req_type, operation_day, sender, state, created_at) " + + "VALUES ('RQFD-NONE', DATE '2026-09-12', 'RMS', 'PENDING', now())", + ) + stmt.executeUpdate( + "INSERT INTO req_track (req_type, operation_day, sender, state, created_at) " + + "VALUES ('RQFD-NONE', DATE '2026-09-12', 'RMS', 'DONE', now())", + ) + assertThrows(java.sql.SQLException::class.java) { + stmt.executeUpdate( + "INSERT INTO req_track (req_type, operation_day, sender, state, created_at) " + + "VALUES ('RQFD-NONE', DATE '2026-09-12', 'RMS', 'SENT', now())", + ) + } + stmt.executeUpdate( + "UPDATE req_track SET state = 'DONE' WHERE req_type = 'RQFD-NONE' AND state = 'PENDING'", + ) + stmt.executeUpdate( + "INSERT INTO req_track (req_type, operation_day, sender, state, created_at) " + + "VALUES ('RQFD-NONE', DATE '2026-09-12', 'RMS', 'PENDING', now())", + ) + // V6:入队时间是超期判据 R 的比较对象,必须非空(received_at 则允许为 NULL) stmt.executeQuery( "SELECT is_nullable FROM information_schema.columns " +