From 2d5601bda7f1d33e70e0b66b3c51c9b415841f36 Mon Sep 17 00:00:00 2001 From: art Date: Wed, 24 Jun 2026 11:33:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(jam):=20W2-3=20=EC=9E=BC=20=ED=8F=89?= =?UTF-8?q?=EA=B0=80=20=EB=8F=99=EA=B2=B0=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=E2=80=94=20jam=5Fcriteria/scores/votes/awards=20+=20jam=5Fscor?= =?UTF-8?q?e=5Fstats=20VIEW=20(=E2=9A=A0=EF=B8=8F=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EB=8B=A8=EC=9D=BC=EA=B6=8C=EC=9C=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 4테이블 + 1 집계 VIEW(docs/jam-eval-ddl.sql 권위 + db/schema.sql 동기, 멱등) - 평가단위 = (jam_id,game_id) 활성 자연키. FK game_id→games·jam_id→jams 직접(jam_entries.id surrogate 아님). W2-1 jam_entries 활성 UNIQUE 가 1:1 보장 - 무결성: 1심사위원1기준1점·1인1표(UNIQUE), score 1~5·weight≥0·track 4값·rank≥1(CHECK) - jam_score_stats VIEW: WITH per_criterion CTE 선집계 후 LEFT JOIN(fan-out 가드, game_review_stats 선례 동형) + NULLIF 0-division 가드. 미채점 criterion 자동 제외. 컬럼 소문자 snake(하류 매퍼가 AS "camelCase" 인용 매핑 — W2-4 소관) - 하류 W2-4/5/6 소비 계약 동결. 매퍼/컨트롤러는 비목표(하류 산출) 검증: ./mvnw test 119/119 GREEN 유지(Java 무변경·회귀 0). L2 contract PASS — DDL 적용·제약 거부 4종·VIEW 집계 정합(가중합/단순합 손계산 일치, 심사위원 2→4 증가에도 fan-out 왜곡 0, NULLIF 가드). 집합전수 AC-T1~6 PASS(DROP 0·리뷰 인프라 무변경). Co-Authored-By: Claude Opus 4.8 (1M context) --- db/schema.sql | 196 ++++++++++++++++++++++++++++++++++++++++++ docs/jam-eval-ddl.sql | 194 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 docs/jam-eval-ddl.sql diff --git a/db/schema.sql b/db/schema.sql index 4f3eef3..19b98af 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -594,3 +594,199 @@ CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_judges_jam_user" ON "jam_judges" ("jam_id", "user_id"); CREATE INDEX IF NOT EXISTS "idx_jam_judges_jam" ON "jam_judges" ("jam_id"); + +-- --------------------------------------------------------------------------- +-- 잼 평가 W2-3 (권위 DDL — docs/jam-eval-ddl.sql 와 동일. 심사/투표/시상 동결) +-- --------------------------------------------------------------------------- +-- 평가단위 = (jam_id, game_id) 자연키 = 활성 출품작(jam_entries 1:1). jams/games/users 선존재. +-- game_reviews/game_review_stats(W3-2) 무변경 — 시상 USER_RATING 트랙이 VIEW 를 읽기만. + +-- =========================================================================== +-- 1) jam_criteria (잼별 설정형 심사 기준. 고정 6축 강제 대신 정석) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_criteria_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_criteria" ( + "id" bigint DEFAULT nextval('jam_criteria_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, + "criterion_key" character varying(40) NOT NULL, -- 잼 내 기준 식별(영문 키) + "display_name" character varying(100) NOT NULL, -- 표시 라벨 + "sort_order" integer DEFAULT 0 NOT NULL, -- 표시 순서 + "weight" numeric(6,3) DEFAULT 1.0 NOT NULL, -- 가중 종합 산정 가중치(>0 권장) + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_criteria_id_seq" OWNED BY "jam_criteria"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_jam_fkey') THEN + ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + -- weight 음수 방지(0 은 허용하되 가중종합에서 NULLIF 가드 — 0-division 방어는 VIEW) + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_weight_check') THEN + ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_weight_check" + CHECK ("weight" >= 0); + END IF; +END +$$; +-- 잼 내 criterion_key 중복 방지 +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_criteria_jam_key" + ON "jam_criteria" ("jam_id", "criterion_key"); +CREATE INDEX IF NOT EXISTS "idx_jam_criteria_jam" + ON "jam_criteria" ("jam_id", "sort_order"); + +-- =========================================================================== +-- 2) jam_scores (심사위원 점수. criterion 단위 행. is-judge 검증은 앱계층 W2-2) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_scores_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_scores" ( + "id" bigint DEFAULT nextval('jam_scores_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2 + "game_id" bigint NOT NULL, -- 평가단위 자연키 2/2(출품작) + "judge_user_id" bigint NOT NULL, -- 심사위원(FK users; is-judge 는 W2-2) + "criterion_key" character varying(40) NOT NULL, -- jam_criteria.criterion_key 논리참조 + "score" smallint NOT NULL, -- 1~5 + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_scores_id_seq" OWNED BY "jam_scores"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_jam_fkey') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_game_fkey') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_game_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_judge_fkey') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_judge_fkey" + FOREIGN KEY ("judge_user_id") REFERENCES "users" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_score_check') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_score_check" + CHECK ("score" BETWEEN 1 AND 5); + END IF; +END +$$; +-- 한 심사위원이 한 출품작의 한 기준에 1점만(수정=UPDATE, 재입력 멱등) +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_scores_jam_game_judge_criterion" + ON "jam_scores" ("jam_id", "game_id", "judge_user_id", "criterion_key"); +-- 집계 join 대상(출품작 단위 선집계) +CREATE INDEX IF NOT EXISTS "idx_jam_scores_jam_game" + ON "jam_scores" ("jam_id", "game_id"); + +-- =========================================================================== +-- 3) jam_votes (인기투표. 잼당 1인 1표. game_likes 와 별개. 평가기간 게이트=앱계층) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_votes_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_votes" ( + "id" bigint DEFAULT nextval('jam_votes_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2 + "game_id" bigint NOT NULL, -- 투표 대상 출품작 + "voter_user_id" bigint NOT NULL, -- 투표자(FK users; 로그인 1인1표) + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_votes_id_seq" OWNED BY "jam_votes"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_jam_fkey') THEN + ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_game_fkey') THEN + ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_game_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_voter_fkey') THEN + ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_voter_fkey" + FOREIGN KEY ("voter_user_id") REFERENCES "users" ("id"); + END IF; +END +$$; +-- 잼당 1인 1표(최애 1개). 표 변경 = UPDATE game_id 또는 DELETE→INSERT(앱 정책 W2-5) +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_votes_jam_voter" + ON "jam_votes" ("jam_id", "voter_user_id"); +-- 투표 집계(출품작별 count) +CREATE INDEX IF NOT EXISTS "idx_jam_votes_jam_game" + ON "jam_votes" ("jam_id", "game_id"); + +-- =========================================================================== +-- 4) jam_awards (시상. 3트랙 + grand. 트랙별 개별 수상 + 가중 grand) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_awards_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_awards" ( + "id" bigint DEFAULT nextval('jam_awards_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2 + "game_id" bigint NOT NULL, -- 수상 출품작 + "award_track" character varying(20) NOT NULL, -- JUDGE|USER_RATING|POPULAR|GRAND + "rank" integer NOT NULL, -- 트랙 내 순위(1=대상) + "score_value" numeric(10,4), -- 산정 점수(트랙별 의미 다름; NULL 허용) + "computed_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_awards_id_seq" OWNED BY "jam_awards"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_jam_fkey') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_game_fkey') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_game_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_track_check') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_track_check" + CHECK ("award_track" IN ('JUDGE', 'USER_RATING', 'POPULAR', 'GRAND')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_rank_check') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_rank_check" + CHECK ("rank" >= 1); + END IF; +END +$$; +-- 잼·트랙·순위 1행(재산정=DELETE→INSERT 또는 UPSERT 멱등). 동률은 같은 rank 허용 위해 +-- (jam_id, award_track, game_id) UNIQUE 로 출품작 트랙 중복만 차단(같은 rank 동률 허용). +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_awards_jam_track_game" + ON "jam_awards" ("jam_id", "award_track", "game_id"); +CREATE INDEX IF NOT EXISTS "idx_jam_awards_jam_track" + ON "jam_awards" ("jam_id", "award_track", "rank"); + +-- =========================================================================== +-- 5) jam_score_stats (심사 집계 VIEW. criterion별 평균 + 가중 종합. fan-out 방지) +-- game_review_stats(schema.sql:217) 선례: 출품작 단위 선집계 후 노출. +-- 가중 종합 = SUM(criterion_avg * weight) / NULLIF(SUM(weight),0) (0-division 가드) +-- criterion 미채점 시 그 criterion 은 가중 종합에서 제외(점수 있는 기준만). +-- =========================================================================== +CREATE OR REPLACE VIEW "jam_score_stats" AS +WITH per_criterion AS ( + SELECT + s."jam_id" AS jam_id, + s."game_id" AS game_id, + s."criterion_key" AS criterion_key, + AVG(s."score")::numeric AS criterion_avg, + COUNT(DISTINCT s."judge_user_id") AS judge_count + FROM "jam_scores" s + GROUP BY s."jam_id", s."game_id", s."criterion_key" +) +SELECT + pc."jam_id" AS jam_id, + pc."game_id" AS game_id, + -- 출품작 단위 종합: 채점된 criterion 의 가중 평균 + ROUND( + SUM(pc.criterion_avg * COALESCE(c."weight", 1.0)) + / NULLIF(SUM(COALESCE(c."weight", 1.0)), 0) + , 3) AS weighted_total, + ROUND(AVG(pc.criterion_avg), 3) AS simple_total, -- 비가중 평균(참고) + COUNT(pc.criterion_key) AS scored_criteria, -- 채점된 기준 수 + MAX(pc.judge_count) AS judge_count -- 최다 기준 심사 인원 +FROM per_criterion pc +LEFT JOIN "jam_criteria" c + ON c."jam_id" = pc."jam_id" AND c."criterion_key" = pc."criterion_key" +GROUP BY pc."jam_id", pc."game_id"; +COMMENT ON VIEW "jam_score_stats" IS + 'W2-3 동결 심사 집계뷰. 출품작(jam_id,game_id)별 가중 종합·비가중 평균·채점 기준수·심사 인원. fan-out 방지 선집계(game_review_stats 선례).'; diff --git a/docs/jam-eval-ddl.sql b/docs/jam-eval-ddl.sql new file mode 100644 index 0000000..abbe2cf --- /dev/null +++ b/docs/jam-eval-ddl.sql @@ -0,0 +1,194 @@ +-- W2-3 잼 평가 통합(심사점수/인기투표/시상집계). 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용. +-- 선행: docs/jam-ddl.sql(jams/jam_entries — 알파벳 글롭 순 jam-ddl 먼저 적용). +-- game_reviews/game_review_stats(W3-2) 변경 없음 — 시상 USER_RATING 트랙이 VIEW 를 읽기만. +-- 평가단위 = (jam_id, game_id) 자연키 = 활성 출품작(jam_entries 1:1 대응). 추가만, 파괴 없음. + +-- =========================================================================== +-- 1) jam_criteria (잼별 설정형 심사 기준. 고정 6축 강제 대신 정석) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_criteria_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_criteria" ( + "id" bigint DEFAULT nextval('jam_criteria_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, + "criterion_key" character varying(40) NOT NULL, -- 잼 내 기준 식별(영문 키) + "display_name" character varying(100) NOT NULL, -- 표시 라벨 + "sort_order" integer DEFAULT 0 NOT NULL, -- 표시 순서 + "weight" numeric(6,3) DEFAULT 1.0 NOT NULL, -- 가중 종합 산정 가중치(>0 권장) + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_criteria_id_seq" OWNED BY "jam_criteria"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_jam_fkey') THEN + ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + -- weight 음수 방지(0 은 허용하되 가중종합에서 NULLIF 가드 — 0-division 방어는 VIEW) + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_criteria_weight_check') THEN + ALTER TABLE "jam_criteria" ADD CONSTRAINT "jam_criteria_weight_check" + CHECK ("weight" >= 0); + END IF; +END +$$; +-- 잼 내 criterion_key 중복 방지 +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_criteria_jam_key" + ON "jam_criteria" ("jam_id", "criterion_key"); +CREATE INDEX IF NOT EXISTS "idx_jam_criteria_jam" + ON "jam_criteria" ("jam_id", "sort_order"); + +-- =========================================================================== +-- 2) jam_scores (심사위원 점수. criterion 단위 행. is-judge 검증은 앱계층 W2-2) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_scores_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_scores" ( + "id" bigint DEFAULT nextval('jam_scores_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2 + "game_id" bigint NOT NULL, -- 평가단위 자연키 2/2(출품작) + "judge_user_id" bigint NOT NULL, -- 심사위원(FK users; is-judge 는 W2-2) + "criterion_key" character varying(40) NOT NULL, -- jam_criteria.criterion_key 논리참조 + "score" smallint NOT NULL, -- 1~5 + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_scores_id_seq" OWNED BY "jam_scores"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_jam_fkey') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_game_fkey') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_game_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_judge_fkey') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_judge_fkey" + FOREIGN KEY ("judge_user_id") REFERENCES "users" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_scores_score_check') THEN + ALTER TABLE "jam_scores" ADD CONSTRAINT "jam_scores_score_check" + CHECK ("score" BETWEEN 1 AND 5); + END IF; +END +$$; +-- 한 심사위원이 한 출품작의 한 기준에 1점만(수정=UPDATE, 재입력 멱등) +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_scores_jam_game_judge_criterion" + ON "jam_scores" ("jam_id", "game_id", "judge_user_id", "criterion_key"); +-- 집계 join 대상(출품작 단위 선집계) +CREATE INDEX IF NOT EXISTS "idx_jam_scores_jam_game" + ON "jam_scores" ("jam_id", "game_id"); + +-- =========================================================================== +-- 3) jam_votes (인기투표. 잼당 1인 1표. game_likes 와 별개. 평가기간 게이트=앱계층) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_votes_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_votes" ( + "id" bigint DEFAULT nextval('jam_votes_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2 + "game_id" bigint NOT NULL, -- 투표 대상 출품작 + "voter_user_id" bigint NOT NULL, -- 투표자(FK users; 로그인 1인1표) + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_votes_id_seq" OWNED BY "jam_votes"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_jam_fkey') THEN + ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_game_fkey') THEN + ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_game_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_votes_voter_fkey') THEN + ALTER TABLE "jam_votes" ADD CONSTRAINT "jam_votes_voter_fkey" + FOREIGN KEY ("voter_user_id") REFERENCES "users" ("id"); + END IF; +END +$$; +-- 잼당 1인 1표(최애 1개). 표 변경 = UPDATE game_id 또는 DELETE→INSERT(앱 정책 W2-5) +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_votes_jam_voter" + ON "jam_votes" ("jam_id", "voter_user_id"); +-- 투표 집계(출품작별 count) +CREATE INDEX IF NOT EXISTS "idx_jam_votes_jam_game" + ON "jam_votes" ("jam_id", "game_id"); + +-- =========================================================================== +-- 4) jam_awards (시상. 3트랙 + grand. 트랙별 개별 수상 + 가중 grand) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_awards_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_awards" ( + "id" bigint DEFAULT nextval('jam_awards_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, -- 평가단위 자연키 1/2 + "game_id" bigint NOT NULL, -- 수상 출품작 + "award_track" character varying(20) NOT NULL, -- JUDGE|USER_RATING|POPULAR|GRAND + "rank" integer NOT NULL, -- 트랙 내 순위(1=대상) + "score_value" numeric(10,4), -- 산정 점수(트랙별 의미 다름; NULL 허용) + "computed_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_awards_id_seq" OWNED BY "jam_awards"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_jam_fkey') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_game_fkey') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_game_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_track_check') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_track_check" + CHECK ("award_track" IN ('JUDGE', 'USER_RATING', 'POPULAR', 'GRAND')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_awards_rank_check') THEN + ALTER TABLE "jam_awards" ADD CONSTRAINT "jam_awards_rank_check" + CHECK ("rank" >= 1); + END IF; +END +$$; +-- 잼·트랙·순위 1행(재산정=DELETE→INSERT 또는 UPSERT 멱등). 동률은 같은 rank 허용 위해 +-- (jam_id, award_track, game_id) UNIQUE 로 출품작 트랙 중복만 차단(같은 rank 동률 허용). +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_awards_jam_track_game" + ON "jam_awards" ("jam_id", "award_track", "game_id"); +CREATE INDEX IF NOT EXISTS "idx_jam_awards_jam_track" + ON "jam_awards" ("jam_id", "award_track", "rank"); + +-- =========================================================================== +-- 5) jam_score_stats (심사 집계 VIEW. criterion별 평균 + 가중 종합. fan-out 방지) +-- game_review_stats(schema.sql:217) 선례: 출품작 단위 선집계 후 노출. +-- 가중 종합 = SUM(criterion_avg * weight) / NULLIF(SUM(weight),0) (0-division 가드) +-- criterion 미채점 시 그 criterion 은 가중 종합에서 제외(점수 있는 기준만). +-- =========================================================================== +CREATE OR REPLACE VIEW "jam_score_stats" AS +WITH per_criterion AS ( + SELECT + s."jam_id" AS jam_id, + s."game_id" AS game_id, + s."criterion_key" AS criterion_key, + AVG(s."score")::numeric AS criterion_avg, + COUNT(DISTINCT s."judge_user_id") AS judge_count + FROM "jam_scores" s + GROUP BY s."jam_id", s."game_id", s."criterion_key" +) +SELECT + pc."jam_id" AS jam_id, + pc."game_id" AS game_id, + -- 출품작 단위 종합: 채점된 criterion 의 가중 평균 + ROUND( + SUM(pc.criterion_avg * COALESCE(c."weight", 1.0)) + / NULLIF(SUM(COALESCE(c."weight", 1.0)), 0) + , 3) AS weighted_total, + ROUND(AVG(pc.criterion_avg), 3) AS simple_total, -- 비가중 평균(참고) + COUNT(pc.criterion_key) AS scored_criteria, -- 채점된 기준 수 + MAX(pc.judge_count) AS judge_count -- 최다 기준 심사 인원 +FROM per_criterion pc +LEFT JOIN "jam_criteria" c + ON c."jam_id" = pc."jam_id" AND c."criterion_key" = pc."criterion_key" +GROUP BY pc."jam_id", pc."game_id"; +COMMENT ON VIEW "jam_score_stats" IS + 'W2-3 동결 심사 집계뷰. 출품작(jam_id,game_id)별 가중 종합·비가중 평균·채점 기준수·심사 인원. fan-out 방지 선집계(game_review_stats 선례).';