bibimbap/.atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md

603 lines
48 KiB
Markdown

---
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<Map<String,Object>>`(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<JamData> listVisibleKeyset(java.time.OffsetDateTime cursorCreatedAt, // 커서 시각(첫페이지 null)
Long cursorId, // 커서 id tie-break(첫페이지 null)
int limit) // pageSize+1(hasNext 판정)
List<JamData> 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<JamEntryData> listByJam(long jamId) // 상세 출품작(JOIN games 표시필드)
boolean exists(long jamId, long gameId) // 사전 중복 체크(409 친절 메시지용)
// JamTeamsMapper (@Mapper, #{} only)
int insert(JamTeamData team) // 팀 생성
JamTeamData getById(long teamId) // 멤버 추가 시 팀장 검증 소스
List<JamTeamData> 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` 로 이관.