48 KiB
| phase | agent | agent_version | generated_at | workstream | concerns | concerns_checked | self_verification | references | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| design | design-advisor | 1 | 2026-06-23T12:30:00+09:00 | W2-1-게임잼 엔티티/라이프사이클 |
|
true |
|
|
설계: 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_loginsert(감사). 자동전이는 같은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, DOguard, CREATE UNIQUE INDEX IF NOT EXISTS, ALTER ADD COLUMN IF NOT EXISTS. 타입은 기존 스타일(bigint/varchar/timestamptz/text).
신규 파일: docs/jam-ddl.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 진입부 게이트 헬퍼: 각 액션 시작에서 아래 순서.
gate.isAuthenticated(session)거짓 → 페이지면 redirect:/login, API 면 401.gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())거짓 → 403(JSON 또는 페이지 403).- 통과 후 본문.
- 이유: 인터셉터는 단일 권한(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 방지)
// 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 영향 재확인 의무).
// (후속) 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 인프라 우회·정석 위반(금지) | 기각 |
롤아웃 / 마이그레이션
순서
- 스키마 적용:
docs/jam-ddl.sql→db/apply-local-ddl.sh(로컬). 운영은 동일 멱등 DDL 수동 적용. games 무변경 → 기존 데이터 회귀 0. - 코드 배포: J-DOMAIN → J-MAPPER → J-PUBLIC/J-ADMIN/J-CONFIG. InterceptorConfig exclude 와 JamAdminController 게이트는 동시 배포(exclude 만 먼저 가면 잼 경로 무보호 노출, 게이트만 먼저 가면 SUBADMIN 인터셉터에 막힘 — 같은 PR/커밋으로).
- 권한 시드 불필요: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 enforcement 연결만.
- 운영: 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 EXISTS5건(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값 정합 불변식 —
JamStatusenum 멤버 수 == 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 로 이관.