feat: Flyway V5 community schema 基线 + 迁移验证集成测试(T3-01)

从目标模型 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 16:16:16 +08:00
parent 64c9b72fd1
commit a97814ac1c
2 changed files with 369 additions and 0 deletions
@@ -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();
@@ -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();
}
}