From a97814ac1cfd515e2c35a6fe75e864980bd0bf46 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 16:16:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Flyway=20V5=20community=20schema=20?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF=20+=20=E8=BF=81=E7=A7=BB=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=EF=BC=88T3-01=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从目标模型 718~875 行提取 community 全部 8 表(含 topics,ADR-018 表建功能剪、 无种子进生产链);启用 pg_trgm(02 号发现 V1 漏建,trgm 索引照建);剥离 2 条 跨 schema FK:posts.generation_job_id→creation(M4 补回)、posts.region_id→ platform.regions(M5 地区体系补回),裸可空 uuid 列与索引保留,照 V3 剪 marketplace FK 先例。CommunityMigrationIntegrationTest 8 例验证结构与裁剪。 Co-Authored-By: Claude Fable 5 --- .../db/migration/V5__community_baseline.sql | 210 ++++++++++++++++++ .../CommunityMigrationIntegrationTest.java | 159 +++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 patbond-user/src/main/resources/db/migration/V5__community_baseline.sql create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/persistence/CommunityMigrationIntegrationTest.java diff --git a/patbond-user/src/main/resources/db/migration/V5__community_baseline.sql b/patbond-user/src/main/resources/db/migration/V5__community_baseline.sql new file mode 100644 index 0000000..a2c0b4e --- /dev/null +++ b/patbond-user/src/main/resources/db/migration/V5__community_baseline.sql @@ -0,0 +1,210 @@ +/* + V5 baseline: community schema (M3 社区), extracted from + patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model, + lines 718-875) per iteration-3 T3-01 (ADR-017/ADR-018). + + Scope notes: + - All 8 community objects are created here: posts, post_media, comments, + post_likes, post_bookmarks, user_follows, topics, post_topics. The topics + tables follow the target model even though the topic feature itself is cut + from the M3 MVP (ADR-018): structure tracks the target model, feature + rollout does not. No topic seed data enters the production chain. + - pg_trgm is enabled here: V1 created only pgcrypto and citext, but + ix_posts_content_trgm (gin_trgm_ops) requires it (iteration-3/02 finding). + citext is repeated with IF NOT EXISTS for idempotence; it exists since V1. + - Cross-schema FKs into schemas not yet migrated are STRIPPED (mandatory + cut, T3-01), same precedent as V3's four marketplace FK cuts: + * posts.generation_job_id -> creation.generation_jobs(id) ON DELETE SET NULL + (creation schema belongs to M4; the M4 migration that creates the + creation schema re-adds this constraint — M4 补回) + * posts.region_id -> platform.regions(id) ON DELETE SET NULL + (the region system belongs to M5 per ADR-018's scope cut; the M5 + region-system migration re-adds this constraint — M5 补回) + Both columns are kept as bare nullable uuid and their indexes + (ix_posts_generation_job, ix_posts_region, ix_posts_region_feed) are + created as modeled. + - FKs to identity.users, pet_health.pets and media.assets are kept as-is: + all three schemas exist since V1/V3 (same shared-database precedent). + - Primary keys keep DEFAULT gen_random_uuid() as modeled (V1/V3 同规); + application code supplies explicit UUIDv7 ids, the default is a fallback. + - updated_at triggers on posts/comments reuse platform.set_updated_at() + created in V1. + - Structure only; no seed data. + - Never edit this file after release; subsequent changes go into V6+. +*/ + +CREATE EXTENSION IF NOT EXISTS citext; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE SCHEMA community; + +COMMENT ON SCHEMA community IS 'Posts, comments, reactions, follows and topics'; + +CREATE TABLE community.posts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL, + -- generation_job_id: bare nullable uuid, FK to creation.generation_jobs stripped (M4 补回) + generation_job_id uuid, + category varchar(24) NOT NULL DEFAULT 'general', + title varchar(120), + content text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'draft', + visibility varchar(16) NOT NULL DEFAULT 'public', + -- region_id: bare nullable uuid, FK to platform.regions stripped (M5 补回) + region_id uuid, + location_text_snapshot varchar(128), + like_count bigint NOT NULL DEFAULT 0, + comment_count bigint NOT NULL DEFAULT 0, + bookmark_count bigint NOT NULL DEFAULT 0, + idempotency_key varchar(128), + request_hash bytea, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + published_at timestamptz, + deleted_at timestamptz, + version integer NOT NULL DEFAULT 0, + CONSTRAINT uq_posts_author_idempotency UNIQUE (author_user_id, idempotency_key), + CONSTRAINT ck_posts_category CHECK (category IN ('general', 'help', 'ai_creation')), + CONSTRAINT ck_posts_title CHECK (title IS NULL OR char_length(btrim(title)) BETWEEN 1 AND 120), + CONSTRAINT ck_posts_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 10000), + CONSTRAINT ck_posts_status CHECK (status IN ('draft', 'published', 'hidden', 'archived')), + CONSTRAINT ck_posts_visibility CHECK (visibility IN ('public', 'followers', 'private')), + CONSTRAINT ck_posts_publish_state CHECK ( + (status = 'published' AND published_at IS NOT NULL AND deleted_at IS NULL) + OR status <> 'published' + ), + CONSTRAINT ck_posts_counts CHECK (like_count >= 0 AND comment_count >= 0 AND bookmark_count >= 0), + CONSTRAINT ck_posts_idempotency CHECK ( + (idempotency_key IS NULL AND request_hash IS NULL) + OR ( + idempotency_key = btrim(idempotency_key) + AND char_length(idempotency_key) BETWEEN 1 AND 128 + AND octet_length(request_hash) = 32 + ) + ), + CONSTRAINT ck_posts_version CHECK (version >= 0) +); + +CREATE INDEX ix_posts_feed + ON community.posts (published_at DESC, id DESC) + WHERE status = 'published' AND visibility = 'public'; +CREATE INDEX ix_posts_author_created + ON community.posts (author_user_id, created_at DESC, id DESC); +CREATE INDEX ix_posts_pet_created ON community.posts (pet_id, created_at DESC); +CREATE INDEX ix_posts_generation_job ON community.posts (generation_job_id); +CREATE INDEX ix_posts_region_feed + ON community.posts (region_id, published_at DESC, id DESC) + WHERE status = 'published' AND visibility = 'public'; +CREATE INDEX ix_posts_region ON community.posts (region_id); +CREATE INDEX ix_posts_content_trgm + ON community.posts USING gin (content gin_trgm_ops) + WHERE status = 'published'; + +CREATE TABLE community.post_media ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + position smallint NOT NULL, + asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT, + is_cover boolean NOT NULL DEFAULT false, + caption varchar(300), + PRIMARY KEY (post_id, position), + UNIQUE (post_id, asset_id), + CONSTRAINT ck_post_media_position CHECK (position >= 0) +); + +CREATE INDEX ix_post_media_asset ON community.post_media (asset_id); +CREATE UNIQUE INDEX uq_post_media_cover + ON community.post_media (post_id) + WHERE is_cover; + +-- Comments are deliberately one flat level. reply_to_user_id supports @ replies +-- without parent_comment_id, recursive queries, or unbounded comment nesting. +CREATE TABLE community.comments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + reply_to_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL, + content varchar(2000) NOT NULL, + status varchar(16) NOT NULL DEFAULT 'visible', + client_request_id varchar(128), + request_hash bytea, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + UNIQUE (author_user_id, client_request_id), + CONSTRAINT ck_comments_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 2000), + CONSTRAINT ck_comments_status CHECK (status IN ('visible', 'hidden', 'deleted')), + CONSTRAINT ck_comments_idempotency CHECK ( + (client_request_id IS NULL AND request_hash IS NULL) + OR ( + client_request_id = btrim(client_request_id) + AND char_length(client_request_id) BETWEEN 1 AND 128 + AND octet_length(request_hash) = 32 + ) + ), + CONSTRAINT ck_comments_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); + +CREATE INDEX ix_comments_post_created + ON community.comments (post_id, created_at DESC, id DESC); +CREATE INDEX ix_comments_author_created + ON community.comments (author_user_id, created_at DESC, id DESC); +CREATE INDEX ix_comments_reply_user + ON community.comments (reply_to_user_id, created_at DESC); + +CREATE TABLE community.post_likes ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (post_id, user_id) +); + +CREATE INDEX ix_post_likes_user_created + ON community.post_likes (user_id, created_at DESC, post_id); + +CREATE TABLE community.post_bookmarks ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (post_id, user_id) +); + +CREATE INDEX ix_post_bookmarks_user_created + ON community.post_bookmarks (user_id, created_at DESC, post_id); + +CREATE TABLE community.user_follows ( + follower_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + followee_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (follower_user_id, followee_user_id), + CONSTRAINT ck_user_follows_self CHECK (follower_user_id <> followee_user_id) +); + +CREATE INDEX ix_user_follows_followee + ON community.user_follows (followee_user_id, created_at DESC, follower_user_id); + +CREATE TABLE community.topics ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name citext NOT NULL UNIQUE, + description varchar(300), + status varchar(16) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_topics_name CHECK (name::text = btrim(name::text) AND char_length(name::text) BETWEEN 1 AND 32), + CONSTRAINT ck_topics_status CHECK (status IN ('active', 'hidden')) +); + +CREATE TABLE community.post_topics ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + topic_id uuid NOT NULL REFERENCES community.topics(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, topic_id) +); + +CREATE INDEX ix_post_topics_topic ON community.post_topics (topic_id, post_id); + +-- Automatic updated_at maintenance (function created in V1). Business version +-- increments remain explicit so optimistic locking stays visible in +-- repository update statements. +CREATE TRIGGER trg_posts_updated_at BEFORE UPDATE ON community.posts + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_comments_updated_at BEFORE UPDATE ON community.comments + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/persistence/CommunityMigrationIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/persistence/CommunityMigrationIntegrationTest.java new file mode 100644 index 0000000..894a45b --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/persistence/CommunityMigrationIntegrationTest.java @@ -0,0 +1,159 @@ +package com.patbond.patbond.user.persistence; + +import com.patbond.patbond.user.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.simple.JdbcClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Flyway V5 (community schema baseline) applies cleanly on a postgres:18 + * container after V1..V4 and produces the expected structure. Validates the + * mandatory cross-schema FK cuts (ADR-017/ADR-018, T3-01): + * generation_job_id (creation belongs to M4) and region_id (regions belong + * to the M5 region system) exist as bare nullable uuid columns without FKs, + * and the pg_trgm extension missing from V1 is now installed. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class CommunityMigrationIntegrationTest { + + @Autowired + private JdbcClient jdbcClient; + + @Test + void v5CreatesCommunitySchema() { + String exists = jdbcClient.sql( + "SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'community'") + .query(String.class) + .optional() + .orElse(null); + assertThat(exists).isEqualTo("community"); + } + + @Test + void v5CreatesAllCommunityTables() { + int count = jdbcClient.sql( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = 'community' AND table_type = 'BASE TABLE'") + .query(Integer.class) + .single(); + // 8 tables: posts, post_media, comments, post_likes, post_bookmarks, + // user_follows, topics, post_topics (topics built per target model + // even though the topic feature is cut from the M3 MVP, ADR-018). + assertThat(count).isEqualTo(8); + } + + @Test + void v5EnablesPgTrgmAndBuildsContentTrgmIndex() { + int extension = jdbcClient.sql( + "SELECT COUNT(*) FROM pg_extension WHERE extname = 'pg_trgm'") + .query(Integer.class) + .single(); + assertThat(extension).isEqualTo(1); + + int index = jdbcClient.sql( + "SELECT COUNT(*) FROM pg_indexes " + + "WHERE schemaname = 'community' AND tablename = 'posts' " + + "AND indexname = 'ix_posts_content_trgm'") + .query(Integer.class) + .single(); + assertThat(index).isEqualTo(1); + } + + @Test + void v5PostsColumnsExistWithoutCreationAndRegionFKs() { + // generation_job_id and region_id columns exist (bare nullable uuid) + int colCount = jdbcClient.sql( + "SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_schema = 'community' AND table_name = 'posts' " + + "AND column_name IN ('generation_job_id', 'region_id') " + + "AND udt_name = 'uuid' AND is_nullable = 'YES'") + .query(Integer.class) + .single(); + assertThat(colCount).isEqualTo(2); + + // No FK constraints targeting creation.generation_jobs (M4) or + // platform.regions (M5 region system) + int fkCount = jdbcClient.sql( + "SELECT COUNT(*) FROM information_schema.table_constraints " + + "WHERE table_schema = 'community' AND table_name = 'posts' " + + "AND constraint_type = 'FOREIGN KEY' " + + "AND (constraint_name LIKE '%generation%' OR constraint_name LIKE '%region%')") + .query(Integer.class) + .single(); + assertThat(fkCount).isEqualTo(0); + } + + @Test + void v5PostsKeepsInSchemaAndV1V3FKs() { + // author_user_id -> identity.users and pet_id -> pet_health.pets stay, + // both target schemas exist since V1/V3. + int fkCount = jdbcClient.sql( + "SELECT COUNT(DISTINCT ccu.table_schema || '.' || ccu.table_name) " + + "FROM information_schema.table_constraints tc " + + "JOIN information_schema.constraint_column_usage ccu " + + " ON tc.constraint_name = ccu.constraint_name " + + " AND tc.constraint_schema = ccu.constraint_schema " + + "WHERE tc.table_schema = 'community' AND tc.table_name = 'posts' " + + "AND tc.constraint_type = 'FOREIGN KEY' " + + "AND ccu.table_schema || '.' || ccu.table_name " + + " IN ('identity.users', 'pet_health.pets')") + .query(Integer.class) + .single(); + assertThat(fkCount).isEqualTo(2); + } + + @Test + void v5StructureSpotChecks() { + // topics.name is citext (case-insensitive unique topic names) + String nameType = jdbcClient.sql( + "SELECT udt_name FROM information_schema.columns " + + "WHERE table_schema = 'community' AND table_name = 'topics' " + + "AND column_name = 'name'") + .query(String.class) + .single(); + assertThat(nameType).isEqualTo("citext"); + + String versionDefault = jdbcClient.sql( + "SELECT column_default FROM information_schema.columns " + + "WHERE table_schema = 'community' AND table_name = 'posts' " + + "AND column_name = 'version'") + .query(String.class) + .single(); + assertThat(versionDefault).isEqualTo("0"); + + // partial unique cover index on post_media + int coverIndex = jdbcClient.sql( + "SELECT COUNT(*) FROM pg_indexes " + + "WHERE schemaname = 'community' AND tablename = 'post_media' " + + "AND indexname = 'uq_post_media_cover'") + .query(Integer.class) + .single(); + assertThat(coverIndex).isEqualTo(1); + } + + @Test + void v5TriggersAreCreated() { + // The two updated_at triggers on posts and comments + int triggerCount = jdbcClient.sql( + "SELECT COUNT(DISTINCT trigger_name) FROM information_schema.triggers " + + "WHERE trigger_schema = 'community' " + + "AND trigger_name IN ('trg_posts_updated_at', 'trg_comments_updated_at')") + .query(Integer.class) + .single(); + assertThat(triggerCount).isEqualTo(2); + } + + @Test + void v5TopicsHaveNoSeedRows() { + // ADR-018: no topic seed data enters the production migration chain. + int rows = jdbcClient.sql("SELECT COUNT(*) FROM community.topics") + .query(Integer.class) + .single(); + assertThat(rows).isZero(); + } +}