-- ============================================================================= -- bibimbap local-dev schema bootstrap -- ============================================================================= -- 출처/권위 수준 (반드시 읽을 것): -- * recruit_posts : 권위 DDL. docs/recruit-posts-ddl.sql 와 동일 스펙. -- * user_auth_identities : security-hardening 의 active-unique index 포함(docs/security-hardening-ddl.sql). -- * users / games / -- game_comments / game_likes : **비권위 복원본**. 운영 DB 의 pg_dump 가 아니라 -- MyBatis 매퍼(@Insert/@Select)의 컬럼 목록 + data POJO 의 Java 타입에서 역추출. -- 컬럼 "이름/유무" 는 매퍼와 일치(확인됨)하나, **타입·길이·기본값·제약은 추론값**이다. -- 실제 운영 스키마와 대조(pg_dump) 전까지 권위 자료로 신뢰하지 말 것. -- -- 타입 매핑 근거: Long→bigint, Integer→integer, Boolean→boolean, -- String→varchar(길이추정)/text, OffsetDateTime→timestamptz. -- -- 적용 범위: 이 스크립트는 dev 스키마를 채운다(기본 프로필). live 스키마는 빈 상태로 -- 생성만 한다. live 도 채우려면 아래 'SET search_path TO live;' 로 바꿔 동일 블록을 재실행. -- PostgreSQL 컨테이너의 docker-entrypoint-initdb.d 에서 최초 1회 자동 실행된다. -- ============================================================================= CREATE SCHEMA IF NOT EXISTS dev; CREATE SCHEMA IF NOT EXISTS live; SET search_path TO dev; -- --------------------------------------------------------------------------- -- users (비권위 복원본) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "users_id_seq"; CREATE TABLE IF NOT EXISTS "users" ( "id" bigint DEFAULT nextval('users_id_seq'::regclass) NOT NULL, "display_name" character varying(200), "canonical_email" character varying(320), "avatar_url" character varying(500), "role" character varying(30) DEFAULT 'USER' NOT NULL, "status" character varying(30), "last_login_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "is_delete" boolean DEFAULT false NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "users_id_seq" OWNED BY "users"."id"; -- W1 RBAC: users.role 값집합 확장(CHECK) + permissions_epoch (권위: docs/rbac-ddl.sql) DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_role_check') THEN ALTER TABLE "users" ADD CONSTRAINT "users_role_check" CHECK ("role" IN ('ADMIN', 'SUBADMIN', 'USER')); END IF; END $$; ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "permissions_epoch" bigint DEFAULT 0 NOT NULL; -- --------------------------------------------------------------------------- -- user_auth_identities (비권위 복원본 + security-hardening active-unique index) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "user_auth_identities_id_seq"; CREATE TABLE IF NOT EXISTS "user_auth_identities" ( "id" bigint DEFAULT nextval('user_auth_identities_id_seq'::regclass) NOT NULL, "user_id" bigint NOT NULL REFERENCES "users" ("id"), "provider" character varying(30) NOT NULL, "provider_user_id" character varying(200) NOT NULL, "email" character varying(320), "password_hash" character varying(500), "display_name" character varying(200), "avatar_url" character varying(500), "last_login_at" timestamp with time zone, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "is_delete" boolean DEFAULT false NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "user_auth_identities_id_seq" OWNED BY "user_auth_identities"."id"; CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_auth_identities_provider_user_id_active" ON "user_auth_identities" ("provider", "provider_user_id") WHERE "is_delete" IS NOT TRUE; -- --------------------------------------------------------------------------- -- games (비권위 복원본) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "games_id_seq"; CREATE TABLE IF NOT EXISTS "games" ( "id" bigint DEFAULT nextval('games_id_seq'::regclass) NOT NULL, "user_id" bigint NOT NULL REFERENCES "users" ("id"), "name" character varying(200) NOT NULL, "creator_note" text, "git_url" character varying(500), "webgl_path" character varying(500), "thumbnail_url" character varying(500), "like_count" integer DEFAULT 0 NOT NULL, "is_visible" boolean DEFAULT true NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "is_delete" boolean DEFAULT false NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "games_id_seq" OWNED BY "games"."id"; CREATE INDEX IF NOT EXISTS "idx_games_visible_order" ON "games" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC); -- --------------------------------------------------------------------------- -- game_comments (비권위 복원본 — 매퍼 존재하나 컨트롤러 미연결) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "game_comments_id_seq"; CREATE TABLE IF NOT EXISTS "game_comments" ( "id" bigint DEFAULT nextval('game_comments_id_seq'::regclass) NOT NULL, "game_id" bigint NOT NULL REFERENCES "games" ("id"), "nickname" character varying(100), "content" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "deleted_at" timestamp with time zone, "is_delete" boolean DEFAULT false NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "game_comments_id_seq" OWNED BY "game_comments"."id"; -- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴) ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint REFERENCES "users" ("id"); -- --------------------------------------------------------------------------- -- game_reviews (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq"; CREATE TABLE IF NOT EXISTS "game_reviews" ( "id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL, "game_id" bigint NOT NULL REFERENCES "games" ("id"), "user_id" bigint NOT NULL REFERENCES "users" ("id"), "rating" smallint NOT NULL, "body" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "deleted_at" timestamp with time zone, "is_delete" boolean DEFAULT false NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "game_reviews_rating_check" CHECK ("rating" BETWEEN 1 AND 5) ); ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id"; CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active" ON "game_reviews" ("game_id", "user_id") WHERE "is_delete" IS NOT TRUE; CREATE INDEX IF NOT EXISTS "idx_game_reviews_game" ON "game_reviews" ("game_id") WHERE "is_delete" = false; -- C3 재분류: 다축(육각형) game_review_axes + 집계뷰 game_review_stats 는 아래에 W3-2 일반 DDL 로 신설(roadmap.md:63,201,202, W2-3 동결 무관). -- --------------------------------------------------------------------------- -- game_review_axes / is_rating_manual / updated_at / game_review_stats (W3-2 고도화, docs/game-reviews-ddl.sql 와 동일) -- 1) game_comments.updated_at (A3) ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL; -- 기존 댓글이 '수정됨' 오표시되지 않도록 정렬(멱등: 이미 정렬된 행엔 무영향) UPDATE "game_comments" SET "updated_at" = "created_at" WHERE "updated_at" > "created_at"; COMMENT ON COLUMN "game_comments"."updated_at" IS '덧글 마지막 수정 시각. updated_at > created_at 이면 수정됨(리뷰 대칭)'; -- 2) game_reviews.is_rating_manual (overall 출처 구분) ALTER TABLE "game_reviews" ADD COLUMN IF NOT EXISTS "is_rating_manual" boolean DEFAULT false NOT NULL; COMMENT ON COLUMN "game_reviews"."is_rating_manual" IS 'true=유저 직접선택 overall, false=6축 자동평균'; -- 3) game_review_axes (다축 평점, 리뷰당 6행) CREATE SEQUENCE IF NOT EXISTS "game_review_axes_id_seq"; CREATE TABLE IF NOT EXISTS "game_review_axes" ( "id" bigint DEFAULT nextval('game_review_axes_id_seq'::regclass) NOT NULL, "review_id" bigint NOT NULL, "axis_key" character varying(20) NOT NULL, "score" smallint NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "game_review_axes_id_seq" OWNED BY "game_review_axes"."id"; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_review_id_fkey') THEN ALTER TABLE "game_review_axes" ADD CONSTRAINT "game_review_axes_review_id_fkey" FOREIGN KEY ("review_id") REFERENCES "game_reviews" ("id"); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_score_check') THEN ALTER TABLE "game_review_axes" ADD CONSTRAINT "game_review_axes_score_check" CHECK ("score" BETWEEN 1 AND 5); END IF; END $$; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_axis_key_check') THEN ALTER TABLE "game_review_axes" ADD CONSTRAINT "game_review_axes_axis_key_check" CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual')); END IF; END $$; CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_review_axes_review_axis" ON "game_review_axes" ("review_id", "axis_key"); CREATE INDEX IF NOT EXISTS "idx_game_review_axes_review" ON "game_review_axes" ("review_id"); COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6행). axis_key 6종 각 1~5'; COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual'; -- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원) -- fan-out 방지: game_review_axes 직접 JOIN 시 리뷰가 axes 행수만큼 복제되어 -- COUNT(*)/AVG(rating) 왜곡. 축 평균은 서브쿼리에서 game 단위 선집계 후 LEFT JOIN. CREATE OR REPLACE VIEW "game_review_stats" AS SELECT r."game_id" AS "game_id", ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating", COUNT(*) AS "review_count", MAX(ax."avg_immersion") AS "avg_immersion", MAX(ax."avg_creativity") AS "avg_creativity", MAX(ax."avg_controls") AS "avg_controls", MAX(ax."avg_completeness") AS "avg_completeness", MAX(ax."avg_sound") AS "avg_sound", MAX(ax."avg_visual") AS "avg_visual" FROM "game_reviews" r LEFT JOIN ( SELECT rv."game_id" AS "game_id", ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='immersion'),1) AS "avg_immersion", ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='creativity'),1) AS "avg_creativity", ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='controls'),1) AS "avg_controls", ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='completeness'),1) AS "avg_completeness", ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='sound'),1) AS "avg_sound", ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual" FROM "game_reviews" rv JOIN "game_review_axes" a ON a."review_id" = rv."id" WHERE rv."is_delete" IS NOT TRUE GROUP BY rv."game_id" ) ax ON ax."game_id" = r."game_id" WHERE r."is_delete" IS NOT TRUE GROUP BY r."game_id"; COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균'; -- --------------------------------------------------------------------------- -- game_likes (비권위 복원본 — 매퍼는 hard delete 사용, is_delete 컬럼 없음) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "game_likes_id_seq"; CREATE TABLE IF NOT EXISTS "game_likes" ( "id" bigint DEFAULT nextval('game_likes_id_seq'::regclass) NOT NULL, "game_id" bigint NOT NULL REFERENCES "games" ("id"), "user_key" character varying(200) NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "game_likes_id_seq" OWNED BY "game_likes"."id"; -- --------------------------------------------------------------------------- -- recruit_posts (권위 DDL — docs/recruit-posts-ddl.sql 와 동일) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "recruit_posts_id_seq"; CREATE TABLE IF NOT EXISTS "recruit_posts" ( "id" bigint DEFAULT nextval('recruit_posts_id_seq'::regclass) NOT NULL, "user_id" bigint NOT NULL, "project_name" character varying(200) NOT NULL, "genre" character varying(80), "summary" character varying(200) NOT NULL, "role" character varying(30) NOT NULL, "project_status" character varying(50) NOT NULL, "participation_type" character varying(30) NOT NULL, "expected_period" character varying(80), "team_members" character varying(200), "contact" character varying(200) NOT NULL, "description" text, "reference_url" character varying(500), "deadline_at" timestamp with time zone, "is_visible" boolean DEFAULT true NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "deleted_at" timestamp with time zone, "is_delete" boolean DEFAULT false NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "recruit_posts_id_seq" OWNED BY "recruit_posts"."id"; ALTER TABLE "recruit_posts" ADD CONSTRAINT "recruit_posts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id"); ALTER TABLE "recruit_posts" ADD CONSTRAINT "recruit_posts_role_check" CHECK ("role" IN ('기획', '아트', '프로그래머')); ALTER TABLE "recruit_posts" ADD CONSTRAINT "recruit_posts_participation_type_check" CHECK ("participation_type" IN ('취미', '수익쉐어', '유급', '게임잼')); CREATE INDEX IF NOT EXISTS "idx_recruit_posts_visible_order" ON "recruit_posts" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC); CREATE INDEX IF NOT EXISTS "idx_recruit_posts_role" ON "recruit_posts" ("role") WHERE "is_delete" = false AND "is_visible" = true; CREATE INDEX IF NOT EXISTS "idx_recruit_posts_participation_type" ON "recruit_posts" ("participation_type") WHERE "is_delete" = false AND "is_visible" = true; -- --------------------------------------------------------------------------- -- W1 RBAC: permissions / user_permissions / rbac_audit_log -- (권위 DDL — docs/rbac-ddl.sql 와 동일 스펙) -- --------------------------------------------------------------------------- CREATE SEQUENCE IF NOT EXISTS "permissions_id_seq"; CREATE TABLE IF NOT EXISTS "permissions" ( "id" bigint DEFAULT nextval('permissions_id_seq'::regclass) NOT NULL, "permission_key" character varying(50) NOT NULL, "display_name" character varying(100) NOT NULL, "is_active" boolean DEFAULT true NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "permissions_id_seq" OWNED BY "permissions"."id"; CREATE UNIQUE INDEX IF NOT EXISTS "ux_permissions_key" ON "permissions" ("permission_key"); CREATE SEQUENCE IF NOT EXISTS "user_permissions_id_seq"; CREATE TABLE IF NOT EXISTS "user_permissions" ( "id" bigint DEFAULT nextval('user_permissions_id_seq'::regclass) NOT NULL, "user_id" bigint NOT NULL, "permission_key" character varying(50) NOT NULL, "granted_by" bigint, "created_at" timestamp with time zone DEFAULT now() NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "user_permissions_id_seq" OWNED BY "user_permissions"."id"; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_permissions_user_id_fkey') THEN ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id"); END IF; END $$; CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_permissions_user_key" ON "user_permissions" ("user_id", "permission_key"); CREATE INDEX IF NOT EXISTS "idx_user_permissions_user" ON "user_permissions" ("user_id"); CREATE SEQUENCE IF NOT EXISTS "rbac_audit_log_id_seq"; CREATE TABLE IF NOT EXISTS "rbac_audit_log" ( "id" bigint DEFAULT nextval('rbac_audit_log_id_seq'::regclass) NOT NULL, "actor_id" bigint NOT NULL, "target_id" bigint NOT NULL, "action" character varying(30) NOT NULL, "permission_key" character varying(50), "created_at" timestamp with time zone DEFAULT now() NOT NULL, PRIMARY KEY ("id") ); ALTER SEQUENCE "rbac_audit_log_id_seq" OWNED BY "rbac_audit_log"."id"; DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'rbac_audit_log_action_check') THEN ALTER TABLE "rbac_audit_log" ADD CONSTRAINT "rbac_audit_log_action_check" CHECK ("action" IN ('APPOINT', 'DEMOTE', 'GRANT', 'REVOKE')); END IF; END $$;