---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T14:30:00+09:00
workstream: W2-3-잼 평가 통합설계(스키마 동결)
concerns:
  - "★평가단위 식별자 이중 모델 — W2-1 은 평가 단위를 jam_entries.id 로 제공한다고 명시했으나, 본 동결 스키마(orchestrator 확정)는 jam_scores/jam_votes/jam_awards 가 (jam_id, game_id) 를 참조한다. 본 설계는 둘을 정합시킨다: (jam_id, game_id) = 활성 출품작 자연키, jam_entries(jam_id,game_id active-UNIQUE) 와 1:1 대응. FK 는 game_id→games, jam_id→jams 로 직접 걸고, '출품 여부' 는 앱계층에서 jam_entries 활성행 존재로 검증(W2-4/5 진입 게이트). 구현 단계에서 jam_entries.id 직접 FK 채택 여부를 W2-1 소유자와 재확인 필요(현 동결은 game_id 자연키 채택 — 근거: orchestrator 확정 컬럼 + entrant 종류 무관 단일화). crossRefs 참조."
  - "신규 매퍼(JamScoresMapper/JamVotesMapper/JamCriteriaMapper/JamAwardsMapper/JamScoreStatsMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. (본 W2-3 은 스키마+계약 동결이 범위이므로 매퍼/컨트롤러 구현은 W2-4/5/6 소관 — 본 설계는 매퍼 시그니처 계약만 고정, 실제 빈 등록 책임은 하류.)"
  - "jam_score_stats VIEW 의 가중 종합점수(SUM(avg*weight)/SUM(weight)) 는 criterion weight 가 numeric 이고 NULL/0 가능 → 0-division 가드 필요. 집계 VIEW 매퍼는 camelCase alias 큰따옴표(AS \"weightedTotal\") 필수(케이스 폴딩, verification-strategies §33). dev DB contract(L2) 로 fan-out·NULL·0-division 실측 권장 — game_review_stats BUG-1 fan-out 선례(커밋 21892c8) 재발 방지."
  - "유저평점 트랙 최소 리뷰수 임계 N — 본 설계는 기본값 N=3 으로 확정(아래 §시상 트랙 계약). 잼별 가변 임계가 필요하면 jams 또는 jam_awards 산정 파라미터로 확장 — 구현 점검 항목(현 동결은 상수 3, 시상 산정 로직에 위치)."
  - "평가단위 = 활성 출품작이라는 전제는 jam_entries 가 (jam_id,game_id) 활성 UNIQUE 를 보장함에 의존(W2-1 ux_jam_entries_jam_game_active). 이 UNIQUE 가 동결 전 변경되면 jam_scores/jam_votes 의 game_id 참조 정합이 깨진다 — W2-1 PK/UNIQUE 동결 계약 유지 필수(crossRefs)."
concerns_checked: true
self_verification:
  checklist_passed: true
references:
  requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
  research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
  adrs:
    - .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
    - .atp/work-session/20260622-180054/implementation/W1-design.md
    - docs/work-log/2026-06-17-jam-platform-roadmap.md
    - docs/development/verification-strategies.md
    - docs/jam-ddl.sql
---

# 설계: W2-3 — 잼 평가 통합설계 (심사점수 / 인기투표 / 시상집계 스키마 동결 + 단방향 평점 계약 + 평가기간 게이트 계약)

> ⚠️ **결합 클러스터 forward phase-gate (§2.7)**. 이 문서가 W2-4(심사평가)·W2-5(인기투표)·W2-6(시상집계) 가 **공유·소비하는 동결 스키마 + 계약의 권위**다. 하류 3워크스트림은 이 문서를 읽고 **API/로직만** 설계한다. 본 동결이 확정되기 전 하류 착수 금지(재작업 차단).

## 목표 / 비목표

### 목표 (FR/NFR 추적 — 골자 W2-3)
- **G1 심사점수 스키마 동결**: 잼별 설정형 심사 기준(`jam_criteria`) + 심사위원 점수(`jam_scores`) + 집계 VIEW(`jam_score_stats`). 고정 6축 강제 대신 정석 잼 심사 모델.
- **G2 인기투표 스키마 동결**: 잼당 1인 1표(`jam_votes`). `game_likes` 와 별개 신규 테이블(game_likes 는 user_key varchar·1인1표 비보장이라 재활용 안 함). 로그인 user_id 기반.
- **G3 시상 스키마 동결**: 3트랙(JUDGE/USER_RATING/POPULAR) + grand(`jam_awards`). 트랙별 개별 수상 + 가중 grand prize.
- **G4 ★단방향 유저평점 트랙 계약**: 시상 USER_RATING 트랙 = `game_review_stats` VIEW.avg_rating(overall) 소비. **단일 평균 채택**(6축은 표시 전용). 리뷰 도메인은 잼 무관 → 시상이 읽기만(write 0).
- **G5 평가기간 게이트 계약**: 심사점수(W2-4)·투표(W2-5) 는 `jams.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]` 일 때만 허용. 시상 집계(W2-6)는 `status='CLOSED'` 또는 eval 종료 후 확정.
- **G6 집계 노출 계약**: 심사 집계 = `jam_score_stats` VIEW(criterion별 평균 + 가중 종합, fan-out 방지 선집계 — game_review_stats VIEW 선례). 투표 집계 = count.
- **G7 평가단위 정합 계약**: 평가 단위 자연키 = `(jam_id, game_id)` = 활성 출품작(jam_entries 1:1 대응). W2-4/5/6 은 entrant 종류(개인/팀)를 몰라도 game_id 만 참조.
- **NFR**: 상태변경 CSRF 전수(하류 구현), `#{}` 바인딩(`${}` 금지), 평가기간 게이트, VIEW 매퍼 alias 큰따옴표(case-folding), 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql.

### 비목표 (스코프 밖 — 본 W2-3 은 스키마·계약 동결만)
- **심사 점수 입력 API·UI·컨트롤러 본체** — **W2-4 소유**. 본 설계는 jam_criteria/jam_scores 스키마 + jam_score_stats VIEW + 매퍼 시그니처 계약만 제공.
- **투표 토글 API·UI** — **W2-5 소유**. 본 설계는 jam_votes 스키마 + 1인1표 UNIQUE + 평가기간 게이트 계약만.
- **시상 산정 알고리즘 본체·확정 UI** — **W2-6 소유**. 본 설계는 jam_awards 스키마 + 3트랙 정의 + 유저평점 소비 계약(소스/NULL/임계) + grand 가중 규칙 자리만.
- **심사위원 자격 판정(jam_judges)** — **W2-2 소유**. 본 설계는 jam_scores.judge_user_id 가 FK users 이고 "is judge" 검증은 앱계층(W2-2 게이트)임만 명시.
- **댓글/리뷰 스키마 변경** — W3-2(구현완료). 본 동결 묶음 아님. 시상이 `game_review_stats` VIEW 를 **읽기만**(game_reviews 무변경, jam FK 추가 없음).
- **game_review_stats VIEW 재집계·평가기간 필터 적용** — 채택 안 함(아래 §유저평점 트랙 계약 근거). 시상은 출품작 전체 리뷰 avg_rating 을 그대로 소비.

---

## 개요

bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 잼 평가 스키마(jam_criteria/jam_scores/jam_votes/jam_awards/jam_score_stats)는 전무하다(grep 0 hit — 본 advisor 직접 확인). W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작) 를 제공하고, W3-2 가 `game_review_stats` VIEW(avg_rating numeric / review_count / 6축평균 9컬럼, schema.sql:217-244 직접 확인)를 제공한다.

본 설계는 **잼 평가 4테이블(+집계 VIEW 1)을 신규 동결**하고, 하류 W2-4/5/6 이 의존할 **3개 계약(단방향 유저평점 / 평가기간 게이트 / 집계 노출)**을 구체화한다. 동결 후 하류는 이 스키마를 **변경 없이** 소비한다.

확정된 정석 결정(전제 — 재논의 금지):
- **심사 척도 = 잼별 설정형 criteria** : 고정 6축 강제 대신 `jam_criteria` 로 잼마다 심사 기준 정의(정석 잼 심사 모델). `jam_scores` 는 criterion_key 단위 행(criterion별 1~5).
- **인기투표 = 잼당 1인 1표** : `jam_votes(jam_id, voter_user_id)` UNIQUE. game_likes 재활용 안 함(비권위·varchar·1인1표 비보장).
- **시상 = 3트랙 + grand** : `jam_awards.award_track CHECK('JUDGE','USER_RATING','POPULAR','GRAND')`.
- **유저평점 트랙 = avg_rating 단일 평균** (6축 표시 전용, 단방향 읽기).
- **평가단위 = (jam_id, game_id) 자연키** (활성 출품작, entrant 종류 무관 단일화).

가장 까다로운 세 난제 확정:
- **난제1 (평가단위 식별자 — W2-1 jam_entries.id vs 동결 game_id 정합)**: W2-1 은 "평가 단위 = jam_entries.id" 라 했고 orchestrator 동결 컬럼은 (jam_id, game_id) 다. 본 설계는 **자연키 (jam_id, game_id) 채택**으로 정합한다 — jam_entries 가 `ux_jam_entries_jam_game_active`(jam_id,game_id 활성 UNIQUE, W2-1 §데이터모델4)를 보장하므로 (jam_id, game_id) 는 활성 출품작과 **1:1 대응하는 자연키**다. FK 는 game_id→games·jam_id→jams 로 직접 걸고, "출품작인가" 검증(점수/투표 진입 게이트)은 앱계층에서 jam_entries 활성행 존재로 수행한다. surrogate jam_entries.id 직접 FK 보다 (jam_id,game_id) 자연키가 ① orchestrator 동결 컬럼과 일치 ② game_review_stats(game_id 기준) 와 join 정합 ③ entrant 종류 불투명(W2-1 의도)을 유지하는 장점이 있다(concern 1 에 W2-1 소유자 재확인 마킹).
- **난제2 (유저평점 트랙 소스·기간·NULL — 단방향 계약)**: 소스 = `game_review_stats.avg_rating`(overall 단일 평균). **6축은 표시 전용, 시상 산정 미사용**(단일평균 채택 — 6축 가중을 시상에 끌어오면 잼별 criteria 와 의미 충돌). 기간 = 잼 평가기간 필터 **미적용**, 출품 게임의 전체 리뷰 avg_rating 소비(리뷰는 잼 무관 상시 작성 → game_review_stats 재집계 부담 회피, 정석). NULL/미달 = 리뷰 0개/axes 0행 출품작은 avg_rating NULL → 시상 산정 시 **NULLS LAST + review_count >= N(기본 3) 임계 미달 시 트랙 제외**. 리뷰 도메인 write 0(읽기만, game_reviews 에 jam FK 추가 안 함 — code-fact 정합).
- **난제3 (집계 fan-out·가중종합·NULL)**: 심사 집계 `jam_score_stats` VIEW 는 game_review_stats 의 fan-out 방지 선례를 따른다(criterion별 평균은 FILTER 또는 GROUP BY 선집계). 가중 종합 = `SUM(criterion_avg * weight) / NULLIF(SUM(weight), 0)`(0-division 가드). criterion 미채점(0행) 시 해당 criterion 평균 NULL → 가중 종합에서 제외(COALESCE 또는 FILTER). 매퍼 alias 는 집계 VIEW 이므로 큰따옴표(`AS "weightedTotal"`) 필수.

---

## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 동결값)

| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| F1 심사 척도 | 잼별 설정형 criteria | `jam_criteria(jam_id, criterion_key, display_name, sort_order, weight numeric)`. 고정 6축 강제 안 함 |
| F2 심사 점수 | criterion 단위 행 | `jam_scores(jam_id, game_id, judge_user_id, criterion_key, score 1~5)` + UNIQUE(jam_id,game_id,judge_user_id,criterion_key) |
| F3 인기투표 | 잼당 1인 1표 | `jam_votes(jam_id, game_id, voter_user_id)` + UNIQUE(jam_id, voter_user_id). game_likes 재활용 안 함 |
| F4 시상 | 3트랙 + grand | `jam_awards.award_track CHECK('JUDGE','USER_RATING','POPULAR','GRAND')` + rank + score_value |
| F5 유저평점 트랙 | avg_rating 단일평균(단방향) | game_review_stats.avg_rating 읽기만. 6축 표시전용. 기간필터 미적용(전체). NULLS LAST + review_count>=3 |
| F6 평가기간 게이트 | EVAL + now∈[eval_start,eval_end] | 점수(W2-4)·투표(W2-5) 허용 조건. 시상(W2-6)=CLOSED/eval종료후 |
| F7 집계 노출 | 심사=VIEW, 투표=count | `jam_score_stats` VIEW(criterion평균+가중종합, fan-out 방지). 투표는 COUNT |
| F8 평가단위 | (jam_id, game_id) 자연키 | 활성 출품작(jam_entries 1:1). FK game_id→games·jam_id→jams. 출품검증=앱계층 |

---

## 데이터 모델 (DDL)

> 권위 = **신규 파일 `docs/jam-eval-ddl.sql`** (apply-local-ddl.sh 가 docs/*-ddl.sql 알파벳 글롭으로 멱등 적용, ON_ERROR_STOP, search_path=dev). `db/schema.sql` 에 동기 사본(아래 §schema.sql 반영). 선례: W1 docs/rbac-ddl.sql, W2-1 docs/jam-ddl.sql(직접 확인). **game_reviews/game_review_stats 변경 없음**(시상이 VIEW 를 읽기만). **jams/jam_entries 변경 없음**(game_id/jam_id 를 FK 참조만). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO $$ guard, CREATE UNIQUE INDEX IF NOT EXISTS, CREATE OR REPLACE VIEW. 타입은 기존 스타일(bigint/varchar/timestamptz/numeric/smallint).
>
> ⚠️ **알파벳 글롭 순서 주의**: `apply-local-ddl.sh` 가 docs/*-ddl.sql 을 알파벳순으로 적용한다. FK 가 jams/jam_entries 를 참조하므로 `jam-ddl.sql`(W2-1) 이 `jam-eval-ddl.sql`(W2-3) 보다 **먼저** 적용돼야 한다. `jam-ddl` < `jam-eval-ddl`(알파벳: 'd' < 'e' 위치는 'jam-' 공통 뒤 'd' vs 'e' → jam-ddl 먼저) 이 성립하므로 순서 안전(롤아웃 §순서에서 재확인).

### 신규 파일: `docs/jam-eval-ddl.sql`

```sql
-- 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 선례).';
```

> **criterion별 평균 노출 형태 결정**: VIEW 는 출품작 단위 **1행**(weighted_total/simple_total 종합)으로 노출한다. criterion별 개별 평균이 화면에 필요하면 W2-4 가 `per_criterion` 상당의 매퍼 쿼리(또는 별도 criterion-level 조회)를 직접 작성한다(본 VIEW 는 종합만 — 출품작 정렬·시상 산정 소비에 최적). 이유: 시상(W2-6) JUDGE 트랙은 출품작 종합점수로 랭크하므로 1행 종합이 핵심 소비 형태이고, criterion 펼침은 표시 전용이라 VIEW fan-out 을 늘리지 않는다.

### `db/schema.sql` 반영 (최초 기동 1회 자동 주입 — docs/jam-eval-ddl.sql 의 사본)
- W2-1 의 jams/jam_entries 블록 **뒤**(또는 마지막 테이블 블록 뒤)에 위 1~5 전체를 **신설 블록**으로 추가.
- 헤더 주석: `-- 잼 평가 W2-3 (권위 DDL — docs/jam-eval-ddl.sql 와 동일. 심사/투표/시상 동결)` — game_reviews 블록 schema.sql:128 의 `(권위 DDL — docs/...-ddl.sql 와 동일)` 선례와 동형(직접 확인).
- 반영 방식: **docs/jam-eval-ddl.sql 이 권위, schema.sql 은 사본**. 두 곳에 동일 멱등 DDL. jams/jam_entries 블록보다 **뒤**에 위치(FK 참조 순서 — schema.sql 은 순차 실행이므로 참조 테이블이 먼저 정의돼야 함).

### game_reviews / game_review_stats 무변경 확인 (G4 단방향)
- W3-2 산출물(game_reviews/game_review_axes/game_review_stats VIEW) 변경 0. 시상 USER_RATING 트랙은 `game_review_stats.avg_rating` 을 **SELECT 만** 한다. game_reviews 에 jam_id FK 추가 없음(code-fact: 리뷰는 잼 무관). → W3-2 리뷰 동작·집계 회귀 0.

---

## 외부 계약 (소비 계약 — 본 W2-3 은 스키마+계약 동결. API 본체는 하류)

> 본 문서는 API 엔드포인트를 **신설하지 않는다**(스키마+계약 동결이 범위). 아래는 하류 W2-4/5/6 이 **준수해야 할 계약**이다. 컨트롤러 패턴은 RecruitController(읽기=JSP 뷰, 쓰기=ResponseEntity JSON status/message) + CSRF + 401/403 정책(W1-design 일치)을 따른다.

### 401 vs 403 정책 (W1-design / W2-1 과 일치 — 하류 강제)
- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`, 페이지는 `redirect:/login`.
- **인증·미인가**(심사위원 아님 / 권한 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
- **평가기간 외**(F6 게이트 위반): **422** JSON `{status:422, message:"평가 기간이 아닙니다."}` (인가는 됐으나 도메인 상태 위반 → 403 아님 422).
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.

### 평가기간 게이트 계약 (F6 — W2-4/W2-5 진입 공통 전제)
- **점수 입력(W2-4) 허용 조건**: `jam.status = 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]`. 미충족 시 422. AND 심사위원 자격(W2-2 jam_judges 게이트) AND 출품작 존재(jam_entries 활성행). 세 게이트 모두 앱계층.
- **투표(W2-5) 허용 조건**: `jam.status = 'EVAL' AND now() ∈ [eval_start_at, eval_end_at]` AND 로그인 user_id AND 출품작 존재. 자기 출품작 투표 가부는 W2-5 정책(본 동결은 스키마만 — UNIQUE(jam_id, voter_user_id) 로 1인1표만 강제).
- **시상 집계(W2-6) 허용 조건**: `jam.status = 'CLOSED'` 또는 eval 종료 후(now() > eval_end_at). 산정은 재실행 가능(멱등 — jam_awards DELETE→INSERT 또는 UPSERT).
- **게이트 위치**: 모두 컨트롤러/서비스 진입부 앱계층. DB CHECK 로 기간을 강제하지 않음(시각 비교는 런타임). DB 는 1인1표·점수범위·트랙값만 강제.

### 단방향 유저평점 트랙 계약 (G4/F5 — W2-6 소비, 권위)
- **소스**: `game_review_stats.avg_rating`(overall 단일 평균, numeric). 6축평균(avg_immersion 등)은 **시상 산정에 미사용**(표시 전용).
- **방향**: 단방향 읽기. 시상이 VIEW 를 SELECT 만, 리뷰 도메인(game_reviews/axes/stats) write 0. game_reviews 에 jam FK 추가 금지(잼 무관 유지).
- **기간**: 평가기간 필터 **미적용**. 출품 게임의 전체 리뷰 avg_rating 소비(잼 무관 상시 리뷰 → game_review_stats 재집계 부담 회피). 설계 명시 결정(orchestrator 확정).
- **NULL/미달**: 리뷰 0개 또는 axes 0행 → avg_rating NULL. 시상 산정 시:
  - 정렬 `ORDER BY avg_rating DESC NULLS LAST`.
  - **임계**: `review_count >= 3`(기본 N=3) 미달 출품작은 USER_RATING 트랙 **제외**(소수 리뷰 편향 방지). 임계값은 시상 산정 상수(W2-6 구현에 위치, concern 4 — 잼별 가변 필요 시 확장).
- **소비 SQL 형태(W2-6 참고)**:
  ```sql
  SELECT e.game_id, st."avgRating", st."reviewCount"
  FROM jam_entries e
  LEFT JOIN game_review_stats st ON st.game_id = e.game_id
  WHERE e.jam_id = #{jamId} AND e.is_delete IS NOT TRUE
    AND st.review_count >= 3
  ORDER BY st.avg_rating DESC NULLS LAST, e.game_id ASC;
  ```
  (매퍼 alias: game_review_stats 는 집계 VIEW → camelCase 큰따옴표 `AS "avgRating"` — GameReviewStatsMapper.java:13 선례.)

### 집계 노출 계약 (G6/F7 — W2-4/W2-6 소비)
- **심사 집계**: `jam_score_stats` VIEW. 출품작(jam_id, game_id) 단위 1행 = {weighted_total, simple_total, scored_criteria, judge_count}. W2-4 정렬·W2-6 JUDGE 트랙 랭크 소스. fan-out 방지(per_criterion 선집계).
- **투표 집계**: `SELECT game_id, COUNT(*) FROM jam_votes WHERE jam_id = #{jamId} GROUP BY game_id`. 별도 VIEW 불필요(단순 count — over-engineering 회피). W2-6 POPULAR 트랙 소스.
- **시상 트랙 산정(W2-6)**:
  - JUDGE: jam_score_stats.weighted_total DESC.
  - USER_RATING: game_review_stats.avg_rating DESC NULLS LAST (review_count>=3).
  - POPULAR: jam_votes count DESC.
  - GRAND: 3트랙 결과의 가중 종합(가중치는 jam 설정 또는 동률 규칙 — W2-6 산정 파라미터). 본 동결은 jam_awards.award_track='GRAND' 행 자리만 제공.

### 매퍼 시그니처 계약 (하류가 구현할 매퍼의 동결 인터페이스 — 시그니처만, 본체는 하류)
> 본 W2-3 은 매퍼 클래스를 **생성하지 않는다**. 아래는 하류가 따를 동결 시그니처(snake→camel 직접 alias 일반매퍼 표준, 집계 VIEW 매퍼만 큰따옴표). 최소 인자.

```java
// --- W2-4 소유 (jam_criteria / jam_scores / jam_score_stats) ---
// JamCriteriaMapper (@Mapper, #{} only, snake→camel)
int insertCriterion(JamCriterionData criterion)   // 잼 심사기준 등록(관리자)
List<JamCriterionData> listByJam(long jamId)       // 점수입력 폼·집계 라벨

// JamScoresMapper (@Mapper, #{} only)
int upsertScore(long jamId,            // 평가단위 1/2
                long gameId,           // 평가단위 2/2(출품작)
                long judgeUserId,      // 심사위원(세션)
                String criterionKey,   // 채점 기준
                int score)             // 1~5 (UNIQUE 충돌 시 UPDATE — ON CONFLICT 또는 exists→update)
List<JamScoreData> listByJudge(long jamId, long judgeUserId)  // 심사위원 본인 입력 현황

// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias)
List<Map<String,Object>> listStatsByJam(long jamId)  // 출품작별 종합(정렬·시상 소스)
Map<String,Object> getStats(long jamId, long gameId) // 단건 출품작 종합

// --- W2-5 소유 (jam_votes) ---
// JamVotesMapper (@Mapper, #{} only)
int castVote(long jamId, long gameId, long voterUserId)  // 투표(UNIQUE 1인1표; 변경=update game_id)
int updateVote(long jamId, long voterUserId, long gameId) // 표 변경(최애 교체)
boolean hasVoted(long jamId, long voterUserId)            // 1인1표 사전 체크
long countByGame(long jamId, long gameId)                 // 출품작 득표(또는 listCounts 집계)

// --- W2-6 소유 (jam_awards + 3트랙 소비) ---
// JamAwardsMapper (@Mapper, #{} only)
int upsertAward(JamAwardData award)               // 트랙·순위 수상 기록(재산정 멱등)
int deleteByJamTrack(long jamId, String track)    // 재산정 전 트랙 초기화
List<JamAwardData> listByJam(long jamId)           // 시상 결과 노출
```
> inflate 마킹(concern 2): 위 시그니처는 **계약 골격**이다. 하류 구현 시 각 인자가 실제 SQL/로직에 쓰이는지 재확인(예: `JamScoresMapper.upsertScore` 를 ON CONFLICT 로 구현하면 별도 exists 조회 불요 — exists 메서드 추가 inflate 금지). 매퍼 본체·@MockBean 등록은 하류 소관.

---

## 시퀀스 (계약 검증용 의사코드 — 하류 구현이 따를 흐름)

### S1. 심사 점수 입력 (W2-4 — 본 동결 스키마 소비)
```
[심사위원 세션] POST /jams/{slug}/scores (CSRF, gameId, {criterionKey: score, ...})
  → (W2-4 컨트롤러)
      → CsrfTokens.isValid                              (아니면 403)
      → userId = sessionUserId                          (없으면 401)
      → jam = jamsMapper.getBySlug(slug)                (없으면 404)
      → 평가기간 게이트(F6): jam.status=='EVAL' AND now∈[eval_start,eval_end]?  (아니면 422)
      → 출품작 존재: jamEntriesMapper.exists(jam.id, gameId)?  (아니면 404/422)
      → 심사위원 자격(W2-2): jamJudgesGate.isJudge(jam.id, userId)?  (아니면 403)
      → for (criterionKey, score) in 입력:
            jamScoresMapper.upsertScore(jam.id, gameId, userId, criterionKey, score)
      → 200 {message}
   # jam_score_stats VIEW 가 자동 반영(집계는 읽기 시점 계산).
```

### S2. 인기투표 (W2-5 — 본 동결 스키마 소비)
```
[로그인 유저] POST /jams/{slug}/votes (CSRF, gameId)
  → (W2-5 컨트롤러)
      → CsrfTokens.isValid                              (아니면 403)
      → userId = sessionUserId                          (없으면 401)
      → jam = getBySlug(slug); 평가기간 게이트(F6)        (아니면 422)
      → 출품작 존재(jam_entries 활성)?                   (아니면 404)
      → hasVoted(jam.id, userId)?
          미투표 → castVote(jam.id, gameId, userId)       (UNIQUE 보장 1인1표)
          기투표 → updateVote(jam.id, userId, gameId)     (최애 교체 정책 — W2-5 결정)
      → 200 {message, votedGameId}
```

### S3. 시상 집계 확정 (W2-6 — 3트랙 + grand, 본 동결 스키마+계약 소비)
```
[관리자] POST /admin/jams/{jamId}/awards/compute (CSRF, GAME_JAM_MANAGE 게이트)
  → (W2-6 컨트롤러)
      → requireJamManage + CSRF
      → jam.status=='CLOSED' OR now()>eval_end_at?       (아니면 422 — 시상 미개방)
      → JUDGE 트랙:    jam_score_stats.weighted_total DESC → rank 부여 → upsertAward(track='JUDGE')
      → USER_RATING:   game_review_stats.avg_rating DESC NULLS LAST, review_count>=3
                       → rank → upsertAward(track='USER_RATING')   # 단방향 읽기(G4)
      → POPULAR:       jam_votes count DESC → rank → upsertAward(track='POPULAR')
      → GRAND:         3트랙 가중 종합(가중치 jam 설정/동률규칙) → 최상위 → upsertAward(track='GRAND')
      → 200 {message, awardCounts}
   # 재실행 = deleteByJamTrack 후 재산정(멱등). game_reviews write 0.
```

---

## 파일 영향 맵

> 본 W2-3 은 **스키마+계약 동결**이 범위다. 신규 = DDL 권위 파일 + schema.sql 동기 2건만. 매퍼/data POJO/컨트롤러/JSP/테스트는 **하류 W2-4/5/6 소유**(아래 "하류 소유 — 본 설계 미생성" 표는 계약 추적용으로만 명시, 본 설계가 만들지 않음).
>
> 소유권 분할(본 W2-3 worker 단위): **E-SCHEMA**(jam-eval-ddl.sql + schema.sql 동기) 단일. data POJO 계약(JamCriterionData/JamScoreData/JamAwardData 등)은 하류가 자기 워크스트림에서 생성.

### 본 W2-3 이 생성/수정 (동결 산출물)
| 변경 유형 | 경로 | 역할 | 소유 |
|---|---|---|---|
| 신규 | `docs/jam-eval-ddl.sql` | 권위 DDL(jam_criteria/jam_scores/jam_votes/jam_awards/jam_score_stats VIEW). apply-local-ddl.sh 자동 적용(알파벳: jam-ddl 뒤) | E-SCHEMA |
| 수정 | `db/schema.sql` | 위 4테이블+1뷰 블록 추가(jam-eval-ddl 사본). jams/jam_entries 블록 뒤. game_reviews/games 무변경 | E-SCHEMA |

### 하류 소유 — 본 설계 미생성 (계약 추적용 — crossRefs)
| 소유 W | 생성물(예) | 본 동결 의존점 |
|---|---|---|
| W2-4 | JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper + data POJO + 점수입력 컨트롤러/JSP | jam_criteria/jam_scores/jam_score_stats VIEW + 평가기간 게이트(F6) + 집계 계약(G6) |
| W2-5 | JamVotesMapper + data + 투표 컨트롤러/JSP | jam_votes UNIQUE(1인1표) + 평가기간 게이트(F6) |
| W2-6 | JamAwardsMapper + data + 시상 산정/확정 컨트롤러/JSP | jam_awards 3트랙 + 단방향 유저평점 계약(G4/F5) + 집계 계약(G6) + jam_score_stats/game_review_stats/jam_votes count |
| W2-2 | jam_judges 게이트 | jam_scores.judge_user_id 의 is-judge 검증(앱계층) |

> SSR 호출지점 영향(verification §영향맵): 본 W2-3 은 신규 DDL/VIEW 만 추가, 기존 매퍼·JSP·컨트롤러 무변경 → 기존 소비처 0 영향. game_review_stats VIEW 무변경이므로 W3-2 리뷰 요약 화면 회귀 0.

---

## 대안 비교

| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 평가단위 식별자 | (A) (jam_id, game_id) 자연키 + 앱계층 출품검증 | orchestrator 동결 컬럼 일치, game_review_stats join 정합, entrant 불투명 유지 | FK 출품 보장은 앱계층(jam_entries 활성행) | **채택(F8)** |
| | (B) jam_entries.id 직접 FK | 출품 보장 DB 강제 | game_review_stats(game_id)와 join 불일치, orchestrator 동결과 어긋남, entrant.id 노출 | 기각(concern 1 재확인 마킹) |
| 심사 척도 | (A) 잼별 설정형 jam_criteria | 잼마다 기준 다름(정석), 가중 종합 가능 | criteria 테이블 1개 | **채택(F1)** |
| | (B) 고정 6축(리뷰 axes 재사용) | 단순 | 잼별 심사 기준 다양성 부정, 리뷰축과 의미 강결합 | 기각 |
| 유저평점 트랙 소스 | (A) avg_rating 단일평균 | 단순·명확, 리뷰 6축과 의미 충돌 회피, VIEW 그대로 | 6축 정보 미반영 | **채택(F5)** |
| | (B) 6축 가중 평균 | 다면 평가 | 잼 criteria 와 의미 이중화, 6축 NULL 처리 복잡 | 기각(6축=표시전용) |
| 유저평점 기간 | (A) 전체 리뷰 avg_rating | game_review_stats 재집계 0, 리뷰=잼무관 상시 정합 | 평가기간 외 리뷰도 반영 | **채택(F5)** |
| | (B) 평가기간 내 리뷰만 | 기간 정합 | game_review_stats 기간필터 재집계 부담, 리뷰 도메인 잼 결합 유발 | 기각 |
| 인기투표 저장 | (A) jam_votes 신규(1인1표 UNIQUE) | 1인1표 DB 강제, user_id 기반, 잼 무관 game_likes 분리 | 테이블 1개 | **채택(F3)** |
| | (B) game_likes 재활용 | 재사용 | 비권위 복원본·user_key varchar·1인1표 비보장(grounding R-D) | 기각 |
| 심사 집계 노출 | (A) jam_score_stats VIEW(선집계) | fan-out 방지(game_review_stats 선례), 정렬/시상 소스 단일 | VIEW 1개 | **채택(F7)** |
| | (B) 매퍼 GROUP BY 매번 | VIEW 없음 | 소비처마다 집계 SQL 중복, fan-out 위험 반복 | 기각 |
| 투표 집계 | (A) COUNT 쿼리(VIEW 없음) | 단순, over-engineering 회피 | — | **채택(F7)** |
| | (B) jam_vote_stats VIEW | 일관성 | 단순 count 에 VIEW 과도 | 기각 |

---

## 롤아웃 / 마이그레이션

### 순서
1. **스키마 적용**: `docs/jam-eval-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용.
   - **선행 의존**: jam-eval-ddl 의 FK 가 jams/jam_entries(W2-1 docs/jam-ddl.sql) + games/users(기존) 를 참조 → **jam-ddl.sql 이 먼저 적용돼야 함**. apply-local-ddl.sh 알파벳 글롭에서 `jam-ddl.sql` < `jam-eval-ddl.sql`(공통 prefix `jam-` 뒤 `d` < `e`) → 순서 자동 보장. game_reviews(W3-2 docs/game-reviews-ddl.sql, `g` < `j`)도 먼저 적용됨 → game_review_stats VIEW 존재 보장(시상 소비 가능).
2. **하류 코드 배포**: W2-4/5/6 가 본 동결 스키마를 소비하는 매퍼/컨트롤러 구현. 본 W2-3 은 코드 배포 없음(스키마만).
3. **권한**: GAME_JAM_MANAGE(W1 시드) + jam_judges(W2-2). 본 W2-3 추가 시드 없음.

### 역호환
- games/game_reviews/game_review_stats/game_likes/jams/jam_entries **전부 무변경**. 기존 사용자·리뷰·게임 동작 0 영향.
- 신규 테이블/VIEW 는 추가 전용(비파괴). 하류 미배포 상태에서도 빈 테이블/VIEW 로 잔존 무해.

### 롤백
- 스키마 롤백: 신규 4테이블+1뷰는 추가 전용 → drop 없이 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance(`DROP VIEW IF EXISTS jam_score_stats; DROP TABLE IF EXISTS jam_awards, jam_votes, jam_scores, jam_criteria CASCADE;` — FK 역순).
- 코드 롤백: 본 W2-3 은 코드 0 → 롤백 대상 없음. 하류 롤백은 각 워크스트림 소관.

---

## AC 매핑

| AC | 요구(골자 W2-3) | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | 심사 척도 = 잼별 설정형 criteria | jam_criteria(jam_id, criterion_key, weight) + ux_jam_criteria_jam_key | F1, §데이터모델1 |
| AC-2 | 심사 점수 1~5, 1심사위원1기준1점 | jam_scores score CHECK 1~5 + ux_jam_scores_jam_game_judge_criterion | F2, §데이터모델2 |
| AC-3 | 인기투표 잼당 1인1표 | jam_votes ux_jam_votes_jam_voter UNIQUE | F3, §데이터모델3 |
| AC-4 | 투표 game_likes 와 별개 | jam_votes 신규 테이블(user_id 기반), game_likes 무참조 | F3, §대안 |
| AC-5 | 시상 3트랙 + grand | jam_awards award_track CHECK 4값(JUDGE/USER_RATING/POPULAR/GRAND) | F4, §데이터모델4 |
| AC-6 | ★유저평점 트랙 단방향(avg_rating 단일) | game_review_stats.avg_rating 읽기만, 6축 미사용, game_reviews 무변경 | G4/F5, §단방향계약 |
| AC-7 | 유저평점 NULL/미달 처리 | NULLS LAST + review_count>=3 임계 | F5, §단방향계약 |
| AC-8 | 평가기간 게이트(점수/투표 EVAL, 시상 CLOSED) | F6 계약(앱계층 jam.status + now∈eval 구간) | §평가기간게이트계약 |
| AC-9 | 심사 집계 fan-out 방지 VIEW | jam_score_stats per_criterion 선집계 + 가중종합 NULLIF 가드 | F7/난제3, §데이터모델5 |
| AC-10 | 투표 집계 = count | jam_votes COUNT 계약(VIEW 없음) | F7, §집계노출계약 |
| AC-11 | 권한/평가 SQL `${}` 0 | DDL·계약 매퍼 시그니처 `#{}` only(하류 강제) | §매퍼시그니처계약 |
| AC-12 | 집계 VIEW 매퍼 alias 큰따옴표 | game_review_stats/jam_score_stats 소비 매퍼 AS "..." | §단방향계약/집계계약 |

---

## 검증 포인트 (verification-advisor 점검 대상)

> L레벨 매핑(verification-strategies): 동결 스키마 무결성(CHECK/UNIQUE/FK)·집계 VIEW(fan-out/NULL/0-division) = **L1+L2(dev DB contract)**. 단방향 계약·평가기간 게이트 = **L1**(스키마 차원) + 하류 구현 시 **L3**. 본 W2-3 은 코드 빈 추가 0이므로 contextLoads(§30 @MockBean)는 **하류 책임**(본 설계 매퍼 미생성).

### 시나리오 검증
- **VP-1 (AC-2/3 무결성, L2)**: jam_scores 같은 (jam_id,game_id,judge,criterion) 중복 INSERT → UNIQUE 거부. score 0/6 INSERT → CHECK 거부. jam_votes 같은 (jam_id, voter) 2표 INSERT → UNIQUE 거부(1인1표). dev DB contract 실측.
- **VP-2 (AC-9 집계 정합, L2)**: jam_score_stats 가 동일 출품작에 다수 심사위원·다수 criterion 입력 시 ① fan-out 없이 weighted_total 정확(SUM(avg*weight)/SUM(weight)) ② criterion 일부 미채점 시 채점 기준만 반영 ③ weight 전부 0 인 경계에서 0-division 없이 NULL(NULLIF 가드) — 샘플 데이터 실측(game_review_stats BUG-1 fan-out 선례 재발 방지).
- **VP-3 (AC-6 단방향, L1)**: 시상 USER_RATING 소비 SQL 이 game_review_stats 를 SELECT 만(write 0), game_reviews 에 jam FK 부재 확인(code 정합). 6축 컬럼 미참조 확인.
- **VP-4 (AC-7 NULL/미달, L2)**: 리뷰 0개 출품작 avg_rating NULL → NULLS LAST 정렬 말단 + review_count<3 제외. review_count==3 경계 포함. 샘플 실측.
- **VP-5 (AC-5 트랙값, L2)**: jam_awards award_track 에 'JUDGE'/'USER_RATING'/'POPULAR'/'GRAND' 외 값 INSERT → CHECK 거부.
- **VP-6 (FK 적용 순서, L2)**: apply-local-ddl.sh 가 jam-ddl(W2-1) 적용 후 jam-eval-ddl 적용 시 FK 생성 성공(jams/jam_entries/games/users 선존재). 단독/역순 적용 시 FK 실패 검출.
- **VP-7 (AC-12 alias, L2)**: 하류 jam_score_stats 매퍼 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper 케이스폴딩 BUG-2 선례 회피).

### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
> self-audit(시점): 아래 카운트는 **본 W2-3 이 신규 생성하는 정적 산출물**(DDL 테이블·VIEW·CHECK·트랙값)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 하류(W2-4/5/6)는 본 동결 스키마를 **소비만** 하고 본 DDL 파일을 수정하지 않으므로(소유 분리), 본 파일 카운트는 verification 시점에 불변.
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 CHECK IN 목록/트랙 enum/테이블 생성문 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건은 부재 검증이라 리터럴 정당.

- **AC-T1 잼 평가 신규 테이블 전수 4건 + VIEW 1건** — docs/jam-eval-ddl.sql 의 `CREATE TABLE IF NOT EXISTS` 4건(jam_criteria/jam_scores/jam_votes/jam_awards) AND `CREATE OR REPLACE VIEW` 1건(jam_score_stats): `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-eval-ddl.sql` == 4 AND `grep -c 'CREATE OR REPLACE VIEW' docs/jam-eval-ddl.sql` == 1. AND db/schema.sql 에 동일 4테이블+1뷰 전수 존재(동기 사본 누락 검출). 테이블/뷰 추가·삭제 누락을 갯수로 동시 커버.
- **AC-T2 시상 트랙 전수 4종 정합 불변식** — jam_awards_track_check CHECK 의 IN 목록(JUDGE/USER_RATING/POPULAR/GRAND) 4종 == 시상 산정(W2-6)이 upsert 하는 award_track 집합. 검증: DDL CHECK IN 항목 4 AND (하류 W2-6 구현 시) 3개별 트랙 + GRAND 전수 산정 경로 존재. 트랙 추가·누락을 갯수 1로 커버(F4 핵심 가드).
- **AC-T3 점수/투표 무결성 제약 전수** — 동결 핵심 UNIQUE/CHECK 4종 전수 존재: ux_jam_scores_jam_game_judge_criterion(1심사위원1기준1점), ux_jam_votes_jam_voter(1인1표), jam_scores_score_check(1~5), jam_awards_track_check(4트랙). 검증: `grep -c 'CREATE UNIQUE INDEX' docs/jam-eval-ddl.sql` >= 4(criteria/scores/votes/awards 각 1) AND 위 4 제약명 전수 존재. 1건 누락 = 무결성 결함(1인1표/중복채점 우회) → FAIL.
- **AC-T4 평가 매퍼 시그니처 계약 전수 `${` 0건(하류 검증)** — 하류 W2-4/5/6 가 본 계약대로 구현한 신규 매퍼 전수에 `${` 매치 0: `grep -rc '\${' <하류 jam-eval 매퍼들>` == 0 (AC-11, `${}` 금지). 부재 검증이라 리터럴 정당. **본 W2-3 은 매퍼 미생성 → 이 AC 는 하류 verification 시점 검사**(계약 위임 명시).
- **AC-T5 단방향 무결성(write 0) 불변식** — game_reviews/game_review_axes/game_review_stats 가 본 동결로 인해 변경 0: docs/jam-eval-ddl.sql 에 `game_reviews`/`game_review` 토큰의 ALTER/CREATE/INSERT/UPDATE 0건(읽기 계약뿐 — 주석/SELECT 형태 예시는 무방하나 DDL 변경문 0). 검증: `grep -E 'ALTER TABLE .*game_review|CREATE TABLE .*game_review' docs/jam-eval-ddl.sql` 0건. 단방향 계약(G4) 위반(시상이 리뷰 스키마 손대기) 즉시 검출.
- **AC-T6 FK 적용 순서 불변식** — jam-eval-ddl 의 FK 가 참조하는 선행 테이블 전수(jams/jam_entries/games/users) 가 apply 시점에 존재: 알파벳 글롭 순서상 jam-ddl/game-reviews-ddl 가 jam-eval-ddl 보다 먼저(공통 prefix 비교) → FK 생성 성공. 검증: apply-local-ddl.sh dry-run 또는 dev DB 전체 적용 후 jam_scores/jam_votes/jam_awards/jam_criteria 의 FK 4종 전수 생성 확인(pg_constraint). 순서 깨짐 시 FK 미생성 검출.

---

## 잔여 오픈 질문
없음(0). 동결 결정 F1~F8 전제 고정. 세 난제(평가단위 식별자 정합·단방향 유저평점 계약·집계 fan-out/가중/NULL)는 본 설계가 구체 메커니즘으로 확정. 평가기간 게이트·집계 노출·매퍼 시그니처 계약 동결. 구현 점검 항목(평가단위 jam_entries.id vs game_id 재확인·하류 @MockBean·VIEW 0-division dev contract·최소리뷰수 임계 가변화·jam_entries UNIQUE 동결 유지)은 오픈 질문이 아니라 `concerns` 로 이관.
