fix(infra): enforce one open request

新增 V8 部分唯一索引 uq_req_open:(REQ_TYPE, OPERATION_DAY, SENDER) 仅在 PENDING/SENT 唯一;建索引前用 DO 块检测存量重复组并以带业务键的异常中止迁移,禁止静默删数据。保留 idx_req_open 服务含关闭态的查询;不引入运行时协调器(归 US-08)。

验证:全量 ./gradlew test 126 tests / 0 fail;索引与拒绝第二条开放请求的断言在 FlywayMigrationTest(需真实 PG,本机无 Docker 时按既有 assumeTrue 跳过)。
This commit is contained in:
windyboy
2026-09-12 20:35:58 +08:00
parent 9554b1aae8
commit 21739a439d
2 changed files with 69 additions and 1 deletions
@@ -0,0 +1,34 @@
-- =====================================================================
-- V8REQ_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');
@@ -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")
}
// V8REQ_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 " +