diff --git a/.atp/work-session/20260622-170857/report.md b/.atp/work-session/20260622-170857/report.md new file mode 100644 index 0000000..ab3557d --- /dev/null +++ b/.atp/work-session/20260622-170857/report.md @@ -0,0 +1,193 @@ +--- +schema_version: 2 +sid: 20260622-170857 +resumed_from: 20260622-092800 +started_at: 2026-06-22T17:08:57+09:00 +ended_at: +user_request: | + W3-2 댓글/리뷰 고도화 검증 부채 해소 (검증 전용 — 신규 기능 추가 금지). + 직전 세션 코드 L1 43/43 GREEN. 미검증분 3가지만 닫는다: + (1) DDL 적용 + dev 스키마 4객체 검증 + (2) L1 회귀 가드 (mvn test 43 GREEN 재확인) + (3) L3 브라우저 스모크 (XSS 미실행·재방문 영속·다축평점·페이지네이션·키보드·UX·A2 마스킹) + 완료 시 changes 문서 §검증결과 표 + security checklist B3 갱신. +--- + +# Summary +W3-2 댓글/리뷰 고도화 검증 부채(DDL 적용 / L1 회귀 / L3 브라우저 스모크) 전량 해소. 검증 중 서버 집계의 실버그 2건(뷰 fan-out, 매퍼 alias 케이스 폴딩) 발견·최소수정. L3 12개 항목 전수 PASS. 검증 데이터는 세션 종료 시 정리해 원본 시드 복귀. + +핵심 결과: +- STEP1 DDL: dev 4객체 검증 PASS. +- STEP2 L1: mvn test 43/43 GREEN(버그 수정 후 재실행도 43/43, 무회귀). +- STEP3 L3: XSS 미실행·재방문 영속·다축6행·SVG레이더·집계·페이지네이션·키보드·C1/C2/C4·B2·A2 전부 PASS. +- 버그수정: GameReviewStatsMapper.java(alias 3개 quote), docs/game-reviews-ddl.sql + db/schema.sql(뷰 fan-out 수정), 실행 DB CREATE OR REPLACE 적용. +- 환경: 가동 중이던 stale spring-boot:run(목요일 기동, 구 JSP)을 현재 빌드로 재기동. claude-in-chrome computer 도구는 document_idle 차단으로 javascript_tool/curl/psql 조합 검증. + +# Advisor Invocation Decision Log +# 각 advisor 호출/스킵 판단 즉시 1줄 append + +- advisor: requirements-advisor + decision: skip + rationale: '사용자가 STEP1/2/3 + 완료처리까지 정밀 명세. 요구 모호성 0.' + checked_at: 2026-06-22T17:09:00+09:00 +- advisor: graphify-lookup-advisor / research-advisor + decision: skip + rationale: '검증 전용. 신규 설계·외부조사 불요. 배경문서(changes/local-setup) 직독으로 충분.' + checked_at: 2026-06-22T17:09:00+09:00 +- advisor: design-advisor / implementation-advisor + decision: skip + rationale: '코드 변경 기본 없음(검증 전용). 버그 발견 시에만 최소수정 — 그때 재평가.' + checked_at: 2026-06-22T17:09:00+09:00 +- advisor: verification-advisor + decision: call + rationale: 'STEP1(DDL 멱등적용+4객체 psql 검증) + STEP2(mvn test 43 GREEN) Bash 검증 위임. 출력 verbose → 컨텍스트 격리 가치.' + checked_at: 2026-06-22T17:09:30+09:00 + +# Invocations +- advisor: verification-advisor + phase: STEP1+STEP2 + model_choice: { tier: medium, rationale: 'Bash 실행+AC 판정, 코드추론 불요' } + result: PASS + detail: | + AC-1 game_review_axes PASS (score CHECK 1-5, axis_key CHECK 6종, UNIQUE는 INDEX ux_game_review_axes_review_axis 로 구현 — constraint 카탈로그 미표기지만 기능동등) + AC-2 game_review_stats 뷰 9컬럼 PASS + AC-3 game_comments.updated_at timestamptz NOT NULL, mismatched=0/total=3 PASS + AC-4 game_reviews.is_rating_manual boolean DEFAULT false NOT NULL PASS + AC-5 mvn test: Tests run 43 Failures 0 Errors 0 (GameComment17/GameReview20/Csrf5/App1) PASS + concerns: 'AC-1 UNIQUE 구현형태=INDEX(not constraint). 기능동등, 비차단.' + +# Bug Findings (L3 실게임 검증 중 발견 — L1 mock 사각) +## BUG-1 game_review_stats 뷰 fan-out (집계 왜곡) +- 근거: docs/game-reviews-ddl.sql:171-185 (수정전) — `LEFT JOIN game_review_axes` 직접 조인. +- 증상: 리뷰에 axes 행이 존재하면 리뷰가 axes 행수(최대 6)만큼 복제 → `COUNT(*)`=11(실제 6), `AVG(rating)`=2.8(실제 3.5). axes 0행일 땐 잠복(이전 "3.8(5)" 정상), 다축 리뷰 입력 순간 발현. +- 수정: 축 평균을 서브쿼리에서 game 단위 선집계 후 LEFT JOIN(MAX), 리뷰 단위 집계는 game_reviews 단독. docs/game-reviews-ddl.sql + db/schema.sql 동기 + 실행 DB CREATE OR REPLACE 적용. +- 수정후 검증: 뷰 game3 → review_count=6, avg_rating=3.5 (실제값 일치). PASS. + +## BUG-2 GameReviewStatsMapper alias 케이스 폴딩 → summary 항상 null +- 근거: GameReviewStatsMapper.java:13-15 (수정전) — `AS gameId/avgRating/reviewCount` (따옴표 없음). +- 증상: Postgres가 따옴표 없는 alias 를 소문자(gameid/avgrating/reviewcount)로 폴딩 → MyBatis Map 키 소문자. buildSummary(GameReviewController.java:372-390) 가 `stats.get("reviewCount")` camelCase 조회 → null → reviewCount=0 → summary=null. JSP(game-detail.jsp:2188 `if(!summary||!summary.reviewCount)`)는 항상 "아직 평가 없음" 오표시. 서버집계 기능 전면 무력. +- 왜 L1 통과: BibimbapApplicationTests/컨트롤러테스트가 매퍼를 @MockBean 으로 대체 → 실제 Postgres alias 폴딩 미발생. mock-vs-reality 갭. +- 수정: alias 3개 따옴표(`AS "gameId"/"avgRating"/"reviewCount"`). axis alias 는 소문자=AXIS_KEYS 일치라 유지. +- 수정후 검증: 앱 재기동 후 curl 재확인(아래 verified_by_me). + +# Decisions +- 버그 발견으로 "검증 전용·코드무수정 기본" 가정 반전 → task 의 "실제 버그 발견 시 최소수정" 사전승인 하에 수정. 수정 범위: 매퍼 alias 3개 + 뷰 정의(2파일) + 실행DB 재적용. 비파괴(CREATE OR REPLACE, alias quote). +- 회귀테스트: 두 버그 모두 DB-통합 계층(L1 mock 우회). 기존 단위 harness(@MockBean)로 재현 불가 → 자동 회귀 테스트는 별도 L2 contract(dev DB 연동) 인프라 필요. 본 세션은 L3 curl+브라우저 before/after(11/2.8/null → 6/3.5/정상)를 회귀 근거로 삼고, L2 contract 테스트 신설을 open_items 로 권고. +- DDL 적용은 §6 파괴 게이트 비해당: 멱등 CREATE TABLE/ALTER ADD/CREATE OR REPLACE VIEW (DROP/TRUNCATE/rollback 아님). changes 문서서 이미 일반 DDL 분류. → 사용자 재확인 불요. +- detail 라우트 = /game/{id} (numeric games.id). dev 가시게임 id=3. +- L3 브라우저 스모크는 claude-in-chrome 필요 → advisor 미보유 → orchestrator 직접 수행. + +# verified_by_me +- L1: unit+regression — mvn test 43/43 GREEN (GameComment17/GameReview20/Csrf5/App1), 버그수정 후 재실행도 43/43 (verification-advisor 2회 독립 판정). +- DDL: dev 스키마 4객체 psql 검증 (game_review_axes UNIQUE index+CHECK 6종+score 1~5 / game_review_stats 9컬럼 / game_comments.updated_at timestamptz NOT NULL / game_reviews.is_rating_manual boolean DEFAULT false NOT NULL). +- L3 (javascript_tool+curl+psql+JSP정적): XSS 미실행(alert 0회, textContent 텍스트노드) / 재방문 영속(쿠키없는 GET 서버데이터) / 다축 6행+overall 자동(false)·수동(true) / SVG레이더+aria 6축 / 집계 3.5·(6) / 페이지네이션 21건 page0=20 hasMore=true page1=1 / 키보드 roving+preventDefault / C1 disabled+aria-busy / C2 상대시각+title / C4 0/200·0/1000 / B2 2자→400 / A2 테스터·탈퇴마스킹. +- 버그수정 검증: BUG-1 뷰 review_count 11→6, avg 2.8→3.5. BUG-2 summary null→{avgRating:3.5,reviewCount:6,axes}. +- 로그 스캔: clean (앱 기동 로그 ERROR 0, 환경성 WARN만). + +# needs_user_verification +- (선택) claude-in-chrome computer/screenshot 도구가 본 dev 페이지에서 document_idle 미도달로 차단됨 — 시각적 스크린샷 증빙 없음(검증은 DOM/이벤트/서버계약으로 동치 수행). 원인이 WebGL iframe/page idle 휴리스틱인지 사용자 환경 확인 권장(비차단). +- 가동 앱: 본 세션이 stale spring-boot:run(구 JSP)을 현재 빌드로 재기동해 8080에서 실행 중. 사용자가 별도 기동 흐름이 있었다면 인지 필요. + +# graph_refresh +fresh → 후속 없음. graph-refresh-checker 판정: 변경분(매퍼 alias 따옴표 + 뷰 내부 집계로직)이 심볼 시그니처·뷰/테이블/컬럼 토폴로지를 바꾸지 않음(구조 시그널 0). 재생성·삭제 불필요. source_commit 메타 갱신은 선택(필수 아님) — 본 세션 미수행. + +# ended_at +2026-06-22T17:50:00+09:00 + +# open_items +- L2 contract 테스트 신설 권고: game_review_stats 뷰 집계 정합 + GameReviewStatsMapper Map 키를 dev DB 연동으로 가드. BUG-1/2가 L1 @MockBean 사각으로 누출된 근본원인 차단용. (changes 문서 §범위밖/이월 + security checklist 연계) +- 미커밋 잔여 0 목표 — 본 작업 단위 커밋으로 마감. + +# user_signals +positive: + - quote: "dev 픽스처로 자동 로그인 (Recommended) 수락" + note: 제안한 검증 경로/권장안을 1회에 수락(이견 없음). +negative: [] + +# Retrospective +Retrospective: + signals: + positive: + - quote_or_paraphrase: "dev 픽스처로 자동 로그인 (Recommended) 수락" + about: "L3 브라우저 스모크 진입 전 인증 우회 경로로 'dev 픽스처 자동 로그인' 권장안을 제시 → 사용자가 1회에 수락(이견·재지시 0). 통상 검증 인증 셋업은 왕복 협의가 흔한데 권장안 단독 채택됨." + negative: [] + what_went_well: + - "검증 전용 task 였으나 L3 실게임 스모크에서 서버 집계 실버그 2건(뷰 fan-out, 매퍼 alias 케이스 폴딩)을 잡아냄. L1 43/43 GREEN 만 믿지 않고 실제 런타임·DB 까지 내려간 판단이 사용자 노출 직전 차단으로 이어짐." + - "computer/screenshot 도구가 document_idle 미도달로 차단된 상황에서 멈추지 않고 javascript_tool + curl + psql + JSP 정적 조합으로 동치 검증을 구성해 L3 12항목 전수 PASS. 도구 1종 차단을 검증 포기 사유로 삼지 않음." + - "버그 발견 시 task 의 '실버그 발견 시 최소수정' 사전승인 범위 안에서만 비파괴 수정(CREATE OR REPLACE, alias quote)으로 한정. 검증 전용 scope 를 임의 확장하지 않음." + - "advisor 호출/스킵 판단을 즉시 1줄 로그로 남기고(검증 전용 → 설계/구현 advisor skip, verbose Bash 검증만 verification-advisor 위임), 버그 발견 시 'minimal-fix 재평가' 트리거를 사전 명시." + - "수정 후 회귀 근거를 before/after 수치(뷰 11/2.8/null → 6/3.5/정상)로 명시하고, L1 @MockBean 으로 재현 불가한 한계를 인정하며 L2 contract 신설을 open_item 으로 위임. 회귀 가드의 공백을 숨기지 않음." + what_to_improve: + - "BUG-2(매퍼 alias 케이스 폴딩→summary 항상 null)는 서버 집계 기능을 전면 무력화하는 사용자 가시 버그였는데 L1 43건 전부가 매퍼를 @MockBean 으로 대체해 우회했다. DB-통합 계층(매퍼 Map 키, 뷰 집계 정합)에 대한 자동 회귀 가드가 0인 상태가 구조적 사각. L2 contract(dev DB 연동) 부재가 근본원인." + - "stale spring-boot:run(목요일 기동, 구 JSP)이 8080 을 점유한 채로 검증을 시작하면 JSP docBase 가 동결돼 라이브 리로드가 안 되고 현재 빌드가 검증되지 않는다. 검증 진입 전 '가동 중 앱이 현재 빌드인지' 확인하는 절차가 부재했다(재기동으로 해소했으나 절차화 안 됨)." + - "claude-in-chrome computer/screenshot 가 본 dev 페이지(WebGL iframe 추정)에서 document_idle 미도달로 차단되는 원인이 미규명. 시각 증빙이 매번 막히면 L3 효율이 떨어지므로 idle 휴리스틱 우회/대체 증빙 표준이 필요." + memory_candidates: + - name: mock-vs-reality-db-contract-gap + type: feedback + description: "@MockBean 매퍼 대체 단위테스트는 DB-방언 계약 버그(alias 케이스 폴딩·뷰 fan-out)를 구조적으로 놓친다 — DB-통합 계약은 L2 로 가드." + body_draft: | + What: 컨트롤러/애플리케이션 단위테스트가 MyBatis 매퍼를 @MockBean 으로 대체하면, + 실제 Postgres 가 일으키는 계약 결함이 전부 우회된다. 검증된 사각 2종: + 1. 따옴표 없는 SQL alias(AS gameId)는 Postgres 가 소문자로 폴딩(gameid) → MyBatis Map 키 소문자 + → 컨트롤러의 camelCase get("reviewCount") 이 null → summary 전면 무력. + 2. 집계 뷰의 1:N LEFT JOIN fan-out → COUNT/AVG 왜곡(자식행 0일 땐 잠복, 입력 순간 발현). + Why: 두 결함 다 L1 43/43 GREEN 을 통과했다. mock 은 '내가 부른 메서드가 불렸나'만 보고 + 'DB 가 그 SQL 을 우리 기대대로 해석하나'는 검증하지 못한다. 단위 통과율이 높을수록 오히려 + DB-통합 계약 공백이 숨는다. + How to apply: + - 매퍼 메서드를 신규 추가/시그니처 변경하거나 집계 뷰를 정의·수정할 때 L1 GREEN 으로 끝내지 말 것. + - dev DB 연동 L2 contract 로 (a) 매퍼 반환 Map 키가 컨트롤러 조회 키와 정합, (b) 뷰 집계가 + 샘플 데이터에서 손계산과 일치, 를 가드한다. + - SQL alias 는 camelCase 가 필요하면 반드시 큰따옴표("gameId"). 소문자 폴딩 허용 alias 만 무따옴표. + rationale_for_saving: "동일 패턴(매퍼 추가/뷰 집계 변경) 재발 가능. 코드·git log 로 유도 불가(런타임 DB 방언 동작은 관찰로만 드러남). 기존 verification-strategies '신규 컨트롤러/매퍼 의존 변경 시 full test 의무'는 컨텍스트 로드 회귀만 다루고 DB 방언 계약 사각은 미커버." + signal_source: observation + docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md + memory_optional: true + - name: verify-against-current-build-not-stale-server + type: feedback + description: "L3 검증 진입 전 가동 중 앱이 현재 빌드인지 확인 — JSP docBase 동결로 stale spring-boot:run 은 라이브 리로드 불가, 재기동 필수." + body_draft: | + What: bibimbap 직접 실행(spring-boot:run / provided Tomcat)에서 JSP 는 docBase 가 기동 시점에 + 동결돼 라이브 리로드되지 않는다. 과거 세션에 띄워둔 stale 프로세스가 8080 을 점유한 채 검증을 + 시작하면 구 JSP 가 응답하고 현재 빌드는 검증되지 않는다(거짓 PASS/FAIL 위험). + Why: 본 세션은 목요일 기동된 구 JSP 프로세스가 떠 있어, 현재 빌드를 검증하려면 명시적으로 + 재기동해야 했다. flyway/liquibase 부재(local-setup §4)와 같은 결의 함정 — '띄워져 있음'이 + '최신임'을 보장하지 않는다. + How to apply: + - L3/브라우저 스모크 진입 전: 8080 점유 프로세스의 기동 시각·아티팩트가 현재 작업 빌드인지 확인. + - 불일치/불확실하면 현재 빌드로 재기동(JSP 변경분은 재기동 없이 반영 불가). + - DDL 변경분은 별도로 db/apply-local-ddl.sh 로 실행 DB 에 적용(local-setup §4.1). + rationale_for_saving: "검증 세션마다 재발 가능한 함정. JSP docBase 동결·재기동 필요는 코드에 안 적혀 있고 런타임 관찰로만 드러남. local-setup §4.1 은 DDL 적용만 다루고 'stale 프로세스 vs 현재 빌드' 가드는 미기재." + signal_source: observation + docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/usage/local-setup.md + memory_optional: true + - name: recommended-option-accepted-low-friction + type: feedback + description: "검증 인증 우회 같은 비차단 셋업 결정은 (Recommended) 단독안으로 제시하면 왕복 협의 없이 1회 수락되는 경향." + body_draft: | + What: L3 검증 진입을 막는 비차단 셋업(인증 우회 경로 등)에서 'dev 픽스처 자동 로그인 (Recommended)'을 + 단일 권장안으로 제시 → 사용자가 즉시 수락(이견·재지시 0). + Why: 검증 진척을 위한 저위험·가역 셋업 결정은 옵션 나열보다 근거 붙인 권장안 단독 제시가 마찰을 줄였다. + 본질적 산출물 결정이 아니라 검증 수단 결정이라 사용자 비용을 권장안에 위임하는 게 맞았다. + How to apply: + - 가역적·저위험·검증진척용 셋업 결정은 옵션 매트릭스 대신 (Recommended) 단독안 + 한 줄 근거로 제시. + - 단, 산출물 본질·비가역 결정은 이 패턴을 적용하지 말 것(옵션 제시 유지). + rationale_for_saving: "긍정 시그널이 검증한 비자명한 판단(옵션 나열 대신 권장안 단독). 재현성 있음 — 검증 세션마다 인증/픽스처 셋업 결정이 반복됨. 단 적용 경계(가역·저위험 한정)가 핵심이라 기록 가치." + signal_source: positive + docs_sync_target: null + memory_optional: true + protocol_feedback: + - "검증 사다리(verification-strategies §버그 범주→L 레벨)에 '신규 매퍼 메서드/집계 뷰 정의·변경'은 L1 + L2(dev DB contract) 의무로 명시 권고. 현재 표는 '순수 도메인 로직=L1', '외부 API=L1+L2' 만 있고 내부 DB-방언 계약(매퍼 Map 키·뷰 집계)은 L 레벨 매핑 공백. BUG-1/2 가 이 공백으로 누출됨 (structural)." + - "ATP §2.3 시그널 세탁 경계 관점: 본 세션 user_signals 는 positive 1/negative 0 로 기록됐고 회고에서 재검토 결과 세탁 정황은 없음(사용자가 사실·데이터 오류를 잡아낸 발화 없음, 버그 2건은 orchestrator 자가 발견). 다만 '검증 전용 task 에서 서버 실버그가 나왔는데 negative 시그널 0'은 사용자가 결함을 사후 인지하지 못한 정황일 수 있으므로, 검증 세션 회고에서는 '발견 버그 수 vs negative 시그널 수'를 교차 점검하는 절차를 protocol 에 추가 권고." + - "claude-in-chrome 미보유 advisor 상황에서 orchestrator 가 L3 브라우저 스모크를 직접 수행했고 computer 도구 차단을 javascript_tool 등으로 우회했다. document_idle 미도달 차단의 대체 증빙 표준(JS 이벤트/DOM/서버계약 동치)을 verification-strategies L3 항목에 등재 권고(긍정 패턴 보존)." + applied_changes: + - candidate: mock-vs-reality-db-contract-gap + decision: accepted + docs_applied: docs/development/verification-strategies.md (L레벨 표에 'DB-방언 계약' 행 + 'mock-vs-reality' 노트 추가) + - candidate: verify-against-current-build-not-stale-server + decision: accepted + docs_applied: docs/usage/local-setup.md (§4.2 '가동 앱이 현재 빌드인지 확인' 신설) + - candidate: recommended-option-accepted-low-friction + decision: docs-declined + rationale: 'docs_sync_target=null(소프트 작업흐름 패턴). 회고 기록으로 보존, 별도 docs sink 없음. memory 는 사용자 memory 설정 시에만 보조 — 본 세션 memory 미기록(docs-first 단독 마감).' + orchestrator_note: 'memory 기록은 사용자 memory 활성 신호 부재로 미수행(memory_optional). docs-first 로 #1·#2 반영 완료. protocol_feedback 3건은 외부 atp 번들 대상이라 본 repo 미적용(권고 보존).' diff --git a/.atp/work-session/20260622-170857/verification.md b/.atp/work-session/20260622-170857/verification.md new file mode 100644 index 0000000..0901818 --- /dev/null +++ b/.atp/work-session/20260622-170857/verification.md @@ -0,0 +1,61 @@ +--- +phase: verification +agent: verification-advisor +agent_version: 1 +generated_at: 2026-06-22T17:48:00+09:00 +concerns: [] +concerns_checked: true +--- + +# 검증 결과 + +## Acceptance Criteria (입력 받은 그대로 인용) +`export JAVA_HOME=/opt/homebrew/opt/openjdk@21 && ./mvnw -P dev test` 실행 → BUILD SUCCESS + 총 43건 GREEN (Failures 0, Errors 0). "Tests run: N, Failures: F, Errors: E" 합계 라인 인용. 회귀(F/E>0) 시 명확히 FAIL + 실패 테스트명·메시지 인용. + +변경 scope(검증 대상 아님, 맥락): GameReviewStatsMapper.java SQL alias 3개 따옴표 추가(gameId/avgRating/reviewCount), game_review_stats 뷰 정의 변경(DDL, Java 무관). + +## 실행된 전략 + +레지스트리(`docs/development/verification-strategies.md`)는 템플릿 상태(실제 `cmd` 미기재)이고 통합 검증 스크립트(`make verify`/`scripts/verify.sh`)가 부재. AC가 직접 지정한 `./mvnw -P dev test`가 L1(typecheck + 단위/회귀) 통합 실행 수단이다 — Maven test phase가 test-compile(타입체크) → surefire(단위/회귀)를 순차 포함. + +변경 scope = MyBatis mapper SQL alias + 뷰 DDL. 외부 서비스 live contract(L2) 의존 없음 → L2 해당 없음(skip 아님, scope 미매칭). + +| id | cmd | exit | severity | 결과 | +|---|---|---|---|---| +| verify-l1 | `export JAVA_HOME=/opt/homebrew/opt/openjdk@21 && ./mvnw -P dev test` | 0 | blocker | pass | + +분해 결과: + +| 단계 | 결과 | +|---|---| +| L1 typecheck (test-compile) | pass (Nothing to compile - all classes up to date; compile error 0) | +| L1 unit+regression (surefire) | pass (43/43 GREEN) | +| L2 contract | n/a (변경 scope에 외부 의존 계약 없음) | +| 로그 스캔 | clean (FAIL/ERROR/assert 없음; Mockito self-attach + JDK agent warning은 환경성 비기능 경고로 테스트 무관) | + +합계 라인(원문 인용): +- `[INFO] Tests run: 43, Failures: 0, Errors: 0, Skipped: 0` +- `[INFO] BUILD SUCCESS` + +클래스별 분해(원문 인용): +- `GameCommentControllerTest` Tests run: 17, Failures: 0, Errors: 0, Skipped: 0 +- `GameReviewControllerTest` Tests run: 20, Failures: 0, Errors: 0, Skipped: 0 +- `UserControllerCsrfTest` Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 +- `BibimbapApplicationTests` Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 +- 합계 17+20+5+1 = 43 + +## 실패 상세 +없음 (회귀 0건). + +## 종합 판정 +overall: pass +rollback_signal: none + +## Acceptance 매칭 +| criterion | 매칭 전략 | 판정 | +|---|---|---| +| BUILD SUCCESS | verify-l1 | pass (`[INFO] BUILD SUCCESS`) | +| 총 43건 GREEN | verify-l1 | pass (`Tests run: 43`) | +| Failures 0 | verify-l1 | pass (`Failures: 0`) | +| Errors 0 | verify-l1 | pass (`Errors: 0`) | +| 합계 라인 인용 | verify-l1 | pass (`Tests run: 43, Failures: 0, Errors: 0, Skipped: 0`) | diff --git a/.atp/work-session/20260622-180054/implementation/W1-design.md b/.atp/work-session/20260622-180054/implementation/W1-design.md new file mode 100644 index 0000000..ddfa183 --- /dev/null +++ b/.atp/work-session/20260622-180054/implementation/W1-design.md @@ -0,0 +1,438 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-23T00:00:00Z +workstream: W1-거버넌스/RBAC +concerns: + - "PermissionService / PermissionGate 의 신규 메서드 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2)." + - "comment/review 컨트롤러에 PermissionGate(또는 UserPermissionsMapper) 신규 의존이 추가된다 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException." + - "신규 매퍼 SQL(UserPermissionsMapper / PermissionsMapper / UsersMapper.epoch)은 DB-방언 계약(L2) 대상 — camelCase alias 는 반드시 큰따옴표(AS \"permissionKey\")로 감싼다(SQL alias 케이스 폴딩 함정, verification-strategies §33). dev DB contract 미구축은 기존 open item." + - "users 테이블 스키마 변경(role 값집합 확장 + permissions_epoch 컬럼)은 비권위 복원본(db/schema.sql:27) 대상 — 실제 운영 DB 와 대조(pg_dump) 전까지 DDL 의 컬럼 타입은 추론값. 부트스트랩 seed UPDATE 는 운영 maintenance 절차로 분리." +concerns_checked: true +self_verification: + checklist_passed: true +references: + requirements: .atp/work-session/20260622-180054/research/W1-requirements.md + research: null + adrs: + - docs/work-log/2026-06-17-jam-platform-roadmap.md + - docs/work-log/2026-06-17-w3-feature-skeletons.md + - docs/development/verification-strategies.md +--- + +# 설계: W1 — 거버넌스 / RBAC (관리자 콘솔 + 권한 게이트 인터셉터 + 세션 권한 전파) + +## 목표 / 비목표 + +### 목표 (FR/NFR 추적) +- **G1 부트스트랩** (FR-12): 최초 ADMIN 을 DB seed/수동 승격으로 지정. 코드 자동 승격 경로 0. +- **G2 임명** (FR-4): ADMIN 이 USER 를 SUBADMIN 으로 승격. +- **G3 권한 토글 부여** (FR-5): ADMIN 이 SUBADMIN 에게 개별 permission 부여. +- **G4 인터셉터** (FR-9, FR-10, FR-11): `HandlerInterceptor` + `addInterceptors` 로 보호 경로 권한 검사. +- **G5 권한 회수** (FR-5 역방향): 부여한 permission 회수 — 즉시 반영. +- **G6 강등/해임** (FR-6): SUBADMIN → USER, 전 권한 일괄 회수. +- **G7 세션 권한 전파** (FR-14, NFR-보안 세션 무결성): role/권한 변경 후 후속 요청에 즉시 반영. 부여·회수 **대칭**. +- **FR-13 흡수**: comment/review 의 `ROLE_ADMIN.equals(role)` 를 (ADMIN 암묵전권 OR SUBADMIN+`CONTENT_MODERATE`) 게이트로 재정의. ADMIN 통과 동작 회귀 0 보존. +- **카탈로그 동기화 계약** (결정2 난제): 코드 권한 키 상수 ↔ DB `permissions` 행을 부팅 시 검증·시드해 오타·불일치 방어. +- **NFR**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), 세션 고정 방어(`changeSessionId`) 보존, 감사 로그 대칭, 비파괴 마이그레이션. + +### 비목표 (스코프 밖 — 게이트 인프라만 제공) +- 게임잼관리 액션 본체(W2) — `GAME_JAM_MANAGE` 권한 키만 카탈로그에 등록, 보호 경로 등록은 W2 가 수행. +- 포스팅 작성 기능 본체(W3-3) — `POST_WRITE` 권한 키만 등록, 경로 등록은 W3-3 이 수행. +- 심사위원 역할(W2-2), 리뷰어/기술자 배지(W4) — 본 설계는 키 추가 흡수 가능성만 열어둠. +- 권한 변경 이력 열람 UI, 일괄 작업, 사용자 검색 고도화(후속). +- Spring Session 도입 / 세션 저장소 외부화 — 본 설계는 톰캣 in-memory 세션 제약 하에서 회수 즉시성을 달성(아래 §세션 무효화 메커니즘). + +--- + +## 개요 + +bibimbap 는 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation mapper(`@Mapper` + `#{}`) + JSP 스택이다. 현재 권한은 `users.role` 단일 varchar(30) 와 세션 `role` attr(`UserController:508`)로만 표현되며, comment/review 모더레이션은 `ROLE_ADMIN.equals(role)` 하드코딩이고(`GameCommentController:201`, `GameReviewController:419`), 인터셉터·관리자 콘솔은 전무하다. + +본 설계는 **role(ADMIN/SUBADMIN/USER) + user_permissions join** 모델(결정1)을 도입한다. ADMIN 은 전 권한 암묵 보유, SUBADMIN 은 join 에 있는 키만, USER 는 0. 권한 카탈로그는 **DB `permissions` 테이블**(결정2)로 두되, 코드 상수(`PermissionKeys`)와 부팅 시 동기화 검증 계약으로 오타를 방어한다. + +가장 까다로운 두 난제를 다음과 같이 확정한다. + +- **난제1 (결정2 — 코드↔DB 동기화)**: 단일 정의처는 **코드의 `PermissionKeys` enum 상수**다. 부팅 시 `PermissionCatalogVerifier`(`ApplicationRunner`)가 각 enum 키를 DB `permissions` 에 멱등 시드(UPSERT)하고, 카탈로그에서 enum 에 없는 활성 키가 발견되면 경고 로그를 남긴다. 권한 판정 코드는 항상 enum 을 사용하므로 런타임 오타가 컴파일 단계에서 차단된다. +- **난제2 (결정4 — 타 사용자 세션 회수 즉시성)**: Spring Session/SessionRegistry 가 없어 **타 사용자의 HttpSession 객체에 직접 접근할 표준 경로가 없다**(코드 사실: pom.xml 에 spring-session 의존 없음, in-memory 톰캣 세션). 따라서 "타 세션을 직접 무효화"하지 않고 **`users.permissions_epoch` 버전 스탬프 + 요청당 단일 PK 조회 대조**로 회수 즉시성을 보장한다. 권한을 변경하는 모든 콘솔 액션은 대상 사용자의 epoch 를 +1 한다. 게이트는 세션에 캐시된 (권한 집합 + epoch) 를 쓰되, **요청당 1회 `getPermissionsEpoch(userId)`(PK 단일 인덱스 조회, 권한 join 전체 스캔 아님)로 세션 epoch 와 DB epoch 를 대조**한다. 불일치 시 그 자리에서 권한 재로딩 → 세션 갱신 → 새 권한으로 판정. 결과적으로 **부여·회수 모두 다음 요청에서 즉시 반영**(AC-2 충족)되며, 매 요청 권한 전체 조회보다 싸다(epoch 만 조회, 변경 없으면 권한 join 미조회). + +--- + +## 핵심 결정 요약 (전제 — 재논의 금지, 난제는 본 설계가 확정) + +| 결정 | 확정값 | 본 설계의 구체화 | +|---|---|---| +| 결정1 권한모델 | role + user_permissions join | `users.role` 값집합 확장 + `user_permissions(user_id, permission_key)` | +| 결정2 카탈로그 | DB `permissions` 테이블 | **단일 정의처 = 코드 `PermissionKeys` enum**. 부팅 시 `PermissionCatalogVerifier` 가 DB 멱등 시드+검증 | +| 결정3 ADMIN 흡수 | 권한 게이트로 통일 | comment/review `isOperator` → `PermissionGate.canModerate(session)` (ADMIN OR SUBADMIN+CONTENT_MODERATE) | +| 결정4 세션 전파 | 세션 캐시 + 변경 시 무효화 | **`users.permissions_epoch` 스탬프 + 요청당 PK epoch 대조**. mismatch 시 세션 권한 재로딩 | +| 부트스트랩 | DB seed/수동 승격 | `db/bootstrap-admin.sql` 템플릿 + maintenance 절차 문서 | +| 인터셉터 | W1 포함, 보호범위 콘솔/잼관리/포스팅 | URL 패턴 매핑(콘솔 ADMIN) + 게이트 헬퍼(잼관리/포스팅은 W2/W3-3 이 경로 등록) | + +--- + +## 데이터 모델 (DDL) + +> 권위 수준 주의(concern 4): `users` 는 `db/schema.sql:27` 의 **비권위 복원본**. 아래 DDL 은 멱등(`IF NOT EXISTS`/`DO $$ guard`)으로 작성해 `db/apply-local-ddl.sh` 로 실행 DB 에 비파괴 적용한다. 타입은 기존 스타일(`bigint`/`varchar`/`timestamptz`)을 따른다. + +### 신규 파일: `docs/rbac-ddl.sql` (권위 DDL — apply-local-ddl.sh 가 docs/*-ddl.sql 글롭으로 자동 적용) + +```sql +-- W1 거버넌스/RBAC. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용. +-- 기존 데이터(전원 role='USER') 호환 — 추가만, 파괴 없음. + +-- 1) users.role 값집합 확장 (컬럼 신규 아님 — 값 집합만 ADMIN/SUBADMIN/USER 로 확장) +-- 기존 varchar(30) DEFAULT 'USER' 유지. CHECK 제약을 멱등 추가해 오타 role 차단. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_role_check') THEN + ALTER TABLE "users" + ADD CONSTRAINT "users_role_check" + CHECK ("role" IN ('ADMIN', 'SUBADMIN', 'USER')); + END IF; +END +$$; + +-- 2) users.permissions_epoch (결정4 — 세션 권한 전파 버전 스탬프) +-- 회수/부여/임명/강등 시 +1. 인터셉터가 세션 캐시 epoch 와 PK 단일조회로 대조. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "permissions_epoch" bigint DEFAULT 0 NOT NULL; +COMMENT ON COLUMN "users"."permissions_epoch" IS + 'RBAC 권한 변경 버전. 변경 시 +1 → 세션 캐시 epoch 와 mismatch 시 권한 재로딩(회수 즉시성, 결정4)'; + +-- 3) permissions (권한 카탈로그 — 결정2. 코드 PermissionKeys enum 이 단일 정의처, DB 는 멱등 시드) +CREATE SEQUENCE IF NOT EXISTS "permissions_id_seq"; +CREATE TABLE IF NOT EXISTS "permissions" ( + "id" bigint DEFAULT nextval('permissions_id_seq'::regclass) NOT NULL, + "permission_key" character varying(50) NOT NULL, + "display_name" character varying(100) NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "permissions_id_seq" OWNED BY "permissions"."id"; +CREATE UNIQUE INDEX IF NOT EXISTS "ux_permissions_key" + ON "permissions" ("permission_key"); + +-- 4) user_permissions (SUBADMIN 개별 권한 join — 결정1) +CREATE SEQUENCE IF NOT EXISTS "user_permissions_id_seq"; +CREATE TABLE IF NOT EXISTS "user_permissions" ( + "id" bigint DEFAULT nextval('user_permissions_id_seq'::regclass) NOT NULL, + "user_id" bigint NOT NULL, + "permission_key" character varying(50) NOT NULL, + "granted_by" bigint, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "user_permissions_id_seq" OWNED BY "user_permissions"."id"; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_permissions_user_id_fkey') THEN + ALTER TABLE "user_permissions" + ADD CONSTRAINT "user_permissions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + END IF; +END +$$; +-- 같은 사용자에 같은 키 중복 부여 방지(토글 멱등성 보장) +CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_permissions_user_key" + ON "user_permissions" ("user_id", "permission_key"); +CREATE INDEX IF NOT EXISTS "idx_user_permissions_user" + ON "user_permissions" ("user_id"); + +-- 5) rbac_audit_log (NFR-감사로그 — 임명/강등/토글 대칭 기록) +CREATE SEQUENCE IF NOT EXISTS "rbac_audit_log_id_seq"; +CREATE TABLE IF NOT EXISTS "rbac_audit_log" ( + "id" bigint DEFAULT nextval('rbac_audit_log_id_seq'::regclass) NOT NULL, + "actor_id" bigint NOT NULL, -- 변경 수행 ADMIN + "target_id" bigint NOT NULL, -- 변경 대상 사용자 + "action" character varying(30) NOT NULL, -- APPOINT/DEMOTE/GRANT/REVOKE + "permission_key" character varying(50), -- GRANT/REVOKE 시만 + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "rbac_audit_log_id_seq" OWNED BY "rbac_audit_log"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'rbac_audit_log_action_check') THEN + ALTER TABLE "rbac_audit_log" + ADD CONSTRAINT "rbac_audit_log_action_check" + CHECK ("action" IN ('APPOINT', 'DEMOTE', 'GRANT', 'REVOKE')); + END IF; +END +$$; +``` + +### `db/schema.sql` 반영 (dev/live 듀얼 스키마, 최초 기동 시 1회 자동 주입) +- `db/schema.sql` 의 `users` 블록 뒤(`ALTER SEQUENCE "users_id_seq" OWNED BY ...` 직후)에 위 1·2번(role CHECK + permissions_epoch)을 추가. +- `recruit_posts` 블록 뒤에 3·4·5번(permissions / user_permissions / rbac_audit_log)을 신설 블록으로 추가. +- 반영 방식은 `game_reviews`(권위 DDL)가 schema.sql 에 동기화된 선례(schema.sql:115~232)와 동일 — **docs/rbac-ddl.sql 이 권위, schema.sql 은 그 사본**. + +### 부트스트랩 ADMIN seed (G1 / FR-12) +- 신규 파일 `db/bootstrap-admin.sql` (수동 maintenance 전용 — apply-local-ddl.sh 글롭 `docs/*-ddl.sql` 에 안 걸림. 자동 적용 금지가 의도): +```sql +-- 최초 ADMIN 지정. 운영자가 대상 user 를 식별해 1회 수동 실행. +-- 자동 승격 경로 부재(FR-12, AC-6) — 코드/설정 노출 0. +UPDATE "users" SET "role" = 'ADMIN', "permissions_epoch" = "permissions_epoch" + 1 +WHERE "canonical_email" = :'admin_email' AND "is_delete" IS NOT TRUE; +``` +- maintenance 절차는 `docs/usage/` 또는 `docs/development/` 에 1단락 추가(documentation-advisor 소관 — 본 설계는 seed SQL 과 절차 요구만 명시). + +--- + +## 외부 계약 (API) + +> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`). 모든 콘솔 API 는 인터셉터에서 ADMIN 게이트 통과 후 도달. 응답은 기존 컨트롤러 패턴(`Map` + `status`/`message`)을 따른다. + +### 401 vs 403 정책 (확정) +- **미인증**(세션에 `userId` 없음): API 엔드포인트는 **401**(JSON `{status:401, message:"로그인이 필요합니다."}`), 콘솔 **페이지** 진입은 `redirect:/login`. +- **인증·미인가**(로그인됐으나 권한 없음): **403** (JSON `{status:403, message:"권한이 없습니다."}`), 페이지도 403(리다이렉트 아님 — 권한 없음을 로그인으로 오인 유도 방지). +- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`. + +### 콘솔 페이지 (뷰) +| method | path | 권한 | 응답 | +|---|---|---|---| +| GET | `/admin/console` | ADMIN | `admin-console` JSP. 운영진 목록(FR-7) + 카탈로그 + CSRF 토큰 모델 주입 | + +### 콘솔 4액션 (상태변경 API — 전부 CSRF + ADMIN 게이트) +| 액션 | method | path | 요청 | 응답(200) | 에러 | +|---|---|---|---|---|---| +| 임명(G2) | POST | `/admin/users/{userId}/appoint` | (path userId) | `{status:200, message, userId, role:"SUBADMIN"}` | 404(대상 없음), 409(이미 ADMIN/SUBADMIN), 403(CSRF/권한) | +| 권한 토글(G3 부여 / G5 회수) | POST | `/admin/users/{userId}/permissions/{permissionKey}/toggle` | (path) | `{status:200, message, userId, permissionKey, granted:true|false}` | 404(대상/키 없음), 422(대상이 SUBADMIN 아님), 403 | +| 강등/해임(G6) | POST | `/admin/users/{userId}/demote` | (path userId) | `{status:200, message, userId, role:"USER", revokedCount:N}` | 404, 422(이미 USER), 403 | +| 운영진 목록(FR-7) | GET | `/admin/operators` | (없음) | `{status:200, operators:[{userId, displayName, role, permissions:[...]}]}` | 403 | + +- **토글 단일 엔드포인트 채택 근거**: 부여/회수는 대칭 역연산(결정·요구 FR-5 G3/G5)이므로 같은 path 에서 현재 상태를 토글한다. 응답 `granted` 로 결과 방향 명시. 별도 grant/revoke 2엔드포인트 대비 표면 최소, 대칭 보장. +- **ADMIN 권한 부여 불가**(FR-8): appoint 는 USER→SUBADMIN 만. ADMIN role 부여 API 없음(부트스트랩 전용). +- **공통 부작용**: appoint/toggle/demote 는 모두 (a) `users.permissions_epoch += 1` (대상), (b) `rbac_audit_log` insert. demote 는 추가로 `user_permissions` 전건 삭제. + +### 인터셉터 권한 판정 계약 +- 입력: `HttpServletRequest`(세션 + 요청 경로). 출력: `boolean`(true=통과, false=거부 후 응답 작성). +- 판정: 보호 경로 → 필요 권한 키 매핑 → `PermissionGate.has(session, request, requiredKey)`. +- 거부 시 응답: API 경로(`/admin/**`, `/api/**`)는 status JSON, 페이지 경로는 401→redirect / 403→상태코드. + +--- + +## 인터셉터 설계 (G4 / QG-1) + +### 보호 경로 매핑 방식 (택1 확정: **URL 패턴 + 게이트 헬퍼 병용**) +- **콘솔 경로**(`/admin/**`)는 인터셉터의 `addPathPatterns("/admin/**")` 로 **URL 패턴 매핑** → ADMIN 게이트. 이유: 콘솔 경로 전체가 단일 권한(ADMIN only)이고 W1 에서 경로가 확정되므로 URL 패턴이 가장 단순·명시적. +- **잼관리/포스팅 액션**(W2/W3-3 소비)은 본 W1 에서 경로가 미확정이므로 인터셉터에 등록하지 않는다. 대신 **`PermissionGate` 헬퍼**(`gate.require(session, request, PermissionKeys.GAME_JAM_MANAGE)`)를 제공해 W2/W3-3 컨트롤러가 액션 진입부에서 직접 호출. 어노테이션 방식은 신규 커스텀 어노테이션 + ArgumentResolver/AOP 인프라가 필요해 과도(미채택). 핸들러 메타 방식도 동일 이유로 미채택. +- **결정 근거**: 콘솔=URL패턴(경로 확정·단일권한), 소비 액션=게이트 헬퍼(경로 미확정·호출지점 결합). 두 방식의 권한 판정 코어는 동일 `PermissionGate.has(...)` 로 단일화 → 중복 0. + +### 미인증 vs 미인가 응답 정책 (확정 — §외부 계약과 일치) +- 미인증: 페이지 `redirect:/login`, API 401. +- 미인가: 페이지/API 모두 403(리다이렉트 금지). +- 인터셉터는 경로가 `/admin/**` 중 API(`/admin/operators`, `/admin/users/**`)인지 페이지(`/admin/console`)인지로 분기. + +### 결정4(세션 캐시 + epoch 무효화)와의 연동 +- `PermissionGate.has(session, request, key)` 내부: + 1. 세션 `userId` 없음 → 미인증(false, 401/redirect). + 2. 세션 캐시된 `role`·`permissions`(Set)·`permsEpoch` 읽기. + 3. **요청당 1회** `usersMapper.getPermissionsEpoch(userId)` (PK 단일조회) → `dbEpoch`. + 4. `dbEpoch != sessionPermsEpoch` 면: `role` 재조회 + `userPermissionsMapper.listKeys(userId)` 재로딩 → 세션 갱신(`role`, `permissions`, `permsEpoch=dbEpoch`). + 5. 판정: `role == ADMIN` → true(암묵 전권). `role == SUBADMIN && permissions.contains(key)` → true. else false. +- **회수 즉시성 논증(AC-2)**: ADMIN 이 회수하면 대상 `permissions_epoch += 1`. 대상의 다음 요청에서 3→4 단계가 mismatch 를 감지해 권한을 재로딩하므로 회수가 즉시 반영된다. 세션을 직접 무효화하지 않고도(인프라 부재 우회) 회수 우회가 불가능하다. +- **성능 논증(NFR-성능)**: 변경 없는 정상 요청은 epoch PK 조회 1회만 추가(권한 join 미조회). 변경 직후 1회만 재로딩. 매 요청 전체 권한 조회 대비 저비용. + +--- + +## 시퀀스 + +### S1. 임명 → 토글(부여) → 회수 → 강등 + 세션 전파 +``` +[ADMIN 세션] POST /admin/users/42/appoint (CSRF) + → 인터셉터: PermissionGate ADMIN 통과 + → AdminConsoleController.appoint(42) + → CSRF 검증 + → usersMapper.getUser(42) 존재·role==USER 확인 (아니면 409/404) + → usersMapper.updateRole(42, "SUBADMIN") + → usersMapper.bumpPermissionsEpoch(42) # epoch +1 + → auditMapper.insert(actor, 42, "APPOINT", null) + → 200 {role:"SUBADMIN"} + +[ADMIN] POST /admin/users/42/permissions/POST_WRITE/toggle (CSRF) + → AdminConsoleController.togglePermission(42, "POST_WRITE") + → CSRF + 대상 role==SUBADMIN 확인 (아니면 422) + → PermissionKeys.isValid("POST_WRITE") 확인 (아니면 404) + → 현재 보유? userPermissionsMapper.exists(42,"POST_WRITE") + - 미보유 → insert (granted_by=actor) → granted=true + - 보유 → delete → granted=false (회수=대칭 역연산) + → usersMapper.bumpPermissionsEpoch(42) # 부여·회수 둘 다 +1 + → auditMapper.insert(actor, 42, granted?"GRANT":"REVOKE", "POST_WRITE") + → 200 {granted} + +[사용자 42] (다음 요청) GET 포스팅 작성 액션 + → W3-3 컨트롤러 진입부: gate.require(session, request, POST_WRITE) + → epoch mismatch 감지(이전 단계 bump) → 권한 재로딩 + → 부여 상태면 통과 / 회수 상태면 403 ← AC-1 / AC-2 + +[ADMIN] POST /admin/users/42/demote (CSRF) + → AdminConsoleController.demote(42) + → CSRF + role==SUBADMIN 확인 (아니면 422) + → userPermissionsMapper.deleteAllByUser(42) # 전 권한 회수(G6) + → usersMapper.updateRole(42, "USER") + → usersMapper.bumpPermissionsEpoch(42) + → auditMapper.insert(actor, 42, "DEMOTE", null) + → 200 {role:"USER", revokedCount:N} ← AC-3 +``` + +### S2. comment/review 모더레이션 흡수 후 판정 (FR-13) +``` +[사용자 X 세션] DELETE /game/1/comments/5 (CSRF) + → GameCommentController.deleteComment + → CSRF 검증 (기존 보존) + → userId = sessionUserId(session) + → canModify(userId, comment.userId, session): + 작성자 본인? → true + else → permissionGate.canModerate(session, request): + epoch 대조 후 (role==ADMIN) OR (SUBADMIN && perms.contains(CONTENT_MODERATE)) + → 통과 시 삭제, 아니면 403 +``` +- **ADMIN 회귀 보존 논증(AC-7)**: 흡수 후에도 `role==ADMIN` 분기가 `canModerate` 첫 조건이라 기존 ADMIN 통과 동작은 동일하게 유지된다. 기존 테스트 `deleteCommentByOperatorSucceeds`(loginSession 99L/"ADMIN") · `deleteReviewByOperatorSucceeds`(99L/"ADMIN")는 세션 role==ADMIN 이므로 epoch 대조에서 mismatch 가 없으면(테스트 세션 epoch 미설정 시 0==0 정합) ADMIN 분기로 통과. 신규로 SUBADMIN+CONTENT_MODERATE 통과 케이스만 추가된다. + +--- + +## 파일 영향 맵 + +> 소유권 분할 가이드(implementation-advisor 용 worker 단위 후보): +> **U-SCHEMA**(DDL/seed) · **U-DOMAIN**(enum/data/mapper/catalog verifier) · **U-GATE**(PermissionGate/Interceptor/config) · **U-CONSOLE**(콘솔 컨트롤러+JSP) · **U-ABSORB**(comment/review 흡수) · **U-SESSION**(로그인 세션 권한 스냅샷). +> 의존: U-SCHEMA → U-DOMAIN → {U-GATE, U-CONSOLE, U-ABSORB, U-SESSION}. U-GATE 는 U-CONSOLE/U-ABSORB/W2·W3-3 소비의 공통 선행. + +| 변경 유형 | 경로 | 역할 | 소유 | +|---|---|---|---| +| 신규 | `docs/rbac-ddl.sql` | 권위 DDL(permissions/user_permissions/rbac_audit_log/role CHECK/epoch). apply-local-ddl.sh 자동 적용 | U-SCHEMA | +| 신규 | `db/bootstrap-admin.sql` | 최초 ADMIN seed 템플릿(수동 전용, 자동적용 제외 글롭 밖) | U-SCHEMA | +| 수정 | `db/schema.sql` | users 블록에 role CHECK+epoch, 신규 3테이블 블록 추가(rbac-ddl 사본) | U-SCHEMA | +| 신규 | `src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java` | 권한 키 enum **단일 정의처**(GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE + displayName) + `isValid(String)` | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/security/Roles.java` | role 상수(ADMIN/SUBADMIN/USER). comment/review/UserController 중복 상수 단일화 대체 | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/config/PermissionCatalogVerifier.java` | `ApplicationRunner` — 부팅 시 enum→DB permissions 멱등 시드 + 불일치 경고(난제1 동기화 계약) | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PermissionData.java` | permissions 행 POJO(permissionKey/displayName/isActive) | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/data/OperatorView.java` | 운영진 목록 행(userId/displayName/role/permissionKeys) | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PermissionsMapper.java` | `@Mapper` 카탈로그 시드/조회(`#{}`) | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserPermissionsMapper.java` | `@Mapper` user_permissions CRUD(listKeys/exists/insert/delete/deleteAllByUser, `#{}`) | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/RbacAuditMapper.java` | `@Mapper` 감사 로그 insert(`#{}`) | U-DOMAIN | +| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/UsersMapper.java` | `getPermissionsEpoch(userId)`·`bumpPermissionsEpoch(userId)`·`updateRole(userId, role)`·운영진 목록 조회 추가(`#{}`, camelCase alias 큰따옴표) | U-DOMAIN | +| 신규 | `src/main/java/com/pandoli365/bibimbap/security/PermissionGate.java` | 권한 판정 코어 — `has(session, request, key)`/`canModerate(session, request)`/`require(...)` + epoch 대조·재로딩(결정4) | U-GATE | +| 신규 | `src/main/java/com/pandoli365/bibimbap/security/RbacInterceptor.java` | `HandlerInterceptor` — `/admin/**` ADMIN 게이트, 401/403 분기 | U-GATE | +| 신규 | `src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java` | `WebMvcConfigurer.addInterceptors` 로 RbacInterceptor 등록(`/admin/**`) | U-GATE | +| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/AdminConsoleController.java` | 콘솔 페이지(GET /admin/console) + 4액션 API(appoint/toggle/demote/operators) | U-CONSOLE | +| 신규 | `src/main/webapp/WEB-INF/views/admin-console.jsp` | 운영진 목록·임명·토글·강등 폼(CSRF hidden, 표시용 — 게이트 아님) | U-CONSOLE | +| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java` | `isOperator(role)` → `permissionGate.canModerate(session, request)` 흡수(FR-13). PermissionGate 의존 주입 | U-ABSORB | +| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java` | 동일 흡수(FR-13). PermissionGate 의존 주입 | U-ABSORB | +| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/UserController.java` | `saveLoginSession` 에 권한 스냅샷 추가: `permissions`(Set), `permsEpoch`(user.permissions_epoch). role attr 보존 | U-SESSION | +| 수정 | `src/main/java/com/pandoli365/bibimbap/data/UserData.java` | `permissionsEpoch` 필드 + getter/setter 추가(매퍼 alias 매핑용) | U-SESSION | +| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼·PermissionGate `@MockBean` 등록(contextLoads 보존 — verification §30) | (검증) | +| 신규 | `src/test/.../AdminConsoleControllerTest.java` | 4액션 + 401/403/CSRF/422 단위 테스트 | (검증) | +| 신규 | `src/test/.../PermissionGateTest.java` | epoch 대조·재로딩·ADMIN/SUBADMIN/USER 판정·회수 즉시성 단위 | (검증) | +| 수정 | `src/test/.../GameCommentControllerTest.java` | 흡수 회귀: ADMIN 통과 보존 + SUBADMIN+CONTENT_MODERATE 통과 케이스 추가 | (검증) | +| 수정 | `src/test/.../GameReviewControllerTest.java` | 동일 흡수 회귀 | (검증) | + +> SSR 호출지점 전수 확인(verification-strategies §영향맵 SSR 포함): `UserData.permissionsEpoch` 추가는 신규 필드라 기존 호출지점 깨짐 0. role 세션 attr 의 SSR 소비처(JSP `${role}`)는 표시용이며 게이트 아님(NFR-인가경계) — 동작 불변. + +### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지) +```java +// PermissionGate — 권한 판정 코어. session+request 만으로 epoch 대조·판정 가능(최소). +boolean has(HttpSession session, // userId/캐시된 role·permissions·permsEpoch 출처 + HttpServletRequest request, // (미사용 후보 — concern: 응답작성/경로분기 불요하면 제거) + String permissionKey) // 통과에 필요한 권한 키(PermissionKeys 값) +boolean canModerate(HttpSession session, // 동일 세션 출처 + HttpServletRequest request) // (미사용 후보 — concern 동일) +// require: 미인가 시 예외/거부 신호. 호출자(W2/W3-3)가 응답 매핑. +boolean require(HttpSession session, String permissionKey) // 호출지점 결합 게이트 + +// UsersMapper 추가 (@Mapper, #{} only, camelCase alias 는 "AS \"...\"") +long getPermissionsEpoch(long userId) // 요청당 PK 단일조회(결정4 대조 좌변) +int bumpPermissionsEpoch(long userId) // 변경 시 +1(부여·회수·임명·강등 공통) +int updateRole(long userId, String role) // 임명/강등 role 전이 +List listOperators() // FR-7 운영진 목록(role IN (ADMIN,SUBADMIN)) + +// UserPermissionsMapper (@Mapper, #{} only) +List listKeys(long userId) // 세션 권한 재로딩 소스 +boolean exists(long userId, String permissionKey) // 토글 방향 결정 +int insert(long userId, String permissionKey, long grantedBy) // 부여 +int delete(long userId, String permissionKey) // 회수 +int deleteAllByUser(long userId) // 강등 시 전건 회수 +``` +> ⚠️ inflate 마킹(concern 1): `PermissionGate.has`/`canModerate` 의 `request` 파라미터는 "거부 응답을 게이트가 직접 작성하는가, 아니면 호출자가 작성하는가"에 따라 사용 여부가 갈린다. 본 설계는 **거부 응답을 호출자(인터셉터/컨트롤러)가 작성**하고 게이트는 boolean 만 반환하도록 권장 → 그 경우 `request` 는 제거 대상. 구현 1보에서 boolean-only 로 시작하고, 게이트가 응답을 직접 써야 할 필요가 확정되면 그때 `request` 추가(최소 인자 원칙). + +--- + +## 대안 비교 + +| 주제 | 안 | 장점 | 단점 | 채택 | +|---|---|---|---|---| +| 세션 회수 즉시성 | (A) epoch 스탬프 + 요청당 PK 대조 | in-memory 세션 제약 우회, 회수 즉시, 저비용 | users 컬럼 1개 추가 | **채택** | +| | (B) Spring Session + SessionRegistry 로 타 세션 직접 invalidate | 표준적 | 신규 의존·세션 저장소 외부화·WAR 설정 대공사, W1 스코프 초과 | 기각 | +| | (C) 매 요청 권한 전체 DB 조회 | 단순 | 모든 요청에 join 스캔(성능), 캐시 이점 0 | 기각 | +| 카탈로그 정의처 | (A) 코드 enum 단일 + DB 멱등 시드/검증 | 컴파일 타임 오타 차단, 운영 카탈로그 가시성 | 부팅 verifier 1개 | **채택** | +| | (B) DB 단독(코드 문자열) | 운영자 즉시 추가 | 권한 키 오타 런타임까지 잠복(난제) | 기각 | +| 권한 토글 API | (A) 단일 toggle 엔드포인트 | 부여·회수 대칭 보장, 표면 최소 | 현재 상태 조회 1회 | **채택** | +| | (B) grant/revoke 2엔드포인트 | 의도 명시적 | 대칭 책임 분산, 경로 2배 | 기각 | +| 보호 경로 매핑 | (A) 콘솔=URL패턴 + 소비=게이트 헬퍼 | 경로 확정/미확정 각각 최적, 코어 단일 | 두 진입 방식 | **채택** | +| | (B) 커스텀 어노테이션 + AOP | 선언적 | 신규 인프라 과도, W1 2~3 경로엔 오버엔지니어링 | 기각 | + +--- + +## 롤아웃 / 마이그레이션 + +### 순서 (NFR-운영) +1. **스키마 적용**: `docs/rbac-ddl.sql` → `db/apply-local-ddl.sh` (로컬) / 운영은 동일 멱등 DDL 수동 적용. 기존 전원 role='USER' 호환(추가만, 파괴 0). `permissions_epoch` DEFAULT 0 → 기존 사용자 세션 epoch(미설정=0)와 정합. +2. **코드 배포**: PermissionKeys/PermissionGate/Interceptor/콘솔/흡수. 부팅 시 `PermissionCatalogVerifier` 가 permissions 카탈로그 시드. +3. **부트스트랩 ADMIN**: `db/bootstrap-admin.sql` 로 최초 ADMIN 1회 수동 지정(epoch +1 포함). 이후 콘솔 진입 가능. +4. 인터셉터 활성 → 보호 경로 게이트 발효. + +### 역호환 +- 기존 USER: role/세션 attr 불변, comment/review 작성·본인 수정 동작 불변(canModify 의 작성자 본인 분기 보존). +- 기존 ADMIN(부트스트랩 전): 흡수 후에도 `role==ADMIN` 분기로 모더레이션 통과 보존(AC-7). +- 세션 attr `role` 보존 — 신규 `permissions`/`permsEpoch` attr 추가만. JSP `${role}` 표시 불변. + +### 롤백 +- 코드 롤백: 흡수 전 `isOperator(role)=ROLE_ADMIN.equals(role)` 로 되돌리면 comment/review 는 ADMIN-only 로 복귀(SUBADMIN 모더레이션만 사라짐). 인터셉터 미등록 시 콘솔 경로는 노출되나 콘솔 컨트롤러 자체가 ADMIN 게이트를 내부에서도 호출하므로 안전. +- 스키마 롤백: 신규 테이블/컬럼은 추가 전용이라 drop 없이 잔존해도 무해(비파괴). 필요 시 명시적 DROP 은 별도 maintenance. + +--- + +## AC 매핑 + +| AC | 요구 | 만족 설계 요소 | 비고 | +|---|---|---|---| +| AC-1 | 부여한 POST_WRITE 로 포스팅 액션 통과 | toggle 부여 → epoch bump → 대상 다음 요청에서 gate 재로딩 → 통과 | S1 | +| AC-2 | **회수 시 다음 요청부터 403(즉시)** | toggle 회수 → epoch bump → 대상 다음 요청 gate epoch mismatch → 권한 재로딩 → 403. **타 세션 직접 무효화 없이 epoch 대조로 회수 우회 차단** | §인터셉터-결정4 연동 논증, S1 | +| AC-3 | 강등 시 전 권한 회수 + 목록 미표시 | demote: deleteAllByUser + updateRole(USER) + epoch bump → listOperators 는 role IN (ADMIN,SUBADMIN) 만 | S1 | +| AC-4 | 비-ADMIN 콘솔 접근 차단 | RbacInterceptor `/admin/**` ADMIN 게이트 → 403(페이지)/401(미인증 redirect) | §인터셉터 | +| AC-5 | 콘솔 상태변경 CSRF 없으면 403 | 4액션 전부 `CsrfTokens.isValid` 선검증 → 403 + errorBody | §외부계약 공통 | +| AC-6 | 부트스트랩 ADMIN 만 진입, 자동승격 부재 | appoint 는 SUBADMIN 까지만(FR-8), ADMIN 부여 API 없음. 최초 ADMIN=수동 seed | §부트스트랩 | +| AC-7 | **모더레이션: ADMIN+CONTENT_MODERATE 통과 + ADMIN 회귀 보존(W3-2 PASS)** | canModerate 첫 분기 role==ADMIN → 기존 테스트(deleteCommentByOperatorSucceeds/deleteReviewByOperatorSucceeds, 세션 ADMIN) 통과 보존. SUBADMIN+CONTENT_MODERATE 케이스 신규 추가 | S2 논증 | +| AC-8 | 임명/토글/회수/강등 감사 대칭 기록 | rbac_audit_log: APPOINT/GRANT/REVOKE/DEMOTE 4액션 전부 insert | §데이터모델 5 | +| AC-9 | 권한 SQL `${}` 없음 | 신규 매퍼 전부 `#{}` 바인딩만, `${}` 0 | §파일영향맵 매퍼 | + +--- + +## 검증 포인트 (verification-advisor 점검 대상) + +> L레벨 매핑(verification-strategies.md): 인증/인가/롤 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무. + +### 회귀·게이트 검증 (시나리오) +- **VP-1 (AC-7 회귀, L1)**: `GameCommentControllerTest.deleteCommentByOperatorSucceeds` + `GameReviewControllerTest.deleteReviewByOperatorSucceeds` 가 흡수 후에도 PASS(ADMIN 세션 모더레이션 통과 보존). revert 흡수 시에도 PASS, 흡수 후 PASS — 동작 불변. +- **VP-2 (AC-2 회수 즉시성, L1+L3)**: PermissionGateTest — epoch mismatch 시 권한 재로딩 후 회수된 키가 false. L3 스모크: 부여→토글 회수→대상 다음 요청 403. +- **VP-3 (AC-4 인터셉터 게이트, L1+L3)**: 비-ADMIN `/admin/**` 접근 → 403, 미인증 → 401/redirect. +- **VP-4 (AC-5 CSRF, L1)**: 4액션 CSRF 누락 → 403 + mapper 미호출(기존 `deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용). +- **VP-5 (DB-방언 계약, L2)**: 신규 매퍼 반환 Map/POJO 키가 컨트롤러 조회 키와 정합(camelCase alias 큰따옴표 확인) + listOperators 집계가 샘플 데이터와 일치. +- **VP-6 (contextLoads, L1)**: `BibimbapApplicationTests` 에 신규 매퍼·PermissionGate @MockBean 등록 후 PASS(verification §30). + +### 집합 전수 체크 AC (design-advisor 집합 전수 패턴 — 시점·표현 self-audit 적용) +> self-audit: 아래 카운트는 모두 **본 설계가 신규 생성하는 정적 산출물**이며 verification 시점까지 본 워크스트림 외 변경 주체가 없다(시점 안정). 표현은 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK 목록/audit action 처럼 **구조적 불변식**에 앵커한다. + +- **AC-T1 권한 카탈로그 전수 3건 정합** — `PermissionKeys` enum 멤버 수 == DB 시드 키 수 == 3 (GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE). 검증: enum 멤버 `grep -c` == 3 AND PermissionCatalogVerifier 시드 대상 == enum 전체(코드상 enum values() 순회이므로 멤버 추가 시 자동 동기 — 불변식). 키 추가/삭제 누락을 갯수 1로 동시 커버. +- **AC-T2 콘솔 상태변경 액션 전수 4건 CSRF 가드** — AdminConsoleController 의 상태변경 핸들러(appoint/toggle/demote 및 추가분) 전수에 `CsrfTokens.isValid` 선검증 존재: `grep -c 'CsrfTokens.isValid' AdminConsoleController.java` == 상태변경 핸들러 수(≥3, GET operators 제외). 핸들러 추가 시 가드 누락을 동시 검출. +- **AC-T3 감사 action 전수 4종 기록** — `rbac_audit_log_action_check` CHECK 의 IN 목록(APPOINT/DEMOTE/GRANT/REVOKE) 4종 == 콘솔 액션이 insert 하는 action 종류 집합. 검증: DDL CHECK IN 항목 4 AND auditMapper insert 호출지점이 4종 전수 사용(임명/강등/부여/회수 대칭, AC-8). +- **AC-T4 epoch bump 전수** — 권한을 변경하는 모든 콘솔 액션(appoint/toggle/demote)이 `bumpPermissionsEpoch` 를 호출: `grep -c 'bumpPermissionsEpoch' AdminConsoleController.java` == 권한변경 핸들러 수. 1건이라도 누락 시 회수 우회 보안결함(AC-2 위반) → FAIL. **이 전수 AC 가 결정4 즉시성의 핵심 가드**. +- **AC-T5 권한 SQL `${}` 0건** — 신규 매퍼 4개(PermissionsMapper/UserPermissionsMapper/RbacAuditMapper/UsersMapper 추가분)에 `${` 매치 0: `grep -rc '\${' <매퍼 4파일>` == 0 (AC-9). + +--- + +## 잔여 오픈 질문 +없음(0). 결정1~4 전제 고정, 두 난제(카탈로그 동기화·세션 무효화)는 본 설계가 구체 메커니즘으로 확정. 시그니처 inflate 위험·신규 매퍼 의존 full-test·DB-방언 L2·users 스키마 권위 수준은 오픈 질문이 아니라 **구현 단계 점검 항목**으로 `concerns` 에 이관. diff --git a/.atp/work-session/20260622-180054/implementation/W1-impl-log.md b/.atp/work-session/20260622-180054/implementation/W1-impl-log.md new file mode 100644 index 0000000..e9225b8 --- /dev/null +++ b/.atp/work-session/20260622-180054/implementation/W1-impl-log.md @@ -0,0 +1,82 @@ +--- +phase: implementation +agent: implementation-advisor +agent_version: 1 +generated_at: 2026-06-23T00:00:00Z +workstream: W1-거버넌스/RBAC +concerns: [] +concerns_checked: true +workers_spawned: 22 +planned_workers: 24 +actual_workers: 22 +self_verification: + checklist_passed: true + unused_diagnostics: 0 + full_test: "BUILD SUCCESS — 65 tests, 0 failures, 0 errors, 0 skipped" +--- + +# W1 구현 로그 — 거버넌스/RBAC + +## 구현 순서 (설계 의존 준수) +U-SCHEMA → U-DOMAIN → U-GATE(+verifier) → {U-CONSOLE, U-ABSORB, U-SESSION} → 테스트. +의존 있는 파일(PermissionGate 소비 컨트롤러/테스트)은 선행 완료 후 spawn(병렬 충돌 0). + +## worker 분할 (1파일 1worker, 소유권 겹침 0) +| worker | 파일 | 유형 | 결과 | +|---|---|---|---| +| w-001 migration-writer | docs/rbac-ddl.sql, db/bootstrap-admin.sql, db/schema.sql | create×2/modify | OK (멱등 DDL, 글롭 분리 검증) | +| w-002 | security/PermissionKeys.java | create | OK (3멤버+isValid) | +| w-003 | security/Roles.java | create | OK | +| w-004 | data/PermissionData.java | create | OK | +| w-005 | data/OperatorView.java | create | OK | +| w-006 | mapper/PermissionsMapper.java | create | OK (alias 큰따옴표, ON CONFLICT) | +| w-007 | mapper/UserPermissionsMapper.java | create | OK (#{} only) | +| w-008 | mapper/RbacAuditMapper.java | create | OK | +| w-009 | mapper/UsersMapper.java | modify | OK (epoch/role/listOperators, alias 큰따옴표) | +| w-010 | config/PermissionCatalogVerifier.java | create | OK (+advisor null-guard 보강) | +| w-011 | security/PermissionGate.java | create | OK (+advisor isAdmin/isAuthenticated 추가) | +| w-012 | security/RbacInterceptor.java | create | OK (401/403/redirect 분기) | +| w-013 | config/InterceptorConfig.java | create | OK (/admin/**) | +| w-014 | controller/AdminConsoleController.java | create | OK (5엔드포인트, CSRF×3, bump×3, audit 4종) | +| w-015 | webapp/.../admin-console.jsp | create | OK (scriptlet+HtmlUtils escape, CSRF) | +| w-016 | controller/api/GameCommentController.java | modify | OK (흡수, isOperator/ROLE_ADMIN/sessionRole 삭제) | +| w-017 | controller/api/GameReviewController.java | modify | OK (흡수, 동일) | +| w-018 | controller/api/UserController.java | modify | OK (세션 권한 스냅샷, 생성자 +UserPermissionsMapper) | +| w-019 | data/UserData.java | modify | OK (permissionsEpoch) | +| w-020 | test/BibimbapApplicationTests.java | modify | OK (@MockBean ×4 추가) | +| w-021 | test/AdminConsoleControllerTest.java | create | OK (13 케이스) | +| w-022 | test/security/PermissionGateTest.java | create | OK (7 케이스, AC-2 회수) | +| w-023 | test/GameCommentControllerTest.java | modify | OK (게이트 mock + SUBADMIN 신규) | +| w-024 | test/GameReviewControllerTest.java | modify | OK (동일) | + +## advisor 직접 처리 (worker 미spawn — planned 24 vs actual 22) +1. **PermissionGate.isAdmin/isAuthenticated 추가** (w-011 산출 후 advisor Edit): 인터셉터가 ADMIN-only 게이트를 epoch 모델로 단일 소스화하려면 게이트에 메서드가 필요. 단일 파일·소수 라인 편집이라 신규 worker spawn 불필요(계량: 1파일 <20줄). +2. **PermissionCatalogVerifier null-guard** (advisor Edit): mock listActiveKeys() null 반환 시 contextLoads NPE 방지. 1파일 3줄. +3. **UserControllerCsrfTest 생성자 인자 보정** (advisor Edit): 영향 맵 밖 발견 파일(discovered dependency). UserController 생성자 변경(+UserPermissionsMapper)으로 기존 테스트 `new UserController(2-arg)` 컴파일 깨짐 → 3-arg + @Mock 추가. 2파일 위치 <4줄 기계적 수정 → advisor 직접(계량: <500줄, 파일<8). + +## Bash 단계 (advisor 직접) +- `./mvnw -o compile` (중간 검증, U-DOMAIN+gate 후) → EXIT 0. +- `./mvnw -o test` (full) → **BUILD SUCCESS, 65 tests, 0 fail/error/skip**. + - PermissionGateTest 7, AdminConsoleControllerTest 13, GameCommentControllerTest 18, GameReviewControllerTest 21, UserControllerCsrfTest 5, BibimbapApplicationTests(contextLoads) 1. +- 빌드 경고: `@MockBean` deprecation(Spring Boot 3.4+)만 — 기존 8필드에도 이미 존재하는 프로젝트 관례. unused/dead 경고 0. +- 환경: JAVA_HOME=/opt/homebrew/opt/openjdk@21 (셸 PATH 미설정 → export 로 해결). + +## 설계 concerns 처리 결과 (구현 점검 항목) +1. **시그니처 inflate(concern 1)**: PermissionGate `has`/`canModerate` 의 `request` 파라미터 **제거 확정**. 게이트는 boolean 만 반환, 거부응답은 호출자(인터셉터/컨트롤러)가 작성. 흡수 호출지점 `permissionGate.canModerate(session)` (request 없음). `HttpServletRequest` import 0 (grep 확인). dead parameter 0. +2. **@MockBean / full-test(concern 2)**: BibimbapApplicationTests 에 PermissionsMapper/UserPermissionsMapper/RbacAuditMapper/PermissionGate @MockBean 4개 등록. full `./mvnw -o test` 실행 — contextLoads NoSuchBeanDefinitionException 없음(verifier null-guard 로 ApplicationRunner NPE 도 방지). +3. **DB-방언 L2(concern 3)**: 신규 매퍼 camelCase alias 전부 큰따옴표(`AS "permissionKey"` 등). `${}` 0건(PermissionsMapper/UserPermissionsMapper/RbacAuditMapper/UsersMapper 신규분 grep). dev DB contract 실행 검증은 verification-advisor + DDL 적용 후 가능(기존 open item). +4. **users 스키마 권위 수준(concern 4)**: DDL 멱등(IF NOT EXISTS / DO $$ guard). docs/rbac-ddl.sql 권위, db/schema.sql 사본 반영. bootstrap seed 는 글롭 밖(db/bootstrap-admin.sql) 수동 전용. + +## 정적 AC 게이트 (구현 시점 self-check — 최종 판정은 verification) +- AC-T1: PermissionKeys 멤버 3 (grep -c = 3). +- AC-T2: AdminConsoleController CsrfTokens.isValid = 3 (appoint/toggle/demote). +- AC-T4: bumpPermissionsEpoch = 3 (동일 핸들러). +- AC-T3/AC-8: audit action 리터럴 APPOINT/DEMOTE/GRANT/REVOKE 4종 전수. +- AC-T5/AC-9: 신규 매퍼 4파일 `${` = 0. + +## DDL 적용 대기 (orchestrator 게이트 필요) +- `db/apply-local-ddl.sh` **미실행**(금지 준수). docs/rbac-ddl.sql 작성까지만. +- orchestrator 가 게이트 후: `db/apply-local-ddl.sh docs/rbac-ddl.sql` (자동 글롭) + 최초 ADMIN 은 `psql -v admin_email=... -f db/bootstrap-admin.sql` 수동. + +## 미해결 이슈 +없음. 컴파일·full test 통과. DDL 실DB 적용/방언 L2 실행 검증은 orchestrator 게이트 + verification-advisor 영역. diff --git a/.atp/work-session/20260622-180054/implementation/ownership.md b/.atp/work-session/20260622-180054/implementation/ownership.md new file mode 100644 index 0000000..a50a555 --- /dev/null +++ b/.atp/work-session/20260622-180054/implementation/ownership.md @@ -0,0 +1,64 @@ +--- +phase: implementation +agent: implementation-advisor +agent_version: 1 +generated_at: 2026-06-23T00:00:00Z +workstream: W1-거버넌스/RBAC +--- + +# 파일 소유권 맵 (W1 — RBAC/거버넌스) + +의존 순서: U-SCHEMA → U-DOMAIN → {U-GATE, U-CONSOLE, U-ABSORB, U-SESSION}. +U-GATE 는 U-CONSOLE/U-ABSORB 의 공통 선행(컴파일 의존: PermissionGate 타입). +따라서 spawn 그룹: +- 그룹0 (병렬): U-SCHEMA(migration-writer) + U-DOMAIN 데이터/매퍼/enum (code-writer 다수) — 단 PermissionGate 미존재로 U-GATE/U-CONSOLE/U-ABSORB 는 대기. +- 의존 정밀화: U-DOMAIN 산출(PermissionKeys/Roles/매퍼/data) 완료 후 U-GATE → 완료 후 U-CONSOLE/U-ABSORB/U-SESSION + 테스트. + +## 시그니처 inflate 결정 (concern 1 — request 제거) +PermissionGate 거부 응답은 호출자(인터셉터/컨트롤러)가 작성. 게이트는 boolean 반환. +→ `request` 파라미터 전부 제거. 확정 시그니처: +- `boolean has(HttpSession session, String permissionKey)` +- `boolean canModerate(HttpSession session)` +- `boolean require(HttpSession session, String permissionKey)` (require 도 boolean; 호출자 매핑. 실질 has 위임) +흡수 호출지점: `permissionGate.canModerate(session)` (request 인자 없음). + +## 소유권 테이블 + +| 파일 | 담당 worker | worker id | 변경 유형 | 의존 | +|---|---|---|---|---| +| docs/rbac-ddl.sql | migration-writer | w-001 | create | - | +| db/bootstrap-admin.sql | migration-writer | w-001 | create | - | +| db/schema.sql | migration-writer | w-001 | modify | - | +| src/main/java/.../security/PermissionKeys.java | code-writer | w-002 | create | - | +| src/main/java/.../security/Roles.java | code-writer | w-003 | create | - | +| src/main/java/.../data/PermissionData.java | code-writer | w-004 | create | - | +| src/main/java/.../data/OperatorView.java | code-writer | w-005 | create | - | +| src/main/java/.../mapper/PermissionsMapper.java | code-writer | w-006 | create | PermissionData | +| src/main/java/.../mapper/UserPermissionsMapper.java | code-writer | w-007 | create | - | +| src/main/java/.../mapper/RbacAuditMapper.java | code-writer | w-008 | create | - | +| src/main/java/.../mapper/UsersMapper.java | code-writer | w-009 | modify | OperatorView | +| src/main/java/.../config/PermissionCatalogVerifier.java | code-writer | w-010 | create | PermissionKeys, PermissionsMapper | +| src/main/java/.../security/PermissionGate.java | code-writer | w-011 | create | Roles, UsersMapper, UserPermissionsMapper, PermissionKeys | +| src/main/java/.../security/RbacInterceptor.java | code-writer | w-012 | create | Roles, PermissionGate? (no — ADMIN role 직접 검사) | +| src/main/java/.../config/InterceptorConfig.java | code-writer | w-013 | create | RbacInterceptor | +| src/main/java/.../controller/AdminConsoleController.java | code-writer | w-014 | create | PermissionKeys, Roles, mappers, CsrfTokens | +| src/main/webapp/WEB-INF/views/admin-console.jsp | code-writer | w-015 | create | - | +| src/main/java/.../controller/api/GameCommentController.java | code-writer | w-016 | modify | PermissionGate | +| src/main/java/.../controller/api/GameReviewController.java | code-writer | w-017 | modify | PermissionGate | +| src/main/java/.../controller/api/UserController.java | code-writer | w-018 | modify | UserPermissionsMapper, UserData | +| src/main/java/.../data/UserData.java | code-writer | w-019 | modify | - | +| src/test/java/.../BibimbapApplicationTests.java | code-writer | w-020 | modify | new mappers, PermissionGate | +| src/test/.../AdminConsoleControllerTest.java | code-writer | w-021 | create | AdminConsoleController | +| src/test/.../PermissionGateTest.java | code-writer | w-022 | create | PermissionGate | +| src/test/.../GameCommentControllerTest.java | code-writer | w-023 | modify | GameCommentController(+gate) | +| src/test/.../GameReviewControllerTest.java | code-writer | w-024 | modify | GameReviewController(+gate) | + +## 불변식 점검 +- 동일 파일 1 worker 원칙 준수 (UsersMapper 는 w-009 단독, UserData 는 w-019 단독). +- 의존 있는 파일은 선행 그룹 완료 후 spawn (PermissionGate 의존 컨트롤러/테스트는 w-011 후). +- 스키마/seed/마이그레이션은 migration-writer(w-001) 로 격리. + +## 계량 판단: 직접 실행 vs worker +파일 25개, 신규 코드 다수(enum/mapper/gate/interceptor/controller/jsp/test). 500줄 초과 확실. +→ worker 병렬화 이득 있음. planned_workers = 24 (논리 단위). 단 advisor 직접 실행으로 + 자잘한 의존 파일은 묶을 수 있음 (실제 spawn 수는 report 에 기록). diff --git a/.atp/work-session/20260622-180054/report.md b/.atp/work-session/20260622-180054/report.md new file mode 100644 index 0000000..37cc08f --- /dev/null +++ b/.atp/work-session/20260622-180054/report.md @@ -0,0 +1,86 @@ +--- +schema_version: 1 +sid: 20260622-180054 +started_at: 2026-06-22T18:00:54+09:00 +ended_at: +user_request: "남은 W 작업들 우선 전체 구현하려는데 실현가능성 있어?" +mode: feasibility-assessment +--- + +# Summary + +feasibility 질문 — 코드 변경 없음. 남은 W 워크스트림 전체를 한 번에 구현하는 것의 실현가능성 판정. + +현 상태 (코드 grounding): +- 구현 완료: **W3-2 댓글/리뷰만** (GameReviewsMapper/GameReviewStatsMapper/GameReviewController 존재) +- 전부 부재: W1(Interceptor 0) · W2-1~W2-6 게임잼(jam 0) · W3-1 태그(tags 0) · W3-3 포스팅(board/posts 0) · W3-4 메인허브 · W3-5 Unity업로드(deferred) · W4 배지(badge 도메인 0) +- 규모감: controller 9 · mapper 9 (소~중형 레거시 Spring Boot+MyBatis+JSP) + +판정: **"전체 한 번에"는 비현실적**. 사유 — 설계 미완(W3 윤곽 카탈로그 깊이, 미결질문 미해소 / W1·W2·W4 로드맵 깊이만), QG-1/2/3 미결, W3-5 선행 조사 필요, 외부 fetch SSRF 표면(W3-3). 의존 사슬상 순차 + design-gate 진행이 현실 경로. + +# Invocations + +[] + +# Advisor Invocation Decision Log +# 각 advisor 호출/스킵 판단 즉시 1줄 append + +- advisor: requirements-advisor + decision: skip + rationale: '로드맵+W3 골자 카탈로그에 분해·의존·미결이 이미 확정 기록됨. feasibility 판정에 재분해 불필요.' + checked_at: 2026-06-22T18:01:00+09:00 +- advisor: research-advisor + decision: skip + rationale: 'graphify-lookup 대신 orchestrator 직접 grounding 스캔으로 현 코드 상태(jam/interceptor/tags/board/badge 부재, review 존재) 확인 완료. 외부 자료 불요.' + checked_at: 2026-06-22T18:01:30+09:00 +- advisor: design/implementation/verification + decision: defer + rationale: 'feasibility 질문 단계 — 구현 착수 아님. 사용자 scope 결정(plan gate) 후 해당 W 착수 시 호출.' + checked_at: 2026-06-22T18:02:00+09:00 +- advisor: requirements-advisor (W1) + decision: call + rationale: '사용자 전략 = W1부터 의존 순차. W1은 로드맵 깊이뿐 — 권한 모델(단일 role→권한부여형 불가)·QG-1·기존 임시 ROLE_ADMIN 흡수 등 결정 미확정. design 진입 전 오픈질문 0 필요.' + checked_at: 2026-06-23T09:00:00+09:00 + model_choice: { tier: inherit, rationale: 'foundational RBAC 권한 모델 결정은 결과 파급 큼 — parent(opus) 상속 유지' } + +- advisor: design-advisor (W1) + decision: call + rationale: 'requirements 확정(FR14/NFR9/AC9, 오픈질문 0). 4개 결정 사용자 확정 — 2개 비권장(DB카탈로그·세션캐시무효화)이 설계 난제. DDL/계약/인터셉터/세션무효화 설계 필요.' + checked_at: 2026-06-23T09:15:00+09:00 + model_choice: { tier: inherit, rationale: 'foundational RBAC 설계 — 세션 무효화·인터셉터 매핑 복잡. parent(opus) 상속' } + +# W1 확정 결정 (사용자 2026-06-23) +decisions_W1: + - 결정1 권한모델: role(ADMIN/SUBADMIN/USER) + user_permissions join [권장 채택] + - 결정2 카탈로그: DB permissions 테이블 [비권장 선택 — 코드↔DB 키 동기화 계약 필요] + - 결정4 ADMIN흡수: 권한 게이트 통일(ADMIN 암묵전권 OR SUBADMIN+CONTENT_MODERATE) [권장 채택 — W3-2 회귀검증] + - 결정7 세션전파: 세션 캐시 + 변경 시 무효화 [비권장 선택 — 타 사용자 세션 무효화 메커니즘이 보안 난제] + - 결정3 부트스트랩: DB seed/수동 승격 [requirements 권장 — 미질문, 안전기본] + - 결정5 인터셉터: W1 포함, 보호범위 콘솔(ADMIN)/잼관리/포스팅 [QG-1 확정] + +# user_signals +positive: + - "'W1부터 의존 순차'(Recommended) 1회 만에 수락 — 권장안 정합" + - "W1 4개 결정 1배치로 확정 — 2개는 비권장이지만 명확한 의지(유연성·성능 우선)" +negative: [] + +# Decisions + +- 이번 턴은 코드 변경 0 — feasibility 판정 + 현실 경로 제시 + 사용자 scope 결정 요청(plan gate). + +# verified_by_me + +- (코드 변경 없음 — L1/L2 해당 없음) +- grounding: rg 스캔으로 jam/interceptor/tags/board/badge 도메인 부재, review 도메인 존재 확인 + +# needs_user_verification + +- scope 결정: 전체 일괄 vs 의존 사슬 순차(권장) + +# open_items + +- (없음 — git clean 유지) + +# graph_refresh + +- (코드 변경 없음 — 세션 종료 시 판정) diff --git a/.atp/work-session/20260622-180054/research/W1-requirements.md b/.atp/work-session/20260622-180054/research/W1-requirements.md new file mode 100644 index 0000000..9739bfa --- /dev/null +++ b/.atp/work-session/20260622-180054/research/W1-requirements.md @@ -0,0 +1,174 @@ +--- +phase: requirements +agent: requirements-advisor +agent_version: 1 +generated_at: 2026-06-23T00:41:35Z +workstream: W1-거버넌스/RBAC +concerns: + - "권한 모델 형태(결정1)·관리자 부트스트랩(결정3)·인터셉터 보호범위(결정5)는 권장안을 제시했으나 사용자 최종 확정 필요 — AskUserQuestion 서브에이전트 불가로 orchestrator 경유 확인 요함" + - "RBAC 도입은 신규 DB 스키마(permissions/user_permissions)를 강제함 — schema.sql + DDL 적용 절차(maintenance) 동반 변경" + - "role 변경 시 세션 role attr(UserController:508) stale — 세션 동기화/재로그인 전략은 design 결정이나 보안 비기능 요구로 격상" + - "기존 임시 ROLE_ADMIN(.equals) 흡수 시 comment/review 모더레이션 동작 변경 — W3-2 산물 회귀 점검 필요" +concerns_checked: true +self_verification: + checklist_passed: true +--- + +# W1 — 거버넌스 / RBAC 요구사항 + +## 원 요청 (로드맵 §W1 인용) +> 관리자(전체 권한) / 부관리자(허용된 권한만 = 권한부여형) 모델 / 관리자 콘솔 — 부관리자 임명 + 권한 토글 / 권한 체크 인터셉터 / 권한 토글 항목 예시: 게임잼관리, 포스팅작성(=포스터 권한). 의존 없음. 공유자원 users, security/, 세션/인증. + +--- + +## §0. RBAC 권한 생명주기 맵 (end-to-end — 구조적 gap-hunt) + +순차 상태 전이가 내포된 개념(부관리자 임명→권한 토글→권한 회수/강등, role 변경→세션 반영)이 있으므로 표면 요구 너머의 단절을 적극 헌트한다. 각 단계에 현재 코드 상태 + 단절 여부를 표기. + +| 단계 | 전이 | 현재 코드 상태 | 단절 | +|---|---|---|---| +| L0 | 가입 → USER 부여 | `UserController:295` 전원 `setRole("USER")` | OK | +| L1 | 최초 ADMIN 부트스트랩 | **경로 없음** — signup 전원 USER, 승격 수단 0 | **단절 — 갭 G1** | +| L2 | ADMIN → 부관리자(SUBADMIN) 임명 | 콘솔/임명 API 0 | **단절 — 갭 G2** | +| L3 | 부관리자에게 권한 토글 부여 | 권한 저장 구조 0 (role 단일 String) | **단절 — 갭 G3** | +| L4 | 요청 시 권한 게이트 통과 검사 | Interceptor 0, `addInterceptors` 0 | **단절 — 갭 G4 (QG-1 핵심)** | +| L5 | 권한 토글 **회수**(역방향) | 회수 API·세션 무효화 0 | **단절 — 갭 G5 (역방향 전이)** | +| L6 | 부관리자 → USER **강등/해임**(종료) | 강등 경로 0 | **단절 — 갭 G6 (종료 전이)** | +| L7 | role/권한 변경 후 **세션 반영** | 세션에 `role` attr 박제(`:508`), 변경 전파 0 → **stale 권한** | **단절 — 갭 G7 (2차 단절·보안)** | + +> happy-path(L0~L4)만이 아니라 **회수/강등/세션 무효화**(L5~L7)를 반드시 W1 스코프에 포함한다. 권한을 줄 수만 있고 회수·전파가 없으면 거버넌스로서 미완이며 보안 결함(권한 회수 후에도 세션 유효)이 된다. + +### 복수 이벤트 조건 대칭 점검 (병렬 분기) +- **권한 부여 시 / 권한 회수 시** 세션 반영이 대칭이어야 한다. 부여만 즉시 반영하고 회수는 다음 로그인까지 지연되면 보안 비대칭(회수 우회). → 결정축에 반영(아래 결정7). +- **임명(승격) 시 / 해임(강등) 시** 감사 로그 기록이 대칭이어야 한다. → NFR-보안 감사로그. + +--- + +## 기능 요구 (FR) + +### 권한 모델·저장 +- **FR-1**: 사용자는 ADMIN / SUBADMIN / USER 중 하나의 기본 role을 가진다. (`users.role` 재사용, 값 집합 확장) +- **FR-2**: SUBADMIN(부관리자)은 0개 이상의 개별 권한(permission)을 부여받을 수 있다. ADMIN은 전 권한을 암묵적으로 보유한다(권한 토글 무관). USER는 권한 0. +- **FR-3**: 권한 카탈로그는 최소 `GAME_JAM_MANAGE`(게임잼관리), `POST_WRITE`(포스팅작성/포스터)를 포함한다. 모더레이션 권한(`CONTENT_MODERATE`)을 추가 후보로 둔다(결정4 연계). + +### 관리자 콘솔 (L1~L6) +- **FR-4**: ADMIN은 콘솔에서 사용자를 SUBADMIN으로 **임명**(승격)할 수 있다. (G2) +- **FR-5**: ADMIN은 콘솔에서 SUBADMIN의 각 권한을 **토글(부여/회수)**할 수 있다. (G3, G5 — 부여·회수 대칭) +- **FR-6**: ADMIN은 SUBADMIN을 USER로 **강등/해임**할 수 있고, 강등 시 부여 권한은 전부 회수된다. (G6) +- **FR-7**: 콘솔은 현재 운영진(ADMIN/SUBADMIN) 목록과 각자의 권한 상태를 조회한다. +- **FR-8**: ADMIN 권한 자체는 콘솔에서 부여하지 않는다(최초 ADMIN은 부트스트랩 전용 — 결정3). 콘솔에서 다루는 임명 대상은 SUBADMIN과 그 권한에 한정한다. + +### 권한 체크 인터셉터 (L4 — QG-1 선행) +- **FR-9**: `HandlerInterceptor` 구현 + `addInterceptors` 등록으로 보호 경로 진입 시 권한을 검사한다. +- **FR-10**: 보호 대상 — (a) 관리자 콘솔 경로 전체(ADMIN only), (b) 게임잼관리 액션(`GAME_JAM_MANAGE` 보유자 — W2 소비), (c) 포스팅 작성 액션(`POST_WRITE` 보유자 — W3-3 소비). 미보유 시 거부(미인증=401/로그인 리다이렉트, 인증·미인가=403). +- **FR-11**: 인터셉터는 세션 식별값(`userId`)을 기준으로 권한을 판정한다. 세션 role attr 단독 신뢰 여부는 결정7(세션 stale)에 종속. + +### 부트스트랩 (L1) +- **FR-12**: 최초 ADMIN은 운영 DB seed/수동 승격으로 지정한다(결정3 권장안). 코드 자동 승격 경로는 두지 않는다. + +### 기존 임시 ADMIN 흡수 (결정4) +- **FR-13**: comment/review 모더레이션 체크(`GameCommentController:201`, `GameReviewController:419`의 `ROLE_ADMIN.equals(role)`)를 W1 권한 게이트(운영자 판정)로 대체한다. ADMIN + `CONTENT_MODERATE` 보유 SUBADMIN이 통과하도록 재정의한다. (권장안 — 사용자 확정 시) + +### 세션·권한 전파 (L7 — 2차 단절) +- **FR-14**: role/권한 변경(임명·토글·회수·강등) 후, 대상 사용자의 후속 요청에서 새 권한이 반영되어야 한다. (부여·회수 **대칭** — 결정7) + +--- + +## 비기능 요구 (NFR) + +- **NFR-보안(CSRF)**: 콘솔의 모든 상태변경(임명/토글/회수/강등)은 `CsrfTokens.isValid` 검증 적용. (기존 패턴 — comment/review/UserController 일관) +- **NFR-보안(SQL)**: 권한 조회·갱신 MyBatis SQL은 `#{}` 바인딩만 사용, `${}` 금지. +- **NFR-보안(인가 경계)**: 권한 판정은 서버측 인터셉터/컨트롤러에서 수행. 클라이언트(JSP) 노출은 표시용일 뿐 게이트 아님. 콘솔 진입·임명 API는 ADMIN 이외 도달 시 403. +- **NFR-보안(세션 무결성)**: 권한 변경 시 stale 세션 권한 방지(FR-14). 권한 **상승은 즉시, 회수는 즉시** 반영을 목표(비대칭 금지). 세션 고정 방어(`changeSessionId` — 로그인 시 이미 존재)는 보존. +- **NFR-보안(감사 로그)**: 임명/강등/권한 토글은 누가·누구를·언제·무엇을 변경했는지 기록(감사 추적). 임명·해임 **대칭** 기록. +- **NFR-운영(롤백/마이그레이션)**: 신규 권한 스키마는 schema.sql + 적용 DDL로 제공, 기존 데이터(전원 role='USER')와 호환(추가만, 파괴 없음). 롤아웃 순서: 스키마 적용 → 부트스트랩 ADMIN 지정 → 인터셉터 배포. +- **NFR-호환성**: 기존 `users.role` 컬럼·세션 role attr·comment/review 동작을 보존하거나 명시적으로 대체(FR-13). USER 기존 사용자에 영향 없음. +- **NFR-i18n**: 콘솔 UI 문자열은 기존 JSP 한국어 직접 출력 패턴 따름(별도 i18n 프레임워크 없음 — 해당 없음 수준). +- **NFR-접근성**: 콘솔은 관리자 전용 내부 화면 — 표준 폼/키보드 조작 보장 외 특별 요건 없음(해당 최소). +- **NFR-성능**: 권한 체크는 요청당 1회 발생 — 권한 조회 캐시/세션 캐싱 여부는 design 결정(stale 트레이드오프와 결합, 결정7). 상한 요구 없음. + +--- + +## 6개 핵심 결정의 확정값 (권장안 + 트레이드오프) + +> AskUserQuestion이 서브에이전트에서 불가하여, 각 결정에 권장안을 명시하고 사용자 확정이 필요한 항목은 [확정필요]로 표기했다. design 진입 전 orchestrator가 사용자에게 확인. + +### 결정1 — 권한 모델 형태 [확정필요·권장 (a)] +- **권장 (a)**: `users.role`(ADMIN/SUBADMIN/USER) 유지 + `user_permissions` join 테이블(user_id × permission). 부관리자별 개별 토글을 직접 표현. +- 근거: 기존 `.equals(role)`(comment/review) + 세션 role attr 코드를 **호환 보존**하면서 부분집합을 추가. 변경 표면 최소. +- 트레이드오프: 판정이 role 분기 + permission 조회 2단계. (b)보다 모델이 약간 복잡. +- (b) user↔permission 직접: role 개념 제거 — 기존 ADMIN 체크/세션 전면 교체 비용. 기각. +- (c) role↔permission 매핑: 역할 단위라 "부관리자 개인별 토글" 요구와 불일치. 기각. + +### 결정2 — 초기 permission 카탈로그 [권장 확정: DB 카탈로그 + 2~3권한] +- **권장**: `GAME_JAM_MANAGE`, `POST_WRITE` 출시 포함 + (결정4 채택 시) `CONTENT_MODERATE`. 카탈로그를 **DB 테이블**(`permissions`)로 관리해 운영자 추가·향후 W2-2(심사위원)/W4(배지)와 분리 흡수 가능. +- 트레이드오프: DB 카탈로그는 코드 enum 대비 타입 안전성↓, 권한 키 오타 가능 → permission 키는 코드 상수와 DB 시드를 동기화하는 계약 필요(design). +- 대안: 코드 enum(단순·타입안전, 추가 시 배포). W1 권한이 2~3개로 적어 enum도 무리 없음 — [확정필요] 여지. + +### 결정3 — 관리자 부트스트랩 [확정필요·권장: DB seed/수동 승격] +- **권장**: 운영 DB에서 특정 user.role을 ADMIN으로 직접 UPDATE(seed 스크립트 또는 1회 수동 maintenance). 코드 변경·설정 노출 없음. +- 근거: 가장 안전(공격면 0), 순환문제(콘솔 임명은 ADMIN 선존 필요) 회피. +- 트레이드오프: 운영 수동 절차 1회 필요 → maintenance 문서화 동반. +- 대안: 설정 기반 자동 승격(이메일 목록) — 설정 노출·관리 부담으로 비권장. + +### 결정4 — 기존 임시 ROLE_ADMIN 흡수 [확정필요·권장: 권한 게이트로 통일] +- **권장**: comment/review 모더레이션을 ADMIN + `CONTENT_MODERATE` 보유자 통과로 재정의(FR-13). 부관리자도 모더레이션 권한 토글 가능. +- 근거: 임시 산물(W3-2)을 W1 체계로 흡수해 권한 일원화. 미루면 ADMIN 하드코딩 분산 유지. +- **2차 단절 점검**: 변경 시 기존 ADMIN 통과 동작이 끊기면 안 됨 → ADMIN은 모든 권한 암묵 보유(FR-2)로 회귀 방지. W3-2 모더레이션 테스트 회귀 확인 필요(concern). +- 대안: 현행 `.equals("ADMIN")` 호환만 유지(부관리자 모더레이션 불가, 최소 변경) — W1 일원화 목적엔 미달. + +### 결정5 — 인터셉터 보호 범위 (QG-1) [확정필요·권장 범위 확정] +- **권장**: W1 출시에 인터셉터 **포함 확정**(W3-3가 이를 선행 의존 — QG-1). 보호 대상: + - 관리자 콘솔 경로 전체 → ADMIN only. + - 게임잼관리 액션 → `GAME_JAM_MANAGE` (W2가 경로 확정 시 등록, W1은 게이트 인프라 제공). + - 포스팅 작성 액션 → `POST_WRITE` (W3-3 소비). +- 근거: QG-1에서 "W3-3은 W1 완료 후 착수(임시 체크 안 함)" 이미 확정 → 인터셉터가 W1 산출에 반드시 포함. +- 트레이드오프: 보호 경로 매핑 방식(URL 패턴 vs 어노테이션 vs 핸들러 메타) = design 결정. 미인증(401/리다이렉트) vs 미인가(403) 응답 정책 = design 확정. + +### 결정6 — 관리자 콘솔 범위 [권장 최소 범위] +- **권장 최소**: (1) 운영진 목록 조회(FR-7), (2) 부관리자 임명(FR-4), (3) 권한 토글 부여/회수(FR-5), (4) 강등/해임(FR-6). 4개 액션. 모두 CSRF 보호. +- 제외(후속): 권한 변경 이력 열람 UI, 일괄 작업, 사용자 검색 고도화. + +### 결정7 (신규 — gap-hunt 산물) — 세션 권한 전파(stale) [확정필요·권장: 즉시 반영, 부여·회수 대칭] +- 배경: 세션에 `role` attr 박제(`UserController:508`). 인터셉터가 세션 role만 신뢰하면 권한 변경이 다음 로그인까지 미반영 → **회수 우회 보안 결함**(L7/G7). +- **권장**: 인터셉터는 권한 판정 시 권위 소스(DB user_permissions 또는 세션과 동기화된 캐시)를 신뢰. 변경 시 부여·회수 **대칭 즉시 반영**. +- 트레이드오프: 매 요청 DB 조회(성능) vs 세션 캐시(stale 위험). design에서 캐시 TTL/무효화 전략 결정. **단 회수의 즉시성은 비기능 보안 요구로 양보 불가.** + +--- + +## 스코프 +- **포함**: ADMIN/SUBADMIN/USER role 확장, user_permissions 저장, 권한 카탈로그(2~3개), 관리자 콘솔 4액션(임명·토글·회수·강등 — 부여/회수 대칭), 권한 체크 인터셉터(QG-1), 부트스트랩 절차, 기존 ADMIN 흡수(결정4 채택 시), 세션 권한 전파(결정7). +- **제외**: 심사위원 역할(W2-2), 리뷰어/기술자 배지(W4), 게임잼관리 액션의 실제 비즈니스(W2 — W1은 게이트만 제공), 포스팅 작성 기능 본체(W3-3 — W1은 권한만 제공), 권한 변경 이력 UI·일괄작업(후속). + +--- + +## 가정 / 추측 +- (가정) `users.role` 컬럼(varchar(30), DEFAULT 'USER')을 그대로 재사용해 값 집합만 확장 — schema.sql:35 근거. role 컬럼 자체 신규 추가 아님. +- (가정) 세션 고정 방어(`changeSessionId`)는 로그인에 이미 존재(`UserController:160`)하므로 W1은 보존만, 재구현 불필요. +- (가정) comment/review의 CSRF·canModify 패턴이 W1 콘솔의 참조 표준(동일 `CsrfTokens.isValid`). +- (추측→concern) RBAC 도입은 신규 DB 스키마를 강제함 → schema.sql + DDL 적용 + maintenance 문서 동반 변경. concerns에 이관. + +## 확정 필요 (오픈 질문 — orchestrator 경유 사용자 확인) +> 서브에이전트 AskUserQuestion 불가로 권장안 채택을 전제하되, 아래는 사용자 명시 확정이 바람직한 항목. 미응답 시 권장안을 design 가정으로 진행 가능(파괴적 결정 아님). + +- **Q1 (결정1)**: 권한 모델 = role + user_permissions join (권장 a) 채택 확인. +- **Q2 (결정3)**: 부트스트랩 = DB seed/수동 승격 (권장) 채택 확인. +- **Q3 (결정4)**: 기존 ADMIN 흡수 = 권한 게이트로 통일(`CONTENT_MODERATE`) 채택 확인. 미채택 시 comment/review 현행 유지. +- **Q4 (결정5)**: 인터셉터 W1 포함 + 보호범위(콘솔 ADMIN / 잼관리 / 포스팅) 확인. (QG-1상 사실상 포함 확정이나 범위 동의 필요) +- **Q5 (결정2)**: 권한 카탈로그 저장 = DB vs 코드 enum 택1. +- **Q6 (결정7)**: 권한 회수 즉시 반영(부여/회수 대칭) 요구 동의 — 보안상 강한 권고. + +## Acceptance Criteria 후보 (design이 만족시킬 목표) +- AC-1: ADMIN이 임명한 SUBADMIN에게 `POST_WRITE`를 토글하면, 해당 사용자가 포스팅 작성 액션에 통과한다. +- AC-2: 위 권한을 회수하면, **다음 요청부터** 동일 액션이 403으로 거부된다(즉시·세션 잔류 없음 — 결정7). +- AC-3: SUBADMIN을 강등하면 모든 권한이 회수되고 콘솔 운영진 목록에서 SUBADMIN으로 표시되지 않는다. +- AC-4: 비-ADMIN이 관리자 콘솔 경로에 접근하면 403/리다이렉트로 차단된다(인터셉터 게이트). +- AC-5: 콘솔의 모든 상태변경 요청은 CSRF 토큰 없으면 403. +- AC-6: 부트스트랩 절차로 지정된 최초 ADMIN만 콘솔에 진입 가능(자동 승격 경로 부재). +- AC-7: (결정4 채택 시) comment/review 모더레이션이 ADMIN + `CONTENT_MODERATE` 보유자에게 통과하고, 기존 ADMIN 통과 동작은 회귀 없이 보존(W3-2 테스트 PASS 유지). +- AC-8: 임명/토글/회수/강등이 감사 로그에 대칭 기록된다. +- AC-9: 권한 관련 SQL에 `${}` 동적 치환이 없다. + +## design-advisor 전달 단절 목록 (명시 인계) +- G1 부트스트랩, G2 임명, G3 권한토글부여, G4 인터셉터(QG-1), **G5 권한회수(역방향)**, **G6 강등/해임(종료)**, **G7 세션 권한 전파(2차 단절·보안)**. +- 병렬 대칭: 권한 부여/회수 세션 반영 대칭, 임명/해임 감사로그 대칭. 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, `#{}` 바인딩 전용, `${}` 금지. 동적 절은 `` 를 삽입하고, 기존 인라인 함수 선언을 제거한다. + +### 파일 경로 및 API 계약 (함수 시그니처) + +**신규 파일**: `src/main/webapp/js/bibimbap-utils.js` + +```javascript +window.BibimbapUtils = (function () { + + /** + * CSRF 토큰을 포함한 application/x-www-form-urlencoded POST 요청을 전송한다. + * + * @param {string} url - 요청 대상 URL + * @param {URLSearchParams|null} params - 요청 바디 파라미터 (null 이면 빈 URLSearchParams 사용) + * @param {string} csrfToken - X-CSRF-Token 헤더에 설정할 토큰 값 + * @returns {Promise} + */ + function post(url, params, csrfToken) { + // ... + } + + /** + * fetch Response가 ok이면 location.reload(), 아니면 오류 메시지를 콜백으로 전달한다. + * + * @param {Response} res - fetch가 반환한 Response 객체 + * @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백 + */ + function handleResult(res, onError) { + // ... + } + + /** + * 네트워크/예외 오류 발생 시 오류 메시지를 콜백으로 전달한다. + * + * @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백 + * @returns {function} catch 핸들러로 사용 가능한 함수 + */ + function makeErrorHandler(onError) { + // ... + } + + return { post: post, handleResult: handleResult, makeErrorHandler: makeErrorHandler }; +})(); +``` + +**시그니처 결정 이유**: + +- `post(url, params, csrfToken)`: 기존 admin 4개 파일의 `post()` 는 클로저로 `CSRF_TOKEN` 변수를 캡처하는 방식이었다. 전역 파일로 추출하면 클로저 캡처가 불가능하므로 `csrfToken`을 명시 파라미터로 받는다. 호출부가 항상 토큰을 직접 넘기므로 의도가 코드에 드러나고 타입 검사도 명확해진다. + +- `handleResult(res, onError)`: 기존 구현은 `notify()`를 직접 호출했다. `notify()`는 파일마다 title이 달라 전역화 불가능하므로, 대신 오류 메시지 문자열을 콜백으로 전달하는 방식으로 변경한다. 호출부가 메시지를 받아 직접 표시하므로 유연성과 분리가 동시에 달성된다. + +- `makeErrorHandler(onError)`: 기존 `handleError()`는 매직 문자열 `'요청 중 오류가 발생했습니다.'` 를 하드코딩했다. 유틸리티 레벨에서 특정 언어 문자열을 결정하지 않도록 콜백으로 위임한다. + +**inflate 경고**: `post()` 의 세 번째 파라미터 `csrfToken`이 항상 사용되는지 구현 시 확인 필요. 만약 호출부 중 하나라도 빈 문자열을 넘기는 패턴이 발견되면 `BibimbapCsrf.token()` 통합을 고려한다 (concerns 참조). + +### 마이그레이션 경로 + +1. `bibimbap-utils.js` 작성 (3개 함수 포함) +2. `admin-post-categories.jsp` 에 script 태그 추가 + 인라인 `post`, `handleResult`, `handleError` 함수 제거. `BibimbapUtils.post(...)`, `BibimbapUtils.handleResult(...)`, `BibimbapUtils.makeErrorHandler(...)` 로 호출부 수정 +3. 기능 검증 후 나머지 admin 3개 파일에 동일 적용 + +### 위험 평가 + +| 위험 | 가능성 | 대응 | +|---|---|---| +| admin 파일마다 `post()` 구현이 미묘하게 달라 통합 시 동작 변경 | 중간 | 통합 전 4개 파일 `post()` 구현 3-way diff 수행 (concerns 등록) | +| `handleResult` 시그니처 변경으로 기존 호출부 수정 필요 | 확실 | 마이그레이션 범위를 admin 4개로 한정, 변경이 넓지 않음 | + +--- + +## 권고 3 — 모달 사용 패턴 표준화 (P1) + +### 접근 + +**표준 패턴 채택**: `BibimbapModal` API (`window.BibimbapModal.alert / .confirm / .prompt`) 를 직접 호출하는 인라인 패턴을 표준으로 정한다. 기존 헬퍼 래퍼(`openModal()`, `notify()`)는 더 이상 신규 작성하지 않는다. + +**패턴 A (openModal 래퍼) 처리**: login/signup/game-register/profile 4개 파일의 `openModal()` 로컬 함수를 제거하고 호출부를 `window.BibimbapModal.alert({...})` 직접 호출로 대체한다. `BibimbapModal` 미존재 폴백은 `else { alert(message); }` 로 단순화한다. + +**패턴 B (notify 래퍼) 처리**: admin 4개 파일의 `notify()` 를 제거한다. 권고 2의 `BibimbapUtils.handleResult(res, onError)` 에서 `onError` 콜백이 `BibimbapModal.alert` 를 직접 호출하도록 호출부를 작성한다. 이렇게 하면 `notify()` 래퍼 없이도 동일 효과를 얻는다. + +**패턴 C (인라인 직접 체크) 처리**: posts-form/recruit-form/game-detail 은 이미 인라인이므로 현재 형태 유지. 다만 `game-detail.jsp` 내 `notifyError()` / `confirmAction()` 헬퍼(라인 1553-1561)는 파일 전용이므로 제거하지 않는다. 단일 파일에서의 로컬 헬퍼는 허용. + +**window.confirm 직접 사용 수정 (보안/일관성)**: +- `admin-post-categories.jsp:323` 의 `window.confirm()` → `BibimbapModal.confirm({...})` 으로 변경 +- `admin-unity-feeds.jsp:401` 의 `window.confirm()` → `BibimbapModal.confirm({...})` 으로 변경 + +이 두 곳은 삭제 확인 흐름이므로 `onConfirm` 콜백에 실제 삭제 로직을 이동한다. + +### 표준 패턴 예시 (참고용) + +```javascript +// 알림 (확인 버튼만) +if (window.BibimbapModal) { + window.BibimbapModal.alert({ title: '제목', message: '내용', confirmText: '확인' }); +} else { + alert('내용'); +} + +// 확인/취소 대화 +if (window.BibimbapModal) { + window.BibimbapModal.confirm({ + title: '삭제 확인', + message: '삭제하시겠습니까?', + onConfirm: function () { /* 삭제 로직 */ } + }); +} else { + if (window.confirm('삭제하시겠습니까?')) { /* 삭제 로직 */ } +} +``` + +### 파일 영향 맵 + +| 변경 유형 | 경로 | 역할 | +|---|---|---| +| 수정 — openModal 제거 | login.jsp, signup.jsp, game-register.jsp, profile.jsp | 패턴 A → 직접 호출 | +| 수정 — notify 제거 | admin-console.jsp, admin-jam-list.jsp, admin-post-categories.jsp, admin-unity-feeds.jsp | 패턴 B → 콜백 방식 | +| 수정 — window.confirm 교체 | admin-post-categories.jsp:323, admin-unity-feeds.jsp:401 | BibimbapModal.confirm 사용 | +| 유지 — 변경 없음 | game-detail.jsp (notifyError/confirmAction), posts-form.jsp, recruit-form.jsp | 파일 전용 헬퍼 허용 | + +### 마이그레이션 경로 + +1. `admin-post-categories.jsp` + `admin-unity-feeds.jsp` 의 `window.confirm` 2곳 먼저 수정 (보안 갭과 연결) +2. login/signup openModal 제거 + 직접 호출 전환 +3. game-register/profile 동일 처리 +4. admin 4개 파일 notify 제거 (권고 2와 동시 진행) + +### 위험 평가 + +| 위험 | 가능성 | 대응 | +|---|---|---| +| BibimbapModal이 modal.jsp 로드 전에 JS가 실행되는 경우 | 낮음 (header.jsp가 modal.jsp를 항상 include) | 변경 없이 현 구조 유지 | +| window.confirm 삭제 시 콜백 이동 누락으로 삭제 로직 미실행 | 중간 | 각 파일 수정 후 삭제 기능 수동 검증 | + +--- + +## 권고 4 — 폼 제출 유틸리티 + CSRF 갭 수정 (P0 — 보안) + +### 접근 + +**즉시 수정 (CSRF 갭)**: `recruit-form.jsp` 의 fetch 호출부에서 `BibimbapCsrf` 미존재 폴백(라인 390-394)이 CSRF 토큰을 전혀 포함하지 않는 문제를 수정한다. 두 가지 방법 중 **방법 A를 채택**한다. + +**방법 A (채택)**: 폴백 브랜치에 `hidden input`에서 추출한 토큰을 직접 삽입한다. + +```javascript +// recruit-form.jsp 수정안 +var csrfToken = (document.querySelector('input[name="_csrf"]') || {}).value || ''; +fetch(form.action, { + method: 'POST', + headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({ + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }) : { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-Token': csrfToken // 갭 수정 + }, + body: body +}) +``` + +**전제**: `recruit-form.jsp` 에 `` hidden input을 추가해야 한다. 현재 `recruit-form.jsp` 에는 이 hidden input이 없다. 추가하면 `new FormData(form)` 이 자동으로 `_csrf` 파라미터를 포함하므로 서버사이드 form 파라미터 검증도 함께 강화된다. + +**방법 B (미채택)**: `theme-init.jsp` 의 `BibimbapCsrf` 를 항상 신뢰하여 폴백 분기 자체를 제거. 단, `BibimbapCsrf` 미존재 케이스를 완전히 제거하면 `theme-init.jsp` 가 로드 실패 시 CSRF 토큰이 아예 없어지는 더 큰 갭이 생긴다. 따라서 채택하지 않는다. + +**401 리다이렉트 불일치 수정**: `recruit-form.jsp` 의 401 처리를 `posts-form.jsp` 와 동일하게 `redirectLogin` 함수로 분리한다. + +**성공 시 폴백 불일치 수정**: `recruit-form.jsp:419-421` 의 `alert(...)` 호출을 `posts-form.jsp:260-261` 패턴에 맞게 `alert` 없이 `go()` 만 호출하도록 변경한다. + +### 파일 영향 맵 + +| 변경 유형 | 경로 | 역할 | +|---|---|---| +| 수정 (보안) | recruit-form.jsp | hidden _csrf input 추가 + 폴백 브랜치 토큰 삽입 | +| 수정 (일관성) | recruit-form.jsp | 401 처리 redirectLogin 함수 분리 | +| 수정 (일관성) | recruit-form.jsp | 성공 폴백 alert 제거 | +| 변경 없음 | posts-form.jsp | 현행 유지 (기준 파일) | + +### 마이그레이션 경로 + +1. `recruit-form.jsp` 에 `` 추가 (JSP EL 또는 request attribute 사용) +2. JS 폴백 브랜치에 `X-CSRF-Token` 헤더 추가 +3. 401 처리 함수 분리 +4. 성공 폴백 통일 +5. 실제 폼 제출(등록) + 401 시나리오(로그아웃 후 제출) 수동 검증 + +### 위험 평가 + +| 위험 | 가능성 | 대응 | +|---|---|---| +| hidden _csrf input 추가 시 서버 컨트롤러가 기대하는 파라미터명 불일치 | 낮음 | posts-form.jsp와 동일 파라미터명 `_csrf` 사용 | +| BibimbapCsrf가 항상 존재한다고 가정하고 폴백을 제거하고 싶은 유혹 | 중간 | 방법 B 미채택 이유 참조 — 폴백 브랜치 유지 | + +--- + +## 권고 5 — 날짜 포맷 유틸리티 모듈 (P2) + +### 접근 + +`game-detail.jsp` 인라인의 `fmtAbsolute`, `fmtRelative`, `buildTimeEl` 세 함수를 `src/main/webapp/js/bibimbap-date.js` 로 추출하고 `window.BibimbapDate` 네임스페이스에 노출한다. + +`game-detail.jsp` 는 `` 를 추가하고 기존 인라인 선언을 제거한다. 다른 detail 페이지(posts-detail, recruit-detail, jam-detail)에서 날짜 포맷이 필요해질 때 이 파일을 include하면 된다 — 현재는 필요하지 않으므로 강제 적용하지 않는다. + +### 파일 경로 및 API 계약 (함수 시그니처) + +**신규 파일**: `src/main/webapp/js/bibimbap-date.js` + +```javascript +window.BibimbapDate = (function () { + + /** + * ISO 8601 문자열을 ko-KR 로케일 절대 날짜/시각 문자열로 변환한다. + * + * @param {string} iso - ISO 8601 날짜 문자열 + * @returns {string} - "2026. 6. 30. 오전 10:00:00" 형식, 파싱 실패 시 빈 문자열 + */ + function fmtAbsolute(iso) { ... } + + /** + * ISO 8601 문자열을 상대 시각 문자열로 변환한다 (7일 이내: "n분/시간/일 전", 초과: 절대). + * + * @param {string} iso - ISO 8601 날짜 문자열 + * @returns {string} - "3시간 전" 또는 절대 날짜, 파싱 실패 시 빈 문자열 + */ + function fmtRelative(iso) { ... } + + /** + *