From bfbe1de9e42e46f229e4620cdc7022cd0d3e8b8d Mon Sep 17 00:00:00 2001 From: art Date: Tue, 23 Jun 2026 12:14:32 +0900 Subject: [PATCH] =?UTF-8?q?docs(design):=20W2(=EA=B2=8C=EC=9E=84=EC=9E=BC)?= =?UTF-8?q?+W3(=EC=9E=94=EC=97=AC)+W4=20=ED=92=80=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20+=20=EA=B3=A8=EC=9E=90=20=EC=B9=B4?= =?UTF-8?q?=ED=83=88=EB=A1=9C=EA=B7=B8=20+=20stale=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계 전용 세션(코드 0줄, src/·pom.xml 무변경). 골자 → 정석 결정 확정 → 풀설계. - 골자 신규: W2(6서브)·W4 카탈로그(docs/work-log/2026-06-23-w2-w4-feature-skeletons.md). W3-* 는 기존 골자 유지. - 풀설계 11기능(W2-1~6·W3-1/3-3/3-4/3-5·W4) — W1-design 깊이(DDL/API계약/시퀀스/파일영향맵/대안비교/AC매핑), 오픈질문 0. 본문 = .atp/work-session/20260623-104307/implementation/. - 정석 크로스-W 결정(개입없이 orchestrator 확정, 사용자 위임): 잼 스코프=별도 jam_judges / 평가단위=(jam_id,game_id) 자연키 / 유저평점=avg_rating 단방향 / 잼연결=조인테이블 jam_entries + 팀출품 1차 / 인기투표=1인1표 UNIQUE / SSRF=공용 SsrfSafeFetcher 9항목 / 권한키 BADGE_MANAGE 신규. - 교차정합 감사(_cross-consistency-audit.md): 동결 단일권위(W2-3) 유지·하류 정합 PASS. HIGH 1(W2-1 평가단위 표현 drift, DDL정합) 5개소 정정. LOW 3 무해. - 골자 stale 정정 3건: RBAC 인프라 실재(enforcement 갭)·리뷰 하이브리드(overall+6축)+VIEW·/game/** 정상 서빙(QG-3 해소, GameAssetController). - 통합 인덱스: docs/work-log/2026-06-23-w2-w4-full-design-summary.md. 검증: 설계 전용 — 코드 변경 0이라 L1/L2 해당 없음(graph-refresh §3.2 no-scope-change skip). 구현·검증은 착수 시 별도 세션. game_likes 운영 DB UNIQUE 는 미확인(추정, 운영 확인 권장). resumed_from: 20260622-180054 (W1 설계/구현) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../implementation/W2-1-jam-entity-design.md | 602 ++++++++++++++++++ .../implementation/W2-2-judge-role-design.md | 416 ++++++++++++ .../implementation/W2-3-eval-freeze-design.md | 544 ++++++++++++++++ .../W2-4-judge-scoring-design.md | 354 ++++++++++ .../W2-5-popular-vote-design.md | 328 ++++++++++ .../W2-6-award-aggregation-design.md | 385 +++++++++++ .../implementation/W3-1-tags-search-design.md | 485 ++++++++++++++ .../W3-3-posting-board-design.md | 543 ++++++++++++++++ .../implementation/W3-4-main-hub-design.md | 346 ++++++++++ .../implementation/W3-5-upload-design.md | 434 +++++++++++++ .../implementation/W4-badges-design.md | 461 ++++++++++++++ .../_cross-consistency-audit.md | 59 ++ .atp/work-session/20260623-104307/report.md | 229 +++++++ .../research/W2-W4-grounding.md | 165 +++++ .../research/W3-5-upload-research.md | 114 ++++ .../2026-06-17-w3-feature-skeletons.md | 4 +- .../2026-06-23-w2-w4-feature-skeletons.md | 224 +++++++ .../2026-06-23-w2-w4-full-design-summary.md | 87 +++ docs/work-log/index.md | 4 +- 19 files changed, 5782 insertions(+), 2 deletions(-) create mode 100644 .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W3-3-posting-board-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W3-4-main-hub-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W3-5-upload-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/W4-badges-design.md create mode 100644 .atp/work-session/20260623-104307/implementation/_cross-consistency-audit.md create mode 100644 .atp/work-session/20260623-104307/report.md create mode 100644 .atp/work-session/20260623-104307/research/W2-W4-grounding.md create mode 100644 .atp/work-session/20260623-104307/research/W3-5-upload-research.md create mode 100644 docs/work-log/2026-06-23-w2-w4-feature-skeletons.md create mode 100644 docs/work-log/2026-06-23-w2-w4-full-design-summary.md diff --git a/.atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md b/.atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md new file mode 100644 index 0000000..88996f8 --- /dev/null +++ b/.atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md @@ -0,0 +1,602 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-23T12:30:00+09:00 +workstream: W2-1-게임잼 엔티티/라이프사이클 +concerns: + - "JamService / 컨트롤러의 신규 헬퍼 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 entrant 분기 헬퍼 resolveEntrant(...)." + - "신규 컨트롤러(JamController/JamAdminController) + 신규 매퍼(JamsMapper/JamEntriesMapper/JamTeamsMapper/JamStatusLogMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException." + - "신규 매퍼 SQL 은 DB-방언 계약(L2) 대상 — snake→camel 직접 alias(jam.created_at AS createdAt)가 일반 매퍼 표준(verification-strategies §33). 큰따옴표 alias 는 집계 VIEW 매퍼만. keyset 커서 비교(created_at,id) 의 PostgreSQL row-comparison 또는 OR 분해 SQL 은 dev DB contract 로 실측 검증 권장." + - "games↔jam 연결 = 조인테이블 jam_entries 로 확정(games 무변경). **평가 단위 = (jam_id, game_id) 활성 자연키**(W2-3 동결 권위) — jam_entries 의 ux_jam_entries_jam_game_active(jam_id,game_id 활성 UNIQUE)가 이 자연키를 활성 출품작과 1:1 보장한다. W2-4/5/6 FK 는 game_id→games·jam_id→jams 직접이고 '출품 여부'는 앱계층 jam_entries 활성행 존재로 검증(jam_entries.id surrogate FK 아님). 이 (jam_id,game_id) 활성 UNIQUE 계약을 동결 전 변경 금지. crossRefs 참조." + - "잼 상태 자동전이(스케줄러)는 본 설계에서 훅 인터페이스만 정의하고 구현은 후속(W2-1 범위 밖). @Scheduled 빈 도입 시 BibimbapApplicationTests context 영향 재확인 — 본 설계는 수동 전이 enforcement 만 구현 범위." + - "잼 slug 는 사용자 비입력(서버 생성) 정석. slug 충돌 시 재시도 로직 필요 — 구현 점검 항목." +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/20260622-180054/implementation/W1-design.md + - docs/work-log/2026-06-17-jam-platform-roadmap.md + - docs/development/verification-strategies.md + - docs/rbac-ddl.sql +--- + +# 설계: W2-1 — 게임잼 엔티티 + 라이프사이클 (jams / jam_entries / jam_teams + 관리자 CRUD + 공개 목록·상세 + GAME_JAM_MANAGE enforcement) + +## 목표 / 비목표 + +### 목표 (FR/NFR 추적 — 골자 W2-1) +- **G1 잼 엔티티**: 회차 독립 게임잼(`jams`) 도입. 모집→개발→평가→종료 라이프사이클 명시 컬럼 + 기간 필드. +- **G2 잼-게임 연결(정규화)**: 출품작 = 기존 `games` 재사용 + 조인테이블 `jam_entries`(games 무변경). 잼당 게임 1회 출품(UNIQUE). +- **G3 출품 주체 = 개인 OR 팀**: `jam_teams`/`jam_team_members` + `jam_entries.entrant_type CHECK('USER','TEAM')`. 둘 중 하나 NOT NULL(CHECK). +- **G4 관리자 잼 CRUD**: 잼 생성/수정/상태전이/삭제. `GAME_JAM_MANAGE` 게이트 enforcement 연결(W1 인프라 위, 임시 role 직접체크 금지). +- **G5 공개 페이지**: 잼 목록(`/jams`) + 상세(`/jams/{slug}`). RecruitController 패턴(읽기=JSP 뷰, 쓰기=JSON+CSRF). keyset 페이징. +- **G6 상태전이 + 감사**: 관리자 수동 전이(기간 정합 검증) + 전이 감사 기록(`jam_status_log`). 자동전이는 훅만(후속). +- **G7 이중 노출**: 출품작은 잼 전용 뷰 + 기존 일반 게임 허브(`games.is_visible` 유지) 둘 다 노출. +- **G8 운영 표시 필드**: discord_url/prize_info/sponsor_info(`jams` 컬럼, 표시 전용 — 실지급 수동). +- **NFR**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 sanitize/길이 검증, 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql. + +### 비목표 (스코프 밖) +- **평가/심사/투표/시상 스키마**(jam_criteria/jam_scores/jam_votes/jam_awards) — **W2-3 동결 소유**. 본 설계는 평가 단위인 `(jam_id, game_id)` **활성 자연키**(jam_entries 의 활성 UNIQUE 로 1:1 보장)를 제공만. W2-3 동결은 이 자연키를 직접 FK(game_id→games·jam_id→jams)로 채택하며 jam_entries.id surrogate 를 FK 로 쓰지 않는다. +- **심사위원 역할/권한**(jam_judges) — **W2-2 소유**. 여기서 만들지 않음. +- **잼 투표 1인1표**(W2-5), **시상 집계**(W2-6) — 별도. +- **상태 자동전이 스케줄러 본체** — 본 설계는 전이 검증 메서드 + 훅 인터페이스만. `@Scheduled` 구현은 후속. +- **팀 초대/승인 워크플로 고도화** — 1차는 팀 생성 + 멤버 직접 추가까지. 초대 수락 플로우는 후속. +- **게임 업로드 경로 변경** — 기존 `/game/new` 생성 경로 무변경. 잼 연결은 별도 출품(entry) 액션. + +--- + +## 개요 + +bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 `jams` 테이블·`jam_id` 는 전무하고(grounding R-C: games 13컬럼에 jam_id 부재, jam grep 0 hit), `GAME_JAM_MANAGE` 권한 키는 enum 선언만 있고 소비처 0건이다(grounding R-A: PermissionKeys.java:4). RBAC 인프라(`PermissionGate.has(session, key)` 2인자, `RbacInterceptor` /admin/** + isAdmin, epoch 전파 `refreshIfStale`)는 W1 에서 완비됐다(PermissionGate.java:22,86 직접 확인). + +본 설계는 **게임잼 엔티티 4테이블(+감사 1)을 신규 도입**하고, **GAME_JAM_MANAGE 게이트를 관리자 잼 CRUD enforcement 에 연결**하며, **공개 목록/상세를 RecruitController 패턴 + keyset 페이징**으로 제공한다. + +확정된 정석 결정(전제): +- **연결 = 조인테이블 `jam_entries`** (games.jam_id 컬럼 끼워넣기 기각). games 무변경 → 일반 허브 노출/삭제연쇄 회귀 0, 정규화, entry 메타데이터 보유 가능. +- **출품 주체 = 개인 OR 팀 둘 다 1차 포함** (잼은 팀 이벤트 본질). `jam_entries.entrant_type` + entrant_user_id/jam_team_id 둘 중 하나 NOT NULL(CHECK). +- **상태 = 명시 컬럼 + 관리자 수동 전이(감사) + 자동전이 훅(후속)**. +- **enforcement 갭 메우기**: RbacInterceptor 는 `/admin/**` + isAdmin 만이므로 SUBADMIN+GAME_JAM_MANAGE 는 인터셉터를 통과 못 한다(isAdmin 은 ADMIN 만 true, PermissionGate.java:55-62 확인). 따라서 `/admin/jams/**` 컨트롤러 진입부에 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 헬퍼를 적용(W1-design 의 "콘솔=URL패턴, 소비 액션=게이트 헬퍼" 선례 그대로). 임시 role 직접체크 금지. + +가장 까다로운 두 난제 확정: +- **난제1 (개인/팀 이중 주체 정합)**: `jam_entries` 단일 테이블 + `entrant_type CHECK('USER','TEAM')` + `entrant_user_id`(nullable) + `jam_team_id`(nullable) + **XOR CHECK**(둘 중 정확히 하나 NOT NULL). 출품 노출/집계 쿼리는 entrant_type 분기 없이 game_id 로 단일화. **평가 단위 = (jam_id, game_id) 활성 자연키**(W2-3 동결) — jam_entries active-UNIQUE 가 활성 출품작과 1:1 보장하므로 W2-3/4/5/6 은 entrant 종류를 몰라도 (jam_id, game_id) 만 참조한다(jam_entries.id surrogate 아님). +- **난제2 (상태전이 정합·감사·자동전이 공존)**: 상태는 `jams.status` 명시 컬럼(CHECK 4값)이 단일 진실. 관리자 수동 전이는 **허용 전이 그래프**(RECRUIT→DEV→EVAL→CLOSED + 역행/취소 제한)를 `JamLifecycle` 도메인이 검증하고, 전이마다 `jam_status_log` insert(감사). 자동전이는 같은 `JamLifecycle.transition(...)` 코어를 호출하는 **훅 인터페이스**만 정의(스케줄러 본체는 후속) → 수동/자동이 동일 검증·감사 경로를 공유(중복 0). + +--- + +## 핵심 결정 요약 (전제 — 재논의 금지) + +| 결정 | 확정값 | 본 설계의 구체화 | +|---|---|---| +| D1 연결 | 조인테이블 `jam_entries` | games 무변경. UNIQUE(jam_id, game_id) = 잼당 게임 1회. entry 메타 보유 | +| D2 출품 주체 | 개인 OR 팀 둘 다 | `jam_teams`/`jam_team_members` + entrant_type CHECK + XOR CHECK(user/team 정확히 하나) | +| D3 상태 | 명시 컬럼 + 수동전이(감사) + 자동전이 훅 | `jams.status` CHECK('RECRUIT','DEV','EVAL','CLOSED') + `JamLifecycle` 전이 그래프 + `jam_status_log` | +| D4 enforcement | GAME_JAM_MANAGE 게이트 헬퍼 | `/admin/jams/**` 컨트롤러 진입부 `PermissionGate.has(session, GAME_JAM_MANAGE.name())`. 인터셉터 미등록(SUBADMIN 통과 위함) | +| D5 공개 페이지 | `/jams` 목록 + `/jams/{slug}` 상세 | RecruitController 패턴. **keyset 페이징(created_at,id 커서)** | +| D6 이중 노출 | 잼 전용 뷰 + 일반 허브 | jam_entries JOIN games. games.is_visible 유지(허브 동시 노출) | +| D7 운영 표시 | jams 컬럼 표시 전용 | discord_url/prize_info/sponsor_info — 실지급 수동, 표시만 | +| D8 식별자 | slug(서버 생성) | URL 안전 slug, 잼당 UNIQUE. 내부 join 은 jam_id(bigint) | + +--- + +## 데이터 모델 (DDL) + +> 권위 = **신규 파일 `docs/jam-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(직접 확인). **games 변경 없음**(연결은 jam_entries 보유). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO $$ guard, CREATE UNIQUE INDEX IF NOT EXISTS, ALTER ADD COLUMN IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz/text). + +### 신규 파일: `docs/jam-ddl.sql` + +```sql +-- W2-1 게임잼 엔티티/라이프사이클. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용. +-- games 변경 없음(연결은 jam_entries 가 보유). 추가만, 파괴 없음. + +-- =========================================================================== +-- 1) jams (게임잼 회차. 회차 독립 = 다중 인스턴스) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jams_id_seq"; +CREATE TABLE IF NOT EXISTS "jams" ( + "id" bigint DEFAULT nextval('jams_id_seq'::regclass) NOT NULL, + "slug" character varying(80) NOT NULL, -- URL 식별자(서버 생성, UNIQUE) + "title" character varying(200) NOT NULL, + "description" text, + "status" character varying(20) DEFAULT 'RECRUIT' NOT NULL, -- 라이프사이클 + "recruit_start_at" timestamp with time zone, -- 모집 시작(기간 정합 검증용) + "dev_start_at" timestamp with time zone, -- 개발 시작 + "eval_start_at" timestamp with time zone, -- 평가 시작(W2-4/5 게이트 기준) + "eval_end_at" timestamp with time zone, -- 평가 종료(=종료 전이 기준) + "discord_url" character varying(500), -- 운영 표시 전용 + "prize_info" text, -- 운영 표시 전용(실지급 수동) + "sponsor_info" text, -- 운영 표시 전용 + "is_visible" boolean DEFAULT true NOT NULL, -- 공개 목록 노출 + "created_by" bigint, -- 생성 관리자(감사 보조) + "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 "jams_id_seq" OWNED BY "jams"."id"; + +-- status 값집합 CHECK(멱등) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jams_status_check') THEN + ALTER TABLE "jams" + ADD CONSTRAINT "jams_status_check" + CHECK ("status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED')); + END IF; +END +$$; + +-- slug 활성 UNIQUE(삭제분 제외 — recruit/games 의 active-unique 선례와 동형) +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active" + ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE; +-- 공개 목록 keyset 페이징 인덱스(created_at DESC, id DESC 커서) +CREATE INDEX IF NOT EXISTS "idx_jams_visible_keyset" + ON "jams" ("is_visible", "is_delete", "created_at" DESC, "id" DESC); + +-- =========================================================================== +-- 2) jam_teams (잼별 팀. 잼 회차에 종속 = 회차 독립) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_teams_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_teams" ( + "id" bigint DEFAULT nextval('jam_teams_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, + "name" character varying(120) NOT NULL, + "owner_user_id" bigint NOT NULL, -- 팀 생성자(팀장) + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "is_delete" boolean DEFAULT false NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_teams_id_seq" OWNED BY "jam_teams"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_jam_id_fkey') THEN + ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_jam_id_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_teams_owner_fkey') THEN + ALTER TABLE "jam_teams" ADD CONSTRAINT "jam_teams_owner_fkey" + FOREIGN KEY ("owner_user_id") REFERENCES "users" ("id"); + END IF; +END +$$; +CREATE INDEX IF NOT EXISTS "idx_jam_teams_jam" ON "jam_teams" ("jam_id"); + +-- =========================================================================== +-- 3) jam_team_members (팀 멤버. 한 유저는 한 팀에 1회 — 잼 내 중복 가입은 멤버십 UNIQUE) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_team_members_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_team_members" ( + "id" bigint DEFAULT nextval('jam_team_members_id_seq'::regclass) NOT NULL, + "jam_team_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_team_members_id_seq" OWNED BY "jam_team_members"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_team_fkey') THEN + ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_team_fkey" + FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_team_members_user_fkey') THEN + ALTER TABLE "jam_team_members" ADD CONSTRAINT "jam_team_members_user_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + END IF; +END +$$; +-- 같은 팀에 같은 유저 중복 가입 방지 +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_team_members_team_user" + ON "jam_team_members" ("jam_team_id", "user_id"); + +-- =========================================================================== +-- 4) jam_entries (출품작 = 잼-게임 연결 조인. 평가 단위 = (jam_id, game_id) 활성 자연키 — W2-3/4/5/6 참조점, jam_entries.id surrogate 아님. active-UNIQUE 가 1:1 보장) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_entries_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_entries" ( + "id" bigint DEFAULT nextval('jam_entries_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, + "game_id" bigint NOT NULL, + "entrant_type" character varying(10) NOT NULL, -- 'USER' | 'TEAM' + "entrant_user_id" bigint, -- entrant_type='USER' 시 NOT NULL + "jam_team_id" bigint, -- entrant_type='TEAM' 시 NOT NULL + "submitted_at" timestamp with time zone DEFAULT now() NOT NULL, + "is_delete" boolean DEFAULT false NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_entries_id_seq" OWNED BY "jam_entries"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_jam_fkey') THEN + ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_game_fkey') THEN + ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_game_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_team_fkey') THEN + ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_team_fkey" + FOREIGN KEY ("jam_team_id") REFERENCES "jam_teams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_user_fkey') THEN + ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_user_fkey" + FOREIGN KEY ("entrant_user_id") REFERENCES "users" ("id"); + END IF; + -- entrant_type 값집합 + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_type_check') THEN + ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_type_check" + CHECK ("entrant_type" IN ('USER', 'TEAM')); + END IF; + -- XOR: 개인이면 user 만, 팀이면 team 만 NOT NULL(정확히 하나) + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_entries_entrant_xor_check') THEN + ALTER TABLE "jam_entries" ADD CONSTRAINT "jam_entries_entrant_xor_check" + CHECK ( + ("entrant_type" = 'USER' AND "entrant_user_id" IS NOT NULL AND "jam_team_id" IS NULL) + OR + ("entrant_type" = 'TEAM' AND "jam_team_id" IS NOT NULL AND "entrant_user_id" IS NULL) + ); + END IF; +END +$$; +-- 잼당 게임 1회 출품(활성). 삭제분은 재출품 허용 → 활성 partial unique +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_entries_jam_game_active" + ON "jam_entries" ("jam_id", "game_id") WHERE "is_delete" IS NOT TRUE; +CREATE INDEX IF NOT EXISTS "idx_jam_entries_jam" ON "jam_entries" ("jam_id"); +CREATE INDEX IF NOT EXISTS "idx_jam_entries_game" ON "jam_entries" ("game_id"); + +-- =========================================================================== +-- 5) jam_status_log (상태전이 감사. 수동/자동 전이 공통 기록) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_status_log_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_status_log" ( + "id" bigint DEFAULT nextval('jam_status_log_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, + "from_status" character varying(20), -- 최초 생성 시 NULL 허용 + "to_status" character varying(20) NOT NULL, + "actor_id" bigint, -- 수동=관리자 id, 자동=NULL + "transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL, -- 'MANUAL'|'AUTO' + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_status_log_id_seq" OWNED BY "jam_status_log"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_jam_fkey') THEN + ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_to_status_check') THEN + ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_to_status_check" + CHECK ("to_status" IN ('RECRUIT', 'DEV', 'EVAL', 'CLOSED')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_status_log_transition_type_check') THEN + ALTER TABLE "jam_status_log" ADD CONSTRAINT "jam_status_log_transition_type_check" + CHECK ("transition_type" IN ('MANUAL', 'AUTO')); + END IF; +END +$$; +CREATE INDEX IF NOT EXISTS "idx_jam_status_log_jam" ON "jam_status_log" ("jam_id", "created_at" DESC); +``` + +### `db/schema.sql` 반영 (최초 기동 1회 자동 주입 — docs/jam-ddl.sql 의 사본) +- `recruit_posts` 블록(또는 마지막 테이블 블록) 뒤에 위 1~5 전체를 **신설 블록**으로 추가. +- 헤더 주석: `-- 게임잼 W2-1 (권위 DDL — docs/jam-ddl.sql 와 동일)` — game_reviews 블록이 schema.sql:128 에서 `(권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규)` 라 단 선례와 동형(직접 확인). +- 반영 방식: **docs/jam-ddl.sql 이 권위, schema.sql 은 사본**. 두 곳에 동일 멱등 DDL. + +### games 무변경 확인 (D1) +- `games` 테이블/컬럼 변경 0. jam 연결은 전부 `jam_entries` 가 보유 → 기존 getVisibleGames/searchVisibleGames/삭제연쇄(GamesMapper) 회귀 0. 일반 허브 노출 동작 불변(D6 이중 노출의 "허브" 측). + +--- + +## 외부 계약 (API) + +> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 확인). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity>`(status/message). 관리자 API 는 컨트롤러 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문 수행. + +### 401 vs 403 정책 (W1-design 과 일치) +- **미인증**(세션 `userId` 없음): API 는 **401** JSON `{status:401, message:"로그인이 필요합니다."}`. 페이지(`/jams/{slug}/...` 폼 등 인증 필요분)는 `redirect:/login`. +- **인증·미인가**(로그인됐으나 GAME_JAM_MANAGE 없음): **403** JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지). +- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`. +- 게이트 분기는 `gate.isAuthenticated(session)`(401/redirect) → `gate.has(session, GAME_JAM_MANAGE.name())`(403) 2단계. PermissionGate.isAuthenticated/has 직접 확인(PermissionGate.java:47,22). + +### 공개 페이지 (뷰 — 인증 불필요) +| method | path | 권한 | 응답 | +|---|---|---|---| +| GET | `/jams` | 공개 | `jam-list` JSP. 가시 잼 keyset 1페이지 + `nextCursor` 모델 주입 | +| GET | `/jams/{slug}` | 공개 | `jam-detail` JSP. 잼 + 출품작(jam_entries JOIN games) + CSRF 토큰 모델 주입 | +| GET | `/jams?cursor={createdAt}_{id}` | 공개 | (목록 동일 뷰, keyset 다음 페이지) | + +### 공개 출품/팀 액션 (상태변경 API — 로그인 필요 + CSRF. 관리자 게이트 아님) +| 액션 | method | path | 요청 | 응답(200) | 에러 | +|---|---|---|---|---|---| +| 개인 출품 | POST | `/jams/{slug}/entries` | gameId | `{status:200, message, entryId}` | 401(미인증), 403(CSRF), 404(잼/게임 없음), 409(이미 출품), 422(잼 상태가 출품 불가/게임 소유자 아님) | +| 팀 출품 | POST | `/jams/{slug}/entries` | gameId, jamTeamId | `{status:200, message, entryId}` | 위 + 422(팀 멤버 아님) | +| 팀 생성 | POST | `/jams/{slug}/teams` | name | `{status:200, message, jamTeamId}` | 401, 403, 404, 422(잼 상태/이름 검증) | +| 팀 멤버 추가 | POST | `/jams/{slug}/teams/{teamId}/members` | userId | `{status:200, message}` | 401, 403, 404, 422(팀장 아님/중복) | + +- 출품 단일 엔드포인트(`/entries`)에서 `jamTeamId` 유무로 개인/팀 분기 — entrant_type 결정. games.user_id 소유 검증(개인) 또는 jam_team_members 멤버 검증(팀)으로 도용 차단. +- **출품 가능 상태**: `jams.status IN ('RECRUIT','DEV')` 만 허용(EVAL/CLOSED 출품 거부 → 422). 구체 정책은 D3 + concern(출품 자격) 따름. + +### 관리자 잼 CRUD (상태변경 API — CSRF + GAME_JAM_MANAGE 게이트) +| 액션 | method | path | 요청 | 응답(200) | 에러 | +|---|---|---|---|---|---| +| 콘솔 페이지 | GET | `/admin/jams` | (없음) | `admin-jam-list` JSP(잼 전건 + CSRF) | 401/redirect, 403 | +| 생성 | POST | `/admin/jams` | title, description, 기간필드, 표시필드 | `{status:200, message, jamId, slug}` | 403(CSRF/권한), 422(입력 검증/기간 정합) | +| 수정 | POST | `/admin/jams/{jamId}` | (생성과 동일 필드) | `{status:200, message, jamId}` | 403, 404, 422 | +| 상태전이 | POST | `/admin/jams/{jamId}/status` | toStatus | `{status:200, message, jamId, status:toStatus}` | 403, 404, 409(허용 안 되는 전이), 422(기간 필드 미충족) | +| 가시성 토글 | POST | `/admin/jams/{jamId}/visibility` | (없음) | `{status:200, message, jamId, visible:bool}` | 403, 404 | +| 삭제(소프트) | POST | `/admin/jams/{jamId}/delete` | (없음) | `{status:200, message, jamId}` | 403, 404, 422(출품작 존재 시 정책) | + +- **GAME_JAM_MANAGE enforcement(D4)**: 위 `/admin/jams/**` 6액션 전부 진입부에서 게이트 통과 요구. RbacInterceptor 가 `/admin/**` 에 등록돼 있으나 isAdmin 만 검사하므로(RbacInterceptor.java:34) **SUBADMIN+GAME_JAM_MANAGE 가 인터셉터에서 막힌다** → 인터셉터는 `/admin/jams/**` 를 통과시키지 못함. 해결: 인터셉터 경로 매핑을 변경하지 않고(W1 콘솔 보호 유지), **`/admin/jams/**` 를 인터셉터 exclude 에 추가**한 뒤 컨트롤러 게이트 헬퍼로 GAME_JAM_MANAGE 검사(아래 §인터셉터 연동 D4-A 확정). + +--- + +## 인터셉터 / 게이트 연동 (D4 — enforcement 갭 메우기) + +### 문제 +- `RbacInterceptor.preHandle` 은 `/admin/**` 전체에 대해 `isAdmin(session)` 만 검사한다(RbacInterceptor.java:34, InterceptorConfig.java:19 직접 확인). `isAdmin` 은 role==ADMIN 만 true(PermissionGate.java:55-62). +- 따라서 `/admin/jams/**` 가 인터셉터에 그대로 걸리면 **SUBADMIN(+GAME_JAM_MANAGE 보유)이 잼 관리에 접근 못 한다**. GAME_JAM_MANAGE 권한 키의 존재 의의(ADMIN 아닌 위임 관리자)가 무력화됨. + +### 확정 (D4-A: 인터셉터 exclude + 컨트롤러 게이트 헬퍼) +- **InterceptorConfig 수정**: `addPathPatterns("/admin/**").excludePathPatterns("/admin/jams/**")`. 콘솔(`/admin/console` 등 ADMIN 전용)은 인터셉터 ADMIN 게이트 유지, 잼 관리만 제외. +- **JamAdminController 진입부 게이트 헬퍼**: 각 액션 시작에서 아래 순서. + 1. `gate.isAuthenticated(session)` 거짓 → 페이지면 redirect:/login, API 면 401. + 2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403(JSON 또는 페이지 403). + 3. 통과 후 본문. +- **이유**: 인터셉터는 단일 권한(ADMIN)·URL 패턴에 최적(W1-design 결정), 잼 관리는 "ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE" 라 권한 키 판정이 필요하므로 `gate.has`(ADMIN 암묵전권 + SUBADMIN 키보유, PermissionGate.java:30-36) 가 정확히 들어맞는다. 커스텀 어노테이션/AOP 는 W1-design 에서 이미 오버엔지니어링으로 기각된 선례 → 동일하게 게이트 헬퍼 채택. +- **중복 0**: 6액션 모두 동일한 private 헬퍼 `requireJamManage(session, response/return)` 로 게이트 + 401/403 응답 작성을 단일화. + +### epoch 전파 연동 (W1 결정4) +- `gate.has` 내부가 `refreshIfStale`(PermissionGate.java:86) 로 요청당 epoch 대조 → ADMIN 이 SUBADMIN 에게 GAME_JAM_MANAGE 부여/회수하면 대상 다음 요청에서 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0). + +--- + +## 시퀀스 (주요 플로우 의사코드) + +### S1. 관리자 잼 생성 → 상태전이(감사) +``` +[SUBADMIN(+GAME_JAM_MANAGE) 세션] POST /admin/jams (CSRF, title/기간/표시필드) + → InterceptorConfig: /admin/jams/** exclude → 인터셉터 미개입 + → JamAdminController.createJam + → requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403) + → CsrfTokens.isValid(request) (아니면 403 errorBody) + → 입력 sanitize/길이 검증 + 기간 정합(eval_start ≤ eval_end 등) (아니면 422) + → slug = JamSlugs.generate(title) (충돌 시 재시도 — concern) + → jamsMapper.insertJam(... status='RECRUIT', created_by=actor) + → jamStatusLogMapper.insert(jamId, from=NULL, to='RECRUIT', actor, 'MANUAL') + → 200 {jamId, slug} + +[관리자] POST /admin/jams/42/status (CSRF, toStatus='EVAL') + → JamAdminController.transitionStatus + → requireJamManage + CSRF + → jam = jamsMapper.getById(42) (없으면 404) + → JamLifecycle.assertAllowed(jam.status, 'EVAL') (불가 전이면 409) + → JamLifecycle.assertPeriodReady(jam, 'EVAL') (eval_start_at 미설정 등 422) + → jamsMapper.updateStatus(42, 'EVAL') + → jamStatusLogMapper.insert(42, from=jam.status, to='EVAL', actor, 'MANUAL') + → 200 {status:'EVAL'} +``` + +### S2. 공개 출품(개인/팀 분기) + 이중 노출 +``` +[로그인 유저] POST /jams/{slug}/entries (CSRF, gameId[, jamTeamId]) + → JamController.submitEntry + → CsrfTokens.isValid (아니면 403) + → userId = sessionUserId(session) (없으면 401) + → jam = jamsMapper.getBySlug(slug) (없으면 404) + → jam.status IN ('RECRUIT','DEV')? (아니면 422 출품 불가 상태) + → game = gamesMapper.getGame(gameId) (없으면 404) + → resolveEntrant: + jamTeamId == null → entrant_type='USER': + game.userId == userId? (아니면 422 게임 소유자 아님) + entrantUserId = userId + else → entrant_type='TEAM': + jamTeamMembersMapper.exists(jamTeamId,userId)? (아니면 422 팀 멤버 아님) + → jamEntriesMapper.insert(...) (UNIQUE 위반 시 409 이미 출품 — catch DuplicateKey) + → 200 {entryId} + # 이중 노출(D6,D7): games.is_visible 무변경 → 일반 허브 그대로 노출. + # 잼 상세는 jam_entries JOIN games 로 별도 노출. 두 경로 동시 성립. + +[공개] GET /jams/{slug} + → JamController.detail + → jam = jamsMapper.getBySlug(slug) (없거나 !is_visible → redirect:/jams) + → entries = jamEntriesMapper.listByJam(jam.id) # JOIN games (이름/썸네일/평가단위 (jam_id,game_id) 자연키) + → model: jam, entries, csrfToken → "jam-detail" +``` + +### S3. 공개 목록 keyset 페이징 (D5) +``` +[공개] GET /jams?cursor=2026-06-20T10:00:00Z_57 + → JamController.list + → (cursor 파싱: createdAt, id. 없으면 첫 페이지) + → jams = jamsMapper.listVisibleKeyset(cursorCreatedAt, cursorId, pageSize+1) + # WHERE is_visible IS NOT FALSE AND is_delete IS NOT TRUE + # AND (cursor 있으면) (created_at, id) < (cursorCreatedAt, cursorId) + # ORDER BY created_at DESC, id DESC LIMIT pageSize+1 + → hasNext = jams.size > pageSize; trim; nextCursor = last(created_at)_last(id) + → model: jams, nextCursor → "jam-list" +``` +- **keyset 채택 이유(D5)**: RecruitPostsMapper 는 페이징 전무·전건 로드(RecruitPostsMapper.java:72 직접 확인). 잼 목록/출품작은 누적 증가 → offset 페이징은 깊은 페이지 비용·중복 행 위험. keyset(created_at,id 복합 커서)은 idx_jams_visible_keyset 인덱스로 O(log n) seek. id 동률 tie-break 포함(created_at 단독은 동시각 누락 위험). + +--- + +## 파일 영향 맵 + +> 소유권 분할 가이드(implementation-advisor worker 단위 후보): +> **J-SCHEMA**(DDL/schema 동기) · **J-DOMAIN**(data POJO/enum/JamLifecycle/JamSlugs) · **J-MAPPER**(5 매퍼) · **J-ADMIN**(JamAdminController + JSP, D4 게이트) · **J-PUBLIC**(JamController + JSP, keyset) · **J-CONFIG**(InterceptorConfig exclude). +> 의존: J-SCHEMA → J-DOMAIN → J-MAPPER → {J-ADMIN, J-PUBLIC}. J-CONFIG 는 J-ADMIN 과 짝(exclude 없으면 SUBADMIN 막힘). + +| 변경 유형 | 경로 | 역할 | 소유 | +|---|---|---|---| +| 신규 | `docs/jam-ddl.sql` | 권위 DDL(jams/jam_teams/jam_team_members/jam_entries/jam_status_log). apply-local-ddl.sh 자동 적용 | J-SCHEMA | +| 수정 | `db/schema.sql` | 위 5테이블 블록 추가(jam-ddl 사본). games 무변경 | J-SCHEMA | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamData.java` | jams 행 POJO(id/slug/title/description/status/기간4/표시3/isVisible/createdAt...) | J-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java` | jam_entries 행 + JOIN games 표시필드(gameName/thumbnailUrl/entrantType...) | J-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java` | jam_teams 행(+멤버 수 옵션) | J-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java` | enum RECRUIT/DEV/EVAL/CLOSED + isValid(String) (PermissionKeys 패턴) | J-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java` | 전이 그래프 검증 + 기간 정합 검증(수동/자동 공통 코어, 난제2) | J-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java` | title→URL-safe slug 생성(서버 생성, D8) | J-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java` | `@Mapper` jams CRUD + keyset 목록 + getBySlug + updateStatus(`#{}`, snake→camel alias) | J-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java` | `@Mapper` 출품 insert/listByJam(JOIN games)/exists(`#{}`) | J-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java` | `@Mapper` 팀 insert/getById/listByJam(`#{}`) | J-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java` | `@Mapper` 멤버 insert/exists(`#{}`) | J-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java` | `@Mapper` 상태전이 감사 insert(`#{}`) | J-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamController.java` | 공개 목록(keyset)/상세 + 출품/팀 액션(CSRF, RecruitController 패턴) | J-PUBLIC | +| 신규 | `src/main/webapp/WEB-INF/views/jam-list.jsp` | 잼 목록 + 다음 페이지(nextCursor) | J-PUBLIC | +| 신규 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` | 잼 상세 + 출품작 + 출품/팀 폼(CSRF hidden) | J-PUBLIC | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java` | `/admin/jams/**` CRUD + 상태전이(D4 게이트 헬퍼) | J-ADMIN | +| 신규 | `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | 잼 관리 목록/폼(CSRF hidden) | J-ADMIN | +| 수정 | `src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java` | `.excludePathPatterns("/admin/jams/**")` 추가(D4-A) | J-CONFIG | +| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 5매퍼 @MockBean 등록(contextLoads 보존, verification §30) | (검증) | +| 신규 | `src/test/.../JamAdminControllerTest.java` | CRUD + 상태전이 + 401/403/CSRF/게이트 + 허용전이 검증 | (검증) | +| 신규 | `src/test/.../JamControllerTest.java` | 목록 keyset + 상세 + 출품(개인/팀) + 409/422 + 소유/멤버 검증 | (검증) | +| 신규 | `src/test/.../JamLifecycleTest.java` | 전이 그래프 허용/거부 + 기간 정합 단위 | (검증) | + +> SSR 호출지점 영향(verification §영향맵): jam_entries 는 games 무변경이라 기존 게임 허브 JSP·매퍼 깨짐 0. 신규 뷰·매퍼만 추가 → 기존 소비처 0 영향. + +### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지) +```java +// JamLifecycle — 전이 검증 코어(수동/자동 공통). 상태 enum 만으로 전이 그래프 판정(최소). +boolean isAllowed(JamStatus from, // 현재 상태 + JamStatus to) // 목표 상태 — 허용 전이 그래프 룩업 +void assertPeriodReady(JamData jam, // 기간 필드 보유 잼(eval_start_at 등) + JamStatus to) // 목표 상태가 요구하는 기간 필드 충족 검증(미충족 시 신호) + +// JamSlugs — title → URL-safe slug. title 만으로 생성(충돌 처리는 호출자/매퍼 책임 — concern). +String generate(String title) // 정규화·소문자·하이픈·길이절단 + +// JamsMapper (@Mapper, #{} only, snake→camel 직접 alias) +JamData getById(long jamId) // 관리자 수정/전이 조회 +JamData getBySlug(String slug) // 공개 상세 조회(활성만) +List listVisibleKeyset(java.time.OffsetDateTime cursorCreatedAt, // 커서 시각(첫페이지 null) + Long cursorId, // 커서 id tie-break(첫페이지 null) + int limit) // pageSize+1(hasNext 판정) +List listAllForAdmin() // 관리자 콘솔 전건(삭제 제외) +int insertJam(JamData jam) // 생성(useGeneratedKeys id) +int updateJam(JamData jam) // 수정 +int updateStatus(long jamId, String status) // 상태전이 반영 +int updateVisibility(long jamId, boolean isVisible) // 가시성 토글 +int softDelete(long jamId) // 소프트 삭제 + +// JamEntriesMapper (@Mapper, #{} only) +int insert(JamEntryData entry) // 출품(UNIQUE 위반 시 컨트롤러 catch→409) +List listByJam(long jamId) // 상세 출품작(JOIN games 표시필드) +boolean exists(long jamId, long gameId) // 사전 중복 체크(409 친절 메시지용) + +// JamTeamsMapper (@Mapper, #{} only) +int insert(JamTeamData team) // 팀 생성 +JamTeamData getById(long teamId) // 멤버 추가 시 팀장 검증 소스 +List listByJam(long jamId) // 상세 팀 목록 + +// JamTeamMembersMapper (@Mapper, #{} only) +int insert(long jamTeamId, long userId) // 멤버 추가 +boolean exists(long jamTeamId, long userId) // 팀 출품 시 멤버십 검증 + 중복 가입 방지 + +// JamStatusLogMapper (@Mapper, #{} only) +int insert(long jamId, // 대상 잼 + String fromStatus, // 이전 상태(최초 NULL 허용) + String toStatus, // 전이 후 상태 + Long actorId, // 수동=관리자 id, 자동=null + String transitionType) // 'MANUAL'|'AUTO' +``` +> inflate 마킹(concern 1): `JamLifecycle.assertPeriodReady(jam, to)` 의 `jam` 파라미터는 전체 JamData 를 받지만 실제로는 기간 필드 4개만 읽는다 — 구현에서 사용 필드가 1~2개로 좁혀지면 필요 필드만 받는 시그니처로 축소 검토(과한 전달 방지). `resolveEntrant`(컨트롤러 private)는 의사코드상 분기일 뿐 별도 헬퍼로 추출 시 (userId, jam, gameId, jamTeamId) 전부 실제 사용되는지 구현 시 재확인. + +--- + +## 자동전이 훅 (D3 — 인터페이스만, 구현 후속) +- `JamLifecycle.transition(...)` 코어는 수동(JamAdminController)·자동(후속 스케줄러) 양쪽이 호출하는 단일 경로. 자동전이는 `jam_status_log.transition_type='AUTO'`, actor_id=null 로 기록. +- 본 설계는 **훅 시그니처만 명시**(아래), `@Scheduled` 빈 구현은 W2-1 범위 밖(concern 5 — context 영향 재확인 의무). +```java +// (후속) JamAutoTransitionRunner — eval_end_at 경과 잼을 CLOSED 로. @Scheduled 본체는 후속. +// 같은 JamLifecycle 검증·감사 경로 재사용(중복 0). 본 설계는 호출 계약만 고정. +``` + +--- + +## 대안 비교 + +| 주제 | 안 | 장점 | 단점 | 채택 | +|---|---|---|---|---| +| 잼-게임 연결 | (A) 조인테이블 jam_entries | 정규화, games 무변경(허브 회귀 0), 다회차 출품·entry 메타 | 테이블 1개 추가 | **채택(D1)** | +| | (B) games.jam_id nullable 컬럼 | 단순 | games 변경(허브/삭제연쇄 회귀 위험), 1게임 1잼 한정, entry 메타 불가 | 기각 | +| 출품 주체 | (A) entrant_type + user/team XOR | 개인·팀 동시(잼 본질), 단일 테이블 | CHECK 2개 | **채택(D2)** | +| | (B) 개인만 우선, 팀은 후속 | 1차 단순 | 잼=팀 이벤트 본질과 어긋남, 후속 스키마 변경 재작업 | 기각 | +| 상태 모델 | (A) 명시 컬럼 + 수동전이(감사) + 자동훅 | 운영 통제·감사·자동 보조 공존, 단일 검증 코어 | JamLifecycle 도메인 1개 | **채택(D3)** | +| | (B) 기간 필드만 계산 상태 | 컬럼 절약 | 운영 수동 개입 불가, 감사 부재, 경계시각 모호 | 기각 | +| 목록 페이징 | (A) keyset(created_at,id 커서) | 깊은 페이지 O(log n), 누락/중복 없음 | 커서 파싱 | **채택(D5)** | +| | (B) offset/limit | 단순 | 깊은 페이지 비용·삽입 시 행 밀림 중복 | 기각 | +| | (C) 전건 로드(Recruit 선례) | 최단 | 잼 누적 증가 시 비효율 | 기각 | +| 잼관리 enforcement | (A) 인터셉터 exclude + 컨트롤러 게이트 헬퍼 | SUBADMIN+키 통과, ADMIN 콘솔 보호 유지, W1 선례 | exclude 1줄 + 헬퍼 | **채택(D4-A)** | +| | (B) 인터셉터에 잼 경로별 권한키 매핑 | 중앙집중 | 인터셉터에 경로↔키 테이블 신설(오버엔지니어링, W1서 기각된 방향) | 기각 | +| | (C) 임시 role 직접체크 | 빠름 | W1 인프라 우회·정석 위반(금지) | 기각 | + +--- + +## 롤아웃 / 마이그레이션 + +### 순서 +1. **스키마 적용**: `docs/jam-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용. games 무변경 → 기존 데이터 회귀 0. +2. **코드 배포**: J-DOMAIN → J-MAPPER → J-PUBLIC/J-ADMIN/J-CONFIG. InterceptorConfig exclude 와 JamAdminController 게이트는 **동시 배포**(exclude 만 먼저 가면 잼 경로 무보호 노출, 게이트만 먼저 가면 SUBADMIN 인터셉터에 막힘 — 같은 PR/커밋으로). +3. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 enforcement 연결만. +4. **운영**: ADMIN 이 콘솔에서 SUBADMIN 에게 GAME_JAM_MANAGE 부여 → 잼 관리 위임 가능(W1 epoch 즉시 반영). + +### 역호환 +- 기존 games/허브/리뷰/좋아요 동작 불변(games 무변경, FK 추가만). 기존 사용자 영향 0. +- `/admin/jams/**` exclude 는 신규 경로라 기존 `/admin/console` 보호 무변경. + +### 롤백 +- 코드 롤백: JamController/JamAdminController/InterceptorConfig 되돌리면 잼 경로 미노출. 신규 테이블은 추가 전용이라 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance. +- exclude 롤백 시 `/admin/jams/**` 가 다시 인터셉터 ADMIN 게이트로 — 컨트롤러 게이트 헬퍼가 내부에도 있어 이중 안전(미노출이면 무영향). + +--- + +## AC 매핑 + +| AC | 요구(골자 W2-1) | 만족 설계 요소 | 비고 | +|---|---|---|---| +| AC-1 | jams 라이프사이클 4상태 + 기간 필드 | jams.status CHECK 4값 + 기간 4컬럼 + JamLifecycle | §데이터모델1, D3 | +| AC-2 | 잼-게임 연결 정규화(games 무변경) | jam_entries 조인, games DDL 0변경 | D1, §games무변경 | +| AC-3 | 잼당 게임 1회 출품 | ux_jam_entries_jam_game_active UNIQUE | §데이터모델4 | +| AC-4 | 개인 OR 팀 출품 | entrant_type + XOR CHECK + jam_teams/members | D2 | +| AC-5 | 관리자 잼 CRUD = GAME_JAM_MANAGE 게이트 | JamAdminController requireJamManage(gate.has) + exclude | D4, D4-A | +| AC-6 | SUBADMIN(+키) 잼 관리 통과 / 미보유 403 | gate.has(ADMIN OR SUBADMIN+키), 인터셉터 exclude | D4-A, S1 | +| AC-7 | 공개 목록(keyset)/상세 | JamController list(keyset)/detail | D5, S3 | +| AC-8 | 출품작 이중 노출 | games.is_visible 유지(허브) + jam_entries JOIN(잼뷰) | D6 | +| AC-9 | 상태전이 감사 | jam_status_log insert(수동/자동, from/to/actor) | D3, S1 | +| AC-10 | 상태변경 전수 CSRF | 모든 쓰기 액션 CsrfTokens.isValid 선검증 → 403 | §외부계약 공통 | +| AC-11 | 권한 SQL `${}` 0 | 신규 5매퍼 `#{}` only | §파일영향맵 | +| AC-12 | 운영 표시 필드 | jams.discord_url/prize_info/sponsor_info(표시 전용) | D7 | + +--- + +## 검증 포인트 (verification-advisor 점검 대상) + +> L레벨 매핑(verification-strategies): 인가/게이트/상태전이 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·keyset 커서 비교 = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30). + +### 시나리오 검증 +- **VP-1 (AC-5/6 게이트, L1+L3)**: JamAdminControllerTest — ADMIN 세션 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401(redirect). L3 스모크: InterceptorConfig exclude 후 SUBADMIN 이 `/admin/jams` 도달. +- **VP-2 (AC-9 전이 감사, L1)**: JamLifecycleTest 허용/거부 전이 + JamAdminControllerTest 전이 후 jam_status_log insert 호출(from/to/actor/type). +- **VP-3 (AC-3/4 출품 무결성, L1)**: JamControllerTest — 개인 출품(소유 검증), 팀 출품(멤버 검증), 중복 출품 409, 비소유 게임 422, 비멤버 팀 422, EVAL 상태 출품 422. +- **VP-4 (AC-7 keyset, L1+L2)**: 목록 커서 진행 시 중복/누락 0, 동시각 id tie-break 동작. dev DB contract: row-comparison/OR 분해 SQL 실측. +- **VP-5 (AC-10 CSRF, L1)**: 쓰기 액션 전수 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용). +- **VP-6 (DB-방언 계약, L2)**: 신규 매퍼 반환 POJO 키 == 컨트롤러/JSP 조회 키(snake→camel 직접 alias 확인, 큰따옴표 alias 미사용). XOR CHECK·status CHECK 위반 INSERT 거부 실측. +- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 5매퍼 @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException. + +### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용) +> self-audit(시점): 아래 카운트는 **본 설계가 신규 생성하는 정적 산출물**(DDL 테이블·CHECK·매퍼·액션)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님. +> self-audit(표현): 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK IN 목록/액션 핸들러 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴이 정당). + +- **AC-T1 잼 신규 테이블 전수 5건 존재** — docs/jam-ddl.sql 의 `CREATE TABLE IF NOT EXISTS` 5건(jams/jam_teams/jam_team_members/jam_entries/jam_status_log): `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-ddl.sql` == 5. AND db/schema.sql 에 동일 5 테이블명 전수 존재(동기 사본 누락 검출). 테이블 추가/삭제 누락을 갯수 1로 커버. +- **AC-T2 상태 4값 정합 불변식** — `JamStatus` enum 멤버 수 == jams_status_check CHECK IN 항목 수 == jam_status_log to_status CHECK IN 항목 수 == 4(RECRUIT/DEV/EVAL/CLOSED). 검증: enum 멤버 `grep -c` == 4 AND DDL 두 CHECK IN 목록 각 4항목. 상태 추가 시 enum↔DDL↔log 3곳 동기 누락 동시 검출(불변식). +- **AC-T3 관리자 잼 액션 전수 6건 게이트** — JamAdminController 의 핸들러(콘솔/생성/수정/상태전이/가시성/삭제) 전수가 `requireJamManage` 호출: 게이트 헬퍼 호출 수 == 핸들러 수(상태변경 핸들러는 추가로 CsrfTokens.isValid). 핸들러 추가 시 게이트 누락 = 인가 우회 보안결함 → FAIL. **이 전수 AC 가 D4 enforcement 의 핵심 가드**(수동 판정: @PostMapping/@GetMapping 핸들러 열거 후 각 진입부 requireJamManage 확인 — 리터럴 grep 단독 의존 회피). +- **AC-T4 잼 매퍼 전수 5개 `${` 0건** — 신규 매퍼 5파일(JamsMapper/JamEntriesMapper/JamTeamsMapper/JamTeamMembersMapper/JamStatusLogMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 5파일>` == 0 (AC-11, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당. +- **AC-T5 entrant XOR 무결성** — jam_entries_entrant_xor_check + jam_entries_entrant_type_check 2 CHECK 전수 존재 AND 위반 INSERT(USER인데 jam_team_id 채움 / 둘 다 NULL / 둘 다 채움)가 DB 거부(L2 실측). 개인/팀 정확히 하나 보장(D2 핵심 가드). +- **AC-T6 잼 신규 매퍼 @MockBean 전수 5건** — BibimbapApplicationTests 에 신규 5 매퍼 @MockBean 전수 등록: contextLoads PASS AND `grep -c '@MockBean.*Jam' BibimbapApplicationTests.java` >= 5(또는 매퍼별 등록 수동 확인). 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점에 자기 검증). + +--- + +## 잔여 오픈 질문 +없음(0). 확정 결정 D1~D8 전제 고정, 두 난제(개인/팀 XOR 정합·상태전이 감사+자동훅)는 본 설계가 구체 메커니즘으로 확정. 인터셉터 exclude vs 컨트롤러 게이트(D4-A)도 확정. 구현 점검 항목(시그니처 inflate·full-test @MockBean·keyset SQL 방언·slug 충돌 재시도·자동전이 스케줄러 context)은 오픈 질문이 아니라 `concerns` 로 이관. diff --git a/.atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md b/.atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md new file mode 100644 index 0000000..0cf1216 --- /dev/null +++ b/.atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md @@ -0,0 +1,416 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-23T16:00:00+09:00 +workstream: W2-2-심사위원 역할 권한(잼 스코프 게이트) +concerns: + - "JamRoleGate.isJudge / 컨트롤러 헬퍼 시그니처는 최소 인자(session, jamId)로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 isJudge 가 jamId(bigint) 만 받는지, jam 객체/slug 까지 받는지 — 본 설계는 jamId 최소 채택, 충돌체크 헬퍼 hasOwnEntry(session, jamId) 도 최소 2인자." + - "신규 컨트롤러(JamJudgeAdminController) + 신규 매퍼(JamJudgesMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. JamRoleGate 가 @Component 면 @MockBean 또는 실제 빈+의존 매퍼 MockBean 필요." + - "신규 매퍼 SQL 은 DB-방언 계약(L2) 대상 — snake→camel 직접 alias(jj.created_at AS createdAt)가 일반 매퍼 표준(verification-strategies §33). 큰따옴표 alias 는 집계 VIEW 매퍼만(jam_judges 는 일반 매퍼 → 큰따옴표 금지). exists(EXISTS) 반환 boolean 매핑·listByJam JOIN users 표시필드 dev DB contract 실측 권장." + - "★자기출품 충돌 규칙(심사위원=자기 출품작 점수입력 불가)은 본 설계가 '계약'으로 정의하고 enforce 는 W2-4 점수입력 컨트롤러 소관이다. 본 W2-2 는 충돌 판정 헬퍼(JamRoleGate.hasOwnEntry 또는 JamEntriesMapper.existsEntrantUser)만 제공·계약 고정. W2-4 가 점수입력 진입부에서 이 헬퍼를 소비하는지 verification 시점 재확인 필요(crossRefs)." + - "심사위원 지정 게이트 = GAME_JAM_MANAGE 이며 인터셉터 exclude 경로는 W2-1 의 /admin/jams/** 와 동일 트리(/admin/jams/{id}/judges). W2-1 InterceptorConfig.excludePathPatterns(\"/admin/jams/**\") 가 이미 /admin/jams/{id}/judges 를 커버하므로 본 설계는 InterceptorConfig 를 추가 수정하지 않는다(W2-1 J-CONFIG 와 충돌 0). 단 W2-1 미배포 상태에서 본 컨트롤러만 배포되면 인터셉터 isAdmin 게이트에 SUBADMIN 이 막힘 — 배포 순서 의존(롤아웃 §순서)." + - "심사위원 자기출품 충돌 enforce 시점이 '지정 시점' 이 아니라 '점수입력 시점'(W2-4)이다. 따라서 자기 출품작이 있는 유저도 심사위원으로 지정될 수 있고(다른 출품작은 심사 가능), 자기 출품작에 대해서만 점수입력이 거부된다. 지정 자체를 막지 않는 이유는 §충돌 규칙 계약에 명시 — 구현이 지정 시점에 막지 않도록 주의." +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/20260623-104307/implementation/W2-3-eval-freeze-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/rbac-ddl.sql + - docs/jam-ddl.sql +--- + +# 설계: W2-2 — 심사위원 역할 권한 (잼 스코프 게이트 / jam_judges + 지정 CRUD + 자기출품 충돌 계약) + +> ★보안 워크스트림(권한 스코프). 핵심 보안 단언: **전역 RBAC(user_permissions)는 불변** — 잼 회차별 역할은 **잼 스코프 조인테이블(jam_judges)** 로 표현하고, 판정은 **잼 스코프 게이트(JamRoleGate.isJudge(session, jamId))** 로 한다. 전역 PermissionGate 와 별도 축. W2-4 점수입력이 본 게이트 + 자기출품 충돌 규칙을 소비. + +## 목표 / 비목표 + +### 목표 (FR/NFR 추적 — 골자 W2-2) +- **G1 잼 스코프 역할 모델**: 잼 회차별 심사위원 역할을 **별도 `jam_judges` 테이블**(jam_id, user_id)로 표현. 전역 `user_permissions`(RBAC) 무변경 — 잼별 역할을 전역 권한 모델에 끼워넣지 않음(스코프 갭 정석 해소, QG-W2-A 채택 (b)). +- **G2 잼 스코프 게이트**: `JamRoleGate.isJudge(session, jamId)` — `jam_judges` 조회로 잼별 심사위원 자격 판정. W1 `PermissionGate`(전역 키 판정)와 **별도 축**. 리소스(jamId) 인자 보유. +- **G3 심사위원 지정/해제 = GAME_JAM_MANAGE 게이트**: `/admin/jams/{jamId}/judges` 추가/제거. 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE)` 통과 요구(W1 인프라 위, **임시 role 직접체크 금지**). 임명 주체 = 잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE). +- **G4 자기출품 충돌 계약**: 심사위원은 같은 잼 **자기 출품작** 점수 입력 불가(자기출품 충돌 회피). 본 설계는 **충돌 판정 헬퍼 + 계약**을 제공하고, enforce 는 **W2-4 점수입력 컨트롤러**가 수행(계약 명시). +- **G5 역할 수명**: 잼 종료 후 `jam_judges` 레코드 **잔존**(이력). 점수입력 게이트의 활성 여부는 W2-3 평가기간 게이트(EVAL + 기간)가 별도로 통제 — 역할 자체는 만료 회수하지 않음. +- **G6 심사위원 자격**: 누구나 지정 가능(일반 USER 포함). 권한은 **잼별 부여**(전역 role 무관). USER 도 특정 잼 심사위원이 될 수 있음. +- **G7 지정 현황 조회**: 잼 관리자가 잼별 심사위원 목록을 조회(지정/해제 UI 소스). +- **NFR**: 상태변경(지정/해제) CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 검증, 비파괴 멱등 마이그레이션, DDL 권위=docs/*-ddl.sql. + +### 비목표 (스코프 밖) +- **심사 점수 입력 API·UI·집계** — **W2-4 소유**. 본 설계는 `JamRoleGate.isJudge` 게이트 + 자기출품 충돌 헬퍼 **계약만** 제공. W2-4 가 점수입력 진입부에서 소비. +- **평가기간 게이트(EVAL + now∈[eval_start,eval_end])** — **W2-3 동결 계약(F6)**. 본 설계는 심사위원 *자격* 판정만, 평가 *시점* 게이트는 W2-3 소관(점수입력 시 두 게이트 AND). +- **잼 엔티티/CRUD/출품(jams/jam_entries/jam_teams)** — **W2-1 소유**. 본 설계는 jam_id FK 참조 + jam_entries 활성행 조회만. +- **전역 RBAC 모델 변경(user_permissions scope 컬럼 추가)** — 채택 안 함(QG-W2-A (a) 기각). 전역 모델 불변. +- **심사위원 인원 제한·정원·초대 워크플로** — 1차 미포함(누구나 지정 가능, 인원 무제한). 정원 정책은 후속(concern 아님 — 명시적 비목표). +- **InterceptorConfig 수정** — W2-1 이 이미 `/admin/jams/**` exclude 추가(W2-1 J-CONFIG). `/admin/jams/{id}/judges` 는 그 트리 하위라 추가 수정 불요(아래 §인터셉터 연동). + +--- + +## 개요 + +bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 `user_permissions` 는 **전역(글로벌) 권한 모델**이다 — 컬럼 (id/user_id/permission_key/granted_by/created_at), UNIQUE(user_id, permission_key), **scope/resource_id/jam_id 컬럼 부재**(grounding R-A, db/schema.sql:326-347). `PermissionGate.has(session, permissionKey)` 도 리소스 인자가 없다(PermissionGate.java:22 직접 확인). 따라서 "잼 회차별 심사위원 역할"은 전역 권한 모델 위에 그대로 얹히지 않는다(스코프 갭). + +본 설계는 **별도 잼 스코프 조인테이블 `jam_judges`(jam_id, user_id)** 를 신규 도입하고(QG-W2-A 채택안 (b)), **잼 스코프 게이트 `JamRoleGate.isJudge(session, jamId)`** 를 제공한다. 전역 `PermissionGate` 는 무변경 — 두 게이트는 **서로 다른 축**(전역 권한 키 vs 잼 리소스 역할)이다. 심사위원 **지정/해제**는 잼 관리자 권한(`GAME_JAM_MANAGE`)으로 보호하고(W1 게이트 위, 임시 role 직접체크 금지), **자기출품 충돌**은 W2-4 가 소비할 계약으로 고정한다. + +확정된 정석 결정(전제 — 재논의 금지): +- **별도 테이블 (b) 채택**: 전역 RBAC 에 scope 컬럼을 끼워넣는 (a)안은 전역 모델·게이트 시그니처를 침습적으로 바꾸고(회귀 위험) 잼 외 다른 스코프 역할이 생길 때마다 컬럼이 늘어난다. (b) `jam_judges` 는 잼 스코프 역할의 자연스러운 정규화이며 전역 모델 불변(회귀 0). 하이브리드 (c)는 over-engineering. +- **게이트 축 분리**: `JamRoleGate.isJudge(session, jamId)` 는 잼 리소스 인자를 받는 별도 게이트. 전역 `PermissionGate.has(session, key)` 시그니처를 건드리지 않는다(W1 회귀 0). +- **지정 게이트 = GAME_JAM_MANAGE**: 심사위원 지정은 잼 관리 행위 → W2-1 의 `/admin/jams/**` enforcement 패턴(인터셉터 exclude + 컨트롤러 게이트 헬퍼)을 그대로 재사용. +- **충돌 enforce 시점 = 점수입력(W2-4)**: 지정 시점이 아니라 점수입력 시점에 자기 출품작을 거부. 자기 출품작 보유 유저도 심사위원 지정 가능(다른 작품 심사 가능, 자기 작품만 점수입력 거부). + +가장 까다로운 두 난제 확정: +- **난제1 (스코프 게이트 축 분리 — 전역 모델 보호)**: 잼별 역할을 전역 `user_permissions` 에 표현하려면 (user_id, permission_key) 에 scope/jam_id 가 필요한데, 이는 전역 UNIQUE(user_id, permission_key)·`PermissionGate.has(session, key)` 2인자 시그니처를 깬다(W1 회귀). 본 설계는 **전역 권한 축(PermissionGate, 키 기반, 잼 무관)** 과 **잼 스코프 역할 축(JamRoleGate, jam_judges 조회, jamId 기반)** 을 명확히 분리한다. 심사위원 자격은 전역 권한이 아니라 잼 리소스 멤버십이므로 별도 축이 정석. epoch 전파(W1 결정4)는 전역 권한에만 적용 — 잼 역할은 jam_judges 직접 조회(요청당 조회, 캐시 안 함 — 잼 역할 변경 즉시 반영, 별도 epoch 불요). +- **난제2 (자기출품 충돌 — 개인/팀 entrant 모두 커버)**: W2-1 `jam_entries` 는 entrant_type('USER'/'TEAM') + entrant_user_id(개인) / jam_team_id(팀) XOR 구조다. "자기 출품작" 은 ① 개인 출품: `jam_entries.entrant_user_id == 심사위원 userId`, ② 팀 출품: 심사위원이 그 팀(`jam_entries.jam_team_id`)의 멤버(`jam_team_members.user_id == 심사위원`). 본 설계는 충돌 판정 계약을 **두 경로 모두**로 정의하고, 판정 헬퍼 `hasOwnEntry(session, jamId)`(또는 매퍼 `existsConflictEntry(jamId, userId)`) 를 제공한다. W2-4 가 점수입력 대상 game_id 에 대해 "이 출품작이 심사위원 본인 것인가"를 검사 — 본 W2-2 는 게임 단위 충돌 판정 매퍼 `isOwnEntry(jamId, gameId, userId)` 시그니처를 동결 제공. + +--- + +## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정값) + +| 결정 | 확정값 | 본 설계의 구체화 | +|---|---|---| +| J1 역할 모델 | 별도 `jam_judges` 테이블 | jam_judges(id, jam_id FK, user_id FK, assigned_by FK, created_at, UNIQUE(jam_id,user_id)). 전역 user_permissions 불변 | +| J2 게이트 | `JamRoleGate.isJudge(session, jamId)` | 잼 스코프 게이트(별도 축). jam_judges 조회 판정. PermissionGate(전역)와 분리 | +| J3 지정 게이트 | GAME_JAM_MANAGE | `/admin/jams/{jamId}/judges` 추가/제거 = `PermissionGate.has(session, GAME_JAM_MANAGE.name())`. 임시 role 직접체크 금지 | +| J4 자기출품 충돌 | 점수입력 시 자기출품작 거부 | 본 설계=충돌 판정 헬퍼 + 계약. enforce=W2-4. 개인+팀 entrant 모두 커버 | +| J5 역할 수명 | 잼 종료 후 잔존(이력) | jam_judges 만료 회수 없음. 점수입력 활성=W2-3 평가기간 게이트가 별도 통제 | +| J6 자격 | 누구나 지정(USER 포함) | 전역 role 무관. 권한은 잼별 부여 | +| J7 충돌 enforce 시점 | 점수입력 시점(지정 아님) | 자기출품작 있어도 지정 가능. 자기 작품만 점수입력 거부 | + +--- + +## 데이터 모델 (DDL) + +> 권위 = **신규 파일 `docs/jam-judge-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, W2-3 docs/jam-eval-ddl.sql(직접 확인). **jams/users 변경 없음**(FK 참조만). 멱등: CREATE TABLE/SEQUENCE IF NOT EXISTS, DO $$ guard, CREATE UNIQUE INDEX IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz). +> +> ⚠️ **알파벳 글롭 순서**: `apply-local-ddl.sh` 가 docs/*-ddl.sql 을 알파벳순 적용. `jam_judges` FK 가 `jams`(W2-1 docs/jam-ddl.sql) + `users`(기존) 를 참조하므로 jam-ddl 이 jam-judge 보다 **먼저** 적용돼야 한다. 알파벳: 공통 prefix `jam-` 뒤 `d`(jam-**d**dl) < `j`(jam-**j**udge) → jam-ddl 먼저 적용 보장(롤아웃 §순서 재확인). game-reviews-ddl(`g`)·rbac-ddl(`r`)과도 무충돌. + +### 신규 파일: `docs/jam-judge-ddl.sql` + +```sql +-- W2-2 심사위원 역할 권한(잼 스코프). 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용. +-- 선행: docs/jam-ddl.sql(jams — 알파벳 글롭 순 jam-ddl 먼저 적용). +-- 전역 user_permissions(RBAC) 변경 없음 — 잼 회차별 역할은 잼 스코프 조인이 정석. +-- 추가만, 파괴 없음. + +-- =========================================================================== +-- 1) jam_judges (잼별 심사위원. 잼 스코프 역할. 전역 권한과 별도 축) +-- =========================================================================== +CREATE SEQUENCE IF NOT EXISTS "jam_judges_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_judges" ( + "id" bigint DEFAULT nextval('jam_judges_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, -- 잼 회차(FK jams) + "user_id" bigint NOT NULL, -- 심사위원(FK users; 누구나 가능) + "assigned_by" bigint, -- 지정 관리자(FK users; 감사 보조, nullable) + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_judges_id_seq" OWNED BY "jam_judges"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_jam_fkey') THEN + ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_jam_fkey" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_user_fkey') THEN + ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_user_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'jam_judges_assigned_by_fkey') THEN + ALTER TABLE "jam_judges" ADD CONSTRAINT "jam_judges_assigned_by_fkey" + FOREIGN KEY ("assigned_by") REFERENCES "users" ("id"); + END IF; +END +$$; +-- 같은 잼에 같은 유저 중복 지정 방지(멱등 지정). 잼 종료 후 잔존(J5) — soft delete 없음(이력=행 존재). +-- 해제는 hard DELETE(역할 회수). 재지정은 다시 INSERT. +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"); +``` + +> **soft delete 미채택 근거(J5 정합)**: jam_judges 는 `is_delete` 를 두지 않는다. "잔존 이력"의 단위는 *잼 종료 후에도 행이 남는다*(만료 회수 안 함)는 의미이고, **해제(관리자가 명시적으로 심사위원 자격 박탈)** 는 역할 회수이므로 hard DELETE 가 정석(행 존재 = 현재 심사위원). soft delete 를 두면 isJudge 판정마다 `is_delete IS NOT TRUE` 필터가 필요하고 UNIQUE 도 partial 이어야 해 복잡도만 늘린다. 해제 이력이 필요하면 후속에 별도 감사 로그(비목표). UNIQUE(jam_id, user_id) full 로 멱등 지정 보장. + +### `db/schema.sql` 반영 (최초 기동 1회 자동 주입 — docs/jam-judge-ddl.sql 의 사본) +- W2-1 의 jams/jam_entries 블록 **뒤**(jams 가 FK 참조 대상이므로 schema.sql 순차 실행상 jams 가 먼저 정의돼야 함)에 위 1번을 **신설 블록**으로 추가. +- 헤더 주석: `-- 심사위원 역할 W2-2 (권위 DDL — docs/jam-judge-ddl.sql 와 동일. 잼 스코프 역할)` — game_reviews 블록 schema.sql:128 의 `(권위 DDL — docs/...-ddl.sql 와 동일)` 선례와 동형(직접 확인). +- 반영 방식: **docs/jam-judge-ddl.sql 이 권위, schema.sql 은 사본**. 두 곳에 동일 멱등 DDL. + +### 전역 RBAC / jams / users 무변경 확인 (J1 보안 단언) +- `user_permissions`/`permissions`/`users` 테이블 변경 0. 잼 스코프 역할은 전부 `jam_judges` 가 보유 → W1 RBAC 동작(전역 권한 판정·epoch 전파) 회귀 0. +- `jams`/`jam_entries`/`jam_teams` 변경 0. FK 로 jams.id 참조만. W2-1 잼 동작 회귀 0. + +--- + +## 외부 계약 (API) + +> 공통: 모든 상태변경(지정/해제)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java 확인). 응답은 RecruitController/W2-1 패턴 — 읽기=JSP 뷰이름 반환 또는 JSON 조회, 쓰기=`ResponseEntity>`(status/message). 관리자 API 는 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문 수행(W2-1 requireJamManage 헬퍼 재사용 후보). + +### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 과 일치) +- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`. 페이지는 `redirect:/login`. +- **인증·미인가**(GAME_JAM_MANAGE 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지). +- **자기출품 충돌**(W2-4 점수입력에서 본인 출품작): **422** JSON `{status:422, message:"본인 출품작은 심사할 수 없습니다."}` (인가는 됐으나 도메인 충돌 → 403 아님 422. W2-3 의 "평가기간 외=422" 정책과 동형 — 인가/시점/충돌은 422 계열). +- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`. + +### 심사위원 지정/해제/조회 (관리자 API — CSRF + GAME_JAM_MANAGE 게이트) +| 액션 | method | path | 요청 | 응답(200) | 에러 | +|---|---|---|---|---|---| +| 지정 | POST | `/admin/jams/{jamId}/judges` | userId | `{status:200, message, jamId, userId}` | 401/redirect, 403(CSRF/권한), 404(잼/대상유저 없음), 409(이미 심사위원) | +| 해제 | POST | `/admin/jams/{jamId}/judges/{userId}/remove` | (path) | `{status:200, message, jamId, userId, removed:true}` | 401, 403, 404(지정 안 됨) | +| 목록 조회 | GET | `/admin/jams/{jamId}/judges` | (없음) | `{status:200, judges:[{userId, displayName, assignedBy, createdAt}]}` | 401/redirect, 403 | + +- **지정 게이트(J3) enforcement**: 위 3액션 전부 진입부에서 `gate.isAuthenticated(session)`(401/redirect) → `gate.has(session, GAME_JAM_MANAGE.name())`(403) 2단계. PermissionGate.isAuthenticated(:47)/has(:22) 직접 확인. W2-1 의 `requireJamManage` private 헬퍼와 동일 패턴(재사용 권장 — 중복 0). +- **해제 = hard DELETE**: `removed:true` 응답. 멱등(이미 해제됐으면 404 또는 removed:false — 본 설계는 404 채택, 행 없음). +- **누구나 지정 가능(J6)**: 지정 대상 userId 의 전역 role 검사 없음. USER/SUBADMIN/ADMIN 무관 지정 가능. 단 대상 user 가 실재(`users` 활성)하는지만 검증(404). +- **자기출품 충돌은 지정에서 막지 않음(J7)**: 대상 유저가 그 잼에 출품했어도 지정 허용. 충돌은 점수입력(W2-4)에서만 거부. + +### 점수입력 게이트 계약 (J2/J4 — W2-4 소비, 권위) +> 본 W2-2 는 점수입력 API 를 신설하지 않는다. 아래는 W2-4 가 점수입력 진입부에서 **준수해야 할 계약**이다. + +- **심사위원 자격(J2)**: `JamRoleGate.isJudge(session, jamId)` true 여야 점수입력 허용. false → 403. (전역 GAME_JAM_MANAGE 와 무관 — 잼 스코프 역할.) +- **자기출품 충돌(J4)**: 점수입력 대상 game_id 에 대해 `JamRoleGate.isOwnEntry(jamId, gameId, judgeUserId)` true 면 거부(422 "본인 출품작은 심사할 수 없습니다"). 개인 출품(entrant_user_id==judge) + 팀 출품(judge 가 그 팀 멤버) 모두 충돌(난제2). +- **평가기간 게이트(W2-3 F6)**: `jam.status=='EVAL' AND now()∈[eval_start_at, eval_end_at]` (W2-3 소관). W2-4 진입부 게이트 순서 = ① CSRF → ② 로그인(401) → ③ 평가기간(W2-3, 422) → ④ isJudge(W2-2, 403) → ⑤ 자기출품 충돌(W2-2, 422) → 점수입력. (순서는 W2-4 결정 — 본 계약은 4·5 가 W2-2 소비점임만 고정.) + +--- + +## 인터셉터 / 게이트 연동 (J3 — W2-1 enforcement 재사용) + +### 문제 / 전제 +- `RbacInterceptor.preHandle` 은 `/admin/**` 전체에 `isAdmin(session)` 만 검사(RbacInterceptor.java:34, PermissionGate.isAdmin:55-62 — role==ADMIN 만 true). SUBADMIN+GAME_JAM_MANAGE 는 인터셉터에 막힌다. +- W2-1 이 이 갭을 `InterceptorConfig.addPathPatterns("/admin/**").excludePathPatterns("/admin/jams/**")` 로 해소(W2-1 D4-A, J-CONFIG). `/admin/jams/{jamId}/judges` 는 `/admin/jams/**` 트리 하위이므로 **W2-1 의 exclude 가 이미 커버**한다. + +### 확정 (J3-A: InterceptorConfig 추가 수정 없음 + 컨트롤러 게이트 헬퍼) +- **InterceptorConfig 무수정**: W2-1 의 `/admin/jams/**` exclude 가 `/admin/jams/{id}/judges` 를 포함 → 본 설계는 InterceptorConfig 를 건드리지 않는다(W2-1 J-CONFIG 와 충돌 0, 중복 exclude 0). +- **JamJudgeAdminController 진입부 게이트 헬퍼**: 각 액션 시작에서 `requireJamManage(session)`(W2-1 헬퍼 재사용 또는 동형): + 1. `gate.isAuthenticated(session)` 거짓 → 페이지면 redirect:/login, API 면 401. + 2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403. + 3. 통과 후 본문. +- **이유**: 심사위원 지정은 잼 관리 행위 → W2-1 잼 CRUD 와 동일 권한·동일 경로 트리. 별도 enforcement 메커니즘을 만들 이유 0(W2-1 선례 재사용, 중복 0). +- **배포 순서 의존(concern 5)**: W2-1 의 exclude 가 배포되기 전 본 컨트롤러만 배포되면 인터셉터 isAdmin 게이트에 SUBADMIN 이 막힌다. → W2-1 이후 배포(롤아웃 §순서). + +### epoch 전파 연동 (W1 결정4 — 전역 권한만) +- `gate.has` 내부 `refreshIfStale`(PermissionGate.java:86) 가 요청당 전역 epoch 대조 → ADMIN 이 SUBADMIN 에게 GAME_JAM_MANAGE 부여/회수 시 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0). +- **잼 역할(jam_judges)은 epoch 미사용**: `JamRoleGate.isJudge` 는 요청당 jam_judges 직접 조회(캐시 안 함). 심사위원 지정/해제는 다음 요청에서 즉시 반영(별도 epoch 스탬프 불요 — 조회가 곧 최신). 전역 권한 캐시(세션 permissions Set)와 다른 정책인 이유: 잼 역할은 세션에 캐시하지 않으므로 stale 문제가 없다(저비용 단일 인덱스 EXISTS 조회). + +--- + +## 시퀀스 (주요 플로우 의사코드) + +### S1. 심사위원 지정 → 해제 +``` +[잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE) 세션] POST /admin/jams/42/judges (CSRF, userId=7) + → InterceptorConfig: /admin/jams/** exclude(W2-1) → 인터셉터 미개입 + → JamJudgeAdminController.assignJudge(42, userId=7) + → requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403) + → CsrfTokens.isValid(request) (아니면 403 errorBody) + → jam = jamsMapper.getById(42) (없으면 404) # W2-1 매퍼 소비 + → target = usersMapper.getUser(7) (없으면 404) + → jamJudgesMapper.exists(42, 7)? (이미면 409 이미 심사위원) + → jamJudgesMapper.insert(42, 7, assignedBy=actorId) (UNIQUE 보장 — race 시 catch DuplicateKey→409) + → 200 {jamId:42, userId:7} + # 자기출품 충돌은 지정에서 막지 않음(J7) — target 이 42 에 출품했어도 지정 허용. + +[잼 관리자] POST /admin/jams/42/judges/7/remove (CSRF) + → JamJudgeAdminController.removeJudge(42, 7) + → requireJamManage + CSRF + → affected = jamJudgesMapper.delete(42, 7) # hard DELETE(역할 회수, J5) + → affected == 0 → 404 (지정 안 됨) + → 200 {removed:true} +``` + +### S2. 점수입력 게이트 소비 (W2-4 — 본 계약 검증용. 본 설계 미구현) +``` +[유저 7 세션] POST /jams/{slug}/scores (CSRF, gameId=88, {criterionKey:score,...}) # W2-4 컨트롤러 + → CsrfTokens.isValid (아니면 403) + → userId = sessionUserId(=7) (없으면 401) + → jam = jamsMapper.getBySlug(slug) (없으면 404) + → [W2-3 F6] jam.status=='EVAL' AND now∈[eval_start,eval_end]? (아니면 422 평가기간 외) + → [W2-2 J2] jamRoleGate.isJudge(session, jam.id)? (아니면 403 심사위원 아님) + → [W2-2 J4] jamRoleGate.isOwnEntry(jam.id, gameId=88, userId=7)? + true → 422 "본인 출품작은 심사할 수 없습니다" # 자기출품 충돌(개인 또는 팀멤버) + → jamScoresMapper.upsertScore(...) (W2-3 동결 매퍼) + → 200 {message} +``` + +### S3. isJudge / isOwnEntry 판정 내부 (JamRoleGate) +``` +JamRoleGate.isJudge(session, jamId): + userId = sessionUserId(session) # PermissionGate.sessionUserId 와 동형 + if userId == null: return false # 미인증은 심사위원 아님 + return jamJudgesMapper.exists(jamId, userId) # 단일 인덱스 EXISTS(ux_jam_judges_jam_user) + +JamRoleGate.isOwnEntry(jamId, gameId, userId): # 게임 단위 자기출품 충돌(난제2) + # jam_entries 활성행에서 (jamId, gameId) 출품작의 entrant 가 userId 본인인지. + # 개인: entrant_user_id == userId / 팀: jam_team_members 에 (jam_team_id, userId) 존재. + return jamEntriesMapper.isOwnEntry(jamId, gameId, userId) # 아래 매퍼 SQL 계약 +``` + +--- + +## 파일 영향 맵 + +> 소유권 분할 가이드(implementation-advisor worker 단위 후보): +> **K-SCHEMA**(DDL/schema 동기) · **K-DOMAIN**(data POJO) · **K-MAPPER**(JamJudgesMapper + JamEntriesMapper 충돌판정 메서드 추가) · **K-GATE**(JamRoleGate) · **K-ADMIN**(JamJudgeAdminController + JSP 또는 W2-1 admin-jam JSP 확장). +> 의존: K-SCHEMA → K-DOMAIN → K-MAPPER → {K-GATE, K-ADMIN}. K-GATE 는 W2-4 점수입력의 공통 선행(계약). +> **W2-1 의존**: jamsMapper.getById/getBySlug(존재), JamEntryData/jam_entries 스키마, InterceptorConfig exclude. K-MAPPER 의 isOwnEntry 는 W2-1 의 JamEntriesMapper 에 메서드 추가(소유권 경계 — concern·crossRefs). + +| 변경 유형 | 경로 | 역할 | 소유 | +|---|---|---|---| +| 신규 | `docs/jam-judge-ddl.sql` | 권위 DDL(jam_judges). apply-local-ddl.sh 자동 적용(알파벳: jam-ddl 뒤) | K-SCHEMA | +| 수정 | `db/schema.sql` | jam_judges 블록 추가(jam-judge-ddl 사본). jams 블록 뒤. 전역 RBAC/jams 무변경 | K-SCHEMA | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamJudgeData.java` | jam_judges 행 + JOIN users 표시필드(userId/displayName/assignedBy/createdAt) | K-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamJudgesMapper.java` | `@Mapper` 지정 insert/delete/exists/listByJam(JOIN users)(`#{}`, snake→camel 직접 alias) | K-MAPPER | +| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java` | `isOwnEntry(jamId, gameId, userId)` 추가(자기출품 충돌 판정, 개인+팀멤버 OR). **W2-1 소유 매퍼 — 메서드 추가**(crossRefs) | K-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/security/JamRoleGate.java` | 잼 스코프 게이트 — `isJudge(session, jamId)`/`isOwnEntry(jamId, gameId, userId)` (별도 축, @Component) | K-GATE | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamJudgeAdminController.java` | `/admin/jams/{jamId}/judges` 지정/해제/조회(GAME_JAM_MANAGE 게이트 헬퍼, CSRF) | K-ADMIN | +| 수정 | `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | 잼별 심사위원 지정/해제 폼·목록(CSRF hidden). **W2-1 소유 JSP — 섹션 추가**(또는 신규 admin-jam-judges.jsp). crossRefs | K-ADMIN | +| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 JamJudgesMapper + JamRoleGate @MockBean 등록(contextLoads 보존, verification §30) | (검증) | +| 신규 | `src/test/.../JamJudgeAdminControllerTest.java` | 지정/해제/조회 + 401/403/CSRF/409/404 + GAME_JAM_MANAGE 게이트(ADMIN/SUBADMIN+키/무키) | (검증) | +| 신규 | `src/test/.../JamRoleGateTest.java` | isJudge(지정/미지정/미인증) + isOwnEntry(개인출품/팀멤버출품/타인출품) 단위 | (검증) | + +> SSR 호출지점 영향(verification §영향맵): jam_judges 는 신규 테이블, JamRoleGate 는 신규 컴포넌트 → 기존 소비처 0 영향. JamEntriesMapper.isOwnEntry 는 신규 메서드라 기존 호출지점 깨짐 0(W2-1 메서드 보존). admin-jam-list.jsp 섹션 추가는 기존 폼 보존 + 추가만. + +### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지) +```java +// JamRoleGate — 잼 스코프 게이트(별도 축, @Component). 전역 PermissionGate 시그니처 무변경. +boolean isJudge(HttpSession session, // userId 출처(세션) — 미인증이면 false + long jamId) // 잼 리소스 식별 — jam_judges 조회 키 +boolean isOwnEntry(long jamId, // 잼 식별 + long gameId, // 점수입력 대상 출품작 + long judgeUserId) // 충돌 판정 대상 심사위원 — 본인 출품작이면 true +// (isOwnEntry 가 session 이 아닌 judgeUserId 를 받는 이유: W2-4 가 이미 세션에서 userId 추출 후 호출하므로 +// 게이트는 순수 판정만. session 재추출 중복 방지 — 최소 인자. isJudge 는 게이트 진입점이라 session 직수용.) + +// JamJudgesMapper (@Mapper, #{} only, snake→camel 직접 alias) +int insert(long jamId, // 지정 잼 + long userId, // 심사위원 + long assignedBy) // 지정 관리자(감사) +int delete(long jamId, long userId) // 해제(hard DELETE, 역할 회수). 반환=affected rows(0이면 404) +boolean exists(long jamId, long userId) // 지정 여부(isJudge 소스 + 중복 지정 409 체크) +List listByJam(long jamId)// 관리자 조회(JOIN users displayName) + +// JamEntriesMapper 추가 (W2-1 소유 매퍼에 메서드 추가 — @Mapper, #{} only) +boolean isOwnEntry(long jamId, // 잼 + long gameId, // 출품작 + long userId) // 본인 여부 판정 대상(개인 entrant_user_id 또는 팀멤버) +``` +> inflate 마킹(concern 1): `isOwnEntry` 는 3인자 전부(jamId/gameId/userId) 충돌 판정 SQL 에 쓰인다(개인 OR 팀멤버 EXISTS). 구현에서 gameId 없이 jamId+userId 만으로 "잼 내 본인 출품 존재" 를 본다면 다른 시그니처(hasOwnEntryInJam(jamId, userId))가 되지만, W2-4 는 *점수입력 대상 game_id 가 본인 것인지*를 묻는 게임 단위 판정이 필요하므로 gameId 포함이 정석(다른 출품작은 심사 가능, 자기 작품만 거부 — J7). `assignedBy` 는 감사 컬럼 채움에 실사용(nullable이지만 컨트롤러가 actorId 전달). + +### JamEntriesMapper.isOwnEntry SQL 계약 (난제2 — 개인+팀 OR) +```sql +-- 본인 출품작 충돌 판정: 활성 출품작 (jamId,gameId) 의 entrant 가 userId 본인인가. +-- 개인 출품: entrant_user_id == userId +-- 팀 출품: jam_team_id 가 가리키는 팀에 userId 가 멤버(jam_team_members) +SELECT EXISTS( + SELECT 1 FROM jam_entries e + WHERE e.jam_id = #{jamId} AND e.game_id = #{gameId} AND e.is_delete IS NOT TRUE + AND ( + (e.entrant_type = 'USER' AND e.entrant_user_id = #{userId}) + OR + (e.entrant_type = 'TEAM' AND EXISTS( + SELECT 1 FROM jam_team_members m + WHERE m.jam_team_id = e.jam_team_id AND m.user_id = #{userId} + )) + ) +) +``` +> `#{}` 바인딩만, `${}` 0. jam_entries.entrant_type/entrant_user_id/jam_team_id + jam_team_members 는 W2-1 §데이터모델4·3 스키마. boolean 반환(EXISTS) — UserPermissionsMapper.exists 선례(:21-29)와 동형. + +--- + +## 대안 비교 + +| 주제 | 안 | 장점 | 단점 | 채택 | +|---|---|---|---|---| +| 잼 역할 모델 | (A) 별도 jam_judges 테이블 | 전역 RBAC 불변(회귀 0), 잼 스코프 정규화, 다잼 자연 표현 | 테이블 1개 + 게이트 1개 추가 | **채택(J1, QG-W2-A(b))** | +| | (B) user_permissions 에 scope/jam_id 컬럼 추가 | 권한 단일 모델 | 전역 UNIQUE/PermissionGate.has 시그니처 침습(W1 회귀), 잼 외 스코프마다 컬럼 증식 | 기각 | +| | (C) 하이브리드(전역키 + 스코프 별도) | 유연 | 두 모델 동기·우선순위 복잡, over-engineering | 기각 | +| 게이트 축 | (A) JamRoleGate 별도 게이트(jamId 인자) | 전역 PermissionGate 무변경, 리소스 스코프 명확 | 게이트 클래스 1개 | **채택(J2)** | +| | (B) PermissionGate.has 에 resourceId 인자 추가 | 단일 게이트 | 기존 2인자 호출지점 전수 정정(W1 회귀), 전역 키와 잼 역할 의미 혼재 | 기각 | +| 잼 역할 캐시 | (A) 요청당 jam_judges 직접 조회(캐시 0) | 지정/해제 즉시 반영, epoch 불요, 단순 | 요청당 EXISTS 1회(인덱스 — 저비용) | **채택** | +| | (B) 세션 캐시 + epoch(전역 권한처럼) | 조회 절감 | 잼 역할용 별도 epoch·세션 attr 증식, 다중잼 캐시 복잡 | 기각(over-engineering) | +| 충돌 enforce 시점 | (A) 점수입력 시점(W2-4) 게임단위 거부 | 자기작품만 거부·타작품 심사 허용(유연), 지정 자유 | 시점이 지정과 분리 | **채택(J4/J7)** | +| | (B) 지정 시점에 출품자 제외 | 단순 | 지정 후 출품/지정 전 출품 타이밍 갭, 타작품 심사도 봉쇄(과도) | 기각 | +| 해제 저장 | (A) hard DELETE | 행 존재=현재 심사위원(isJudge 단순), 멱등 | 해제 이력 미보존 | **채택(J5)** | +| | (B) soft delete(is_delete) | 해제 이력 | isJudge 마다 필터·partial UNIQUE 복잡, 이력은 비목표 | 기각 | +| 지정 enforcement | (A) W2-1 exclude + 컨트롤러 게이트 헬퍼 재사용 | W2-1 선례 일치, InterceptorConfig 무수정(충돌 0) | W2-1 배포 의존 | **채택(J3-A)** | +| | (B) 별도 인터셉터 경로 매핑 | 중앙집중 | 경로↔키 테이블 신설(W1/W2-1서 기각된 방향), 중복 | 기각 | +| | (C) 임시 role 직접체크 | 빠름 | W1 인프라 우회·정석 위반(금지) | 기각 | + +--- + +## 롤아웃 / 마이그레이션 + +### 순서 +1. **스키마 적용**: `docs/jam-judge-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용. + - **선행 의존**: jam_judges FK 가 `jams`(W2-1 docs/jam-ddl.sql) + `users`(기존) 참조 → **jam-ddl.sql 이 먼저 적용돼야 함**. apply-local-ddl.sh 알파벳 글롭: `jam-ddl.sql` < `jam-judge-ddl.sql`(공통 `jam-` 뒤 `d` < `j`) → 순서 자동 보장. jam-eval-ddl(W2-3, `jam-e...`)과도 무충돌(jam_judges 는 eval 테이블 미참조). +2. **코드 배포(W2-1 이후)**: K-DOMAIN → K-MAPPER → {K-GATE, K-ADMIN}. **W2-1 의 InterceptorConfig exclude(`/admin/jams/**`) 가 선배포**돼야 SUBADMIN+GAME_JAM_MANAGE 가 `/admin/jams/{id}/judges` 도달(concern 5). W2-1 미배포 시 인터셉터 isAdmin 에 막힘 → W2-1 과 같은 배포 사이클 또는 그 이후. +3. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 W1 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 잼 스코프 역할 enforcement 추가만. +4. **운영**: 잼 관리자(ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE)가 잼별 심사위원 지정 → W2-4 점수입력 게이트가 즉시 소비(jam_judges 직접 조회, epoch 불요). + +### 역호환 +- 전역 RBAC(user_permissions/PermissionGate)·jams/jam_entries/games **전부 무변경**. W1/W2-1 동작 0 영향. +- jam_judges 는 신규 테이블(추가 전용, 비파괴). 기존 사용자 영향 0. +- JamEntriesMapper.isOwnEntry 는 신규 메서드(기존 메서드 보존). admin-jam-list.jsp 는 섹션 추가(기존 폼 보존). + +### 롤백 +- 코드 롤백: JamJudgeAdminController/JamRoleGate/JamJudgesMapper/isOwnEntry 되돌리면 심사위원 지정·점수입력 게이트 소비 불가(W2-4 가 isJudge 의존 시 W2-4 도 영향 — 같은 사이클 롤백 고려). 신규 테이블은 추가 전용이라 잔존 무해. +- 스키마 롤백: jam_judges 는 추가 전용 → drop 없이 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance(`DROP TABLE IF EXISTS jam_judges CASCADE;`). + +--- + +## AC 매핑 + +| AC | 요구(골자 W2-2) | 만족 설계 요소 | 비고 | +|---|---|---|---| +| AC-1 | 잼 회차별 심사위원 역할(전역 RBAC 불변) | jam_judges 별도 테이블 + user_permissions 무변경 | J1, §전역무변경 | +| AC-2 | 잼 스코프 게이트 isJudge(session, jamId) | JamRoleGate.isJudge — jam_judges exists 조회 | J2, S3 | +| AC-3 | 심사위원 지정 = GAME_JAM_MANAGE 게이트 | JamJudgeAdminController requireJamManage(gate.has GAME_JAM_MANAGE) | J3, J3-A | +| AC-4 | SUBADMIN+키 지정 통과 / 무키 403 | gate.has(ADMIN OR SUBADMIN+GAME_JAM_MANAGE), W2-1 exclude | J3-A, S1 | +| AC-5 | 누구나 지정 가능(USER 포함) | 지정 시 대상 전역 role 검사 없음(실재만 404 체크) | J6, §외부계약 | +| AC-6 | 자기출품 충돌(점수입력 시 자기작품 거부) | JamRoleGate.isOwnEntry → W2-4 422. 개인+팀멤버 OR | J4/J7, S2, §isOwnEntry SQL | +| AC-7 | 역할 수명 = 잼 종료 후 잔존 | jam_judges 만료 회수 없음, soft delete 미채택(행 존재=현재). 점수입력 활성=W2-3 게이트 | J5 | +| AC-8 | 지정/해제 상태변경 전수 CSRF | assignJudge/removeJudge CsrfTokens.isValid 선검증 → 403 | §외부계약 공통 | +| AC-9 | 권한 SQL `${}` 0 | JamJudgesMapper + isOwnEntry `#{}` only | §파일영향맵/isOwnEntry SQL | +| AC-10 | 중복 지정 방지(멱등) | ux_jam_judges_jam_user UNIQUE + exists 409 | §데이터모델1, S1 | +| AC-11 | 잼별 심사위원 목록 조회 | listByJam(JOIN users) GET API | G7, §외부계약 | + +--- + +## 검증 포인트 (verification-advisor 점검 대상) + +> L레벨 매핑(verification-strategies): 인가/게이트/잼 스코프 역할 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·isOwnEntry OR-EXISTS = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼·게이트(@Component) 의존 = full `./mvnw -o test` 의무(§30). + +### 시나리오 검증 +- **VP-1 (AC-3/4 지정 게이트, L1+L3)**: JamJudgeAdminControllerTest — ADMIN 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401(redirect). L3 스모크: W2-1 exclude 후 SUBADMIN 이 `/admin/jams/{id}/judges` 도달. +- **VP-2 (AC-2 isJudge, L1)**: JamRoleGateTest — 지정된 유저 true / 미지정 false / 미인증 false. jam_judges exists 조회 정확. +- **VP-3 (★AC-6 자기출품 충돌, L1+L2)**: JamRoleGateTest.isOwnEntry — ① 개인 출품(entrant_user_id==judge) true ② 팀 출품(judge 가 그 팀 멤버) true ③ 타인 출품/타팀 출품 false ④ 비활성(is_delete) 출품 false. dev DB contract: OR-EXISTS(개인 OR 팀멤버) SQL 실측(jam_entries/jam_team_members 샘플). +- **VP-4 (AC-5 누구나 지정, L1)**: USER role 대상 지정 200(전역 role 검사 없음). 미존재 userId 404. +- **VP-5 (AC-8 CSRF, L1)**: 지정/해제 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용). +- **VP-6 (AC-10 멱등, L1+L2)**: 이미 지정된 (jamId,userId) 재지정 → 409(exists) 또는 UNIQUE 거부(catch→409). dev DB: ux_jam_judges_jam_user 중복 INSERT 거부 실측. +- **VP-7 (DB-방언 계약, L2)**: JamJudgesMapper 반환 POJO 키 == 컨트롤러/JSP 조회 키(snake→camel 직접 alias, jam_judges 는 일반매퍼 → **큰따옴표 alias 금지** 확인). isOwnEntry boolean 매핑(EXISTS) 정합. +- **VP-8 (contextLoads, L1)**: BibimbapApplicationTests 에 JamJudgesMapper + JamRoleGate @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException. + +### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용) +> self-audit(시점): 아래 카운트는 **본 W2-2 가 신규 생성하는 정적 산출물**(jam_judges DDL·제약·매퍼 메서드·관리자 액션)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. JamEntriesMapper.isOwnEntry 추가분은 W2-1 매퍼에 들어가지만 메서드 1건 추가라 카운트 안정. +> self-audit(표현): 단일 리터럴 grep 취약성을 피해 제약명/액션 핸들러/충돌 OR-분기 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건은 부재 검증이라 리터럴 정당. + +- **AC-T1 jam_judges 무결성 제약 전수 4건 존재** — docs/jam-judge-ddl.sql 의 제약 전수: FK 3종(jam_id→jams / user_id→users / assigned_by→users) + UNIQUE 1종(ux_jam_judges_jam_user): `grep -c 'ADD CONSTRAINT' docs/jam-judge-ddl.sql` == 3(FK) AND `grep -c 'CREATE UNIQUE INDEX' docs/jam-judge-ddl.sql` == 1. AND db/schema.sql 에 jam_judges 동일 제약 전수 존재(동기 사본 누락 검출). 제약 추가/삭제 누락을 갯수로 동시 커버. +- **AC-T2 관리자 심사위원 액션 전수 3건 게이트** — JamJudgeAdminController 의 핸들러(지정/해제/조회) 전수가 `requireJamManage`(또는 gate.has(GAME_JAM_MANAGE)) 호출: 게이트 헬퍼 호출 수 == 핸들러 수(상태변경 핸들러 지정/해제는 추가로 CsrfTokens.isValid). 핸들러 추가 시 게이트 누락 = 인가 우회 보안결함 → FAIL. **이 전수 AC 가 J3 지정 enforcement 의 핵심 가드**(수동 판정: @PostMapping/@GetMapping 핸들러 열거 후 각 진입부 게이트 확인 — 리터럴 grep 단독 의존 회피). +- **AC-T3 상태변경 액션 전수 CSRF 가드** — JamJudgeAdminController 상태변경 핸들러(지정/해제 2건, GET 조회 제외)에 `CsrfTokens.isValid` 선검증 존재: `grep -c 'CsrfTokens.isValid' JamJudgeAdminController.java` == 상태변경 핸들러 수(2). 핸들러 추가 시 CSRF 누락 동시 검출(AC-8). +- **AC-T4 자기출품 충돌 양경로(개인+팀) 전수** — JamEntriesMapper.isOwnEntry SQL 이 entrant_type 양경로 전수 커버: 'USER'(entrant_user_id) 분기 AND 'TEAM'(jam_team_members EXISTS) 분기 둘 다 존재(OR 결합). 검증: SQL 에 `entrant_type = 'USER'` AND `entrant_type = 'TEAM'` 두 분기 grep 매치 각 1 AND L2 실측(개인/팀 출품 각각 충돌 true, 타인 false). **1경로 누락 = 충돌 우회(팀 출품 심사위원이 자기 팀 작품 채점) 보안결함 → FAIL**. W2-1 jam_entries XOR 구조(entrant_type 2값) 전수 대응 불변식. +- **AC-T5 jam_judges 매퍼 `${` 0건** — JamJudgesMapper + JamEntriesMapper(isOwnEntry 추가분)에 `${` 매치 0: `grep -rc '\${' JamJudgesMapper.java JamEntriesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당. +- **AC-T6 신규 빈 @MockBean 전수 등록** — BibimbapApplicationTests 에 JamJudgesMapper + JamRoleGate 전수 @MockBean 등록: contextLoads PASS AND 두 빈 등록 확인(JamRoleGate 가 JamJudgesMapper/JamEntriesMapper 의존 @Component 이므로 의존 매퍼 MockBean 도 필요). 1건 누락 시 contextLoads NoSuchBeanDefinitionException 로 즉시 검출(§30, verification 시점 자기 검증). +- **AC-T7 전역 RBAC 무변경 불변식** — user_permissions/permissions/users 가 본 동결로 인해 변경 0: docs/jam-judge-ddl.sql 에 `user_permissions`/`permissions`/`users` 테이블의 ALTER/CREATE 변경문 0건(jam_judges FK 가 users 참조하는 `REFERENCES "users"` 는 무방하나, users 테이블 ALTER/CREATE 는 0). 검증: `grep -E 'ALTER TABLE "(user_permissions|permissions|users)"|CREATE TABLE.*"(user_permissions|permissions)"' docs/jam-judge-ddl.sql` 0건. **★보안 단언(J1 전역모델 보호) 위반 즉시 검출**. + +--- + +## 잔여 오픈 질문 +없음(0). 확정 결정 J1~J7 전제 고정. 두 난제(스코프 게이트 축 분리·자기출품 충돌 개인+팀 커버)는 본 설계가 구체 메커니즘으로 확정. 인터셉터 무수정(W2-1 exclude 재사용, J3-A)·해제 hard DELETE(J5)·충돌 enforce 시점 점수입력(J7)도 확정. 구현 점검 항목(시그니처 inflate·신규 매퍼/게이트 @MockBean full-test·DB-방언 L2·W2-1 JamEntriesMapper/admin-jam-list.jsp 소유권 경계·W2-1 배포 순서 의존·충돌 enforce W2-4 소비 재확인)은 오픈 질문이 아니라 `concerns`/`crossRefs` 로 이관. diff --git a/.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md b/.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md new file mode 100644 index 0000000..d002bdc --- /dev/null +++ b/.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md @@ -0,0 +1,544 @@ +--- +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 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 listByJudge(long jamId, long judgeUserId) // 심사위원 본인 입력 현황 + +// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias) +List> listStatsByJam(long jamId) // 출품작별 종합(정렬·시상 소스) +Map 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 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` 로 이관. diff --git a/.atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md b/.atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md new file mode 100644 index 0000000..596a80f --- /dev/null +++ b/.atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md @@ -0,0 +1,354 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-23T16:30:00+09:00 +workstream: W2-4-심사위원 평가(점수 입력/집계 소비) +concerns: + - "★게이트 의존 시그니처 — 점수 입력은 W2-2 의 `JamRoleGate.isJudge(session, jamId)`(자격) + W2-2 자기출품 충돌규칙에 의존한다. 본 W2-4 는 이 게이트를 호출만 하고 소유하지 않는다(W2-2 미설계 시점이므로 시그니처는 orchestrator 확정값 `isJudge(session, jamId)` 를 계약으로 가정). 구현 단계에서 W2-2 산출 게이트의 실제 시그니처(인자·반환·자기출품 충돌 포함 여부)를 재확인 필요 — W2-2 가 충돌검사를 isJudge 안에 합칠지 별도 메서드로 둘지에 따라 본 컨트롤러 호출 코드가 갈린다. crossRefs 참조." + - "신규 컨트롤러(JamScoringController) + 신규 매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무(JamRoleGate/PermissionGate 빈도 컨트롤러 주입 시 동일). 누락 시 contextLoads NoSuchBeanDefinitionException." + - "jam_score_stats 는 집계 VIEW → 소비 매퍼(JamScoreStatsMapper) alias 는 camelCase 큰따옴표(AS \"weightedTotal\") 필수(케이스 폴딩 함정, verification-strategies §33, GameReviewStatsMapper.java:13 선례). criterion 단위 펼침 조회(criterion별 평균)는 VIEW 가 종합 1행만 노출하므로 W2-4 가 jam_scores 직접 GROUP BY 매퍼로 별도 작성 — 이 펼침 쿼리도 집계라 큰따옴표 alias. dev DB contract(L2)로 fan-out·0-division·NULL 실측 권장." + - "JamScoringController 헬퍼(requireEvalOpen/requireJudge/resolveScores) 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 평가기간 게이트가 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽음 → 필요 필드로 좁혀질 수 있음." + - "jam_scores UPSERT 는 PostgreSQL `INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key) DO UPDATE SET score=EXCLUDED.score, updated_at=now()` 단일문 권장(exists→update 2쿼리 inflate 회피). ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 의존. 이 UNIQUE 가 W2-3 동결 후 변경되면 ON CONFLICT 타깃이 깨진다 — crossRefs 동결 유지 필수. dev DB contract 로 ON CONFLICT 실측." + - "criterion_key 검증 — 입력 score 의 criterion_key 가 해당 잼의 jam_criteria 에 실재하는지 앱계층 검증(미등록 키 점수 거부, 422). jam_scores.criterion_key 는 jam_criteria.criterion_key 의 논리참조(W2-3: FK 아님)이므로 DB 가 막지 않음 → 앱계층 화이트리스트 필수(보안/무결성)." +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-3-eval-freeze-design.md + - .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-eval-ddl.sql +--- + +# 설계: W2-4 — 심사위원 평가 (점수 입력 API + 3중 게이트 + criterion 단위 채점 + 가중 집계 소비) + +> ⚠️ **W2-3 동결 스키마 소비 워크스트림**. 본 설계는 신규 테이블·VIEW 를 **만들지 않는다**. W2-3 이 동결한 `jam_criteria`/`jam_scores`/`jam_score_stats`(VIEW)를 **그대로 소비**하고, **API·로직·게이트·매퍼만** 설계한다. 동결 스키마 재정의 금지(W2-3 권위). + +## 목표 / 비목표 + +### 목표 (FR/NFR 추적 — 골자 W2-4 + 확정 결정) +- **G1 점수 입력 API**: 심사위원이 출품작(`(jam_id, game_id)` 자연키)에 jam_criteria 기준별 점수(1~5)를 입력. `jam_scores` UPSERT(criterion 단위 멱등 재입력). +- **G2 3중 진입 게이트(정석 보안)**: ① 심사위원 자격 = `JamRoleGate.isJudge(session, jamId)`(W2-2) ② 평가기간 게이트 = `jam.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]`(W2-3 F6) ③ 자기출품 충돌 = 심사위원이 자기 출품작에 채점 불가(W2-2 충돌규칙). + 상태변경 전수 CSRF. +- **G3 집계 소비**: 심사 집계 = `jam_score_stats` VIEW(criterion별 평균의 가중 종합 `weighted_total` + 비가중 `simple_total` + scored_criteria + judge_count). criterion별 펼침 평균은 jam_scores 직접 GROUP BY 매퍼(VIEW 는 종합 1행만 — W2-3 결정). 심사위원 평균·동률 처리 명시. +- **G4 수정/재입력**: 평가기간 내 점수 수정 허용(UPSERT → updated_at 갱신). 평가 종료(EVAL 이탈 or now>eval_end) 후 입력·수정 잠금(422). +- **G5 부분 입력·미채점 처리**: 심사위원이 일부 criterion 만 입력(부분입력) 허용 — 미입력 criterion 은 jam_scores 행 부재 → 집계에서 그 criterion 제외(W2-3 VIEW per_criterion 선집계). 점수 미입력 criterion·심사위원 부분입력의 집계 의미 명시. +- **G6 본인 입력 현황 조회**: 심사위원이 본인이 출품작에 매긴 점수 현황 조회(폼 prefill·수정용). +- **NFR**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 sanitize(score 범위·criterion_key 화이트리스트), 임시 role 직접체크 금지(W1/W2-2 게이트 위), 신규 테이블 0(동결 소비). + +### 비목표 (스코프 밖) +- **심사 스키마 정의**(jam_criteria/jam_scores/jam_score_stats DDL) — **W2-3 동결 소유**. 본 설계는 소비만(DDL 0). +- **심사위원 자격 판정·지정·자기출품 충돌규칙 본체**(`JamRoleGate.isJudge` / jam_judges) — **W2-2 소유**. 본 설계는 게이트를 호출만. +- **잼 심사 기준(jam_criteria) 등록 UI·CRUD** — 관리자 잼 설정의 일부. 본 설계는 criterion 조회(점수 폼 라벨·검증)만 소비하고, criterion 등록 API 는 W2-1 관리자 콘솔 확장 또는 별도 — 본 설계 비목표(아래 §criterion 등록 소유 결정). +- **인기투표**(W2-5), **시상 집계 산정**(W2-6) — 별도. 본 설계는 jam_score_stats 를 W2-6 JUDGE 트랙이 소비하도록 제공만. +- **점수 입력 이력 감사 로그(누가 언제 무엇을)** — 1차는 jam_scores.updated_at 으로 최종 상태만. 변경 이력 테이블은 후속(선택, 아래 §대안). +- **잼 평가단위 = jam_entries.id vs (jam_id,game_id) 재논의** — W2-3 동결(F8: (jam_id,game_id) 자연키) 그대로 채택. 재정의 안 함. + +--- + +## 개요 + +bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. W2-3 이 잼 평가 스키마를 동결했다 — `jam_criteria`(잼별 설정형 심사 기준, criterion_key/weight) + `jam_scores`(criterion 단위 점수 1~5, UNIQUE(jam_id,game_id,judge_user_id,criterion_key)) + `jam_score_stats`(출품작 단위 가중 종합 VIEW, fan-out 방지 선집계). W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작)를 제공한다. W1 RBAC 의 `PermissionGate.has(session, key)`(2인자, PermissionGate.java:22 직접 확인) + epoch 전파(`refreshIfStale`, PermissionGate.java:86) + `CsrfTokens.isValid(request)`(CsrfTokens.java:35 직접 확인)가 인프라로 완비됐다. + +본 설계는 **심사위원이 출품작에 criterion 단위 점수를 입력하는 API + 3중 진입 게이트 + 집계 소비 매퍼**를 설계한다. 신규 테이블·VIEW 는 0(W2-3 동결 소비). 컨트롤러 패턴은 RecruitController(읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity>` status/message + CSRF, RecruitController.java:140,186 직접 확인)를 따른다. + +확정된 정석 결정(전제 — 재논의 금지, orchestrator 확정): +- **점수 입력 단위 = criterion 단위 UPSERT** : `jam_scores` 의 UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위에 ON CONFLICT UPSERT. 한 출품작에 여러 criterion 을 한 요청으로 batch 입력하되 각 criterion 은 멱등 upsert. +- **3중 게이트(앱계층)** : isJudge(W2-2) + 평가기간(W2-3 F6) + 자기출품 충돌(W2-2) + CSRF. DB CHECK 는 score 범위·UNIQUE 만 강제(시각·자격은 런타임). +- **집계 = jam_score_stats VIEW(종합) + jam_scores GROUP BY(criterion 펼침)** : VIEW 는 출품작 종합 1행(W2-3 결정), criterion별 평균 펼침은 W2-4 매퍼가 jam_scores 직접 집계. +- **수정 = 평가기간 내 UPSERT, 종료 후 잠금** : 평가기간 게이트가 입력·수정 양쪽을 막음(같은 게이트). + +가장 까다로운 세 난제 확정: +- **난제1 (3중 게이트 순서·401/403/422 정합)**: 게이트는 **CSRF → 인증(401) → isJudge 자격(403) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트(422)** 순으로 평가한다(아래 §게이트 연동 확정). 인증 실패=401, 인가(심사위원 아님)=403, 도메인 상태 위반(기간 외/자기출품/미등록 criterion)=422 — W1-design/W2-1/W2-3 의 401≠403≠422 정책과 일치. 순서 근거: 싼 검사(CSRF/세션)·보안 경계(자격) 먼저, DB 조회 필요한 검사(출품작/criterion) 뒤. +- **난제2 (부분 입력·미채점 집계 의미)**: 심사위원은 criterion 일부만 입력 가능(부분입력). 미입력 criterion 은 jam_scores 행 자체가 없음 → W2-3 `jam_score_stats` VIEW 의 `per_criterion` 선집계가 **채점된 criterion 만** 가중 종합에 반영(미채점 criterion 은 자동 제외, weight 분모에서도 빠짐). 따라서 "심사위원 A 가 immersion 만 채점" 해도 종합은 immersion 가중으로만 계산 — 부분입력이 종합을 왜곡하지 않고 채점된 범위로만 산정된다. 출품작별 `judge_count`(criterion별 DISTINCT judge 의 MAX)로 심사 참여 규모를 노출해 부분참여를 가시화. +- **난제3 (동률·심사위원 평균 처리)**: 출품작 종합점수(`weighted_total`) 동률은 **DB 가 강제하지 않음**(VIEW 는 산정만). 동률 정렬 tie-break 는 소비처(W2-4 정렬 목록·W2-6 JUDGE 트랙 rank)가 **2차 키(game_id ASC 또는 judge_count DESC)로 결정론적 정렬**. 본 설계 정렬 목록은 `ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC`(미채점 출품작 말단, 심사 많은 작품 우선, 최종 id tie-break). 시상 rank 부여(동률 시 같은 rank)는 W2-6 소관 — 본 설계는 종합 점수 + 결정론 정렬만 제공. + +--- + +## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 소비) + +| 결정 | 확정값 | 본 설계의 구체화 | +|---|---|---| +| S1 입력 API | POST/PUT `/jams/{jamId}/games/{gameId}/scores` | criterion별 점수(1~5) batch UPSERT. jamId path = 게이트 스코프, gameId path = 출품작 | +| S2 진입 게이트 | isJudge + 평가기간 + 자기출품충돌 + CSRF | 앱계층 3중(+CSRF). isJudge/충돌=W2-2, 기간=W2-3 F6 | +| S3 점수 저장 | jam_scores UPSERT(ON CONFLICT) | UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위 멱등. updated_at 갱신 | +| S4 집계 | jam_score_stats VIEW(종합) + jam_scores GROUP BY(펼침) | VIEW=가중종합 1행(W2-3), criterion 펼침=W2-4 매퍼. 큰따옴표 alias | +| S5 수정/잠금 | 평가기간 내 수정, 종료 후 잠금 | 입력·수정 동일 게이트(평가기간). EVAL 이탈/now>eval_end → 422 | +| S6 부분입력 | 일부 criterion 입력 허용 | 미입력=행 부재→집계 제외(VIEW per_criterion). judge_count 로 가시화 | +| S7 동률/평균 | VIEW 산정 + 소비처 결정론 정렬 | weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC | +| S8 criterion_key | jam_criteria 논리참조(앱계층 화이트리스트) | 입력 키가 잼 criteria 에 실재해야 점수 수용(미등록→422) | + +--- + +## 데이터 모델 (DDL — 신규 0. W2-3 동결 스키마 소비) + +> **본 설계는 DDL 을 작성하지 않는다.** W2-3 `docs/jam-eval-ddl.sql`(권위) 의 `jam_criteria`/`jam_scores`/`jam_score_stats`(VIEW)를 그대로 소비한다. 아래는 **소비 형태 확인용 동결 스키마 요약**(W2-3 동결 — 재정의 금지). + +### 소비하는 동결 테이블/뷰 (W2-3 권위 — 변경 0) +| 객체 | 종류 | 본 설계 소비 컬럼 | 동결 제약(의존) | +|---|---|---|---| +| `jam_criteria` | 테이블 | jam_id / criterion_key / display_name / sort_order / weight | ux_jam_criteria_jam_key(잼 내 키 UNIQUE) — criterion 화이트리스트·라벨 소스 | +| `jam_scores` | 테이블 | jam_id / game_id / judge_user_id / criterion_key / score / updated_at | **ux_jam_scores_jam_game_judge_criterion**(UPSERT ON CONFLICT 타깃) + score_check(1~5) | +| `jam_score_stats` | **VIEW** | jam_id / game_id / weighted_total / simple_total / scored_criteria / judge_count | per_criterion 선집계(fan-out 방지) + NULLIF 0-division 가드 | + +- **신규 0 확인**: 본 W2-4 는 테이블·VIEW·컬럼·인덱스·CHECK 를 추가하지 않는다. `docs/*-ddl.sql` 신규 파일 0, `db/schema.sql` 수정 0. (점수 입력 이력 감사 테이블은 비목표 — 후속.) +- **criterion 등록 소유 결정**: 본 설계는 jam_criteria 를 **읽기만**(화이트리스트·라벨). criterion 등록(INSERT) API 는 W2-1 관리자 잼 CRUD(`/admin/jams/{jamId}` 확장) 또는 별도 워크스트림 소유 — 본 W2-4 비목표. W2-3 동결 매퍼 시그니처 `JamCriteriaMapper.insertCriterion` 은 등록 소유자가 구현(본 설계는 `listByJam` 만 소비). + +--- + +## 외부 계약 (API) + +> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 직접 확인). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity>`(status/message, RecruitController.java:186 직접 확인). 점수 입력은 컨트롤러 진입부에서 3중 게이트(isJudge + 평가기간 + 자기출품) 통과 후 본문 수행. + +### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 과 일치 — 본 설계 준수) +- **미인증**(세션 `userId` 없음): API **401** JSON `{status:401, message:"로그인이 필요합니다."}`. 점수 폼 페이지(인증 필요)는 `redirect:/login`. +- **인증·미인가**(심사위원 아님 = `isJudge` false): **403** JSON `{status:403, message:"심사 권한이 없습니다."}`(리다이렉트 금지). +- **도메인 상태 위반**(평가기간 외 / 자기출품 충돌 / 미등록 criterion / score 범위 외): **422** JSON `{status:422, message:<구체사유>}`. 인가는 됐으나 도메인 규칙 위반 → 403 아님 422(W2-3 §401vs403 정책 일치). +- **출품작 없음**(jam_entries 활성행 부재 또는 game 없음): **404**. +- **CSRF 실패**: **403** + `CsrfTokens.errorBody()`. + +### 점수 입력/수정 (상태변경 API — CSRF + 3중 게이트) +| 액션 | method | path | 요청 body | 응답(200) | 에러 | +|---|---|---|---|---|---| +| 점수 입력/수정 | POST | `/jams/{jamId}/games/{gameId}/scores` | `{"scores":[{"criterionKey":"...","score":1~5}, ...]}` | `{status:200, message, jamId, gameId, savedCount}` | 401(미인증), 403(CSRF/심사권한), 404(잼·출품작 없음), 422(평가기간 외/자기출품/미등록 criterion/score 범위) | +| 점수 입력/수정(멱등 별칭) | PUT | `/jams/{jamId}/games/{gameId}/scores` | (POST 와 동일) | (POST 와 동일) | (동일) | + +- **POST vs PUT 둘 다 채택 근거(orchestrator 확정 "POST/PUT")**: UPSERT 의미는 PUT(멱등 전체 교체)에 정합하나, 기존 RecruitController 등 상태변경이 전부 POST + ResponseEntity JSON 패턴이고 JSP form 은 PUT 미지원(method 오버라이드 필요)이다. 따라서 **POST 를 1차 경로**(JSP form 호환), **PUT 을 동일 핸들러로 추가 매핑**(REST 멱등 의도 명시, API 클라이언트용)한다. 두 매핑은 같은 컨트롤러 메서드(`@RequestMapping(method={POST,PUT})`)로 동작 동일 — 중복 0. +- **batch 입력 채택 근거**: 한 출품작에 criterion 이 여러 개(예: 4개)이고 심사위원은 보통 한 화면에서 전부 매긴다. criterion별 단건 요청은 round-trip N배 + 부분 실패 정합 복잡. body 배열 1요청으로 트랜잭션 내 전 criterion UPSERT(부분입력 시 입력된 것만 배열에 포함). +- **savedCount**: 실제 UPSERT 된 criterion 수(입력 배열 길이와 검증 통과 수 일치 — 미등록 criterion 이 하나라도 있으면 전체 422 거부, 부분 저장 안 함 = 트랜잭션 원자성). +- **자기 채점 금지(자기출품 충돌, W2-2)**: 심사위원이 자신이 출품한(개인 entrant_user_id==judge 또는 팀 멤버) 출품작에 채점 시도 → 422 `{message:"자기 출품작은 심사할 수 없습니다."}`. 충돌 판정은 W2-2 규칙 위임(아래 §게이트 연동). + +### 점수 조회 (읽기 — 심사위원 본인 현황 / 집계) +| 액션 | method | path | 권한 | 응답 | +|---|---|---|---|---| +| 본인 입력 현황 | GET | `/jams/{jamId}/games/{gameId}/scores/mine` | isJudge | `{status:200, scores:[{criterionKey, score}], criteria:[{criterionKey, displayName, sortOrder, weight}]}` (폼 prefill·수정용) | +| 출품작 심사 집계 | GET | `/jams/{jamId}/scores/summary` | 공개 또는 관리자(아래 노출시점 결정) | `{status:200, stats:[{gameId, weightedTotal, simpleTotal, scoredCriteria, judgeCount}]}` (정렬 적용) | + +- **집계 노출 시점 결정(정석)**: 심사 진행 중(EVAL) 실시간 집계 노출은 심사위원 간 점수 동조(anchoring) 편향을 유발한다. 따라서 `/jams/{jamId}/scores/summary` 는 **`jam.status='CLOSED'` 또는 now()>eval_end_at 이후에만 공개**(EVAL 중에는 GAME_JAM_MANAGE 보유 관리자만 조회 — 운영 모니터링). 미개방 시 비관리자는 403/빈 결과. (W2-6 시상 집계도 CLOSED 이후 — W2-3 F6 정합.) +- **본인 현황(`/scores/mine`)은 EVAL 중에도 isJudge 본인에게 허용**(자기 입력 prefill — 동조 편향 무관, 본인 점수만). + +### 게이트 진입 계약 (점수 입력 — 본 설계 핵심) +- 입력: `HttpServletRequest`(CSRF) + `HttpSession`(userId/role/permissions) + path(jamId, gameId) + body(scores). +- 출력: 200(저장) 또는 401/403/404/422. +- 판정 순서(난제1): CSRF → 인증 → isJudge(자격, 403) → 잼 조회(404) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트·score 범위(422) → UPSERT. + +--- + +## 인터셉터 / 게이트 연동 (S2 — 3중 게이트, 앱계층) + +### 인터셉터 미개입 (확정) +- 점수 경로 `/jams/**` 는 RbacInterceptor 등록 대상(`/admin/**`)이 **아니다**(InterceptorConfig.java:18-20, grounding R-A 직접 확인). 따라서 인터셉터는 점수 입력에 개입하지 않고, **게이트는 전부 컨트롤러 진입부 앱계층**에서 수행한다(W2-1 D4-A "소비 액션=게이트 헬퍼" 선례와 동형). 임시 role 직접체크 금지 — 자격은 `JamRoleGate.isJudge`, 권한은 W1 `PermissionGate`(관리자 집계 노출 시). + +### 3중 게이트 구성 (각 게이트의 소유·시그니처) +1. **심사위원 자격 (W2-2 소유 — 호출만)**: `jamRoleGate.isJudge(session, jamId)` → false 면 403. **시그니처는 orchestrator 확정값**(`isJudge(session, jamId)`). 본 설계는 이 메서드를 호출만 하고 구현하지 않는다(concern 1 — W2-2 미설계 시점, 구현 단계 시그니처 재확인). +2. **평가기간 (W2-3 F6 계약 — 본 설계가 검증 로직 구현)**: `jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]` → 위반 시 422. `JamEvalWindow` 헬퍼(본 설계 신규, 아래 §파일영향맵)가 JamData 의 status/eval_start_at/eval_end_at 로 판정. eval_start_at/eval_end_at NULL 이면 "기간 미설정" → 422(EVAL 인데 기간 미설정은 운영 오류). +3. **자기출품 충돌 (W2-2 규칙 — 호출/위임)**: 심사위원이 자기 출품작(개인 entrant 본인 또는 팀 멤버) 채점 금지. **충돌 판정 책임은 W2-2** — 본 설계는 두 경로 중 하나를 호출(concern 1): + - (a) W2-2 가 `isJudge` 에 자기출품 제외를 포함하면 → 별도 호출 불필요(isJudge 가 자기 출품작 게임에 대해 false 반환하도록 jamId+gameId 받는 변형이 필요할 수 있음 — 시그니처 재확인). + - (b) W2-2 가 충돌을 별도 메서드(`isOwnEntry(jamId, gameId, userId)` 등)로 두면 → 본 설계가 isJudge 통과 후 그 메서드 호출해 422. + - **본 설계 기본 가정**: (b) 별도 충돌 검사. isJudge 는 "잼 심사위원인가"(스코프 자격)만, 자기출품 충돌은 출품작 단위라 gameId 가 필요하므로 분리가 자연스럽다. 구현 시 W2-2 산출과 정합(concern 1). +- **epoch 전파 연동(W1 결정4)**: 관리자 집계 노출 게이트에서 `PermissionGate.has(session, GAME_JAM_MANAGE)` 사용 시 `refreshIfStale`(PermissionGate.java:86)로 요청당 epoch 대조 → 권한 부여/회수 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0). isJudge 의 즉시성은 W2-2 소관. +- **중복 0**: 점수 입력 메서드(POST/PUT 공용) 진입부의 게이트 시퀀스를 private 헬퍼 `requireScoringAllowed(session, request, jamId, gameId)` 로 단일화 — 401/403/404/422 응답 작성 포함. + +--- + +## 시퀀스 (주요 플로우 의사코드) + +### S1. 심사위원 점수 입력/수정 (3중 게이트 → batch UPSERT) +``` +[심사위원 세션] POST /jams/42/games/777/scores (CSRF, {scores:[{immersion:5},{fun:4}]}) + → JamScoringController.submitScores(jamId=42, gameId=777, body) + → CsrfTokens.isValid(request) (아니면 403 errorBody) + → userId = sessionUserId(session) (없으면 401) + → jamRoleGate.isJudge(session, 42)? (아니면 403 "심사 권한 없음") [W2-2] + → jam = jamsMapper.getById(42) (없으면 404) [W2-1 매퍼 소비] + → JamEvalWindow.isOpen(jam, now)? (아니면 422 "평가 기간 아님") [W2-3 F6] + → jamEntriesMapper.exists(42, 777)? (아니면 404 "출품작 아님") [W2-1 매퍼 소비] + → 자기출품 충돌(W2-2): isOwnEntry(42, 777, userId)? (참이면 422 "자기 출품작 심사 불가") [W2-2] + → criteria = jamCriteriaMapper.listByJam(42) # 화이트리스트·라벨 [W2-3 매퍼 소비] + → for each {criterionKey, score} in body.scores: + criterionKey ∈ criteria.keys? (아니면 422 "미등록 기준") [S8] + 1 <= score <= 5? (아니면 422 "점수 범위") + # 검증 전수 통과 후 트랜잭션 내 일괄 UPSERT(원자성 — 부분저장 안 함) + → @Transactional: + for each {criterionKey, score}: + jamScoresMapper.upsertScore(42, 777, userId, criterionKey, score) + # INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key) + # DO UPDATE SET score=EXCLUDED.score, updated_at=now() + → 200 {jamId:42, gameId:777, savedCount:N} + # jam_score_stats VIEW 가 다음 집계 조회 시 자동 반영(읽기 시점 계산). +``` + +### S2. 본인 입력 현황 조회 (폼 prefill / 수정) +``` +[심사위원] GET /jams/42/games/777/scores/mine + → JamScoringController.myScores(42, 777) + → userId = sessionUserId (없으면 401) + → jamRoleGate.isJudge(session, 42)? (아니면 403) + → mine = jamScoresMapper.listByJudge(42, 777, userId) # 본인 criterion별 점수 + → criteria = jamCriteriaMapper.listByJam(42) # 라벨·순서·가중 + → 200 {scores: mine, criteria} + # 미입력 criterion 은 scores 에 부재 → 폼은 criteria 전체 표시 + 입력값만 prefill(부분입력 가시화). +``` + +### S3. 심사 집계 조회 (CLOSED 이후 공개 / EVAL 중 관리자만) +``` +[조회자] GET /jams/42/scores/summary + → JamScoringController.summary(42) + → jam = jamsMapper.getById(42) (없으면 404) + → 노출 게이트: + jam.status=='CLOSED' OR now()>jam.eval_end_at → 공개 허용 + else (EVAL 진행 중) → PermissionGate.has(session, GAME_JAM_MANAGE)? (아니면 403/빈) [W1] + → stats = jamScoreStatsMapper.listStatsByJam(42) # jam_score_stats VIEW + # ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC (난제3 결정론) + → 200 {stats} + # W2-6 JUDGE 트랙이 같은 VIEW 의 weighted_total DESC 를 rank 소스로 소비(crossRefs). +``` + +--- + +## 파일 영향 맵 + +> 소유권 분할 가이드(implementation-advisor worker 단위 후보): +> **K-DOMAIN**(JamEvalWindow + data POJO) · **K-MAPPER**(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper — W2-3 동결 시그니처 구현) · **K-CTRL**(JamScoringController + JSP, 3중 게이트) · **K-TEST**(@MockBean + 게이트/UPSERT/집계 테스트). +> 의존: W2-3 동결(DDL) + W2-2 게이트(JamRoleGate) + W2-1 매퍼(JamsMapper.getById/JamEntriesMapper.exists) **선행**. K-DOMAIN → K-MAPPER → K-CTRL. + +| 변경 유형 | 경로 | 역할 | 소유 | +|---|---|---|---| +| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java` | 평가기간 게이트 판정(status='EVAL' AND now∈[eval_start,eval_end]). W2-3 F6 계약 구현 | K-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamCriterionData.java` | jam_criteria 행 POJO(criterionKey/displayName/sortOrder/weight) — 폼 라벨·화이트리스트 | K-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java` | jam_scores 행 POJO(criterionKey/score[/judgeUserId/updatedAt]) — 본인 현황 | K-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java` | `@Mapper` `listByJam(jamId)`(`#{}`, snake→camel 직접 alias). W2-3 동결 시그니처. insertCriterion 은 등록 소유자 소관(본 설계 미구현 가능 — listByJam 만) | K-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java` | `@Mapper` `upsertScore`(ON CONFLICT) + `listByJudge`(`#{}`, snake→camel) | K-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java` | `@Mapper` `listStatsByJam`(jam_score_stats VIEW — **집계 VIEW → 큰따옴표 alias**) | K-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java` | 점수 입력(POST/PUT)/본인현황(GET)/집계(GET). 3중 게이트 헬퍼 requireScoringAllowed | K-CTRL | +| 신규 | `src/main/webapp/WEB-INF/views/jam-scoring.jsp` | 심사위원 채점 폼(criterion별 1~5, CSRF hidden, prefill). 표시용 — 게이트 아님 | K-CTRL | +| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 3매퍼 @MockBean 등록(JamRoleGate/PermissionGate 컨트롤러 주입분도 — contextLoads 보존, §30) | (검증) | +| 신규 | `src/test/.../JamScoringControllerTest.java` | 3중 게이트(isJudge/평가기간/자기출품) + CSRF + 401/403/404/422 + UPSERT 멱등 + 부분입력 | (검증) | +| 신규 | `src/test/.../JamEvalWindowTest.java` | 평가기간 경계(EVAL+구간내/구간밖/status≠EVAL/기간 NULL) 단위 | (검증) | + +> **신규 테이블/VIEW 0 확인**: 본 설계 파일 영향에 `docs/*-ddl.sql` 신규·`db/schema.sql` 수정 **없음**(W2-3 동결 소비). 매퍼는 동결 스키마를 읽고/UPSERT 만. +> SSR 호출지점 영향(verification §영향맵): 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 매퍼/JSP/컨트롤러 소비처 0 영향. jam_score_stats VIEW·jam_scores 는 W2-3 동결이라 본 설계가 스키마를 건드리지 않음. + +### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지) +```java +// JamEvalWindow — 평가기간 게이트 판정(W2-3 F6 구현). jam + now 만으로 판정(최소). +boolean isOpen(JamData jam, // status/eval_start_at/eval_end_at 3필드만 읽음(inflate 마킹) + java.time.OffsetDateTime now) // 비교 기준 시각(테스트 주입 가능 — Clock 대신 인자) + +// JamCriteriaMapper (@Mapper, #{} only, snake→camel 직접 alias) +List listByJam(long jamId) // 점수 폼 라벨 + criterion_key 화이트리스트 소스 + +// JamScoresMapper (@Mapper, #{} only) +int upsertScore(long jamId, // 평가단위 1/2 + long gameId, // 평가단위 2/2(출품작) + long judgeUserId, // 심사위원(세션 userId) + String criterionKey, // 채점 기준(jam_criteria 화이트리스트 통과분) + int score) // 1~5. INSERT ON CONFLICT DO UPDATE(멱등 재입력) +List listByJudge(long jamId, // 잼 스코프 + long gameId, // 출품작 + long judgeUserId)// 본인 현황(폼 prefill) — 3키로 본인 criterion별 점수 + +// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias) +List> listStatsByJam(long jamId) // 출품작별 종합(정렬 적용, 시상/목록 소스) +``` +> ⚠️ inflate 마킹(concern 4): `JamEvalWindow.isOpen(jam, now)` 의 `jam` 은 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽는다 — 구현에서 필요 시 `isOpen(String status, OffsetDateTime start, OffsetDateTime end, OffsetDateTime now)` 로 좁힐 수 있음(최소 인자 원칙). `now` 를 Clock 대신 인자로 둔 이유: 테스트에서 경계 시각 주입(고정 Clock 빈 DI 보다 단순). `JamScoresMapper.upsertScore` 는 ON CONFLICT 단일문 권장 — exists→update 2메서드로 쪼개면 inflate(concern 5). +> ⚠️ criterion 펼침 평균 조회(criterion별 평균 화면 필요 시): W2-3 결정상 VIEW 는 종합 1행만 노출하므로, criterion별 평균이 화면에 필요하면 `JamScoresMapper` 에 `listCriterionAvgByJam(long jamId)`(jam_scores GROUP BY jam_id,game_id,criterion_key) 를 **구현 단계에서 필요 확인 후 추가**(현 설계는 종합 VIEW 소비로 충분 — 선제 추가 안 함, 최소 인자/메서드 원칙). + +--- + +## 대안 비교 + +| 주제 | 안 | 장점 | 단점 | 채택 | +|---|---|---|---|---| +| 점수 저장 | (A) jam_scores UPSERT(ON CONFLICT) | 멱등 재입력, 단일문, 동결 UNIQUE 활용 | PostgreSQL 의존(ON CONFLICT) | **채택(S3)** | +| | (B) exists→insert/update 2쿼리 | 방언 독립 | round-trip 2배·경합 윈도우, 메서드 inflate | 기각 | +| | (C) DELETE→INSERT | 단순 | updated_at 이력 소실, 트랜잭션 내 빈 구간 | 기각 | +| 입력 단위 | (A) criterion batch(body 배열) 1요청 | round-trip 1회, 원자성, 부분입력 자연 | 배열 검증 | **채택(S1)** | +| | (B) criterion 단건 요청 N회 | 단순 매핑 | N round-trip, 부분실패 정합, 폼 1화면과 불일치 | 기각 | +| HTTP method | (A) POST 1차 + PUT 동일핸들러 | JSP form 호환 + REST 멱등 의도, 중복 0 | 매핑 2개 | **채택(S1, orchestrator "POST/PUT")** | +| | (B) PUT 단독 | REST 정석 | JSP form method 오버라이드 필요, 기존 POST 패턴 이탈 | 기각 | +| 자기출품 충돌 위치 | (A) W2-2 별도 메서드 호출(gameId 필요) | 자격(잼)과 충돌(출품작) 관심사 분리, isJudge 단순 | 호출 1회 추가 | **채택(S2 기본가정, concern 1)** | +| | (B) isJudge 에 충돌 합침 | 호출 1회 | isJudge 가 gameId 받아야 함(스코프 자격과 출품작 충돌 혼합) | 조건부(W2-2 결정 따름) | +| 집계 노출 시점 | (A) CLOSED/eval종료 후 공개, EVAL 중 관리자만 | 심사위원 동조(anchoring) 편향 방지(정석) | 시점 분기 | **채택(§API)** | +| | (B) EVAL 중 실시간 공개 | 투명 | 점수 동조 편향(심사 무결성 저해) | 기각 | +| criterion 검증 | (A) 앱계층 화이트리스트(jam_criteria) | DB FK 부재(W2-3 논리참조) 보완, 미등록 키 422 | 조회 1회 | **채택(S8)** | +| | (B) 검증 없이 INSERT | 단순 | 오타·미등록 criterion 점수 오염(무결성/집계 왜곡) | 기각 | +| 입력 이력 | (A) updated_at 최종상태만(1차) | 단순, 동결 스키마 그대로 | 변경 이력 부재 | **채택(비목표 분리)** | +| | (B) jam_score_audit 테이블 | 감사 추적 | 신규 테이블(동결 외)·over-engineering(1차 불요) | 기각(후속) | + +--- + +## 롤아웃 / 마이그레이션 + +### 순서 +1. **스키마 선행(W2-3)**: `docs/jam-eval-ddl.sql`(jam_criteria/jam_scores/jam_score_stats VIEW) 적용 완료가 본 설계 전제. 본 W2-4 는 DDL 0 — 스키마 적용 단계 없음. +2. **선행 의존 코드**: W2-1(JamsMapper.getById/JamEntriesMapper.exists) + W2-2(JamRoleGate.isJudge + 자기출품 충돌) 가 본 설계의 게이트 호출 대상. **W2-2 미배포 시 점수 입력 게이트가 컴파일/동작 불가** → W2-2 와 동시 또는 후행 배포. +3. **코드 배포(본 설계)**: K-DOMAIN → K-MAPPER → K-CTRL. JamScoringController 가 JamRoleGate·PermissionGate·JamsMapper·JamEntriesMapper·3 신규 매퍼 주입. +4. **운영**: 관리자가 잼 criteria 등록(W2-1 콘솔 확장 또는 등록 소유자) + 심사위원 지정(W2-2) → EVAL 전이 후(W2-1 상태전이) 심사위원 채점 가능. + +### 역호환 +- 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 게임/리뷰/잼 동작 0 영향. jam_scores/jam_score_stats 는 W2-3 동결이라 본 설계가 스키마 무변경. +- jam_criteria 등록 전(criteria 0행)이면 화이트리스트가 비어 모든 criterion_key 가 422 — 운영상 criteria 선등록 필요(순서 4). + +### 롤백 +- 코드 롤백: JamScoringController/3매퍼/JamEvalWindow 되돌리면 점수 경로 미노출. 동결 스키마는 추가 전용이라 잔존 무해(데이터 보존, 비파괴). jam_scores 행은 남아도 다른 도메인에 영향 0(읽는 곳이 본 설계뿐). +- 스키마 롤백 없음(DDL 0). + +--- + +## AC 매핑 + +| AC | 요구(골자 W2-4 + 확정 결정) | 만족 설계 요소 | 비고 | +|---|---|---|---| +| AC-1 | 점수 입력 API POST/PUT `/jams/{jamId}/games/{gameId}/scores` | JamScoringController submitScores(POST+PUT 동일핸들러) | S1, §API | +| AC-2 | 심사위원 자격 게이트(isJudge) | jamRoleGate.isJudge(session, jamId) → 아니면 403 | S2-1, §게이트연동 | +| AC-3 | 평가기간 게이트(EVAL + now∈구간) | JamEvalWindow.isOpen(jam, now) → 아니면 422 | S2-2, W2-3 F6 | +| AC-4 | 자기출품 충돌(자기 출품작 채점 불가) | W2-2 isOwnEntry 호출 → 참이면 422 | S2-3, W2-2 | +| AC-5 | 상태변경 CSRF 전수 | submitScores 진입부 CsrfTokens.isValid → 403 | §API 공통 | +| AC-6 | criterion별 점수 1~5 입력 | jam_scores score 1~5(동결 CHECK) + 앱 범위 검증 | S1, S8 | +| AC-7 | jam_scores UPSERT(멱등 재입력) | upsertScore ON CONFLICT (4키) DO UPDATE | S3, 동결 UNIQUE | +| AC-8 | 집계 = jam_score_stats VIEW(가중 종합) | JamScoreStatsMapper.listStatsByJam(큰따옴표 alias) | S4, W2-3 G6 | +| AC-9 | 심사위원 평균 + 동률 처리 명시 | VIEW weighted_total/simple_total + 결정론 정렬(judge_count,game_id) | S7/난제3 | +| AC-10 | 평가기간 내 수정 허용, 종료 후 잠금 | 입력·수정 동일 평가기간 게이트(EVAL 이탈→422) | S5, S1 | +| AC-11 | 점수 미입력 criterion 처리 | 미입력=행 부재→VIEW per_criterion 에서 제외(가중 분모도) | S6/난제2 | +| AC-12 | 심사위원 부분입력 처리 | batch 배열에 입력분만 포함, judge_count 로 참여 가시화 | S6/난제2 | +| AC-13 | criterion_key 화이트리스트(미등록 거부) | jamCriteriaMapper.listByJam 화이트리스트 → 미등록 422 | S8 | +| AC-14 | 권한/평가 SQL `${}` 0 | 신규 3매퍼 `#{}` only | §파일영향맵 | +| AC-15 | 신규 테이블 0(동결 소비) | DDL 0, schema.sql 무변경 | §데이터모델 | + +--- + +## 검증 포인트 (verification-advisor 점검 대상) + +> L레벨 매핑(verification-strategies): 자격/기간/충돌 게이트·점수 입력 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·UPSERT ON CONFLICT·집계 VIEW 소비 = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30). + +### 시나리오 검증 +- **VP-1 (AC-2/3/4 3중 게이트, L1+L3)**: JamScoringControllerTest — isJudge 통과/미심사위원 403, EVAL+구간내 통과/기간밖·status≠EVAL 422, 자기출품 충돌 422, 미인증 401. 게이트 순서(CSRF→인증→자격→기간→출품작→충돌→criterion)대로 첫 실패 지점 응답 검증. L3 스모크: 심사위원이 EVAL 잼 출품작 채점 성공. +- **VP-2 (AC-7 UPSERT 멱등, L1+L2)**: 같은 (jam,game,judge,criterion) 재입력 시 INSERT 아닌 UPDATE(행 수 불변, score·updated_at 갱신). dev DB contract: ON CONFLICT (4키) 실측 — ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 타깃 정합. +- **VP-3 (AC-8/9 집계 정합, L2)**: jam_score_stats 소비 — 다수 심사위원·다수 criterion 입력 시 fan-out 없이 weighted_total 정확, 정렬(weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC) 결정론. (W2-3 가 VIEW 자체 fan-out/0-division 을 검증 — 본 설계는 소비 정렬·alias 정합 검증.) +- **VP-4 (AC-11/12 부분입력·미채점, L1+L2)**: criterion 일부만 입력 시 미입력 criterion 이 종합에서 제외(weight 분모 포함), judge_count 가 부분참여 반영. 트랜잭션 원자성: 미등록 criterion 포함 배열은 전체 422(부분저장 0). +- **VP-5 (AC-5 CSRF, L1)**: 점수 입력 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용, grounding 선례). +- **VP-6 (AC-13 화이트리스트, L1)**: jam_criteria 미등록 criterion_key 입력 → 422, jamScoresMapper.upsertScore 미호출. +- **VP-7 (DB-방언 계약, L2)**: JamScoreStatsMapper 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper.java:13 케이스폴딩 BUG-2 선례 회피). JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias(일반 매퍼 표준). +- **VP-8 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 3매퍼 @MockBean 등록 후 PASS(§30). JamScoringController 가 주입하는 JamRoleGate(W2-2)·PermissionGate·JamsMapper·JamEntriesMapper 빈 가용성 확인. 누락 시 NoSuchBeanDefinitionException. + +### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용) +> self-audit(시점): 아래 카운트는 **본 W2-4 가 신규 생성하는 정적 산출물**(매퍼·게이트·API 핸들러)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 동결 스키마(jam_scores/jam_score_stats/jam_criteria)는 W2-3 권위 — 본 설계가 수정 0이므로 그 카운트는 W2-3 검증 소관(여기서 재카운트 안 함). +> self-audit(표현): 게이트 호출·핸들러 열거는 단일 리터럴 grep 취약성을 피해 **메서드 열거 + 진입부 헬퍼 호출** 구조적 불변식에 앵커(수동 판정). 매퍼 `${` 0건·신규 DDL 0건은 부재 검증이라 리터럴 정당. + +- **AC-T1 점수 입력 핸들러 3중 게이트 전수** — 점수를 **저장하는** 핸들러(submitScores: POST+PUT 동일 메서드 1개)가 진입부에서 게이트 3종(isJudge / JamEvalWindow.isOpen / 자기출품충돌) + CSRF 전수 통과: 수동 판정으로 submitScores 진입 시퀀스에 4가드(CSRF+3게이트) 전수 존재 확인. 1건이라도 누락 = 인가/기간/충돌 우회 보안결함 → FAIL. **이 전수 AC 가 S2 enforcement 의 핵심 가드**(리터럴 grep 단독 의존 회피 — 메서드 본문 게이트 호출 열거). +- **AC-T2 신규 매퍼 전수 3개 `${` 0건** — 신규 매퍼 3파일(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 3파일>` == 0 (AC-14, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당. +- **AC-T3 집계 VIEW 매퍼 alias 큰따옴표 전수** — JamScoreStatsMapper(집계 VIEW 소비)의 camelCase alias 전수가 큰따옴표: jam_score_stats 종합 4컬럼(weightedTotal/simpleTotal/scoredCriteria/judgeCount) 의 alias 가 `AS "..."` 형태(케이스 폴딩 회피, §33). 검증: JamScoreStatsMapper 의 `AS "` 출현이 camelCase alias 수와 일치(수동 판정 — 일반 매퍼 JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias 라 큰따옴표 불요·있으면 안 됨). +- **AC-T4 신규 테이블/VIEW 0건 불변식** — 본 W2-4 가 스키마를 추가/변경하지 않음: 본 워크스트림 파일 영향에 `docs/*-ddl.sql` 신규 0 AND `db/schema.sql` diff 0 AND 신규 매퍼에 `CREATE TABLE`/`ALTER TABLE`/`CREATE VIEW` 토큰 0. 검증: `grep -rE 'CREATE TABLE|ALTER TABLE|CREATE OR REPLACE VIEW' ` == 0(매퍼는 SELECT/INSERT ON CONFLICT 만). 동결 소비 계약(W2-3 권위) 위반(W2-4 가 스키마 손대기) 즉시 검출. +- **AC-T5 401/403/422 정책 응답 전수** — 점수 입력 게이트 실패 분기 전수가 정책대로: 미인증→401, 미심사위원→403, CSRF→403, 평가기간외/자기출품/미등록criterion/score범위→422, 출품작없음→404. 검증: JamScoringControllerTest 가 5분류(401/403/404/422 각) 케이스 전수 보유(테스트 메서드 열거) AND 각 응답 status 코드 정합. 정책 분류 누락(예: 자기출품을 403 으로 잘못 응답) 검출. +- **AC-T6 신규 매퍼 @MockBean 전수 3건** — BibimbapApplicationTests 에 신규 3매퍼 @MockBean 전수 등록: contextLoads PASS AND 3매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 등록 수동 확인. 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점 자기 검증). + +--- + +## 잔여 오픈 질문 +없음(0). 확정 결정 S1~S8 전제 고정. 세 난제(3중 게이트 순서·401/403/422 정합 / 부분입력·미채점 집계 의미 / 동률·심사위원 평균)는 본 설계가 구체 메커니즘으로 확정. 집계 노출 시점(EVAL 중 관리자만/CLOSED 후 공개)·POST+PUT 동일핸들러·UPSERT ON CONFLICT·criterion 화이트리스트도 확정. 구현 점검 항목(W2-2 게이트 시그니처/자기출품 충돌 위치 재확인·신규 매퍼 @MockBean full-test·VIEW alias 큰따옴표·ON CONFLICT dev contract·criterion_key 화이트리스트·헬퍼 시그니처 inflate)은 오픈 질문이 아니라 `concerns` 로 이관. diff --git a/.atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md b/.atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md new file mode 100644 index 0000000..c82198b --- /dev/null +++ b/.atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md @@ -0,0 +1,328 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-23T16:30:00+09:00 +workstream: W2-5-인기투표 +concerns: + - "JamVotesMapper 시그니처는 최소 인자로 명세했다. 본 설계는 변경=DELETE→INSERT 가 아니라 '기존 표 갱신' 정석으로 updateVote(jamId, voterUserId, gameId) 단일경로를 채택했으나, 구현이 ON CONFLICT (jam_id, voter_user_id) DO UPDATE 한 문장으로 castVote 를 멱등 upsert 화하면 hasVoted 사전조회·updateVote 분리가 불요해질 수 있다(dead method/parameter 방지, 프로토콜 §11.2). 구현 1보에서 인자/메서드 전부 실사용 재확인." + - "신규 JamVotesMapper + (신규일 경우) JamVoteController 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper(JamVotesMapper) @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException." + - "투표 집계 조회 SQL(countByGame/listCountsByJam)은 단순 COUNT 라 일반 매퍼 snake→camel 직접 alias(COUNT(*) AS voteCount) 표준 — 집계 VIEW 아님(W2-3 은 jam_votes 에 VIEW 를 두지 않고 COUNT 계약, 본 설계 §집계 준수). 큰따옴표 alias 불요. dev DB contract(L2) 로 GROUP BY count 정합 실측 권장." + - "결과 노출 게이트(평가기간 중 표심 은닉 vs 종료 후 count 공개)는 컨트롤러/JSP 분기다 — DB 가 강제하지 않는다. 노출 시점 판정은 jam.status/eval_end_at 런타임 비교(W2-1 JamLifecycle 또는 인라인). 구현이 이 분기를 누락하면 밴드왜건 회피 요구(결과 은닉) 위반 → AC-T3 전수 가드로 검출. 노출 분기 위치는 구현 점검 항목." + - "자기 출품작 투표 가부는 W2-3 동결에서 'W2-5 정책' 으로 위임됐다. 본 설계는 정석(자기표 허용 — 잼 인기투표는 자기 작품 응원 통상 허용, 1인1표 UNIQUE 가 다중표를 막으므로 자기표가 결과를 왜곡하지 않음)으로 확정한다. 운영 정책상 금지가 필요하면 컨트롤러 422 분기 추가 — 구현 점검 항목(스키마 영향 없음)." +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-3-eval-freeze-design.md + - .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-eval-ddl.sql +--- + +# 설계: W2-5 — 인기투표 (잼당 1인 1표 / 평가기간 게이트 / 표심 은닉→종료 후 공개) + +> ⚠️ **상류 동결 소비 워크스트림**. 본 설계는 W2-3(`.atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md`)이 동결한 `jam_votes` 스키마(테이블·1인1표 UNIQUE·FK)를 **재정의하지 않고 그대로 소비**한다. 신규 DDL 테이블 0건. 본 설계 범위 = 투표 API + 1인1표 토글 로직 + 평가기간 게이트 + 결과 노출 시점 + 신규 매퍼(`JamVotesMapper`) 1개. + +## 목표 / 비목표 + +### 목표 (FR/NFR 추적 — 골자 W2-5) +- **G1 투표 API**: 출품작 투표(POST) / 취소(DELETE). `POST /jams/{slug}/vote`(body: gameId) + `DELETE /jams/{slug}/vote`. RecruitController 패턴(읽기 JSP 뷰, 쓰기 ResponseEntity JSON status/message). +- **G2 잼당 1인 1표(최애 1작품)**: `jam_votes` UNIQUE(jam_id, voter_user_id)(W2-3 동결 `ux_jam_votes_jam_voter`)를 권위로 1인 1표 보장. 표 변경 = 기존 표 갱신(같은 voter 행의 game_id UPDATE), 취소 = DELETE. +- **G3 game_likes 와 별개**: `jam_votes.voter_user_id`(bigint, 로그인 user_id) 기반. game_likes(user_key varchar·1인1표 비권위·grounding R-D) 무참조. +- **G4 평가기간 게이트**: 투표/취소는 `jam.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]` 일 때만 허용(W2-3 §평가기간 게이트 계약 F6). 미충족 시 422. +- **G5 미로그인 차단**: 1인1표 식별자 = 세션 `userId`. 미로그인 401(밴드왜건/중복표 방지의 식별 기반). +- **G6 결과 노출 시점**: **평가기간 중 표심 은닉(밴드왜건 회피)** → 평가 종료 후(`status='CLOSED'` 또는 `now() > eval_end_at`) 공개(출품작별 count). 본인 투표 여부(votedGameId)는 평가기간 중에도 본인에게는 노출(중복 투표 UX). +- **G7 W2-6 POPULAR 트랙 소스 제공**: 시상 인기 트랙은 `jam_votes` count(W2-3 §집계 노출 계약 — VIEW 없이 COUNT). 본 설계 매퍼가 잼·출품작별 집계 조회를 제공. +- **NFR**: 상태변경 CSRF 전수(`CsrfTokens.isValid` → 403 + `errorBody()`), `#{}` 바인딩(`${}` 금지), 1인1표 UNIQUE DB 강제, 비파괴(신규 DDL 0 — 동결 소비). + +### 비목표 (스코프 밖) +- **jam_votes 스키마 정의** — **W2-3 동결 소유**. 본 설계는 컬럼/UNIQUE/FK 를 재정의하지 않고 소비만(신규 DDL 파일 0). +- **시상 산정 알고리즘·POPULAR 트랙 rank 부여** — **W2-6 소유**. 본 설계는 count 집계 조회 매퍼만 제공(트랙 산정은 W2-6). +- **심사 점수 입력(jam_scores)·유저평점 트랙** — W2-4/W2-6 소유. 본 설계 무관. +- **잼 엔티티·상태·출품(jam_entries) 본체** — **W2-1 소유**. 본 설계는 `JamsMapper.getBySlug`·`JamEntriesMapper.exists`(활성 출품작 검증)를 호출 소비만. +- **투표 결과 시각화 차트·실시간 갱신** — 1차는 종료 후 count 단순 노출. 실시간 폴링·차트는 후속. + +--- + +## 개요 + +bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. W2-3 이 `jam_votes`(id/jam_id/game_id/voter_user_id/created_at + FK jams·games·users + `ux_jam_votes_jam_voter` UNIQUE(jam_id, voter_user_id) + `idx_jam_votes_jam_game`)를 동결했고(W2-3 §데이터모델3, 직접 확인), W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작) + `JamsMapper.getBySlug`/`JamEntriesMapper.exists`(W2-1 §파일영향맵·시그니처)를 제공한다. W1 이 `PermissionGate.isAuthenticated(session)`(PermissionGate.java:47, 직접 확인) + `CsrfTokens.isValid(request)`(CsrfTokens.java:35, 직접 확인) + 세션 `userId` attr(RecruitController.java:159 `session.getAttribute("userId")`, 직접 확인)을 제공한다. + +본 설계는 **신규 테이블 0**으로 `jam_votes` 를 소비하는 **투표 토글 API + JamVotesMapper 1개**를 추가한다. 가장 까다로운 두 결정을 다음과 같이 확정한다. + +- **난제1 (1인1표 토글 — 변경 vs 삭제후삽입)**: 잼당 1인 1표(최애 1작품)이므로 "다른 작품으로 표를 바꾸는" 행위는 **기존 voter 행의 game_id 를 UPDATE**(`updateVote`)로 처리한다(DELETE→INSERT 2문장 대신 1문장 — id/created_at 보존, 경합 윈도 최소, UNIQUE 충돌 없음). 사전 `hasVoted` 조회로 미투표→`castVote`(INSERT) / 기투표→`updateVote`(같은 game 재투표는 no-op 멱등) 분기. 취소(`DELETE /vote`)는 `deleteVote`(voter 행 삭제). UNIQUE(jam_id, voter_user_id)가 다중표를 DB 차원에서 차단하므로 동시요청 경합에서도 1인1표가 깨지지 않는다(INSERT 경합 시 한쪽 UNIQUE 위반 → 컨트롤러가 updateVote 재시도 또는 409 친절 처리, §시퀀스 S1 주석). +- **난제2 (결과 노출 시점 — 밴드왜건 회피)**: **평가기간 중에는 집계 count 를 일반 사용자에게 노출하지 않는다**(밴드왜건/표 쏠림 회피 — 골자 W2-5 Q3·확정 결정). 노출 게이트 = `jam.status='CLOSED' OR now() > jam.eval_end_at`. 이 판정은 **컨트롤러/JSP 런타임 분기**(DB 가 강제하지 않음 — 시각 비교는 런타임). 단 (a) 본인의 투표 여부/대상(`votedGameId`)은 평가기간 중에도 본인에게 노출(중복투표·취소 UX 필수), (b) 잼 관리자(GAME_JAM_MANAGE)는 운영 목적상 평가기간 중에도 집계 열람 가능(선택 — 본 설계는 공개 화면 은닉만 강제, 관리자 열람은 W2-6/관리 화면 소관으로 비강제). 결과 = 평가 종료 전 공개 화면은 "총 투표 수/내 투표만", 종료 후 "출품작별 득표 count" 공개. + +--- + +## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 소비) + +| 결정 | 확정값 | 본 설계의 구체화 | +|---|---|---| +| P1 투표 API | POST/DELETE `/jams/{slug}/vote` (body gameId) | 로그인 필수 + 평가기간 게이트 + CSRF. 읽기=잼 상세 JSP(W2-1), 쓰기=JSON status/message | +| P2 1인1표 | `jam_votes` UNIQUE(jam_id, voter_user_id) | W2-3 동결 `ux_jam_votes_jam_voter` 권위. 변경=updateVote(game_id), 취소=deleteVote | +| P3 game_likes 별개 | voter_user_id bigint(로그인) | 신규 jam_votes 소비. game_likes(user_key varchar) 무참조 | +| P4 평가기간 게이트 | EVAL + now∈[eval_start,eval_end] | 미충족 422(W2-3 F6). 게이트 위치=컨트롤러 진입부 앱계층 | +| P5 미로그인 | 세션 userId 식별 | 미로그인 401(redirect 아님 — API). PermissionGate.isAuthenticated | +| P6 결과 노출 | 평가기간 중 은닉 → 종료 후 count 공개 | status='CLOSED' OR now()>eval_end_at 시 출품작별 count. 본인 votedGameId 는 상시 본인 노출 | +| P7 표 변경 정책 | 변경 허용(최애 교체) | hasVoted→updateVote(game_id). 같은 game 재투표=멱등 no-op | +| P8 자기표 | 허용 | 1인1표 UNIQUE 가 다중표 차단하므로 자기 작품 응원 허용(concern 5) | + +--- + +## 데이터 모델 (신규 DDL 0 — W2-3 동결 jam_votes 소비) + +> **신규 테이블/컬럼/VIEW 0건.** 본 설계는 W2-3 `docs/jam-eval-ddl.sql` 의 `jam_votes` 를 **소비만** 한다. 아래는 소비하는 동결 스키마의 **참조 사본**(W2-3 §데이터모델3 권위 — 본 설계가 정의/변경하지 않음. 재게시는 매퍼 SQL 작성 grounding 용). + +### 소비 대상: `jam_votes` (W2-3 동결 — 변경 금지) +```sql +-- W2-3 docs/jam-eval-ddl.sql §3 (권위). 본 W2-5 는 읽기/쓰기 소비만, DDL 미수정. +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 (FK jams) + "game_id" bigint NOT NULL, -- 투표 대상 출품작 (FK games) + "voter_user_id" bigint NOT NULL, -- 투표자 (FK users; 로그인 1인1표) + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +-- 잼당 1인 1표(최애 1개). 표 변경=UPDATE game_id, 취소=DELETE (W2-5 정책 = 본 설계 P7) +CREATE UNIQUE INDEX IF NOT EXISTS "ux_jam_votes_jam_voter" + ON "jam_votes" ("jam_id", "voter_user_id"); +-- 투표 집계(출품작별 count) — W2-6 POPULAR 트랙 소스 +CREATE INDEX IF NOT EXISTS "idx_jam_votes_jam_game" + ON "jam_votes" ("jam_id", "game_id"); +``` + +### 동결 스키마와 본 설계 로직의 정합 확인 +- **1인1표(P2/G2)**: `ux_jam_votes_jam_voter` 가 (jam_id, voter_user_id) 다중행을 DB 차원에서 차단 → 한 유저가 한 잼에 2표 INSERT 불가. 표 변경은 INSERT 추가가 아니라 **기존 행 UPDATE** 라 UNIQUE 충돌 없음. +- **집계(P6/G7)**: `idx_jam_votes_jam_game` 가 `SELECT game_id, COUNT(*) ... GROUP BY game_id` seek 를 지원(W2-3 §집계 노출 계약 — VIEW 없이 COUNT 정석). +- **평가기간 게이트(P4)**: DB CHECK 로 기간 강제 안 함(W2-3 F6 — 시각 비교는 런타임). `created_at` 은 감사/노출 시점 판정 보조이나, 게이트 자체는 `jams.status`+`eval_*_at` 런타임 비교(앱계층). +- **변경 금지 확인**: 본 설계는 `jam_votes` 에 컬럼/제약/인덱스를 추가하지 않는다. `game_likes`/`game_reviews`/`jams`/`jam_entries` 도 무변경(소비만). → 신규 DDL 파일 0, schema.sql 수정 0. + +--- + +## 외부 계약 (API) + +> 공통: 모든 상태변경(POST/DELETE)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 직접 확인). 응답은 RecruitController 패턴 — `ResponseEntity>`(status/message). 잼 상세 화면(투표 UI 부착)은 W2-1 `GET /jams/{slug}`(jam-detail JSP) 재사용 — 본 설계는 그 JSP 에 투표 폼/결과 영역 추가만(신규 페이지 컨트롤러 없음). + +### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 일치) +- **미인증**(세션 `userId` 없음): API **401** JSON `{status:401, message:"로그인이 필요합니다."}`. (투표는 API 이므로 redirect 아님.) +- **인증·미인가**: 본 투표 API 는 별도 권한 키(GAME_JAM_MANAGE 등) 불요 — **로그인 유저 누구나 투표 가능**(개방). 따라서 403(미인가)은 CSRF 실패 전용. +- **평가기간 외**(P4 게이트 위반): **422** JSON `{status:422, message:"투표 기간이 아닙니다."}`(인가는 됐으나 도메인 상태 위반 → 403 아님 422 — W2-3 F6 정책 일치). +- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`. +- **출품작 없음/잼 없음**: 404. + +### 투표 액션 (상태변경 API — 로그인 필수 + CSRF + 평가기간 게이트) +| 액션 | method | path | 요청 | 응답(200) | 에러 | +|---|---|---|---|---|---| +| 투표/변경(G1/P1) | POST | `/jams/{slug}/vote` | gameId | `{status:200, message, votedGameId, changed:bool}` | 401(미인증), 403(CSRF), 404(잼/출품작 없음), 422(평가기간 외) | +| 취소(G1/P1) | DELETE | `/jams/{slug}/vote` | (없음 — voter 세션 식별) | `{status:200, message, votedGameId:null}` | 401, 403(CSRF), 404(잼 없음/미투표), 422(평가기간 외) | +| 내 투표 조회(P6 본인 노출) | GET | `/jams/{slug}/vote/mine` | (없음) | `{status:200, votedGameId:Long|null}` | 401, 404(잼 없음) | +| 집계 조회(P6/G7 — 종료 후만 공개) | GET | `/jams/{slug}/vote/results` | (없음) | 종료 후: `{status:200, results:[{gameId, voteCount}], total}` / 진행 중: `{status:200, open:true, total, results:null}` | 404(잼 없음) | + +- **POST 토글 의미(P7)**: 같은 voter 가 (a) 미투표→투표(`changed:false`, 신규 INSERT), (b) 다른 game 으로 변경(`changed:true`, game_id UPDATE), (c) 같은 game 재요청(멱등 no-op, `changed:false`). 별도 grant/revoke 가 아닌 "현재 표 설정" 의미. +- **DELETE = 취소(P7)**: voter 의 잼 내 표 1행 삭제. 미투표 상태에서 DELETE 는 404(취소할 표 없음) 또는 멱등 200(정책 — 본 설계는 **404 미투표 명시**, 클라가 상태 동기화). +- **집계 노출 게이트(P6/G6 — 밴드왜건 회피 핵심)**: `GET /vote/results` 는 `jam.status='CLOSED' OR now()>jam.eval_end_at` 일 때만 `results` 배열(출품작별 count) 반환. 진행 중에는 `{open:true, results:null, total}`(총합만 — 표 쏠림 정보 미노출). JSP 도 동일 분기로 결과 영역 렌더. +- **자기 출품작 투표(P8)**: 허용. games.user_id == voter 여도 422 안 냄(concern 5 — 운영 금지 정책 시 컨트롤러 분기 추가, 스키마 무관). + +### W2-6 소비 계약 (POPULAR 트랙 소스 — G7) +- W2-6 시상 산정은 본 설계 매퍼의 `listCountsByJam(jamId)`(또는 `countByGame`)를 호출해 출품작별 득표를 얻고 DESC rank 부여 → `jam_awards.award_track='POPULAR'`(W2-3 §시상 트랙 산정). 본 설계는 **count 조회만 제공**, rank/award insert 는 W2-6 소관. +- 집계 SQL(W2-6 참고): `SELECT game_id AS gameId, COUNT(*) AS voteCount FROM jam_votes WHERE jam_id = #{jamId} GROUP BY game_id ORDER BY COUNT(*) DESC, game_id ASC` — 일반 매퍼 snake→camel 직접 alias(집계 VIEW 아님 → 큰따옴표 불요, concern 3). + +--- + +## 인터셉터 / 게이트 연동 + +> 본 투표 API 는 **관리자 게이트(GAME_JAM_MANAGE) 불요** — 로그인 유저 개방. 따라서 `/admin/**` RbacInterceptor 와 무관(투표 경로 `/jams/**` 는 인터셉터 미등록, InterceptorConfig.java:18 `/admin/**` 만 — W2-1 §인터셉터 직접 확인). 게이트는 **컨트롤러 진입부 앱계층**만. + +### 컨트롤러 진입부 게이트 순서 (모든 상태변경 액션 공통) +1. `CsrfTokens.isValid(request)` 거짓 → 403 + `CsrfTokens.errorBody()` (mapper 접근 전 — verification §CSRF-before-mapper 패턴). +2. `userId = sessionUserId(session)` (RecruitController.java:155 선례 — `session.getAttribute("userId")`) null → 401. (PermissionGate.isAuthenticated(session) 동등 — 본 설계는 RecruitController 의 `sessionUserId` 헬퍼 패턴 재사용 권장: 투표 컨트롤러가 PermissionGate 의존 없이 세션 userId 만으로 충족 → 의존 최소화. PermissionGate.isAuthenticated 호출도 동등 허용.) +3. `jam = jamsMapper.getBySlug(slug)` (W2-1 제공) null → 404. +4. **평가기간 게이트(P4/F6)**: `jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]` 거짓 → 422. +5. 출품작 존재: `jamEntriesMapper.exists(jam.id, gameId)` (W2-1 제공, POST 만 — DELETE 는 gameId 불요) 거짓 → 404. +6. 통과 후 토글 본문. + +### epoch 전파 무관 +- 투표는 권한 키 판정이 없으므로 W1 epoch(refreshIfStale) 연동 불요. 세션 userId 존재만 확인(로그인 세션 유효성은 기존 로그인 인프라가 보장). + +--- + +## 시퀀스 (주요 플로우 의사코드) + +### S1. 투표 / 변경 (POST — 1인1표 토글) +``` +[로그인 유저] POST /jams/{slug}/vote (CSRF, gameId) + → JamVoteController.vote + → CsrfTokens.isValid(request) (아니면 403 errorBody) + → userId = sessionUserId(session) (없으면 401) + → jam = jamsMapper.getBySlug(slug) (없으면 404) + → 평가기간 게이트(P4): jam.status=='EVAL' AND now∈[eval_start,eval_end]? (아니면 422) + → jamEntriesMapper.exists(jam.id, gameId)? (아니면 404 출품작 아님) + → current = jamVotesMapper.findVotedGameId(jam.id, userId) # 현재 표(없으면 null) + - current == null → jamVotesMapper.castVote(jam.id, gameId, userId) # INSERT, changed=false + - current == gameId → no-op (멱등) # changed=false + - current != gameId → jamVotesMapper.updateVote(jam.id, userId, gameId) # game_id 교체, changed=true + → 200 {votedGameId: gameId, changed} + # 동시요청 경합(같은 voter 2 INSERT): UNIQUE(jam_id,voter_user_id) 위반 → 한쪽 DataIntegrityViolation + # → 컨트롤러 catch 후 updateVote 재시도 또는 409. 1인1표 불변(DB 강제). + # 결과 count 는 응답에 미포함(P6 밴드왜건 회피 — 본인 표만 반환). +``` + +### S2. 취소 (DELETE) +``` +[로그인 유저] DELETE /jams/{slug}/vote (CSRF) + → JamVoteController.cancel + → CsrfTokens.isValid (아니면 403) + → userId = sessionUserId (없으면 401) + → jam = getBySlug(slug) (없으면 404) + → 평가기간 게이트(P4) (아니면 422) + → affected = jamVotesMapper.deleteVote(jam.id, userId) # voter 행 삭제 + → affected == 0 → 404 (취소할 표 없음) + → 200 {votedGameId: null} +``` + +### S3. 결과 노출 (GET /vote/results — 밴드왜건 회피 분기) +``` +[공개] GET /jams/{slug}/vote/results + → JamVoteController.results + → jam = getBySlug(slug) (없으면 404) + → 노출 게이트(P6): jam.status=='CLOSED' OR now() > jam.eval_end_at? + - 종료 → results = jamVotesMapper.listCountsByJam(jam.id) # [{gameId, voteCount}] DESC + total = sum(voteCount) + 200 {results, total} + - 진행 중 → total = jamVotesMapper.countByJam(jam.id) # 총합만(표심 은닉) + 200 {open:true, results:null, total} + # JSP(jam-detail) 결과 영역도 동일 분기: 종료 후만 출품작별 막대, 진행 중엔 "투표 진행 중 · 총 N표". + +[로그인 유저] GET /jams/{slug}/vote/mine + → jam = getBySlug; userId = sessionUserId (없으면 401) + → votedGameId = jamVotesMapper.findVotedGameId(jam.id, userId) + → 200 {votedGameId} # 본인 표는 진행 중에도 노출(중복투표 UX) +``` + +--- + +## 파일 영향 맵 + +> 소유권 분할 가이드(implementation-advisor worker 단위 후보): +> **V-MAPPER**(JamVotesMapper) · **V-CONTROLLER**(JamVoteController + 401/403/422/CSRF/게이트 헬퍼) · **V-VIEW**(jam-detail.jsp 투표 폼·결과 영역 추가 — W2-1 산출 JSP 수정). +> 의존: W2-3 동결(jam_votes) + W2-1(jams/jam_entries/JamsMapper/JamEntriesMapper/jam-detail.jsp) 선행. V-MAPPER → V-CONTROLLER → V-VIEW. + +| 변경 유형 | 경로 | 역할 | 소유 | +|---|---|---|---| +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java` | `@Mapper` jam_votes 투표/취소/조회/집계(`#{}`, snake→camel 직접 alias) | V-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java` | `/jams/{slug}/vote` POST/DELETE + /mine + /results. CSRF·평가기간 게이트·1인1표 토글 | V-CONTROLLER | +| 수정 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` (W2-1 산출) | 출품작별 투표 버튼(CSRF hidden) + 결과 영역(종료 후 count / 진행 중 은닉) + 내 표 표시 | V-VIEW | +| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 JamVotesMapper @MockBean 등록(contextLoads 보존, verification §30) | (검증) | +| 신규 | `src/test/.../JamVoteControllerTest.java` | 투표/변경/취소 + 401/403(CSRF)/422(평가기간)/404 + 1인1표 + 결과 은닉/공개 분기 | (검증) | + +> SSR 호출지점 영향(verification §영향맵): 본 설계는 jam_votes(W2-3 동결) 소비 + jam-detail.jsp(W2-1) 수정만. games/game_likes/jams/jam_entries 무변경 → 기존 소비처 0 영향. jam-detail.jsp 는 W2-1 신규 산출이라 기존 화면 회귀 없음(투표 영역 추가만). JamVoteController 신규 컨트롤러 의존은 JamsMapper/JamEntriesMapper(W2-1 제공) + JamVotesMapper(신규) → contextLoads 시 신규 매퍼 @MockBean 필요(concern 2). + +### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지) +```java +// JamVotesMapper (@Mapper, #{} only, snake→camel 직접 alias — 집계 VIEW 아님 → 큰따옴표 불요) +int castVote(long jamId, // 평가단위 1/2 (UNIQUE 좌변) + long gameId, // 투표 대상 출품작 + long voterUserId) // 투표자(세션 userId; 1인1표 식별) +int updateVote(long jamId, // 대상 잼 + long voterUserId, // 기존 표 보유자(WHERE 절 식별) + long gameId) // 교체할 출품작(SET game_id) +int deleteVote(long jamId, // 대상 잼 + long voterUserId) // 취소 대상 voter(취소=voter 행 삭제) +Long findVotedGameId(long jamId, // 대상 잼 + long voterUserId) // 본인 현재 표 조회(null=미투표; 토글 분기·/mine 소스) +long countByJam(long jamId) // 진행 중 총합(표심 은닉 시 총 투표 수만) +List> listCountsByJam(long jamId) // 종료 후 출품작별 count(W2-6 POPULAR 소스) +``` +> ⚠️ inflate 마킹(concern 1): `castVote`/`updateVote`/`findVotedGameId` 분리는 "사전조회 후 분기" 전략이다. 구현이 `castVote` 를 `INSERT ... ON CONFLICT (jam_id, voter_user_id) DO UPDATE SET game_id = EXCLUDED.game_id` 단일 멱등 upsert 로 구현하면 `findVotedGameId` 사전조회와 `updateVote` 가 토글 경로에서 불요해질 수 있다(단 changed 플래그·/mine 응답에는 findVotedGameId 가 여전히 필요). `countByJam` 은 진행 중 총합 전용 — 종료 후 화면이 listCountsByJam 으로 total 을 합산하면 countByJam 이 dead 가 될 수 있으니 구현에서 실사용 재확인(최소 인자/메서드 원칙). `listCountsByJam` 반환은 W2-6 소비 형태 확정 전 Map 로 두되, 안정화 시 VoteCountData POJO 로 좁힘 검토. + +--- + +## 대안 비교 + +| 주제 | 안 | 장점 | 단점 | 채택 | +|---|---|---|---|---| +| 표 변경 처리 | (A) updateVote(기존 행 game_id UPDATE) | 1문장, id/created_at 보존, UNIQUE 충돌 없음, 경합 윈도 최소 | 사전 hasVoted 분기 | **채택(P7)** | +| | (B) DELETE→INSERT | 단순 일관 | 2문장 경합 윈도, created_at 리셋, 트랜잭션 필요 | 기각 | +| | (C) ON CONFLICT DO UPDATE upsert | 단일 멱등문 | findVotedGameId 분리(changed/mine) 여전 필요, 구현 시 채택 가능(concern 1) | 구현 재량(미배제) | +| 투표 단위 | (A) 잼당 1표(최애 1작품) | 골자 W2-5 확정, 1인1표 UNIQUE 정합, 밴드왜건 완화 | 여러 작품 응원 불가 | **채택(G2)** | +| | (B) 출품작당 1표(여러 작품 가능) | 폭넓은 응원 | jam_votes UNIQUE(jam_id,voter) 와 충돌(스키마 동결 위반) | 기각(동결 위반) | +| 결과 노출 | (A) 평가기간 중 은닉 → 종료 후 count | 밴드왜건 회피(확정 결정), 표심 쏠림 방지 | 진행 중 결과 궁금증 미충족 | **채택(P6)** | +| | (B) 실시간 공개 | 즉시성·재미 | 밴드왜건(인기작 쏠림) — 확정 결정 위반 | 기각 | +| 식별자 | (A) 세션 userId(bigint) | 로그인 1인1표 정석, voter_user_id FK 정합 | 미로그인 투표 불가 | **채택(P5)** | +| | (B) game_likes user_key varchar 재활용 | 재사용 | 비권위·1인1표 비보장(R-D)·varchar | 기각(별개 동결) | +| 게이트 위치 | (A) 컨트롤러 진입부 앱계층 | W2-3 F6 계약 일치, 시각 비교 런타임 | 컨트롤러 분기 | **채택(P4)** | +| | (B) DB CHECK 로 기간 강제 | DB 보장 | now() CHECK 불가·동결 위반(W2-3 F6 명시) | 기각 | + +--- + +## 롤아웃 / 마이그레이션 + +### 순서 +1. **스키마**: 신규 DDL 0 — W2-3 `docs/jam-eval-ddl.sql`(jam_votes) 이 이미 적용돼 있어야 함(선행). 본 설계 추가 DDL/schema.sql 수정 없음. +2. **선행 의존**: W2-3 동결(jam_votes) + W2-1(jams/jam_entries/JamsMapper/JamEntriesMapper/jam-detail.jsp) 코드 배포 선행. 본 설계는 그 위에 매퍼/컨트롤러/JSP 영역만 추가. +3. **코드 배포**: V-MAPPER → V-CONTROLLER → V-VIEW. BibimbapApplicationTests @MockBean(JamVotesMapper) 동반(contextLoads). +4. **권한 시드 불요**: 투표는 로그인 개방 — 권한 키/시드 없음. + +### 역호환 +- jam_votes(W2-3)/jams/jam_entries(W2-1)/games/game_likes/game_reviews **전부 무변경**. 기존 동작 0 영향. +- jam-detail.jsp 는 W2-1 신규 산출 — 투표 영역 추가만(기존 출품작 표시 회귀 0). + +### 롤백 +- 코드 롤백: JamVoteController/JamVotesMapper 제거 + jam-detail.jsp 투표 영역 제거 → 투표 기능 미노출. jam_votes 테이블은 추가 전용(W2-3 소유)이라 잔존 무해(데이터 남아도 무참조). +- 스키마 롤백: 본 설계 신규 DDL 0 → 롤백 대상 없음(jam_votes drop 은 W2-3 maintenance 소관). + +--- + +## AC 매핑 + +| AC | 요구(골자 W2-5 + 확정 결정) | 만족 설계 요소 | 비고 | +|---|---|---|---| +| AC-1 | 투표 API POST/DELETE `/jams/{slug}/vote`(gameId) | JamVoteController vote/cancel, RecruitController 패턴 | P1, §외부계약 | +| AC-2 | 잼당 1인 1표(최애 1작품) | jam_votes ux_jam_votes_jam_voter(W2-3) + updateVote 변경 경로 | P2/P7, S1 | +| AC-3 | game_likes 와 별개 | jam_votes.voter_user_id bigint 소비, game_likes 무참조 | P3, §대안 | +| AC-4 | 평가기간 게이트(EVAL + window) | 컨트롤러 진입부 jam.status=='EVAL' AND now∈[eval_start,eval_end] → 422 | P4, S1/S2 | +| AC-5 | 미로그인 차단 | sessionUserId null → 401(API) | P5, §게이트 | +| AC-6 | ★평가기간 중 표심 은닉 → 종료 후 공개 | /vote/results 노출 게이트(CLOSED OR now>eval_end) — 진행 중 results:null | P6/G6, S3 | +| AC-7 | 표 변경/취소 허용 | POST 변경(updateVote, changed:true) + DELETE 취소(deleteVote) | P7, S1/S2 | +| AC-8 | 상태변경 전수 CSRF | POST/DELETE 전부 CsrfTokens.isValid 선검증 → 403 errorBody | §게이트 공통 | +| AC-9 | 투표 SQL `${}` 0 | JamVotesMapper `#{}` only | §시그니처 | +| AC-10 | W2-6 POPULAR 트랙 소스 제공 | listCountsByJam(jamId) 출품작별 count DESC | G7, §W2-6계약 | +| AC-11 | 본인 투표 여부 상시 노출 | /vote/mine findVotedGameId(진행 중에도 본인 노출) | P6, S3 | + +--- + +## 검증 포인트 (verification-advisor 점검 대상) + +> L레벨 매핑(verification-strategies): 투표 토글·1인1표·평가기간 게이트·미로그인 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·집계 GROUP BY = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30, @MockBean). + +### 시나리오 검증 +- **VP-1 (AC-2 1인1표, L1+L2)**: 같은 voter 가 같은 잼에 2회 castVote → 2번째 UNIQUE(jam_id,voter_user_id) 위반(DB 강제, L2 dev contract 실측). 다른 game 으로 POST → updateVote 로 행 1개 유지(changed:true), 표 수 불변. +- **VP-2 (AC-4 평가기간 게이트, L1+L3)**: jam.status!='EVAL'(RECRUIT/DEV/CLOSED) 또는 now∉[eval_start,eval_end] 시 POST/DELETE → 422 + mapper 미호출. EVAL+window 내만 200. +- **VP-3 (AC-5 미로그인, L1)**: 세션 userId 없음 → POST/DELETE/mine 401. results 는 미로그인도 200(공개 조회). +- **VP-4 (AC-6 밴드왜건 은닉, L1)**: 진행 중(EVAL) /vote/results → `results:null, open:true`(출품작별 count 미노출). 종료 후(CLOSED 또는 now>eval_end) → results 배열 노출. JSP 동일 분기 렌더 확인. +- **VP-5 (AC-8 CSRF, L1)**: POST/DELETE CSRF 누락 → 403 + mapper 미호출(`deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용 — verification §CSRF-before-mapper). +- **VP-6 (DB-방언 계약, L2)**: JamVotesMapper 반환 키(votedGameId/voteCount/gameId)가 컨트롤러/JSP 조회 키와 정합. listCountsByJam GROUP BY count 가 샘플 데이터와 일치(snake→camel 직접 alias, 집계 VIEW 아님 → 큰따옴표 미사용 확인). +- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 JamVotesMapper @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException. +- **VP-8 (AC-7 변경/취소, L1)**: 투표→다른 game POST(changed:true, 표 수 1 유지)→DELETE(votedGameId:null, 행 0)→재투표(changed:false) 시퀀스 정합. + +### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용) +> self-audit(시점): 아래 카운트는 **본 W2-5 가 신규 생성하는 정적 산출물**(컨트롤러 상태변경 핸들러·매퍼 메서드)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. jam_votes 동결 스키마 카운트는 W2-3 verification 소관(본 설계는 소비만 — 중복 검증 회피). +> self-audit(표현): 단일 리터럴 grep 취약성을 피해 상태변경 핸들러 집합/게이트 호출 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴 정당). + +- **AC-T1 투표 상태변경 핸들러 전수 2건 CSRF + 평가기간 게이트** — JamVoteController 의 상태변경 핸들러(POST vote / DELETE cancel) 전수 2건이 ① `CsrfTokens.isValid` 선검증 ② 평가기간 게이트(jam.status=='EVAL' AND now∈window) 둘 다 보유. 검증: 상태변경 핸들러(@PostMapping/@DeleteMapping) 열거 == 2 AND 각 진입부에 CSRF + 게이트 존재(수동 판정 — @PostMapping/@DeleteMapping 핸들러 열거 후 각 본문 확인, 리터럴 grep 단독 의존 회피). GET(/mine, /results)은 상태변경 아님 → 게이트 비대상(읽기). 핸들러 추가 시 게이트 누락 = 평가기간 우회 보안결함 → FAIL. **이 전수 AC 가 P4 게이트의 핵심 가드**. +- **AC-T2 투표 매퍼 `${` 0건** — JamVotesMapper(1파일)에 `${` 매치 0: `grep -c '\${' JamVotesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당. +- **AC-T3 결과 노출 게이트 불변식(밴드왜건 회피)** — `/vote/results` 핸들러와 jam-detail.jsp 결과 영역 **둘 다** 노출 게이트(`status=='CLOSED' OR now()>eval_end_at`)를 보유: 진행 중 출품작별 count 비노출(results:null/JSP 막대 미렌더). 검증: 컨트롤러 results 분기 + JSP 조건 렌더 전수 2지점 모두 게이트 존재(수동 판정 — 시각 비교는 런타임이라 리터럴 grep 부적합, 의미 불변식 점검). 1지점이라도 무조건 count 노출 시 밴드왜건 회피(P6/G6) 위반 → FAIL. +- **AC-T4 jam_votes 무변경 불변식(동결 소비)** — 본 W2-5 산출물에 `jam_votes` 의 DDL 변경문(ALTER TABLE/CREATE INDEX/CREATE TABLE) 0건: 본 설계는 신규 docs/*-ddl.sql 파일을 만들지 않고 schema.sql 도 수정하지 않음. 검증: 본 워크스트림 diff 에 `jam_votes` 대상 ALTER/CREATE 0건(읽기/쓰기 매퍼 SQL 의 INSERT/UPDATE/DELETE/SELECT 는 무방, DDL 변경문만 0). 동결 소비 계약(W2-3) 위반(투표가 스키마 손대기) 즉시 검출. + +--- + +## 잔여 오픈 질문 +없음(0). 확정 결정 P1~P8 전제 고정. 두 난제(1인1표 토글 변경경로·결과 노출 시점 밴드왜건 회피)는 본 설계가 구체 메커니즘으로 확정. 자기표 허용(P8)·표 변경 허용(P7)도 정석으로 확정. 구현 점검 항목(매퍼 upsert vs 분기 시그니처 inflate·full-test @MockBean·집계 alias·결과 노출 분기 위치·자기표 운영 금지 정책)은 오픈 질문이 아니라 `concerns` 로 이관. diff --git a/.atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-design.md b/.atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-design.md new file mode 100644 index 0000000..6b330e3 --- /dev/null +++ b/.atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-design.md @@ -0,0 +1,385 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-23T16:30:00+09:00 +workstream: W2-6-시상 집계/결과 +concerns: + - "JamAwardService / JamAwardsMapper 의 신규 메서드 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 산정 결과 행 빌더 buildAward(...) 와 정규화 헬퍼 normalizeRankScore(...)." + - "신규 컨트롤러(JamAwardController/JamAwardAdminController)+신규 매퍼(JamAwardsMapper)+소비 매퍼(JamScoreStatsMapper/JamVotesMapper/GameReviewStatsMapper 재사용) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException." + - "유저평점 트랙 소비 SQL 은 game_review_stats(집계 VIEW) 를 읽으므로 매퍼 alias 큰따옴표(AS \"avgRating\"/\"reviewCount\") 필수(케이스 폴딩, verification-strategies §33). jam_score_stats 도 집계 VIEW → weightedTotal 등 큰따옴표. jam_votes count·jam_awards CRUD 는 일반 매퍼 → snake→camel 직접 alias(a.score_value AS scoreValue). dev DB contract(L2) 로 3트랙 join·NULL·동점·재산정 멱등 실측 권장." + - "최소 리뷰수 임계 N=3 은 W2-3 동결 계약(F5)의 상수다. 본 설계는 GRAND 정규화·가중치 기본값(트랙 균등 1/3)도 산정 상수로 둔다 — 잼별 가변(jams 컬럼 또는 jam_award_config)이 필요하면 확장. 현 설계는 상수(W2-6 산정 로직에 위치, 구현 점검 항목)." + - "산정 트리거 시점 = jams.status='CLOSED' OR now()>eval_end_at(W2-3 F6). DB CHECK 로 강제하지 않고 앱계층 게이트(시각 비교 런타임). 재산정 멱등은 deleteByJamTrack 후 재INSERT 를 단일 트랜잭션으로 — 부분 실패 시 트랙 비는 상태 회피. @Transactional 경계는 구현 점검 항목." +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-3-eval-freeze-design.md + - .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-eval-ddl.sql +--- + +# 설계: W2-6 — 시상 집계 / 결과 (3트랙 산정 + GRAND 종합 + 잼 결과 페이지/상세 수상 표시) ★크리티컬 패스 종점 + +> ⚠️ **W2-3 동결 소비 워크스트림**. 본 설계는 `jam_awards` 스키마(W2-3 동결, docs/jam-eval-ddl.sql §4)와 3트랙 소비 계약(G4/F5 단방향 유저평점·G6/F7 집계 노출)을 **재정의하지 않고 그대로 소비**한다. 신규 DDL 테이블 0(jam_awards 는 W2-3 이 이미 생성). 본 W2-6 은 **산정 로직 + 매퍼 + 컨트롤러 + 결과 표시 JSP** 만 신규. + +## 목표 / 비목표 + +### 목표 (FR/NFR 추적 — 골자 W2-6) +- **G1 3트랙 산정** (FR-W2-6 핵심): JUDGE / USER_RATING / POPULAR 각 트랙 독립 랭킹 산정 → `jam_awards` 트랙별 행(rank) 기록. + - JUDGE = `jam_score_stats.weighted_total` DESC (W2-4 산출, W2-3 F7 동결 VIEW). + - USER_RATING = `game_review_stats.avg_rating`(overall) DESC NULLS LAST, `review_count >= 3` 임계 미달 제외 (W2-3 G4/F5 단방향 계약). + - POPULAR = `jam_votes` count DESC (W2-5 산출, W2-3 F7 동결 count 계약). +- **G2 GRAND 종합** (FR-W2-6 종합상): 3트랙 정규화 점수 가중합으로 종합 랭킹 산정 → `jam_awards` award_track='GRAND' 행. 정규화·가중·동점 규칙 본 설계가 확정. +- **G3 NULL/미달 처리** (W2-3 F5 계약 준수): 리뷰 0/axes 0행/심사 미완 출품작은 해당 트랙에서 **제외**(부당 0점 회피). GRAND 는 가용 트랙만 정규화 가중. +- **G4 확정 시점 + 멱등** (W2-3 F6): `jams.status='CLOSED'` 또는 `now() > eval_end_at` 일 때만 산정 허용. 재산정 멱등(트랙별 DELETE→INSERT 트랜잭션). +- **G5 결과 표시**: 잼 상세(`/jams/{slug}`)에 수상 요약 + 별도 결과 페이지(`/jams/{slug}/results`) 트랙별 + 종합 전체 노출. +- **G6 산정 트리거 권한** (W1 인프라 위): 관리자 산정 액션 = `GAME_JAM_MANAGE` 게이트(W2-1 D4-A 게이트 헬퍼 선례). 임시 role 직접체크 금지. +- **NFR**: 상태변경 CSRF 전수(산정 트리거), `#{}` 바인딩(`${}` 금지), 집계 VIEW 매퍼 alias 큰따옴표(case-folding), 단방향 유저평점(리뷰 도메인 write 0), 비파괴(신규 DDL 0 — jam_awards 소비만). + +### 비목표 (스코프 밖) +- **jam_awards 스키마 정의** — **W2-3 동결 소유**(docs/jam-eval-ddl.sql §4). 본 설계는 컬럼/CHECK/UNIQUE 재정의 안 함, 소비만. +- **심사 점수 입력·집계 VIEW** — **W2-4 소유**(jam_score_stats VIEW 는 W2-3 동결, W2-4 가 점수 입력). 본 설계는 weighted_total 을 읽기만. +- **인기투표 토글·집계** — **W2-5 소유**(jam_votes). 본 설계는 count 를 읽기만. +- **리뷰 도메인 변경** — W3-2(구현완료). 본 설계는 `game_review_stats` VIEW 를 **SELECT 만**(W2-3 G4 단방향, game_reviews/axes 무변경). +- **자동 산정 스케줄러 본체** — 1차는 관리자 수동 트리거(`POST /admin/jams/{jamId}/awards/compute`). eval_end_at 경과 자동 산정은 W2-1 의 JamLifecycle 자동전이 훅(후속)과 동일하게 훅만 — 본 설계는 수동 산정 enforcement 만 구현 범위. +- **잼별 가변 가중치 UI/설정 테이블** — 1차는 산정 상수(트랙 균등 1/3 + 임계 N=3). 잼별 가변은 후속(concern 4). + +--- + +## 개요 + +bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. 현재 시상 관련 산출물(jam_awards 소비 매퍼·산정 로직·결과 JSP)은 전무하다. W2-3 동결이 `jam_awards`(award_track CHECK 4값 JUDGE/USER_RATING/POPULAR/GRAND + rank + score_value + computed_at, `ux_jam_awards_jam_track_game` UNIQUE — docs/jam-eval-ddl.sql §4 직접 인용) + 3트랙 소비 계약을 이미 동결했고, W2-1 이 `jams`(status CHECK + slug + eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작 모집단)를 제공한다. + +본 설계는 **3트랙 + GRAND 산정 로직(JamAwardService) + jam_awards CRUD 매퍼 + 관리자 산정 트리거 + 공개 결과 표시(상세 수상 요약 + 결과 페이지)** 를 신규 도입한다. 산정은 출품작 모집단(`jam_entries` 활성행) 위에서 3트랙 소스를 각각 정렬·랭크 → jam_awards 트랙별 행 기록 → GRAND 는 트랙 정규화 가중합으로 종합 랭크. 신규 DDL 테이블 0(jam_awards 소비). + +확정된 정석 결정(전제 — orchestrator 확정 + W2-3 동결 정합): +- **3트랙 = 독립 산정** : 각 트랙은 자기 소스로 독립 랭크. 트랙 간 결합 없음(JUDGE 미완이어도 POPULAR 산정 가능). +- **GRAND = 트랙별 순위점수(rank-score) 정규화 가중합** : 트랙별 스케일 상이(weighted_total numeric / avg_rating 1~5 / vote count 정수) → **순위점수 정규화**(min-max 가 아닌 rank 기반)로 스케일 통일. 가중치 기본 균등(1/3). 가용 트랙만 가중. +- **NULL/미달 = 트랙 제외** (부당 0점 회피, W2-3 F5). +- **확정 시점 = CLOSED/eval종료 후, 멱등** (W2-3 F6). + +가장 까다로운 세 난제 확정: + +- **난제1 (GRAND 정규화 — 트랙별 스케일 통일)**: 3트랙은 스케일이 전혀 다르다(JUDGE weighted_total numeric 1~5 가중평균 / USER_RATING avg_rating numeric 1~5 / POPULAR vote count 정수 0~N). raw 점수 단순 합산은 vote count 가 압도(스케일 폭주). min-max 정규화는 트랙 내 분포에 민감(이상치·단일 출품작 시 0/1 양극단). 본 설계는 **순위점수(rank-score) 정규화**를 채택: 각 트랙에서 출품작을 정렬해 순위를 매기고, 순위를 `rankScore = (참여작수 - rank + 1) / 참여작수` (1위=1.0, 최하위=1/N) 로 [1/N, 1] 정규화한다. 스케일·이상치 무관, 트랙 간 동일 의미(상대 순위). GRAND = `Σ(rankScore_track × weight_track) / Σ(weight_track 가용)`. **가용 트랙만** 분자·분모에 포함(트랙 결측 출품작은 그 트랙 0 아님 — 가중 평균이 가용 트랙으로 정규화됨, F5 부당 0점 회피 정합). 동점은 같은 GRAND 점수 → 같은 rank(아래 동점 규칙). +- **난제2 (NULL/미달 트랙 제외의 산정 위치)**: W2-3 F5 는 "USER_RATING 트랙에서 review_count<3 제외"를 동결했다. 본 설계는 **각 트랙 모집단을 트랙별로 좁힌다**: JUDGE = jam_score_stats 에 행이 있는 출품작(채점 1건 이상), USER_RATING = game_review_stats.review_count>=3, POPULAR = jam_votes count>=1(0표는 트랙 비포함이 아니라 동률 최하위 — 투표 트랙은 0표도 출품작이면 count 0 으로 랭크 가능하나, 정석=득표 0 출품작은 POPULAR 수상권 밖이므로 **rank 부여하되 score_value=0**, GRAND 의 POPULAR rankScore 계산 모집단에는 득표 있는 작품만). 트랙별 모집단을 명시(§시퀀스 S3). GRAND 모집단 = 3트랙 중 **최소 1트랙 이상 가용**한 출품작. +- **난제3 (동점 처리)**: 각 트랙 raw 점수 동점(예: weighted_total 동일, vote count 동일) → **같은 rank 부여**(standard competition ranking: 1,2,2,4). jam_awards UNIQUE 는 `(jam_id, award_track, game_id)` 이므로 같은 rank 동률 다행 허용(W2-3 동결 주석: "동률은 같은 rank 허용 위해 (jam_id, award_track, game_id) UNIQUE"). tie-break 표시 순서는 game_id ASC(결정적·재산정 안정). GRAND 동점도 동일 규칙. + +--- + +## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 정합) + +| 결정 | 확정값 | 본 설계의 구체화 | +|---|---|---| +| A1 트랙 산정 | 3트랙 독립 랭크 | JUDGE=jam_score_stats.weighted_total / USER_RATING=avg_rating(F5) / POPULAR=jam_votes count. 트랙 간 결합 없음 | +| A2 GRAND 정규화 | 순위점수(rank-score) 가중합 | rankScore=(N-rank+1)/N ∈[1/N,1]. GRAND=Σ(rankScore×weight)/Σ(weight 가용). 가용 트랙만 | +| A3 가중치 | 기본 균등 1/3(상수) | 3트랙 동일 weight. 잼별 가변은 후속(concern 4) | +| A4 NULL/미달 | 트랙 제외(부당 0점 회피) | JUDGE=채점 1건+ / USER_RATING=review_count>=3 / POPULAR rankScore 모집단=득표>0. GRAND=가용 트랙만 | +| A5 동점 | standard competition ranking(같은 rank) | jam_awards (jam_id,track,game_id) UNIQUE 가 동률 다행 허용(W2-3 동결). tie-break 표시=game_id ASC | +| A6 확정 시점 | CLOSED/eval종료 후, 멱등 | jam.status='CLOSED' OR now()>eval_end_at. 재산정=deleteByJamTrack→INSERT(트랜잭션) | +| A7 트리거 권한 | GAME_JAM_MANAGE 게이트 | /admin/jams/{jamId}/awards/compute. W2-1 D4-A 게이트 헬퍼 재사용(인터셉터 exclude 범위 내) | +| A8 표시 | 상세 요약 + 결과 페이지 | /jams/{slug} 수상 배지 + /jams/{slug}/results 트랙별+종합 전체 | + +--- + +## 데이터 모델 (DDL — 신규 0건. W2-3 동결 jam_awards 소비) + +> **신규 테이블/VIEW 0**. `jam_awards`(W2-3 동결, docs/jam-eval-ddl.sql §4)를 그대로 소비한다. 본 설계는 DDL 을 **재정의하지 않는다**(W2-3 소유). 아래는 소비 계약 확인용 동결 스키마 재인용(읽기 전용 — 변경 금지). + +### 소비 대상: `jam_awards` (W2-3 동결 — 재정의 금지, 인용만) +```sql +-- docs/jam-eval-ddl.sql §4 (W2-3 동결. 본 W2-6 은 이 스키마를 INSERT/SELECT/DELETE 소비) +-- jam_awards(id, jam_id, game_id, award_track, rank, score_value numeric(10,4), computed_at) +-- award_track CHECK IN ('JUDGE','USER_RATING','POPULAR','GRAND') +-- rank CHECK >= 1 +-- UNIQUE ux_jam_awards_jam_track_game (jam_id, award_track, game_id) ← 동률 다행 허용 +-- INDEX idx_jam_awards_jam_track (jam_id, award_track, rank) ← 결과 조회 정렬 +``` + +### score_value 컬럼 의미 확정 (트랙별 — W2-3 동결 컬럼의 본 설계 채움 규약) +> W2-3 동결은 `score_value numeric(10,4)` 를 "트랙별 의미 다름; NULL 허용"으로만 정의했다. 본 W2-6 이 트랙별 채움 의미를 확정한다(스키마 변경 0 — 같은 컬럼의 값 규약만). + +| award_track | score_value 채움값 | 의미 | +|---|---|---| +| JUDGE | jam_score_stats.weighted_total (numeric, 가중종합 1~5) | 심사 가중 종합점수 | +| USER_RATING | game_review_stats.avg_rating (numeric, overall 1~5) | 유저 평균 별점(overall) | +| POPULAR | jam_votes count (정수를 numeric 로) | 득표수 | +| GRAND | GRAND 종합점수 (Σ(rankScore×weight)/Σweight, [0,1] 정규화) | 트랙 정규화 가중합 | + +- **결정 근거**: score_value 에 트랙별 **raw 점수**(JUDGE/USER_RATING/POPULAR)와 GRAND 종합점수를 저장하면 결과 페이지가 jam_awards 단독 조회로 "점수와 함께" 표시 가능(소스 VIEW 재join 불요). rank 는 정렬·동점, score_value 는 표시·재현 근거. 둘 다 보존이 정석(rank 만으로는 동점 근거·점수 표시 불가). + +### 신규 DDL 0 확인 (G3 비파괴) +- `jam_awards`/`jam_score_stats`/`jam_votes`/`game_review_stats`/`jam_entries`/`jams` **전부 무변경**(전부 선행 워크스트림 소유). 본 W2-6 은 jam_awards 에 INSERT/DELETE/SELECT, 나머지 4개는 SELECT 만. → DDL 파일 신규 0, schema.sql 변경 0. + +--- + +## 외부 계약 (API) + +> 공통: 산정 트리거(상태변경)는 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java 확인). 결과 조회는 공개(인증 불필요). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity>`(status/message). 관리자 산정 API 는 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문(2-arg has — PermissionGate.java:22 직접 확인, request 인자 없음). + +### 401 vs 403 정책 (W1-design / W2-1 / W2-3 과 일치) +- **미인증**(세션 `userId` 없음): API 401 JSON `{status:401, message:"로그인이 필요합니다."}`. 결과 페이지는 공개(미인증도 열람). +- **인증·미인가**(GAME_JAM_MANAGE 없음): 403 JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지). +- **산정 미개방**(F6 게이트 위반 — status≠CLOSED AND now()≤eval_end_at): **422** JSON `{status:422, message:"시상 산정 가능 상태가 아닙니다."}`(인가는 됐으나 도메인 상태 위반 → 403 아님 422, W2-3 F6 422 정책 일치). +- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`. + +### 공개 결과 (뷰 — 인증 불필요) +| method | path | 권한 | 응답 | +|---|---|---|---| +| GET | `/jams/{slug}/results` | 공개 | `jam-results` JSP. 트랙별(JUDGE/USER_RATING/POPULAR) 랭킹 + GRAND 종합. 미산정 잼이면 "결과 준비 중" 안내(빈 jam_awards) | +| GET | `/jams/{slug}` | 공개 | (W2-1 소유 — 본 설계는 수상 요약 모델 주입만 추가) `jam-detail` JSP 에 GRAND 상위 + 트랙 대상 배지 표시. **JamController.detail 의 모델에 awardsSummary 추가**(W2-1 컨트롤러 협의 수정 — concerns crossRef) | + +### 관리자 산정 트리거 (상태변경 API — CSRF + GAME_JAM_MANAGE 게이트) +| 액션 | method | path | 요청 | 응답(200) | 에러 | +|---|---|---|---|---|---| +| 시상 산정/재산정 | POST | `/admin/jams/{jamId}/awards/compute` | (path jamId) | `{status:200, message, jamId, awardCounts:{JUDGE:n, USER_RATING:n, POPULAR:n, GRAND:n}}` | 401(미인증), 403(CSRF/권한), 404(잼 없음), 422(산정 미개방 — CLOSED 아님+eval 진행중) | + +- **단일 산정 엔드포인트 채택 근거**: 산정은 멱등(재실행=전체 재산정)이므로 최초 산정/재산정을 같은 엔드포인트로. 4트랙을 한 트랜잭션에 산정해 트랙 간 부분 산정(일부 트랙만 갱신된 비정합 상태) 회피. `awardCounts` 로 트랙별 수상 행수 반환. +- **경로 소속**: `/admin/jams/{jamId}/awards/compute` 는 `/admin/jams/**` 하위 → W2-1 D4-A 의 InterceptorConfig `.excludePathPatterns("/admin/jams/**")` 범위에 포함되어 인터셉터 ADMIN 게이트를 우회하고 컨트롤러 게이트 헬퍼(GAME_JAM_MANAGE)로 판정(SUBADMIN+키 통과 — W2-1 선례 그대로, 본 설계 인터셉터 추가 작업 0). +- **부작용**: jam_awards 4트랙 전체 deleteByJamTrack → 재INSERT(단일 트랜잭션). game_reviews/jam_scores/jam_votes write 0(읽기만 — 단방향). + +### 결과 조회 계약 +- `JamAwardsMapper.listByJam(jamId)` → 트랙별·rank 정렬(idx_jam_awards_jam_track). 컨트롤러가 트랙별로 그룹핑해 JSP 모델 주입. +- 결과 행에 게임 표시정보(이름/썸네일/출품자) 조인 필요 → `listByJamWithGame(jamId)`(JOIN games + jam_entries 표시필드). game_review_stats 등 소스 VIEW 재조회 불요(score_value 가 표시점수 보존). + +--- + +## 인터셉터 / 게이트 연동 (A7 — W2-1 D4-A 재사용, 본 설계 추가 0) + +### 게이트 경로 (W2-1 확정 그대로 소비) +- `/admin/jams/{jamId}/awards/compute` 는 `/admin/jams/**` 하위 → **W2-1 이 이미 InterceptorConfig 에 `.excludePathPatterns("/admin/jams/**")` 추가함**(W2-1 D4-A). 본 W2-6 은 InterceptorConfig 를 **추가 수정하지 않는다**(W2-1 exclude 가 본 경로를 이미 커버). 인터셉터 우회 후 컨트롤러 게이트 헬퍼가 판정. +- **JamAwardAdminController 진입부 게이트 헬퍼**(W2-1 requireJamManage 패턴 동형): + 1. `gate.isAuthenticated(session)` 거짓 → 401(API). + 2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403. + 3. 통과 후 본문. +- **이유**: 잼 산정은 "ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE" 라 권한 키 판정 필요 → `gate.has`(ADMIN 암묵전권 + SUBADMIN 키보유, PermissionGate.java:30-36 직접 확인) 정확히 적합. 커스텀 어노테이션/AOP 는 W1/W2-1 에서 오버엔지니어링으로 기각된 선례 → 동일하게 게이트 헬퍼. +- **중복 0**: 단일 산정 액션이므로 헬퍼는 1회 호출. W2-1 의 requireJamManage 와 동형 private 헬퍼(컨트롤러 내) 또는 W2-1 헬퍼가 공용 컴포넌트면 재사용(구현 시 W2-1 소유자와 협의 — concerns crossRef). + +### epoch 전파 연동 (W1 결정4 — 추가 작업 0) +- `gate.has` 내부 `refreshIfStale`(PermissionGate.java:86)가 요청당 epoch 대조 → ADMIN 이 GAME_JAM_MANAGE 부여/회수하면 대상 다음 요청에서 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 0). + +--- + +## 시퀀스 (주요 플로우 의사코드) + +### S1. 관리자 시상 산정/재산정 (전체 트랜잭션) +``` +[SUBADMIN(+GAME_JAM_MANAGE) 세션] POST /admin/jams/42/awards/compute (CSRF) + → InterceptorConfig: /admin/jams/** exclude(W2-1) → 인터셉터 미개입 + → JamAwardAdminController.computeAwards(42) + → requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403) + → CsrfTokens.isValid(request) (아니면 403 errorBody) + → jam = jamsMapper.getById(42) (없으면 404) + → 산정 게이트(F6): jam.status=='CLOSED' OR now() > jam.evalEndAt? (아니면 422) + → jamAwardService.recompute(jam.id): # @Transactional — 4트랙 원자적 + for track in [JUDGE, USER_RATING, POPULAR, GRAND]: + jamAwardsMapper.deleteByJamTrack(jam.id, track) # 멱등 재산정 + → S2/S3 트랙별 산정 → jamAwardsMapper.insert(award) 다건 + → 200 {jamId:42, awardCounts:{JUDGE:n,...}} + # game_reviews/jam_scores/jam_votes write 0 (단방향 읽기). +``` + +### S2. 3트랙 독립 산정 (JamAwardService 내부 — 각 트랙 모집단·정렬·랭크) +``` +trackJudge(jamId): + rows = jamScoreStatsMapper.listStatsByJam(jamId) # [{gameId, weightedTotal, ...}] + # W2-3 F7 동결 VIEW. weightedTotal NULL(채점 0) 행은 모집단 제외(A4) + rows = rows.filter(r -> r.weightedTotal != null) + rank = standardCompetitionRank(rows, key=weightedTotal DESC, tiebreak=gameId ASC) + for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'JUDGE', rank[r], r.weightedTotal)) + +trackUserRating(jamId): + rows = gameReviewStatsConsumerMapper.listAvgByJam(jamId) + # jam_entries e LEFT JOIN game_review_stats st (st."avgRating"/"reviewCount") + # WHERE e.jam_id=#{jamId} AND e.is_delete<>true AND st.review_count >= 3 (F5 임계) + # ORDER BY st.avg_rating DESC NULLS LAST, e.game_id ASC (F5 정렬) + rank = standardCompetitionRank(rows, key=avgRating DESC, tiebreak=gameId ASC) + for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'USER_RATING', rank[r], r.avgRating)) + +trackPopular(jamId): + rows = jamVotesMapper.listCountsByJam(jamId) # [{gameId, voteCount}] GROUP BY game_id + # 득표 0 출품작은 GROUP BY 에 안 나옴 → POPULAR rankScore 모집단=득표>0(A4) + rank = standardCompetitionRank(rows, key=voteCount DESC, tiebreak=gameId ASC) + for r: jamAwardsMapper.insert(award(jamId, r.gameId, 'POPULAR', rank[r], r.voteCount)) +``` +- **standardCompetitionRank**(공통 헬퍼): 정렬 후 동점이면 같은 rank, 다음 순위는 건너뜀(1,2,2,4). tiebreak=gameId ASC 로 결정적(재산정 안정). 이 헬퍼는 jam_awards rank 부여와 GRAND rankScore 산정 양쪽 공유(중복 0). + +### S3. GRAND 종합 산정 (순위점수 정규화 가중합 — 난제1) +``` +trackGrand(jamId, weights={JUDGE:1/3, USER_RATING:1/3, POPULAR:1/3}): + # 각 트랙의 "수상권 모집단" 위에서 rankScore 계산 + judgeScore = rankScoreMap(trackJudge 모집단, key=weightedTotal DESC) # gameId -> (N-rank+1)/N + ratingScore = rankScoreMap(trackUserRating 모집단, key=avgRating DESC) + popularScore = rankScoreMap(trackPopular 모집단, key=voteCount DESC) # 득표>0 만 + + grandPop = union(모든 트랙 모집단 gameId) # 최소 1트랙 가용 출품작 + for gameId in grandPop: + avail = [(judgeScore, JUDGE), (ratingScore, USER_RATING), (popularScore, POPULAR)] + .filter(트랙 모집단에 gameId 존재) # 가용 트랙만(A4 부당 0점 회피) + grand = Σ(score[gameId] × weights[track]) / Σ(weights[track] for 가용) # [1/N..1] 정규화 + rank = standardCompetitionRank(grandPop, key=grand DESC, tiebreak=gameId ASC) + for g: jamAwardsMapper.insert(award(jamId, g, 'GRAND', rank[g], grand[g])) +``` +- **정규화 논증(난제1)**: rankScore = (N-rank+1)/N 은 트랙 내 상대 순위만 쓰므로 스케일(numeric 1~5 vs vote count 0~수백) 무관. 가용 트랙만 분모에 넣어 트랙 결측이 부당 0점이 되지 않음(F5). 1트랙만 가용한 출품작도 그 트랙 rankScore 로 GRAND 산정(분모=그 트랙 weight) → 제외 안 함. +- **동점(난제3)**: grand 동일 → 같은 rank. game_id ASC tiebreak. + +### S4. 공개 결과 페이지 +``` +[공개] GET /jams/{slug}/results + → JamAwardController.results(slug) + → jam = jamsMapper.getBySlug(slug) (없거나 !is_visible → redirect:/jams) + → awards = jamAwardsMapper.listByJamWithGame(jam.id) # 트랙·rank 정렬 + JOIN games 표시 + → byTrack = group(awards, award_track) # {JUDGE:[...], USER_RATING:[...], ...} + → model: jam, byTrack(트랙별 랭킹), grand=byTrack.GRAND + → "jam-results" (미산정이면 빈 맵 → "결과 준비 중" 표시) +``` + +--- + +## 파일 영향 맵 + +> 소유권 분할 가이드(implementation-advisor worker 단위 후보): +> **AW-DOMAIN**(data POJO/JamAwardService 산정 코어/순위점수 헬퍼) · **AW-MAPPER**(JamAwardsMapper + 소비 매퍼 listAvgByJam/listCountsByJam — JamScoreStatsMapper.listStatsByJam 는 W2-4 소유 재사용) · **AW-ADMIN**(JamAwardAdminController 산정 트리거 + 게이트) · **AW-PUBLIC**(JamAwardController 결과 페이지 + JSP) · **AW-DETAIL**(W2-1 JamController.detail 수상요약 모델 주입 — W2-1 협의 수정). +> 의존: W2-3 동결(jam_awards) + W2-4(jam_score_stats) + W2-5(jam_votes) + W3-2(game_review_stats) 선행 → AW-DOMAIN → AW-MAPPER → {AW-ADMIN, AW-PUBLIC, AW-DETAIL}. + +| 변경 유형 | 경로 | 역할 | 소유 | +|---|---|---|---| +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamAwardData.java` | jam_awards 행 POJO(jamId/gameId/awardTrack/rank/scoreValue/computedAt) + 표시조인(gameName/thumbnailUrl/entrantType — listByJamWithGame 용) | AW-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/AwardTrack.java` | enum JUDGE/USER_RATING/POPULAR/GRAND + isValid(String) (PermissionKeys/JamStatus 패턴) | AW-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamAwardService.java` | 3트랙+GRAND 산정 코어(recompute, 트랙별 모집단·정렬·rankScore·동점). @Transactional 멱등 재산정 | AW-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/RankScores.java` | 순위점수 유틸(standardCompetitionRank + rankScore (N-rank+1)/N). jam_awards rank·GRAND 정규화 공유(중복 0) | AW-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamAwardsMapper.java` | `@Mapper` jam_awards insert/deleteByJamTrack/listByJam/listByJamWithGame(`#{}`, 일반 매퍼 snake→camel 직접 alias) | AW-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamReviewRatingMapper.java` | `@Mapper` USER_RATING 트랙 소비 — jam_entries LEFT JOIN game_review_stats(집계 VIEW → `AS "avgRating"`/`"reviewCount"` 큰따옴표), review_count>=3 + NULLS LAST(`#{}`) | AW-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamVoteCountMapper.java` | `@Mapper` POPULAR 트랙 소비 — jam_votes GROUP BY game_id count(일반 매퍼 직접 alias)(`#{}`). (W2-5 JamVotesMapper 와 별개 or 재사용 — 구현 시 협의, concerns) | AW-MAPPER | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAwardAdminController.java` | `/admin/jams/{jamId}/awards/compute` 산정 트리거(게이트 헬퍼 + CSRF + F6 422) | AW-ADMIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAwardController.java` | 공개 `/jams/{slug}/results`(결과 페이지, RecruitController 읽기 패턴) | AW-PUBLIC | +| 신규 | `src/main/webapp/WEB-INF/views/jam-results.jsp` | 트랙별 랭킹 + GRAND 종합 표 (HtmlUtils.htmlEscape/JSTL escape, textContent) | AW-PUBLIC | +| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/JamController.java` | (W2-1 소유) detail 모델에 awardsSummary(GRAND 상위 + 트랙 대상) 주입 추가. **W2-1 소유자 협의 — 본 설계는 모델 키 계약만 명시** | AW-DETAIL(W2-1 협의) | +| 수정 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` | (W2-1 소유) 수상 요약 배지 블록 추가(escape) | AW-DETAIL(W2-1 협의) | +| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper) @MockBean 등록(contextLoads 보존, §30) | (검증) | +| 신규 | `src/test/.../JamAwardServiceTest.java` | 3트랙 산정 + GRAND 정규화 + NULL/미달 제외 + 동점 + 멱등 재산정 단위 | (검증) | +| 신규 | `src/test/.../JamAwardAdminControllerTest.java` | 산정 트리거 + 401/403/CSRF/게이트 + F6 422(CLOSED 아님) | (검증) | +| 신규 | `src/test/.../JamAwardControllerTest.java` | 결과 페이지 + 미산정 잼 빈 결과 + 트랙 그룹핑 | (검증) | + +> SSR 호출지점 영향(verification §영향맵 SSR 포함): jam-detail.jsp 수정은 awardsSummary 모델 키 **신규 추가**라 기존 키 소비 깨짐 0(빈 잼이면 awardsSummary 빈 컬렉션 → 표시 분기). JamController.detail 모델 추가는 W2-1 소유 컨트롤러 협의 수정 — 기존 모델 키 보존(추가만). game_review_stats VIEW 무변경 → W3-2 리뷰 요약 회귀 0. + +### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지) +```java +// JamAwardService — 산정 코어. jamId 만으로 4트랙 소스 조회·산정·기록(최소). +int recompute(long jamId) // 산정/재산정(@Transactional). 반환=총 수상 행수(또는 트랙별 Map) + +// RankScores — 순위점수 유틸(트랙 rank·GRAND 정규화 공유). 정렬키 추출은 호출자 제공(제네릭 비교자). +// 최소 인자: 정렬 대상 리스트 + 비교 기준만. (트랙·잼 컨텍스트는 호출자가 알고 있으므로 전달 안 함 — inflate 방지) + Map standardCompetitionRank(List items, // 산정 대상(트랙 모집단) + Comparator byScoreDesc) // 점수 내림차순+tiebreak + Map rankScore(List items, // 동일 모집단 + Comparator byScoreDesc) // (N-rank+1)/N 정규화 — GRAND 가중합 입력 + +// JamAwardsMapper (@Mapper, #{} only, 일반 매퍼 snake→camel 직접 alias) +int insert(JamAwardData award) // 트랙·순위 수상 기록(재산정 후 다건) +int deleteByJamTrack(long jamId, String track) // 재산정 전 트랙 초기화(멱등) +List listByJamWithGame(long jamId) // 결과 페이지(JOIN games 표시 + 트랙·rank 정렬) + +// JamReviewRatingMapper (@Mapper, #{} only, 집계 VIEW 소비 → camelCase 큰따옴표 alias) +List listAvgByJam(long jamId) // USER_RATING 모집단(review_count>=3, NULLS LAST) + +// JamVoteCountMapper (@Mapper, #{} only, 일반 매퍼 직접 alias) +List listCountsByJam(long jamId) // POPULAR 모집단(GROUP BY game_id count) + +// (W2-4 소유 재사용) JamScoreStatsMapper.listStatsByJam(long jamId) — JUDGE 모집단(weightedTotal) +``` +> ⚠️ inflate 마킹(concern 1): `JamAwardService.recompute` 는 jamId 단일 인자가 정석(소스 조회는 매퍼 주입으로 해결 — jam 객체/세션/actor 를 받지 말 것. 산정은 actor 무관 순수 집계). `RankScores` 의 두 메서드는 `Comparator` 만 받아 트랙/잼 컨텍스트를 끌어오지 않음(헬퍼 inflate 차단). 구현에서 buildAward(...) 헬퍼를 추출한다면 (jamId, gameId, track, rank, scoreValue) 전부 실제 INSERT 에 쓰이는지 재확인(dead parameter 방지). JamReviewRatingRow/JamVoteCountRow 는 필요 필드(gameId + 점수)만 — 6축 컬럼 매핑 금지(단방향 G4, 6축 미사용). + +--- + +## 대안 비교 + +| 주제 | 안 | 장점 | 단점 | 채택 | +|---|---|---|---|---| +| GRAND 정규화 | (A) 순위점수(rank-score) 가중합 | 트랙 스케일 무관, 이상치 강건, 트랙 간 동일 의미(상대순위), 가용트랙 정규화로 F5 정합 | 절대 점수차 손실(1위-2위 격차 무시) | **채택(A2)** | +| | (B) min-max 정규화 raw 점수 | 점수차 보존 | 단일/소수 출품작 시 0/1 양극단, 이상치 민감, 트랙 내 분포 의존 | 기각 | +| | (C) raw 점수 단순 합 | 단순 | vote count 가 스케일 압도(수백 vs 1~5) → 사실상 인기상=종합 | 기각 | +| NULL/미달 | (A) 트랙 제외 + GRAND 가용트랙 정규화 | 부당 0점 회피(F5 동결), 1트랙만 가용해도 GRAND 산정 | 가용트랙 적은 작품 변동성 | **채택(A4)** | +| | (B) 미달=0점 | 단순 | 리뷰 0개가 GRAND 최하위 강제(부당, F5 위반) | 기각 | +| 동점 | (A) standard competition rank(같은 rank, 1,2,2,4) | 직관적, jam_awards UNIQUE 동률 다행 허용(W2-3 동결) | rank 건너뜀 | **채택(A5)** | +| | (B) dense rank(1,2,2,3) | 연속 rank | UNIQUE(jam_id,track,game_id) 와 무관하나 표시 관례상 competition 이 시상에 자연 | 기각 | +| | (C) tiebreak 강제 유일순위 | UNIQUE 단순 | 동점을 인위 분리(공정성 훼손) | 기각 | +| score_value 채움 | (A) 트랙=raw 점수, GRAND=종합점수 | 결과 단독조회 표시, 재현근거 보존 | 컬럼 의미 트랙별 분기 | **채택** | +| | (B) score_value 전부 NULL(rank 만) | 단순 | 점수 표시·동점 근거 불가, 소스 VIEW 재조회 필요 | 기각 | +| 산정 트리거 | (A) 관리자 수동(GAME_JAM_MANAGE 게이트) | 운영 통제, W2-1 게이트 재사용, 자동훅은 후속 | 수동 1스텝 | **채택(A7)** | +| | (B) eval_end_at 경과 자동 산정 | 무인 | @Scheduled context 영향, 1차 과도(W2-1 자동전이도 후속) | 기각(후속 훅) | + +--- + +## 롤아웃 / 마이그레이션 + +### 순서 +1. **스키마**: 신규 DDL 0. `jam_awards`(W2-3 docs/jam-eval-ddl.sql)·`jam_score_stats`(W2-3 VIEW)·`jam_votes`(W2-3)·`game_review_stats`(W3-2) 가 **선행 적용돼 있어야 함**. apply-local-ddl.sh 알파벳 글롭: game-reviews-ddl(`g`) < jam-eval-ddl(`j...e`) 선존재. 본 W2-6 은 DDL 추가 0(소비만). +2. **선행 코드 의존**: W2-4(jam_score_stats 채우는 점수 입력) + W2-5(jam_votes 채우는 투표) 배포 후라야 산정이 의미 있음(소스 비면 트랙 모집단 0). 단 **DDL/스키마는 W2-3 동결로 이미 존재**하므로 W2-6 코드는 W2-4/5 코드 배포와 독립 컴파일·배포 가능(빈 소스면 빈 결과 산정 — 무해). +3. **코드 배포**: AW-DOMAIN → AW-MAPPER → AW-ADMIN/AW-PUBLIC. AW-DETAIL(jam-detail 수상요약)은 W2-1 소유 파일 수정이므로 W2-1 소유자와 동시 PR/협의(concerns crossRef). +4. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 게이트 소비만. + +### 역호환 +- 신규 매퍼/컨트롤러/JSP 만 추가. 기존 games/리뷰/잼 동작 불변. jam_awards 는 W2-3 이 생성한 빈 테이블 → 미산정 잼은 결과 페이지 "준비 중"(빈 조회). +- jam-detail.jsp 수상요약 추가는 모델 키 신규(빈 잼 빈 컬렉션) → 기존 표시 회귀 0. + +### 롤백 +- 코드 롤백: JamAwardService/컨트롤러/JSP 되돌리면 산정·결과 미노출. jam_awards 데이터는 잔존(비파괴) — 무해. jam-detail awardsSummary 모델 제거 시 JSP 분기가 빈 컬렉션 처리하면 안전(W2-1 협의 시 빈 처리 명시). +- 데이터 롤백: jam_awards 행은 `deleteByJamTrack` 또는 maintenance DELETE 로 제거 가능(잼 재산정으로 덮어쓰기 멱등). + +--- + +## AC 매핑 + +| AC | 요구(골자 W2-6) | 만족 설계 요소 | 비고 | +|---|---|---|---| +| AC-1 | 3트랙 독립 산정(JUDGE/USER_RATING/POPULAR) | JamAwardService trackJudge/trackUserRating/trackPopular + jam_awards 트랙별 insert | A1, S2 | +| AC-2 | JUDGE = jam_score_stats.weighted_total | JamScoreStatsMapper.listStatsByJam 소비, weightedTotal DESC rank | A1, W2-3 F7 | +| AC-3 | USER_RATING = avg_rating(overall) 단방향, review_count>=3, NULLS LAST | JamReviewRatingMapper.listAvgByJam(game_review_stats SELECT 만, 6축 미사용) | A1/A4, W2-3 G4/F5 | +| AC-4 | POPULAR = jam_votes count | JamVoteCountMapper.listCountsByJam(GROUP BY game_id count) | A1, W2-3 F7 | +| AC-5 | GRAND 종합(정규화 가중합, 동점·NULL 규칙) | trackGrand rankScore 정규화 + 가용트랙 가중 + competition rank | A2/A4/A5, S3, 난제1 | +| AC-6 | NULL/미달 트랙 제외(부당 0점 회피) | 트랙별 모집단 필터(채점0/review<3/득표0) + GRAND 가용트랙만 | A4, 난제2 | +| AC-7 | 확정 시점 CLOSED/eval종료 후 | F6 게이트(status='CLOSED' OR now>eval_end_at) 아니면 422 | A6, S1 | +| AC-8 | 재산정 멱등 | deleteByJamTrack 4트랙 → 재INSERT(@Transactional 단일) | A6, S1 | +| AC-9 | 산정 트리거 = GAME_JAM_MANAGE 게이트 + CSRF | JamAwardAdminController requireJamManage(gate.has) + CsrfTokens.isValid | A7, §게이트연동 | +| AC-10 | 결과 표시(상세 수상 + 결과 페이지 트랙별+종합) | /jams/{slug}/results(jam-results JSP) + jam-detail awardsSummary | A8, S4 | +| AC-11 | 시상 SQL `${}` 0 | 신규 3매퍼 `#{}` only | §파일영향맵 | +| AC-12 | 집계 VIEW 매퍼 alias 큰따옴표 | JamReviewRatingMapper(game_review_stats)·JamScoreStatsMapper(jam_score_stats) AS "avgRating"/"weightedTotal" | §파일영향맵, verification §33 | +| AC-13 | 단방향(리뷰/심사/투표 write 0) | 산정은 4소스 SELECT + jam_awards write 만. game_reviews/jam_scores/jam_votes 무변경 | G3, S1 | + +--- + +## 검증 포인트 (verification-advisor 점검 대상) + +> L레벨 매핑(verification-strategies): 산정 알고리즘(3트랙·GRAND·NULL·동점·멱등) = **L1**(JamAwardServiceTest 단위) + 3트랙 join/정렬/NULL = **L2(dev DB contract)**. 인가/게이트/F6 422 = **L1+L3**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30). + +### 시나리오 검증 +- **VP-1 (AC-1~5 산정 코어, L1)**: JamAwardServiceTest — ① 3트랙 각 정렬·rank 정확(weightedTotal/avgRating/voteCount DESC) ② GRAND rankScore (N-rank+1)/N + 가용트랙 가중합 정확 ③ 1트랙만 가용 출품작이 GRAND 에 포함(분모=그 트랙 weight) ④ 모킹 소스로 결정적. +- **VP-2 (AC-6 NULL/미달, L1+L2)**: 리뷰 0개(avg_rating NULL) 출품작 → USER_RATING 트랙 제외(rank 없음). review_count==3 경계 포함, ==2 제외. 채점 0 출품작 JUDGE 제외. 득표 0 출품작 POPULAR rankScore 모집단 제외. GRAND 는 그래도 가용트랙으로 산정. dev DB contract 샘플 실측. +- **VP-3 (AC-5 동점, L1)**: weightedTotal 동일 2작품 → 같은 rank(competition 1,2,2,4), tiebreak game_id ASC 결정적. GRAND 동점도 동일. 재산정 시 같은 결과(멱등). +- **VP-4 (AC-8 멱등, L1+L2)**: recompute 2회 호출 → jam_awards 행이 중복 누적 아님(deleteByJamTrack 선행). UNIQUE(jam_id,track,game_id) 위반 없음. 부분 실패 시 트랜잭션 롤백(트랙 비는 상태 회피). +- **VP-5 (AC-7/9 게이트·F6, L1+L3)**: JamAwardAdminControllerTest — ADMIN 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401 / CLOSED 아님+eval진행중 422 / CSRF 누락 403(mapper 미호출). L3 스모크: CLOSED 잼 산정 → 결과 페이지 노출. +- **VP-6 (AC-12/13 alias·단방향, L2)**: JamReviewRatingMapper 반환 키 avgRating/reviewCount 정합(game_review_stats 큰따옴표 케이스폴딩 BUG-2 선례 회피, GameReviewStatsMapper.java:14 `AS "avgRating"` 동형). 산정 SQL 이 game_reviews/jam_scores/jam_votes 에 write 0(SELECT 만). 6축 컬럼 미참조. +- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 3매퍼 @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException. + +### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용) +> self-audit(시점): 아래 카운트는 **본 W2-6 이 신규 생성하는 정적 산출물**(트랙 enum·매퍼·산정 트랙 경로) 또는 **W2-3 동결 불변식**(jam_awards CHECK 트랙값)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. jam_awards 스키마는 W2-3 동결(소유 분리) 이므로 본 verification 시점에 트랙 4값 불변. +> self-audit(표현): 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK IN 목록/트랙 산정 메서드 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴이 정당). + +- **AC-T1 시상 트랙 전수 4종 산정 경로 정합 불변식** — `AwardTrack` enum 멤버 수 == jam_awards_track_check CHECK IN 항목 수(W2-3 동결) == JamAwardService 가 insert 하는 award_track 집합 == 4(JUDGE/USER_RATING/POPULAR/GRAND). 검증: enum 멤버 `grep -c` == 4 AND JamAwardService 에 trackJudge/trackUserRating/trackPopular/trackGrand 4 산정 경로 전수 존재(수동 판정: 각 트랙이 jamAwardsMapper.insert(award_track=...) 호출). 트랙 추가/누락을 갯수 1로 동시 커버. **이 전수 AC 가 3트랙+GRAND 완전성의 핵심 가드**(1트랙 누락 시 시상 결과 불완전). +- **AC-T2 산정 소스 매퍼 전수 4종 SELECT-only 단방향 불변식** — 4트랙 소스(jam_score_stats/game_review_stats/jam_votes + jam_entries 모집단)는 본 산정에서 **읽기만**: 신규 산정 매퍼(JamReviewRatingMapper/JamVoteCountMapper)+소비(JamScoreStatsMapper.listStatsByJam) 에 INSERT/UPDATE/DELETE 가 game_reviews/jam_scores/jam_votes 대상으로 0건. 검증: `grep -iE 'INSERT|UPDATE|DELETE' <산정 소스 매퍼들>` 중 game_reviews/jam_scores/jam_votes 대상 0(jam_awards 대상 INSERT/DELETE 만 허용). 단방향(G4/AC-13) 위반 즉시 검출. +- **AC-T3 시상 신규 매퍼 전수 3개 `${` 0건** — 신규 매퍼 3파일(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 3파일>` == 0 (AC-11, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당. +- **AC-T4 집계 VIEW 소비 매퍼 alias 큰따옴표 전수** — 집계 VIEW 를 소비하는 매퍼 전수(JamReviewRatingMapper→game_review_stats, JamScoreStatsMapper→jam_score_stats)가 camelCase alias 를 큰따옴표로 감쌈: 해당 매퍼들의 VIEW 컬럼 alias 가 `AS "avgRating"`/`AS "reviewCount"`/`AS "weightedTotal"` 형태(케이스 폴딩 회피, GameReviewStatsMapper.java:14 선례). 검증: 집계 VIEW 컬럼 alias 전수가 큰따옴표 — 일반 테이블 매퍼(JamAwardsMapper)는 반대로 직접 alias(scoreValue) 사용 확인(혼용 금지). 수동 판정(VIEW vs 테이블 구분 필요 — 리터럴 grep 단독 회피). +- **AC-T5 신규 시상 매퍼 @MockBean 전수 3건** — BibimbapApplicationTests 에 신규 3 매퍼(JamAwardsMapper/JamReviewRatingMapper/JamVoteCountMapper) @MockBean 전수 등록: contextLoads PASS AND 매퍼별 등록 수동 확인(또는 `grep -c '@MockBean' BibimbapApplicationTests.java 증가분 == 3`). 1건 누락 시 contextLoads NoSuchBeanDefinitionException 으로 verification 시점 즉시 검출(§30). +- **AC-T6 jam_awards 단방향 소비 무결성(신규 DDL 0)** — 본 W2-6 이 신규 DDL 파일 0건 추가: `docs/` 에 W2-6 신규 *-ddl.sql 0개(jam_awards 는 W2-3 docs/jam-eval-ddl.sql 소유). 검증: 본 워크스트림 산출물에 신규 docs/*-ddl.sql 0, db/schema.sql 변경 0(jam_awards 재정의 금지). W2-3 동결 스키마를 W2-6 이 변형하지 않음(소유 경계) 확인. + +--- + +## 잔여 오픈 질문 +없음(0). 확정 결정 A1~A8 전제 고정, 세 난제(GRAND 순위점수 정규화·NULL/미달 트랙 제외 산정위치·동점 competition rank)는 본 설계가 구체 메커니즘으로 확정. score_value 트랙별 채움 규약·산정 게이트(F6 422)·재산정 멱등(@Transactional deleteByJamTrack)도 확정. 구현 점검 항목(시그니처 inflate·full-test @MockBean·집계 VIEW alias L2·임계/가중치 상수 가변화·@Transactional 경계·W2-1 jam-detail 협의 수정·JamVoteCountMapper vs W2-5 JamVotesMapper 재사용)은 오픈 질문이 아니라 `concerns` 로 이관. diff --git a/.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md b/.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md new file mode 100644 index 0000000..28dd240 --- /dev/null +++ b/.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md @@ -0,0 +1,485 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-23T10:43:07+09:00 +concerns: + - "TAG_MANAGE 신규 권한키 vs 기존 CONTENT_MODERATE 재사용: §5.2/§13 에서 CONTENT_MODERATE 재사용으로 확정했으나, 향후 태그 전담 운영자 분리 요구가 생기면 PermissionKeys 에 TAG_MANAGE 추가 필요 — 구현 시 권한 게이트 호출부가 단일 상수 참조인지 재확인." + - "searchVisibleGames 확장 시그니처(SearchCriteria): 파라미터(keyword, creator, tags, tagMode, tagCount, sort, jamSlug)가 구현에서 전부 실제 사용되는지 재확인 필요 — 특히 jamSlug 는 잼 검색 경로에서만 사용되므로 단일 진입점 통합 시 null 분기가 dead path 가 되지 않는지, tagCount 는 AND 모드에서만 참조되므로 OR/미선택 시 unused 가 되지 않는지 점검." + - "신규 매퍼 메서드 시그니처 inflate 점검: GameTagsMapper.listGameIdsByTags(tagSlugs, tagMode, tagCount), GameViewsMapper.existsRecentView(gameId, viewerKey, sinceTs) 의 인자가 구현에서 전부 사용되는지 재확인 — tagMode 분기를 매퍼 내부 로 흡수하면 tagCount 가 OR 경로에서 dead 가 됨. 매퍼를 AND/OR 두 메서드로 분리하는 편이 dead parameter 를 줄일 수 있음(구현 advisor 판단)." + - "검색 결과 행 매핑 타입(SearchRow vs GameData 확장): §8 에서 GameData 에 viewCount/avgRating/reviewCount 필드 추가 방향을 제안하나, 기존 GameData 소비처(목록·상세 JSP)에서 신규 필드가 null 로 남는 경로가 생기면 NPE/표시 누락 위험 — 구현 시 GameData 확장 vs 전용 SearchRow 중 소비처 영향 최소안으로 확정." +concerns_checked: true +references: + requirements: .atp/work-session/20260623-104307/requirements.md + research: null + adrs: + - docs/development/agent-team-protocol.md + - docs/rbac-ddl.sql +--- + +# 설계: W3-1 게임 태그 + 검색 확장 + +## ① 목표 / 비목표 + +### 목표 (FR) +- **FR-1 태그 도메인 도입**: 게임·잼에 부착 가능한 통합 태그 체계를 제공한다. 운영자 사전정의 태그(활성)와 사용자 생성 태그(pending→승인) 하이브리드를 지원한다. +- **FR-2 검색 확장**: 기존 `searchVisibleGames`(이름/제작자/노트 ILIKE) 를 ① 태그 다중 필터(AND/OR) ② 제작자(개발자) 검색 ③ 정렬키(최신/좋아요/방문수/리뷰수/평점/태그일치도) 로 확장한다. +- **FR-3 방문수 정렬**: 방문 로그(`game_views`) + 비정규화 카운터(`games.view_count`) 기반 방문수 정렬을 제공한다. 동일 viewer 24h 윈도 dedupe. +- **FR-4 리뷰 정렬**: `game_review_stats` VIEW 를 LEFT JOIN 하여 리뷰수(`review_count`)·평점(`avg_rating`) 정렬을 제공한다. +- **FR-5 태그 관리 API**: 태그 생성(사용자/운영자)·승인·비활성 API 를 권한 게이트 + CSRF 로 보호한다. +- **FR-6 잼 태그 검색 라우트**: 진행중 잼 배너에서 링크할 단일 검색 라우트(잼 출품작 한정)를 단일 출처로 확정한다. (W3-4 가 이 계약을 채택) + +### 목표 (NFR) +- **NFR-1 정규화**: 통합 `tags` 1테이블 + 용도구분(`tag_type`) + 조인 테이블(`game_tags`/`jam_tags`)로 N:M 정규화. 다대다 중복 방지 UNIQUE. +- **NFR-2 멱등 DDL**: `docs/tag-ddl.sql` 신규 — `CREATE TABLE IF NOT EXISTS` / `CREATE UNIQUE INDEX IF NOT EXISTS` / `ALTER ... ADD COLUMN IF NOT EXISTS` / `DO $$` guard. `apply-local-ddl.sh` 알파벳순·`search_path=dev` 멱등 적용. `schema.sql` 동기 사본 갱신. +- **NFR-3 보안**: 태그 입력 sanitize(XSS/주입) + 금칙어 필터 + 길이/화이트리스트 제약. 검색어는 `#{}` 바인딩 + ILIKE 파라미터화(`${}` 금지). 태그 관리 권한 게이트 + CSRF. +- **NFR-4 확장성**: 태그 타입(`GAME`/`JAM`/`COMMON`)·정렬키·검색 모드를 enum/CHECK 로 모델링하여 후속 도메인(post 등) 확장 시 스키마 변경 최소화. + +### 비목표 +- 태그 자동 추천/연관 태그 그래프 (추후 별도 워크). +- 검색 형태소 분석·전문(full-text) 검색 엔진 도입 — 본 워크는 ILIKE + 인덱스 범위. (검색량 증가 시 별도 ADR) +- 태그별 통계 대시보드 / 트렌딩 태그 — 비목표. +- 잼 도메인 자체 구현(W2-1 소관). 본 설계는 `jams`/`jam_entries` 를 **참조만** 한다. +- post(게시판) 태그 — `tag_type='COMMON'`/`'POST'` 확장 여지만 두고 본 워크 스코프 외. + +## ② 개요 + +현재 게임 검색은 `GamesMapper.searchVisibleGames`(GamesMapper.java:85-87)가 `name`·`users.display_name`·`creator_note` 3컬럼에 ILIKE 부분일치를 거는 단일 자유텍스트 검색이다. 정렬은 목록 기본(`getVisibleGames`, GamesMapper.java:62)의 `sort_order ASC, created_at DESC, id DESC` 고정이며, 사용자가 좋아요·방문수·리뷰·평점 기준으로 탐색할 수단이 없다. `games` 테이블에는 태그·방문수 컬럼이 존재하지 않는다. + +이 설계는 (a) 통합 태그 도메인을 신규 도입하고, (b) 검색을 태그 필터 + 제작자 검색 + 다중 정렬키로 확장하며, (c) 방문수 집계 인프라(`game_views` 로그 + `games.view_count` 비정규화)와 (d) 리뷰 정렬(`game_review_stats` VIEW 소비)을 연결한다. 또한 인접 워크 W2-1(잼) 및 W3-4(진행중 잼 배너)와의 계약으로 **잼 태그 검색 라우트를 단일 출처로 확정**한다. + +설계 정석 기준: 정규화(통합 1테이블 + 조인) · 확장성(타입/정렬키 enum 화) · 보안(입력 sanitize, 파라미터 바인딩, 권한 게이트) 우선. 단순 카운터 단축이나 태그 문자열 컬럼 같은 비정규화 over-engineering 회피는 명시적으로 배제(아래 ③ 핵심결정 근거). + +## ③ 핵심 결정 요약표 + +| # | 결정 | 채택안 | 근거 | 기각안 | +|---|---|---|---|---| +| D1 | 태그 저장 모델 | 통합 `tags` 1테이블 + `tag_type` 구분 + `game_tags`/`jam_tags` 조인 | 정규화·N:M·도메인 확장 시 스키마 안정. 태그명 검색/관리 단일 출처 | 도메인별 태그 테이블 분리(중복·관리 분산), `games.tags` 텍스트 컬럼(검색·정규화 불가) | +| D2 | 태그 생성 정책 | 운영자 사전정의(`is_active=true`) + 사용자 생성(pending→승인) 하이브리드 | 초기 카탈로그 보장 + 사용자 확장. 무분별 태그 난립을 승인 흐름으로 차단 | 운영자 전용(확장성↓), 무승인 자유생성(스팸·중복·XSS 위험) | +| D3 | 태그 입력 검증 | 길이 2~20 + 화이트리스트(한/영/숫자/하이픈) + 금칙어 사전 + sanitize | XSS/주입 차단, 표기 정규화(slug). 금칙어 출처는 §아래 명시 | 자유 입력(보안·정규화 실패) | +| D4 | 태그 관리 권한 | 기존 `CONTENT_MODERATE` 재사용 (PermissionGate.has) | W1 권한 인프라 재사용, 신규 키 도입 최소화. 전담 분리 요구 시 TAG_MANAGE 추가(concern 기록) | 신규 `TAG_MANAGE` 키 즉시 도입(인프라 변경 비용↑, 현 시점 불필요) | +| D5 | 방문수 집계 | `game_views` 로그(viewer_key, 24h dedupe) + `games.view_count` 비정규화 카운터 | dedupe 가능·감사 추적 가능. 정렬은 비정규화 컬럼으로 O(1) | 단순 `view_count++`(중복·어뷰징·감사불가), 로그 only(정렬 시 매번 COUNT 집계 비용) | +| D6 | 리뷰 정렬 소스 | `game_review_stats` VIEW LEFT JOIN | 읽기전용 집계 VIEW 재사용, 6축은 표시전용·정렬은 `avg_rating`/`review_count` 단일 | 매 쿼리 reviews 재집계(중복·성능) | +| D7 | 다중 태그 모드 | 쿼리 파라미터 `tagMode=and\|or` (기본 and) | 사용자가 교집합/합집합 선택. AND=전부 보유, OR=하나라도 보유 | 고정 AND(유연성↓) | +| D8 | NULL 정렬 | 평점/리뷰수 NULL `NULLS LAST` | 미평가 게임이 상위 점유 방지 | 기본 NULLS FIRST(UX 저해) | +| D9 | 잼 태그 검색 라우트 | `GET /games/search?jam={jamSlug}` 파라미터 통합(전용 경로 아님) | 단일 검색 진입점 유지, `jam_entries` 조인으로 출품작 한정. W3-4 가 이 계약 채택 | 전용 경로 `/jams/{slug}/games`(검색 로직 중복) | + +## ④ 데이터 모델 + +### 4.1 신규 DDL — `docs/tag-ddl.sql` (전문, 멱등) + +> 적용: `apply-local-ddl.sh` 가 `docs/*-ddl.sql` 을 알파벳순으로 `search_path=dev` 멱등 적용. `schema.sql` 에 동기 사본 반영(아래 4.3). 선례: W1 `docs/rbac-ddl.sql`. + +```sql +-- docs/tag-ddl.sql +-- W3-1 태그 + 검색 확장. 멱등(IF NOT EXISTS / DO $$ guard). search_path=dev. + +-- 1) 통합 태그 마스터 +CREATE TABLE IF NOT EXISTS tags ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(20) NOT NULL, -- 표시명. 길이 2~20 (앱 검증 + CHECK) + slug VARCHAR(40) NOT NULL, -- 정규화 키(소문자/하이픈). 검색·URL 안정 + tag_type VARCHAR(10) NOT NULL, -- 'GAME' | 'JAM' | 'COMMON' + is_active BOOLEAN NOT NULL DEFAULT FALSE, -- 운영자 사전정의=true, 사용자 생성=false(pending) + created_by BIGINT NULL, -- FK users.id (운영자 시드는 NULL 허용) + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- 태그명 길이·타입 CHECK (멱등: DO $$ guard 로 중복 추가 방지) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_name_len') THEN + ALTER TABLE tags ADD CONSTRAINT chk_tags_name_len + CHECK (char_length(name) BETWEEN 2 AND 20); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_type') THEN + ALTER TABLE tags ADD CONSTRAINT chk_tags_type + CHECK (tag_type IN ('GAME', 'JAM', 'COMMON')); + END IF; +END $$; + +-- created_by FK (users 존재 전제. 멱등 guard) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_created_by') THEN + ALTER TABLE tags ADD CONSTRAINT fk_tags_created_by + FOREIGN KEY (created_by) REFERENCES users(id); + END IF; +END $$; + +-- 태그명/slug unique (활성 여부 무관 전역 유일 — 중복 생성 차단) +CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_slug ON tags (slug); +CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_name ON tags (name); +-- 타입+활성 필터 인덱스(태그 목록/자동완성 조회) +CREATE INDEX IF NOT EXISTS idx_tags_type_active ON tags (tag_type, is_active); + +-- 2) 게임-태그 조인 (N:M) +CREATE TABLE IF NOT EXISTS game_tags ( + id BIGSERIAL PRIMARY KEY, + game_id BIGINT NOT NULL, -- FK games.id + tag_id BIGINT NOT NULL, -- FK tags.id + created_at TIMESTAMP NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_game_tags ON game_tags (game_id, tag_id); +CREATE INDEX IF NOT EXISTS idx_game_tags_tag ON game_tags (tag_id); -- 태그→게임 역검색 +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_game') THEN + ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_game + FOREIGN KEY (game_id) REFERENCES games(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_tag') THEN + ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_tag + FOREIGN KEY (tag_id) REFERENCES tags(id); + END IF; +END $$; + +-- 3) 잼-태그 조인 (N:M, jams 는 W2-1 소관) +CREATE TABLE IF NOT EXISTS jam_tags ( + id BIGSERIAL PRIMARY KEY, + jam_id BIGINT NOT NULL, -- FK jams.id + tag_id BIGINT NOT NULL, -- FK tags.id + created_at TIMESTAMP NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_jam_tags ON jam_tags (jam_id, tag_id); +CREATE INDEX IF NOT EXISTS idx_jam_tags_tag ON jam_tags (tag_id); +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_jam') THEN + ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_jam + FOREIGN KEY (jam_id) REFERENCES jams(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_tag') THEN + ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_tag + FOREIGN KEY (tag_id) REFERENCES tags(id); + END IF; +END $$; + +-- 4) 방문 로그 (dedupe 가능) +CREATE TABLE IF NOT EXISTS game_views ( + id BIGSERIAL PRIMARY KEY, + game_id BIGINT NOT NULL, -- FK games.id + viewer_key VARCHAR(200) NOT NULL, -- 세션/해시 식별자(로그인=userId, 비로그인=익명 해시) + viewed_at TIMESTAMP NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_game_views_game ON game_views (game_id); +-- 24h dedupe 조회용(game_id + viewer_key + 시간범위) +CREATE INDEX IF NOT EXISTS idx_game_views_dedupe ON game_views (game_id, viewer_key, viewed_at); +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_views_game') THEN + ALTER TABLE game_views ADD CONSTRAINT fk_game_views_game + FOREIGN KEY (game_id) REFERENCES games(id); + END IF; +END $$; + +-- 5) games 비정규화 방문수 카운터 (기존 테이블 변경: ADD COLUMN IF NOT EXISTS) +ALTER TABLE games ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0; +-- 방문수 정렬 인덱스 +CREATE INDEX IF NOT EXISTS idx_games_view_count ON games (view_count); +``` + +### 4.2 금칙어 사전 출처 (D3) + +- 금칙어 필터는 애플리케이션 레이어에서 적용한다(DDL 외). 출처는 `src/main/resources/banned-words.txt`(신규, 1줄 1단어, 소문자 비교) 를 단일 출처로 한다. 태그 sanitize 단계(§part2 §8 검증 흐름)에서 slug 화 후 부분/완전 매치 검사. (구현 advisor 가 시드 목록 확정 — 본 설계는 위치·매칭 규칙만 확정) + +### 4.3 `schema.sql` 동기 위치 + +- `schema.sql` 은 DDL 권위(`docs/tag-ddl.sql`)의 동기 사본. 위 4.1 의 5개 블록(tags / game_tags / jam_tags / game_views / games.view_count ALTER)을 동일 멱등 형태로 추가한다. +- 추가 지점: 기존 `games` 정의 블록 직후(view_count ALTER) + `users`/`games`/`jams` 정의 이후(FK 의존 순서 보장). 적용 순서는 `apply-local-ddl.sh` 알파벳순에 의존하지 않도록 모든 블록이 멱등·FK guard 처리됨. + +## ⑤ 외부 계약 (API) + +> 규약: 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity`(RecruitController 패턴). 상태변경=`CsrfTokens.isValid` 검증. 태그 관리 쓰기=`PermissionGate.has(session, CONTENT_MODERATE)` 게이트. + +### 5.1 검색 API (읽기) + +``` +GET /games/search +``` + +| 파라미터 | 타입 | 용도 | 기본값 | +|---|---|---|---| +| `keyword` | String? | 자유텍스트 — name/display_name/creator_note ILIKE | null | +| `creator` | String? | 제작자(개발자) 검색 — users.display_name ILIKE 한정 | null | +| `tags` | String? | 태그 slug CSV (예: `rpg,horror`) | null | +| `tagMode` | String | 다중 태그 결합 `and`\|`or` | `and` | +| `sort` | String | 정렬키 `latest`\|`likes`\|`views`\|`reviews`\|`rating`\|`relevance` | `latest` (태그 선택 시 `relevance` 가중 허용) | +| `jam` | String? | 잼 slug — 지정 시 jam_entries 조인으로 해당 잼 출품작 한정 (W3-4 타깃) | null | + +- 응답: 검색 결과 JSP 뷰(`game-search` 또는 기존 목록 뷰 재사용) + model 에 결과 리스트/페이징/선택 태그. +- 보안: 모든 텍스트 파라미터는 MyBatis `#{}` 바인딩 + ILIKE. `${}` 동적 치환 금지. `tags` CSV 는 서버에서 slug 화이트리스트 검증 후 `foreach` 바인딩. + +### 5.2 태그 관리 API (쓰기 — 권한 게이트 + CSRF) + +| Method | Path | 권한 | 용도 | 요청 | 응답 | +|---|---|---|---|---|---| +| `POST` | `/tags` | 로그인(일반) | 사용자 태그 생성(pending: is_active=false) | `{name, tagType}` + CSRF | `ResponseEntity<{id, name, slug, isActive:false}>` | +| `POST` | `/admin/tags` | `CONTENT_MODERATE` | 운영자 태그 생성(즉시 활성) | `{name, tagType}` + CSRF | `ResponseEntity<{id, ..., isActive:true}>` | +| `POST` | `/admin/tags/{id}/approve` | `CONTENT_MODERATE` | pending 태그 승인(is_active=true) | CSRF | `ResponseEntity<{id, isActive:true}>` | +| `POST` | `/admin/tags/{id}/deactivate` | `CONTENT_MODERATE` | 태그 비활성(is_active=false) | CSRF | `ResponseEntity<{id, isActive:false}>` | +| `POST` | `/games/{gameId}/tags` | 게임 소유자 또는 `CONTENT_MODERATE` | 게임에 태그 부착(game_tags upsert) | `{tagIds[]}` + CSRF | `ResponseEntity<{gameId, tagIds[]}>` | +| `GET` | `/tags` | 공개 | 활성 태그 목록/자동완성(tag_type 필터) | `?type=GAME&q=` | `ResponseEntity>` | + +- 사용자 생성(`POST /tags`)은 sanitize + 금칙어 + 길이/화이트리스트 검증 통과 시에만 pending 저장. 검증 실패는 400. +- 모든 상태변경(`POST`)은 `CsrfTokens.isValid` 선검증. 실패 403. +- 권한 결정(D4): 기존 `PermissionKeys.CONTENT_MODERATE` 재사용. (TAG_MANAGE 신규 도입은 concern 기록 — 향후 분리 시) + +### 5.3 잼 태그 검색 라우트 — 단일 출처 확정 (W3-4 타깃) + +``` +GET /games/search?jam={jamSlug}&tags={tagSlugCsv}&tagMode=and&sort=latest +``` + +- **확정 계약**: 잼 검색은 별도 경로가 아니라 `GET /games/search` 의 `jam` 파라미터로 통합한다. `jam` 지정 시 `jam_entries`(W2-1: `jam_id` FK, `game_id` FK) 조인으로 해당 잼 출품작만 결과에 포함한다. +- W3-4(진행중 잼 배너)는 배너 링크 타깃을 `/games/search?jam={진행중잼.slug}` 로 구성한다. 추가로 잼별 추천 태그를 붙이려면 `&tags=` 를 부착(jam_tags 에서 조회). +- `jamSlug` 미해석/존재하지 않을 경우 빈 결과 + 안내(비-에러). + +## ⑥ 검색 쿼리 설계 + +> MyBatis @Mapper, `#{}` 바인딩 전용, `${}` 금지. 동적 절은 `