Compare commits
30 Commits
f81991b409
...
93525cb536
| Author | SHA1 | Date |
|---|---|---|
|
|
93525cb536 | |
|
|
98fba1ec9b | |
|
|
356a0d6a1d | |
|
|
a4866b6420 | |
|
|
9041bb7c70 | |
|
|
3d10449e67 | |
|
|
af298ce8a0 | |
|
|
029b6996d4 | |
|
|
31554f8886 | |
|
|
305cc73860 | |
|
|
08d191ff2e | |
|
|
6047a39ca5 | |
|
|
e28fa60ca6 | |
|
|
35f1dc3de9 | |
|
|
ba1a5fe572 | |
|
|
7ae6589b39 | |
|
|
932ca92753 | |
|
|
b222b0106c | |
|
|
a74bf74d13 | |
|
|
5ed06d9942 | |
|
|
09ed6bcaa6 | |
|
|
2d5601bda7 | |
|
|
71b6b6f32a | |
|
|
ccf1e42430 | |
|
|
4168d4de52 | |
|
|
bfbe1de9e4 | |
|
|
a19619f434 | |
|
|
941f9fb128 | |
|
|
e0eb8eae15 | |
|
|
21892c8a03 |
|
|
@ -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 미적용(권고 보존).'
|
||||
|
|
@ -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`) |
|
||||
|
|
@ -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<String,Object>` + `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<OperatorView> listOperators() // FR-7 운영진 목록(role IN (ADMIN,SUBADMIN))
|
||||
|
||||
// UserPermissionsMapper (@Mapper, #{} only)
|
||||
List<String> 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` 에 이관.
|
||||
|
|
@ -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 영역.
|
||||
|
|
@ -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 에 기록).
|
||||
|
|
@ -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
|
||||
|
||||
- (코드 변경 없음 — 세션 종료 시 판정)
|
||||
|
|
@ -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차 단절·보안)**.
|
||||
- 병렬 대칭: 권한 부여/회수 세션 반영 대칭, 임명/해임 감사로그 대칭.
|
||||
|
|
@ -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<Map<String,Object>>`(status/message). 관리자 API 는 컨트롤러 진입부에서 `PermissionGate.has(session, GAME_JAM_MANAGE.name())` 게이트 통과 후 본문 수행.
|
||||
|
||||
### 401 vs 403 정책 (W1-design 과 일치)
|
||||
- **미인증**(세션 `userId` 없음): API 는 **401** JSON `{status:401, message:"로그인이 필요합니다."}`. 페이지(`/jams/{slug}/...` 폼 등 인증 필요분)는 `redirect:/login`.
|
||||
- **인증·미인가**(로그인됐으나 GAME_JAM_MANAGE 없음): **403** JSON `{status:403, message:"권한이 없습니다."}`(리다이렉트 금지).
|
||||
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||
- 게이트 분기는 `gate.isAuthenticated(session)`(401/redirect) → `gate.has(session, GAME_JAM_MANAGE.name())`(403) 2단계. PermissionGate.isAuthenticated/has 직접 확인(PermissionGate.java:47,22).
|
||||
|
||||
### 공개 페이지 (뷰 — 인증 불필요)
|
||||
| method | path | 권한 | 응답 |
|
||||
|---|---|---|---|
|
||||
| GET | `/jams` | 공개 | `jam-list` JSP. 가시 잼 keyset 1페이지 + `nextCursor` 모델 주입 |
|
||||
| GET | `/jams/{slug}` | 공개 | `jam-detail` JSP. 잼 + 출품작(jam_entries JOIN games) + CSRF 토큰 모델 주입 |
|
||||
| GET | `/jams?cursor={createdAt}_{id}` | 공개 | (목록 동일 뷰, keyset 다음 페이지) |
|
||||
|
||||
### 공개 출품/팀 액션 (상태변경 API — 로그인 필요 + CSRF. 관리자 게이트 아님)
|
||||
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||
|---|---|---|---|---|---|
|
||||
| 개인 출품 | POST | `/jams/{slug}/entries` | gameId | `{status:200, message, entryId}` | 401(미인증), 403(CSRF), 404(잼/게임 없음), 409(이미 출품), 422(잼 상태가 출품 불가/게임 소유자 아님) |
|
||||
| 팀 출품 | POST | `/jams/{slug}/entries` | gameId, jamTeamId | `{status:200, message, entryId}` | 위 + 422(팀 멤버 아님) |
|
||||
| 팀 생성 | POST | `/jams/{slug}/teams` | name | `{status:200, message, jamTeamId}` | 401, 403, 404, 422(잼 상태/이름 검증) |
|
||||
| 팀 멤버 추가 | POST | `/jams/{slug}/teams/{teamId}/members` | userId | `{status:200, message}` | 401, 403, 404, 422(팀장 아님/중복) |
|
||||
|
||||
- 출품 단일 엔드포인트(`/entries`)에서 `jamTeamId` 유무로 개인/팀 분기 — entrant_type 결정. games.user_id 소유 검증(개인) 또는 jam_team_members 멤버 검증(팀)으로 도용 차단.
|
||||
- **출품 가능 상태**: `jams.status IN ('RECRUIT','DEV')` 만 허용(EVAL/CLOSED 출품 거부 → 422). 구체 정책은 D3 + concern(출품 자격) 따름.
|
||||
|
||||
### 관리자 잼 CRUD (상태변경 API — CSRF + GAME_JAM_MANAGE 게이트)
|
||||
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||
|---|---|---|---|---|---|
|
||||
| 콘솔 페이지 | GET | `/admin/jams` | (없음) | `admin-jam-list` JSP(잼 전건 + CSRF) | 401/redirect, 403 |
|
||||
| 생성 | POST | `/admin/jams` | title, description, 기간필드, 표시필드 | `{status:200, message, jamId, slug}` | 403(CSRF/권한), 422(입력 검증/기간 정합) |
|
||||
| 수정 | POST | `/admin/jams/{jamId}` | (생성과 동일 필드) | `{status:200, message, jamId}` | 403, 404, 422 |
|
||||
| 상태전이 | POST | `/admin/jams/{jamId}/status` | toStatus | `{status:200, message, jamId, status:toStatus}` | 403, 404, 409(허용 안 되는 전이), 422(기간 필드 미충족) |
|
||||
| 가시성 토글 | POST | `/admin/jams/{jamId}/visibility` | (없음) | `{status:200, message, jamId, visible:bool}` | 403, 404 |
|
||||
| 삭제(소프트) | POST | `/admin/jams/{jamId}/delete` | (없음) | `{status:200, message, jamId}` | 403, 404, 422(출품작 존재 시 정책) |
|
||||
|
||||
- **GAME_JAM_MANAGE enforcement(D4)**: 위 `/admin/jams/**` 6액션 전부 진입부에서 게이트 통과 요구. RbacInterceptor 가 `/admin/**` 에 등록돼 있으나 isAdmin 만 검사하므로(RbacInterceptor.java:34) **SUBADMIN+GAME_JAM_MANAGE 가 인터셉터에서 막힌다** → 인터셉터는 `/admin/jams/**` 를 통과시키지 못함. 해결: 인터셉터 경로 매핑을 변경하지 않고(W1 콘솔 보호 유지), **`/admin/jams/**` 를 인터셉터 exclude 에 추가**한 뒤 컨트롤러 게이트 헬퍼로 GAME_JAM_MANAGE 검사(아래 §인터셉터 연동 D4-A 확정).
|
||||
|
||||
---
|
||||
|
||||
## 인터셉터 / 게이트 연동 (D4 — enforcement 갭 메우기)
|
||||
|
||||
### 문제
|
||||
- `RbacInterceptor.preHandle` 은 `/admin/**` 전체에 대해 `isAdmin(session)` 만 검사한다(RbacInterceptor.java:34, InterceptorConfig.java:19 직접 확인). `isAdmin` 은 role==ADMIN 만 true(PermissionGate.java:55-62).
|
||||
- 따라서 `/admin/jams/**` 가 인터셉터에 그대로 걸리면 **SUBADMIN(+GAME_JAM_MANAGE 보유)이 잼 관리에 접근 못 한다**. GAME_JAM_MANAGE 권한 키의 존재 의의(ADMIN 아닌 위임 관리자)가 무력화됨.
|
||||
|
||||
### 확정 (D4-A: 인터셉터 exclude + 컨트롤러 게이트 헬퍼)
|
||||
- **InterceptorConfig 수정**: `addPathPatterns("/admin/**").excludePathPatterns("/admin/jams/**")`. 콘솔(`/admin/console` 등 ADMIN 전용)은 인터셉터 ADMIN 게이트 유지, 잼 관리만 제외.
|
||||
- **JamAdminController 진입부 게이트 헬퍼**: 각 액션 시작에서 아래 순서.
|
||||
1. `gate.isAuthenticated(session)` 거짓 → 페이지면 redirect:/login, API 면 401.
|
||||
2. `gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` 거짓 → 403(JSON 또는 페이지 403).
|
||||
3. 통과 후 본문.
|
||||
- **이유**: 인터셉터는 단일 권한(ADMIN)·URL 패턴에 최적(W1-design 결정), 잼 관리는 "ADMIN 또는 SUBADMIN+GAME_JAM_MANAGE" 라 권한 키 판정이 필요하므로 `gate.has`(ADMIN 암묵전권 + SUBADMIN 키보유, PermissionGate.java:30-36) 가 정확히 들어맞는다. 커스텀 어노테이션/AOP 는 W1-design 에서 이미 오버엔지니어링으로 기각된 선례 → 동일하게 게이트 헬퍼 채택.
|
||||
- **중복 0**: 6액션 모두 동일한 private 헬퍼 `requireJamManage(session, response/return)` 로 게이트 + 401/403 응답 작성을 단일화.
|
||||
|
||||
### epoch 전파 연동 (W1 결정4)
|
||||
- `gate.has` 내부가 `refreshIfStale`(PermissionGate.java:86) 로 요청당 epoch 대조 → ADMIN 이 SUBADMIN 에게 GAME_JAM_MANAGE 부여/회수하면 대상 다음 요청에서 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0).
|
||||
|
||||
---
|
||||
|
||||
## 시퀀스 (주요 플로우 의사코드)
|
||||
|
||||
### S1. 관리자 잼 생성 → 상태전이(감사)
|
||||
```
|
||||
[SUBADMIN(+GAME_JAM_MANAGE) 세션] POST /admin/jams (CSRF, title/기간/표시필드)
|
||||
→ InterceptorConfig: /admin/jams/** exclude → 인터셉터 미개입
|
||||
→ JamAdminController.createJam
|
||||
→ requireJamManage(session): isAuthenticated? gate.has(GAME_JAM_MANAGE)? (아니면 401/403)
|
||||
→ CsrfTokens.isValid(request) (아니면 403 errorBody)
|
||||
→ 입력 sanitize/길이 검증 + 기간 정합(eval_start ≤ eval_end 등) (아니면 422)
|
||||
→ slug = JamSlugs.generate(title) (충돌 시 재시도 — concern)
|
||||
→ jamsMapper.insertJam(... status='RECRUIT', created_by=actor)
|
||||
→ jamStatusLogMapper.insert(jamId, from=NULL, to='RECRUIT', actor, 'MANUAL')
|
||||
→ 200 {jamId, slug}
|
||||
|
||||
[관리자] POST /admin/jams/42/status (CSRF, toStatus='EVAL')
|
||||
→ JamAdminController.transitionStatus
|
||||
→ requireJamManage + CSRF
|
||||
→ jam = jamsMapper.getById(42) (없으면 404)
|
||||
→ JamLifecycle.assertAllowed(jam.status, 'EVAL') (불가 전이면 409)
|
||||
→ JamLifecycle.assertPeriodReady(jam, 'EVAL') (eval_start_at 미설정 등 422)
|
||||
→ jamsMapper.updateStatus(42, 'EVAL')
|
||||
→ jamStatusLogMapper.insert(42, from=jam.status, to='EVAL', actor, 'MANUAL')
|
||||
→ 200 {status:'EVAL'}
|
||||
```
|
||||
|
||||
### S2. 공개 출품(개인/팀 분기) + 이중 노출
|
||||
```
|
||||
[로그인 유저] POST /jams/{slug}/entries (CSRF, gameId[, jamTeamId])
|
||||
→ JamController.submitEntry
|
||||
→ CsrfTokens.isValid (아니면 403)
|
||||
→ userId = sessionUserId(session) (없으면 401)
|
||||
→ jam = jamsMapper.getBySlug(slug) (없으면 404)
|
||||
→ jam.status IN ('RECRUIT','DEV')? (아니면 422 출품 불가 상태)
|
||||
→ game = gamesMapper.getGame(gameId) (없으면 404)
|
||||
→ resolveEntrant:
|
||||
jamTeamId == null → entrant_type='USER':
|
||||
game.userId == userId? (아니면 422 게임 소유자 아님)
|
||||
entrantUserId = userId
|
||||
else → entrant_type='TEAM':
|
||||
jamTeamMembersMapper.exists(jamTeamId,userId)? (아니면 422 팀 멤버 아님)
|
||||
→ jamEntriesMapper.insert(...) (UNIQUE 위반 시 409 이미 출품 — catch DuplicateKey)
|
||||
→ 200 {entryId}
|
||||
# 이중 노출(D6,D7): games.is_visible 무변경 → 일반 허브 그대로 노출.
|
||||
# 잼 상세는 jam_entries JOIN games 로 별도 노출. 두 경로 동시 성립.
|
||||
|
||||
[공개] GET /jams/{slug}
|
||||
→ JamController.detail
|
||||
→ jam = jamsMapper.getBySlug(slug) (없거나 !is_visible → redirect:/jams)
|
||||
→ entries = jamEntriesMapper.listByJam(jam.id) # JOIN games (이름/썸네일/평가단위 (jam_id,game_id) 자연키)
|
||||
→ model: jam, entries, csrfToken → "jam-detail"
|
||||
```
|
||||
|
||||
### S3. 공개 목록 keyset 페이징 (D5)
|
||||
```
|
||||
[공개] GET /jams?cursor=2026-06-20T10:00:00Z_57
|
||||
→ JamController.list
|
||||
→ (cursor 파싱: createdAt, id. 없으면 첫 페이지)
|
||||
→ jams = jamsMapper.listVisibleKeyset(cursorCreatedAt, cursorId, pageSize+1)
|
||||
# WHERE is_visible IS NOT FALSE AND is_delete IS NOT TRUE
|
||||
# AND (cursor 있으면) (created_at, id) < (cursorCreatedAt, cursorId)
|
||||
# ORDER BY created_at DESC, id DESC LIMIT pageSize+1
|
||||
→ hasNext = jams.size > pageSize; trim; nextCursor = last(created_at)_last(id)
|
||||
→ model: jams, nextCursor → "jam-list"
|
||||
```
|
||||
- **keyset 채택 이유(D5)**: RecruitPostsMapper 는 페이징 전무·전건 로드(RecruitPostsMapper.java:72 직접 확인). 잼 목록/출품작은 누적 증가 → offset 페이징은 깊은 페이지 비용·중복 행 위험. keyset(created_at,id 복합 커서)은 idx_jams_visible_keyset 인덱스로 O(log n) seek. id 동률 tie-break 포함(created_at 단독은 동시각 누락 위험).
|
||||
|
||||
---
|
||||
|
||||
## 파일 영향 맵
|
||||
|
||||
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||
> **J-SCHEMA**(DDL/schema 동기) · **J-DOMAIN**(data POJO/enum/JamLifecycle/JamSlugs) · **J-MAPPER**(5 매퍼) · **J-ADMIN**(JamAdminController + JSP, D4 게이트) · **J-PUBLIC**(JamController + JSP, keyset) · **J-CONFIG**(InterceptorConfig exclude).
|
||||
> 의존: J-SCHEMA → J-DOMAIN → J-MAPPER → {J-ADMIN, J-PUBLIC}. J-CONFIG 는 J-ADMIN 과 짝(exclude 없으면 SUBADMIN 막힘).
|
||||
|
||||
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||
|---|---|---|---|
|
||||
| 신규 | `docs/jam-ddl.sql` | 권위 DDL(jams/jam_teams/jam_team_members/jam_entries/jam_status_log). apply-local-ddl.sh 자동 적용 | J-SCHEMA |
|
||||
| 수정 | `db/schema.sql` | 위 5테이블 블록 추가(jam-ddl 사본). games 무변경 | J-SCHEMA |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamData.java` | jams 행 POJO(id/slug/title/description/status/기간4/표시3/isVisible/createdAt...) | J-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamEntryData.java` | jam_entries 행 + JOIN games 표시필드(gameName/thumbnailUrl/entrantType...) | J-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamTeamData.java` | jam_teams 행(+멤버 수 옵션) | J-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamStatus.java` | enum RECRUIT/DEV/EVAL/CLOSED + isValid(String) (PermissionKeys 패턴) | J-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamLifecycle.java` | 전이 그래프 검증 + 기간 정합 검증(수동/자동 공통 코어, 난제2) | J-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamSlugs.java` | title→URL-safe slug 생성(서버 생성, D8) | J-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java` | `@Mapper` jams CRUD + keyset 목록 + getBySlug + updateStatus(`#{}`, snake→camel alias) | J-MAPPER |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamEntriesMapper.java` | `@Mapper` 출품 insert/listByJam(JOIN games)/exists(`#{}`) | J-MAPPER |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamTeamsMapper.java` | `@Mapper` 팀 insert/getById/listByJam(`#{}`) | J-MAPPER |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamTeamMembersMapper.java` | `@Mapper` 멤버 insert/exists(`#{}`) | J-MAPPER |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamStatusLogMapper.java` | `@Mapper` 상태전이 감사 insert(`#{}`) | J-MAPPER |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamController.java` | 공개 목록(keyset)/상세 + 출품/팀 액션(CSRF, RecruitController 패턴) | J-PUBLIC |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/jam-list.jsp` | 잼 목록 + 다음 페이지(nextCursor) | J-PUBLIC |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/jam-detail.jsp` | 잼 상세 + 출품작 + 출품/팀 폼(CSRF hidden) | J-PUBLIC |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java` | `/admin/jams/**` CRUD + 상태전이(D4 게이트 헬퍼) | J-ADMIN |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | 잼 관리 목록/폼(CSRF hidden) | J-ADMIN |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/config/InterceptorConfig.java` | `.excludePathPatterns("/admin/jams/**")` 추가(D4-A) | J-CONFIG |
|
||||
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 5매퍼 @MockBean 등록(contextLoads 보존, verification §30) | (검증) |
|
||||
| 신규 | `src/test/.../JamAdminControllerTest.java` | CRUD + 상태전이 + 401/403/CSRF/게이트 + 허용전이 검증 | (검증) |
|
||||
| 신규 | `src/test/.../JamControllerTest.java` | 목록 keyset + 상세 + 출품(개인/팀) + 409/422 + 소유/멤버 검증 | (검증) |
|
||||
| 신규 | `src/test/.../JamLifecycleTest.java` | 전이 그래프 허용/거부 + 기간 정합 단위 | (검증) |
|
||||
|
||||
> SSR 호출지점 영향(verification §영향맵): jam_entries 는 games 무변경이라 기존 게임 허브 JSP·매퍼 깨짐 0. 신규 뷰·매퍼만 추가 → 기존 소비처 0 영향.
|
||||
|
||||
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||
```java
|
||||
// JamLifecycle — 전이 검증 코어(수동/자동 공통). 상태 enum 만으로 전이 그래프 판정(최소).
|
||||
boolean isAllowed(JamStatus from, // 현재 상태
|
||||
JamStatus to) // 목표 상태 — 허용 전이 그래프 룩업
|
||||
void assertPeriodReady(JamData jam, // 기간 필드 보유 잼(eval_start_at 등)
|
||||
JamStatus to) // 목표 상태가 요구하는 기간 필드 충족 검증(미충족 시 신호)
|
||||
|
||||
// JamSlugs — title → URL-safe slug. title 만으로 생성(충돌 처리는 호출자/매퍼 책임 — concern).
|
||||
String generate(String title) // 정규화·소문자·하이픈·길이절단
|
||||
|
||||
// JamsMapper (@Mapper, #{} only, snake→camel 직접 alias)
|
||||
JamData getById(long jamId) // 관리자 수정/전이 조회
|
||||
JamData getBySlug(String slug) // 공개 상세 조회(활성만)
|
||||
List<JamData> listVisibleKeyset(java.time.OffsetDateTime cursorCreatedAt, // 커서 시각(첫페이지 null)
|
||||
Long cursorId, // 커서 id tie-break(첫페이지 null)
|
||||
int limit) // pageSize+1(hasNext 판정)
|
||||
List<JamData> listAllForAdmin() // 관리자 콘솔 전건(삭제 제외)
|
||||
int insertJam(JamData jam) // 생성(useGeneratedKeys id)
|
||||
int updateJam(JamData jam) // 수정
|
||||
int updateStatus(long jamId, String status) // 상태전이 반영
|
||||
int updateVisibility(long jamId, boolean isVisible) // 가시성 토글
|
||||
int softDelete(long jamId) // 소프트 삭제
|
||||
|
||||
// JamEntriesMapper (@Mapper, #{} only)
|
||||
int insert(JamEntryData entry) // 출품(UNIQUE 위반 시 컨트롤러 catch→409)
|
||||
List<JamEntryData> listByJam(long jamId) // 상세 출품작(JOIN games 표시필드)
|
||||
boolean exists(long jamId, long gameId) // 사전 중복 체크(409 친절 메시지용)
|
||||
|
||||
// JamTeamsMapper (@Mapper, #{} only)
|
||||
int insert(JamTeamData team) // 팀 생성
|
||||
JamTeamData getById(long teamId) // 멤버 추가 시 팀장 검증 소스
|
||||
List<JamTeamData> listByJam(long jamId) // 상세 팀 목록
|
||||
|
||||
// JamTeamMembersMapper (@Mapper, #{} only)
|
||||
int insert(long jamTeamId, long userId) // 멤버 추가
|
||||
boolean exists(long jamTeamId, long userId) // 팀 출품 시 멤버십 검증 + 중복 가입 방지
|
||||
|
||||
// JamStatusLogMapper (@Mapper, #{} only)
|
||||
int insert(long jamId, // 대상 잼
|
||||
String fromStatus, // 이전 상태(최초 NULL 허용)
|
||||
String toStatus, // 전이 후 상태
|
||||
Long actorId, // 수동=관리자 id, 자동=null
|
||||
String transitionType) // 'MANUAL'|'AUTO'
|
||||
```
|
||||
> inflate 마킹(concern 1): `JamLifecycle.assertPeriodReady(jam, to)` 의 `jam` 파라미터는 전체 JamData 를 받지만 실제로는 기간 필드 4개만 읽는다 — 구현에서 사용 필드가 1~2개로 좁혀지면 필요 필드만 받는 시그니처로 축소 검토(과한 전달 방지). `resolveEntrant`(컨트롤러 private)는 의사코드상 분기일 뿐 별도 헬퍼로 추출 시 (userId, jam, gameId, jamTeamId) 전부 실제 사용되는지 구현 시 재확인.
|
||||
|
||||
---
|
||||
|
||||
## 자동전이 훅 (D3 — 인터페이스만, 구현 후속)
|
||||
- `JamLifecycle.transition(...)` 코어는 수동(JamAdminController)·자동(후속 스케줄러) 양쪽이 호출하는 단일 경로. 자동전이는 `jam_status_log.transition_type='AUTO'`, actor_id=null 로 기록.
|
||||
- 본 설계는 **훅 시그니처만 명시**(아래), `@Scheduled` 빈 구현은 W2-1 범위 밖(concern 5 — context 영향 재확인 의무).
|
||||
```java
|
||||
// (후속) JamAutoTransitionRunner — eval_end_at 경과 잼을 CLOSED 로. @Scheduled 본체는 후속.
|
||||
// 같은 JamLifecycle 검증·감사 경로 재사용(중복 0). 본 설계는 호출 계약만 고정.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 대안 비교
|
||||
|
||||
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||
|---|---|---|---|---|
|
||||
| 잼-게임 연결 | (A) 조인테이블 jam_entries | 정규화, games 무변경(허브 회귀 0), 다회차 출품·entry 메타 | 테이블 1개 추가 | **채택(D1)** |
|
||||
| | (B) games.jam_id nullable 컬럼 | 단순 | games 변경(허브/삭제연쇄 회귀 위험), 1게임 1잼 한정, entry 메타 불가 | 기각 |
|
||||
| 출품 주체 | (A) entrant_type + user/team XOR | 개인·팀 동시(잼 본질), 단일 테이블 | CHECK 2개 | **채택(D2)** |
|
||||
| | (B) 개인만 우선, 팀은 후속 | 1차 단순 | 잼=팀 이벤트 본질과 어긋남, 후속 스키마 변경 재작업 | 기각 |
|
||||
| 상태 모델 | (A) 명시 컬럼 + 수동전이(감사) + 자동훅 | 운영 통제·감사·자동 보조 공존, 단일 검증 코어 | JamLifecycle 도메인 1개 | **채택(D3)** |
|
||||
| | (B) 기간 필드만 계산 상태 | 컬럼 절약 | 운영 수동 개입 불가, 감사 부재, 경계시각 모호 | 기각 |
|
||||
| 목록 페이징 | (A) keyset(created_at,id 커서) | 깊은 페이지 O(log n), 누락/중복 없음 | 커서 파싱 | **채택(D5)** |
|
||||
| | (B) offset/limit | 단순 | 깊은 페이지 비용·삽입 시 행 밀림 중복 | 기각 |
|
||||
| | (C) 전건 로드(Recruit 선례) | 최단 | 잼 누적 증가 시 비효율 | 기각 |
|
||||
| 잼관리 enforcement | (A) 인터셉터 exclude + 컨트롤러 게이트 헬퍼 | SUBADMIN+키 통과, ADMIN 콘솔 보호 유지, W1 선례 | exclude 1줄 + 헬퍼 | **채택(D4-A)** |
|
||||
| | (B) 인터셉터에 잼 경로별 권한키 매핑 | 중앙집중 | 인터셉터에 경로↔키 테이블 신설(오버엔지니어링, W1서 기각된 방향) | 기각 |
|
||||
| | (C) 임시 role 직접체크 | 빠름 | W1 인프라 우회·정석 위반(금지) | 기각 |
|
||||
|
||||
---
|
||||
|
||||
## 롤아웃 / 마이그레이션
|
||||
|
||||
### 순서
|
||||
1. **스키마 적용**: `docs/jam-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용. games 무변경 → 기존 데이터 회귀 0.
|
||||
2. **코드 배포**: J-DOMAIN → J-MAPPER → J-PUBLIC/J-ADMIN/J-CONFIG. InterceptorConfig exclude 와 JamAdminController 게이트는 **동시 배포**(exclude 만 먼저 가면 잼 경로 무보호 노출, 게이트만 먼저 가면 SUBADMIN 인터셉터에 막힘 — 같은 PR/커밋으로).
|
||||
3. **권한 시드 불필요**: GAME_JAM_MANAGE 키는 PermissionCatalogVerifier 가 이미 시드(grounding R-A). 본 설계는 enforcement 연결만.
|
||||
4. **운영**: ADMIN 이 콘솔에서 SUBADMIN 에게 GAME_JAM_MANAGE 부여 → 잼 관리 위임 가능(W1 epoch 즉시 반영).
|
||||
|
||||
### 역호환
|
||||
- 기존 games/허브/리뷰/좋아요 동작 불변(games 무변경, FK 추가만). 기존 사용자 영향 0.
|
||||
- `/admin/jams/**` exclude 는 신규 경로라 기존 `/admin/console` 보호 무변경.
|
||||
|
||||
### 롤백
|
||||
- 코드 롤백: JamController/JamAdminController/InterceptorConfig 되돌리면 잼 경로 미노출. 신규 테이블은 추가 전용이라 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance.
|
||||
- exclude 롤백 시 `/admin/jams/**` 가 다시 인터셉터 ADMIN 게이트로 — 컨트롤러 게이트 헬퍼가 내부에도 있어 이중 안전(미노출이면 무영향).
|
||||
|
||||
---
|
||||
|
||||
## AC 매핑
|
||||
|
||||
| AC | 요구(골자 W2-1) | 만족 설계 요소 | 비고 |
|
||||
|---|---|---|---|
|
||||
| AC-1 | jams 라이프사이클 4상태 + 기간 필드 | jams.status CHECK 4값 + 기간 4컬럼 + JamLifecycle | §데이터모델1, D3 |
|
||||
| AC-2 | 잼-게임 연결 정규화(games 무변경) | jam_entries 조인, games DDL 0변경 | D1, §games무변경 |
|
||||
| AC-3 | 잼당 게임 1회 출품 | ux_jam_entries_jam_game_active UNIQUE | §데이터모델4 |
|
||||
| AC-4 | 개인 OR 팀 출품 | entrant_type + XOR CHECK + jam_teams/members | D2 |
|
||||
| AC-5 | 관리자 잼 CRUD = GAME_JAM_MANAGE 게이트 | JamAdminController requireJamManage(gate.has) + exclude | D4, D4-A |
|
||||
| AC-6 | SUBADMIN(+키) 잼 관리 통과 / 미보유 403 | gate.has(ADMIN OR SUBADMIN+키), 인터셉터 exclude | D4-A, S1 |
|
||||
| AC-7 | 공개 목록(keyset)/상세 | JamController list(keyset)/detail | D5, S3 |
|
||||
| AC-8 | 출품작 이중 노출 | games.is_visible 유지(허브) + jam_entries JOIN(잼뷰) | D6 |
|
||||
| AC-9 | 상태전이 감사 | jam_status_log insert(수동/자동, from/to/actor) | D3, S1 |
|
||||
| AC-10 | 상태변경 전수 CSRF | 모든 쓰기 액션 CsrfTokens.isValid 선검증 → 403 | §외부계약 공통 |
|
||||
| AC-11 | 권한 SQL `${}` 0 | 신규 5매퍼 `#{}` only | §파일영향맵 |
|
||||
| AC-12 | 운영 표시 필드 | jams.discord_url/prize_info/sponsor_info(표시 전용) | D7 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 포인트 (verification-advisor 점검 대상)
|
||||
|
||||
> L레벨 매핑(verification-strategies): 인가/게이트/상태전이 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·keyset 커서 비교 = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30).
|
||||
|
||||
### 시나리오 검증
|
||||
- **VP-1 (AC-5/6 게이트, L1+L3)**: JamAdminControllerTest — ADMIN 세션 통과 / SUBADMIN+GAME_JAM_MANAGE 통과 / SUBADMIN 무키 403 / 미인증 401(redirect). L3 스모크: InterceptorConfig exclude 후 SUBADMIN 이 `/admin/jams` 도달.
|
||||
- **VP-2 (AC-9 전이 감사, L1)**: JamLifecycleTest 허용/거부 전이 + JamAdminControllerTest 전이 후 jam_status_log insert 호출(from/to/actor/type).
|
||||
- **VP-3 (AC-3/4 출품 무결성, L1)**: JamControllerTest — 개인 출품(소유 검증), 팀 출품(멤버 검증), 중복 출품 409, 비소유 게임 422, 비멤버 팀 422, EVAL 상태 출품 422.
|
||||
- **VP-4 (AC-7 keyset, L1+L2)**: 목록 커서 진행 시 중복/누락 0, 동시각 id tie-break 동작. dev DB contract: row-comparison/OR 분해 SQL 실측.
|
||||
- **VP-5 (AC-10 CSRF, L1)**: 쓰기 액션 전수 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용).
|
||||
- **VP-6 (DB-방언 계약, L2)**: 신규 매퍼 반환 POJO 키 == 컨트롤러/JSP 조회 키(snake→camel 직접 alias 확인, 큰따옴표 alias 미사용). XOR CHECK·status CHECK 위반 INSERT 거부 실측.
|
||||
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 5매퍼 @MockBean 등록 후 PASS(§30). 누락 시 NoSuchBeanDefinitionException.
|
||||
|
||||
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||
> self-audit(시점): 아래 카운트는 **본 설계가 신규 생성하는 정적 산출물**(DDL 테이블·CHECK·매퍼·액션)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님.
|
||||
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 enum 멤버/CHECK IN 목록/액션 핸들러 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴이 정당).
|
||||
|
||||
- **AC-T1 잼 신규 테이블 전수 5건 존재** — docs/jam-ddl.sql 의 `CREATE TABLE IF NOT EXISTS` 5건(jams/jam_teams/jam_team_members/jam_entries/jam_status_log): `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-ddl.sql` == 5. AND db/schema.sql 에 동일 5 테이블명 전수 존재(동기 사본 누락 검출). 테이블 추가/삭제 누락을 갯수 1로 커버.
|
||||
- **AC-T2 상태 4값 정합 불변식** — `JamStatus` enum 멤버 수 == jams_status_check CHECK IN 항목 수 == jam_status_log to_status CHECK IN 항목 수 == 4(RECRUIT/DEV/EVAL/CLOSED). 검증: enum 멤버 `grep -c` == 4 AND DDL 두 CHECK IN 목록 각 4항목. 상태 추가 시 enum↔DDL↔log 3곳 동기 누락 동시 검출(불변식).
|
||||
- **AC-T3 관리자 잼 액션 전수 6건 게이트** — JamAdminController 의 핸들러(콘솔/생성/수정/상태전이/가시성/삭제) 전수가 `requireJamManage` 호출: 게이트 헬퍼 호출 수 == 핸들러 수(상태변경 핸들러는 추가로 CsrfTokens.isValid). 핸들러 추가 시 게이트 누락 = 인가 우회 보안결함 → FAIL. **이 전수 AC 가 D4 enforcement 의 핵심 가드**(수동 판정: @PostMapping/@GetMapping 핸들러 열거 후 각 진입부 requireJamManage 확인 — 리터럴 grep 단독 의존 회피).
|
||||
- **AC-T4 잼 매퍼 전수 5개 `${` 0건** — 신규 매퍼 5파일(JamsMapper/JamEntriesMapper/JamTeamsMapper/JamTeamMembersMapper/JamStatusLogMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 5파일>` == 0 (AC-11, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||
- **AC-T5 entrant XOR 무결성** — jam_entries_entrant_xor_check + jam_entries_entrant_type_check 2 CHECK 전수 존재 AND 위반 INSERT(USER인데 jam_team_id 채움 / 둘 다 NULL / 둘 다 채움)가 DB 거부(L2 실측). 개인/팀 정확히 하나 보장(D2 핵심 가드).
|
||||
- **AC-T6 잼 신규 매퍼 @MockBean 전수 5건** — BibimbapApplicationTests 에 신규 5 매퍼 @MockBean 전수 등록: contextLoads PASS AND `grep -c '@MockBean.*Jam' BibimbapApplicationTests.java` >= 5(또는 매퍼별 등록 수동 확인). 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점에 자기 검증).
|
||||
|
||||
---
|
||||
|
||||
## 잔여 오픈 질문
|
||||
없음(0). 확정 결정 D1~D8 전제 고정, 두 난제(개인/팀 XOR 정합·상태전이 감사+자동훅)는 본 설계가 구체 메커니즘으로 확정. 인터셉터 exclude vs 컨트롤러 게이트(D4-A)도 확정. 구현 점검 항목(시그니처 inflate·full-test @MockBean·keyset SQL 방언·slug 충돌 재시도·자동전이 스케줄러 context)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||
|
|
@ -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<Map<String,Object>>`(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<JamJudgeData> 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` 로 이관.
|
||||
|
|
@ -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<JamCriterionData> listByJam(long jamId) // 점수입력 폼·집계 라벨
|
||||
|
||||
// JamScoresMapper (@Mapper, #{} only)
|
||||
int upsertScore(long jamId, // 평가단위 1/2
|
||||
long gameId, // 평가단위 2/2(출품작)
|
||||
long judgeUserId, // 심사위원(세션)
|
||||
String criterionKey, // 채점 기준
|
||||
int score) // 1~5 (UNIQUE 충돌 시 UPDATE — ON CONFLICT 또는 exists→update)
|
||||
List<JamScoreData> listByJudge(long jamId, long judgeUserId) // 심사위원 본인 입력 현황
|
||||
|
||||
// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias)
|
||||
List<Map<String,Object>> listStatsByJam(long jamId) // 출품작별 종합(정렬·시상 소스)
|
||||
Map<String,Object> getStats(long jamId, long gameId) // 단건 출품작 종합
|
||||
|
||||
// --- W2-5 소유 (jam_votes) ---
|
||||
// JamVotesMapper (@Mapper, #{} only)
|
||||
int castVote(long jamId, long gameId, long voterUserId) // 투표(UNIQUE 1인1표; 변경=update game_id)
|
||||
int updateVote(long jamId, long voterUserId, long gameId) // 표 변경(최애 교체)
|
||||
boolean hasVoted(long jamId, long voterUserId) // 1인1표 사전 체크
|
||||
long countByGame(long jamId, long gameId) // 출품작 득표(또는 listCounts 집계)
|
||||
|
||||
// --- W2-6 소유 (jam_awards + 3트랙 소비) ---
|
||||
// JamAwardsMapper (@Mapper, #{} only)
|
||||
int upsertAward(JamAwardData award) // 트랙·순위 수상 기록(재산정 멱등)
|
||||
int deleteByJamTrack(long jamId, String track) // 재산정 전 트랙 초기화
|
||||
List<JamAwardData> listByJam(long jamId) // 시상 결과 노출
|
||||
```
|
||||
> inflate 마킹(concern 2): 위 시그니처는 **계약 골격**이다. 하류 구현 시 각 인자가 실제 SQL/로직에 쓰이는지 재확인(예: `JamScoresMapper.upsertScore` 를 ON CONFLICT 로 구현하면 별도 exists 조회 불요 — exists 메서드 추가 inflate 금지). 매퍼 본체·@MockBean 등록은 하류 소관.
|
||||
|
||||
---
|
||||
|
||||
## 시퀀스 (계약 검증용 의사코드 — 하류 구현이 따를 흐름)
|
||||
|
||||
### S1. 심사 점수 입력 (W2-4 — 본 동결 스키마 소비)
|
||||
```
|
||||
[심사위원 세션] POST /jams/{slug}/scores (CSRF, gameId, {criterionKey: score, ...})
|
||||
→ (W2-4 컨트롤러)
|
||||
→ CsrfTokens.isValid (아니면 403)
|
||||
→ userId = sessionUserId (없으면 401)
|
||||
→ jam = jamsMapper.getBySlug(slug) (없으면 404)
|
||||
→ 평가기간 게이트(F6): jam.status=='EVAL' AND now∈[eval_start,eval_end]? (아니면 422)
|
||||
→ 출품작 존재: jamEntriesMapper.exists(jam.id, gameId)? (아니면 404/422)
|
||||
→ 심사위원 자격(W2-2): jamJudgesGate.isJudge(jam.id, userId)? (아니면 403)
|
||||
→ for (criterionKey, score) in 입력:
|
||||
jamScoresMapper.upsertScore(jam.id, gameId, userId, criterionKey, score)
|
||||
→ 200 {message}
|
||||
# jam_score_stats VIEW 가 자동 반영(집계는 읽기 시점 계산).
|
||||
```
|
||||
|
||||
### S2. 인기투표 (W2-5 — 본 동결 스키마 소비)
|
||||
```
|
||||
[로그인 유저] POST /jams/{slug}/votes (CSRF, gameId)
|
||||
→ (W2-5 컨트롤러)
|
||||
→ CsrfTokens.isValid (아니면 403)
|
||||
→ userId = sessionUserId (없으면 401)
|
||||
→ jam = getBySlug(slug); 평가기간 게이트(F6) (아니면 422)
|
||||
→ 출품작 존재(jam_entries 활성)? (아니면 404)
|
||||
→ hasVoted(jam.id, userId)?
|
||||
미투표 → castVote(jam.id, gameId, userId) (UNIQUE 보장 1인1표)
|
||||
기투표 → updateVote(jam.id, userId, gameId) (최애 교체 정책 — W2-5 결정)
|
||||
→ 200 {message, votedGameId}
|
||||
```
|
||||
|
||||
### S3. 시상 집계 확정 (W2-6 — 3트랙 + grand, 본 동결 스키마+계약 소비)
|
||||
```
|
||||
[관리자] POST /admin/jams/{jamId}/awards/compute (CSRF, GAME_JAM_MANAGE 게이트)
|
||||
→ (W2-6 컨트롤러)
|
||||
→ requireJamManage + CSRF
|
||||
→ jam.status=='CLOSED' OR now()>eval_end_at? (아니면 422 — 시상 미개방)
|
||||
→ JUDGE 트랙: jam_score_stats.weighted_total DESC → rank 부여 → upsertAward(track='JUDGE')
|
||||
→ USER_RATING: game_review_stats.avg_rating DESC NULLS LAST, review_count>=3
|
||||
→ rank → upsertAward(track='USER_RATING') # 단방향 읽기(G4)
|
||||
→ POPULAR: jam_votes count DESC → rank → upsertAward(track='POPULAR')
|
||||
→ GRAND: 3트랙 가중 종합(가중치 jam 설정/동률규칙) → 최상위 → upsertAward(track='GRAND')
|
||||
→ 200 {message, awardCounts}
|
||||
# 재실행 = deleteByJamTrack 후 재산정(멱등). game_reviews write 0.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파일 영향 맵
|
||||
|
||||
> 본 W2-3 은 **스키마+계약 동결**이 범위다. 신규 = DDL 권위 파일 + schema.sql 동기 2건만. 매퍼/data POJO/컨트롤러/JSP/테스트는 **하류 W2-4/5/6 소유**(아래 "하류 소유 — 본 설계 미생성" 표는 계약 추적용으로만 명시, 본 설계가 만들지 않음).
|
||||
>
|
||||
> 소유권 분할(본 W2-3 worker 단위): **E-SCHEMA**(jam-eval-ddl.sql + schema.sql 동기) 단일. data POJO 계약(JamCriterionData/JamScoreData/JamAwardData 등)은 하류가 자기 워크스트림에서 생성.
|
||||
|
||||
### 본 W2-3 이 생성/수정 (동결 산출물)
|
||||
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||
|---|---|---|---|
|
||||
| 신규 | `docs/jam-eval-ddl.sql` | 권위 DDL(jam_criteria/jam_scores/jam_votes/jam_awards/jam_score_stats VIEW). apply-local-ddl.sh 자동 적용(알파벳: jam-ddl 뒤) | E-SCHEMA |
|
||||
| 수정 | `db/schema.sql` | 위 4테이블+1뷰 블록 추가(jam-eval-ddl 사본). jams/jam_entries 블록 뒤. game_reviews/games 무변경 | E-SCHEMA |
|
||||
|
||||
### 하류 소유 — 본 설계 미생성 (계약 추적용 — crossRefs)
|
||||
| 소유 W | 생성물(예) | 본 동결 의존점 |
|
||||
|---|---|---|
|
||||
| W2-4 | JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper + data POJO + 점수입력 컨트롤러/JSP | jam_criteria/jam_scores/jam_score_stats VIEW + 평가기간 게이트(F6) + 집계 계약(G6) |
|
||||
| W2-5 | JamVotesMapper + data + 투표 컨트롤러/JSP | jam_votes UNIQUE(1인1표) + 평가기간 게이트(F6) |
|
||||
| W2-6 | JamAwardsMapper + data + 시상 산정/확정 컨트롤러/JSP | jam_awards 3트랙 + 단방향 유저평점 계약(G4/F5) + 집계 계약(G6) + jam_score_stats/game_review_stats/jam_votes count |
|
||||
| W2-2 | jam_judges 게이트 | jam_scores.judge_user_id 의 is-judge 검증(앱계층) |
|
||||
|
||||
> SSR 호출지점 영향(verification §영향맵): 본 W2-3 은 신규 DDL/VIEW 만 추가, 기존 매퍼·JSP·컨트롤러 무변경 → 기존 소비처 0 영향. game_review_stats VIEW 무변경이므로 W3-2 리뷰 요약 화면 회귀 0.
|
||||
|
||||
---
|
||||
|
||||
## 대안 비교
|
||||
|
||||
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||
|---|---|---|---|---|
|
||||
| 평가단위 식별자 | (A) (jam_id, game_id) 자연키 + 앱계층 출품검증 | orchestrator 동결 컬럼 일치, game_review_stats join 정합, entrant 불투명 유지 | FK 출품 보장은 앱계층(jam_entries 활성행) | **채택(F8)** |
|
||||
| | (B) jam_entries.id 직접 FK | 출품 보장 DB 강제 | game_review_stats(game_id)와 join 불일치, orchestrator 동결과 어긋남, entrant.id 노출 | 기각(concern 1 재확인 마킹) |
|
||||
| 심사 척도 | (A) 잼별 설정형 jam_criteria | 잼마다 기준 다름(정석), 가중 종합 가능 | criteria 테이블 1개 | **채택(F1)** |
|
||||
| | (B) 고정 6축(리뷰 axes 재사용) | 단순 | 잼별 심사 기준 다양성 부정, 리뷰축과 의미 강결합 | 기각 |
|
||||
| 유저평점 트랙 소스 | (A) avg_rating 단일평균 | 단순·명확, 리뷰 6축과 의미 충돌 회피, VIEW 그대로 | 6축 정보 미반영 | **채택(F5)** |
|
||||
| | (B) 6축 가중 평균 | 다면 평가 | 잼 criteria 와 의미 이중화, 6축 NULL 처리 복잡 | 기각(6축=표시전용) |
|
||||
| 유저평점 기간 | (A) 전체 리뷰 avg_rating | game_review_stats 재집계 0, 리뷰=잼무관 상시 정합 | 평가기간 외 리뷰도 반영 | **채택(F5)** |
|
||||
| | (B) 평가기간 내 리뷰만 | 기간 정합 | game_review_stats 기간필터 재집계 부담, 리뷰 도메인 잼 결합 유발 | 기각 |
|
||||
| 인기투표 저장 | (A) jam_votes 신규(1인1표 UNIQUE) | 1인1표 DB 강제, user_id 기반, 잼 무관 game_likes 분리 | 테이블 1개 | **채택(F3)** |
|
||||
| | (B) game_likes 재활용 | 재사용 | 비권위 복원본·user_key varchar·1인1표 비보장(grounding R-D) | 기각 |
|
||||
| 심사 집계 노출 | (A) jam_score_stats VIEW(선집계) | fan-out 방지(game_review_stats 선례), 정렬/시상 소스 단일 | VIEW 1개 | **채택(F7)** |
|
||||
| | (B) 매퍼 GROUP BY 매번 | VIEW 없음 | 소비처마다 집계 SQL 중복, fan-out 위험 반복 | 기각 |
|
||||
| 투표 집계 | (A) COUNT 쿼리(VIEW 없음) | 단순, over-engineering 회피 | — | **채택(F7)** |
|
||||
| | (B) jam_vote_stats VIEW | 일관성 | 단순 count 에 VIEW 과도 | 기각 |
|
||||
|
||||
---
|
||||
|
||||
## 롤아웃 / 마이그레이션
|
||||
|
||||
### 순서
|
||||
1. **스키마 적용**: `docs/jam-eval-ddl.sql` → `db/apply-local-ddl.sh`(로컬). 운영은 동일 멱등 DDL 수동 적용.
|
||||
- **선행 의존**: jam-eval-ddl 의 FK 가 jams/jam_entries(W2-1 docs/jam-ddl.sql) + games/users(기존) 를 참조 → **jam-ddl.sql 이 먼저 적용돼야 함**. apply-local-ddl.sh 알파벳 글롭에서 `jam-ddl.sql` < `jam-eval-ddl.sql`(공통 prefix `jam-` 뒤 `d` < `e`) → 순서 자동 보장. game_reviews(W3-2 docs/game-reviews-ddl.sql, `g` < `j`)도 먼저 적용됨 → game_review_stats VIEW 존재 보장(시상 소비 가능).
|
||||
2. **하류 코드 배포**: W2-4/5/6 가 본 동결 스키마를 소비하는 매퍼/컨트롤러 구현. 본 W2-3 은 코드 배포 없음(스키마만).
|
||||
3. **권한**: GAME_JAM_MANAGE(W1 시드) + jam_judges(W2-2). 본 W2-3 추가 시드 없음.
|
||||
|
||||
### 역호환
|
||||
- games/game_reviews/game_review_stats/game_likes/jams/jam_entries **전부 무변경**. 기존 사용자·리뷰·게임 동작 0 영향.
|
||||
- 신규 테이블/VIEW 는 추가 전용(비파괴). 하류 미배포 상태에서도 빈 테이블/VIEW 로 잔존 무해.
|
||||
|
||||
### 롤백
|
||||
- 스키마 롤백: 신규 4테이블+1뷰는 추가 전용 → drop 없이 잔존 무해(비파괴). 명시 DROP 은 별도 maintenance(`DROP VIEW IF EXISTS jam_score_stats; DROP TABLE IF EXISTS jam_awards, jam_votes, jam_scores, jam_criteria CASCADE;` — FK 역순).
|
||||
- 코드 롤백: 본 W2-3 은 코드 0 → 롤백 대상 없음. 하류 롤백은 각 워크스트림 소관.
|
||||
|
||||
---
|
||||
|
||||
## AC 매핑
|
||||
|
||||
| AC | 요구(골자 W2-3) | 만족 설계 요소 | 비고 |
|
||||
|---|---|---|---|
|
||||
| AC-1 | 심사 척도 = 잼별 설정형 criteria | jam_criteria(jam_id, criterion_key, weight) + ux_jam_criteria_jam_key | F1, §데이터모델1 |
|
||||
| AC-2 | 심사 점수 1~5, 1심사위원1기준1점 | jam_scores score CHECK 1~5 + ux_jam_scores_jam_game_judge_criterion | F2, §데이터모델2 |
|
||||
| AC-3 | 인기투표 잼당 1인1표 | jam_votes ux_jam_votes_jam_voter UNIQUE | F3, §데이터모델3 |
|
||||
| AC-4 | 투표 game_likes 와 별개 | jam_votes 신규 테이블(user_id 기반), game_likes 무참조 | F3, §대안 |
|
||||
| AC-5 | 시상 3트랙 + grand | jam_awards award_track CHECK 4값(JUDGE/USER_RATING/POPULAR/GRAND) | F4, §데이터모델4 |
|
||||
| AC-6 | ★유저평점 트랙 단방향(avg_rating 단일) | game_review_stats.avg_rating 읽기만, 6축 미사용, game_reviews 무변경 | G4/F5, §단방향계약 |
|
||||
| AC-7 | 유저평점 NULL/미달 처리 | NULLS LAST + review_count>=3 임계 | F5, §단방향계약 |
|
||||
| AC-8 | 평가기간 게이트(점수/투표 EVAL, 시상 CLOSED) | F6 계약(앱계층 jam.status + now∈eval 구간) | §평가기간게이트계약 |
|
||||
| AC-9 | 심사 집계 fan-out 방지 VIEW | jam_score_stats per_criterion 선집계 + 가중종합 NULLIF 가드 | F7/난제3, §데이터모델5 |
|
||||
| AC-10 | 투표 집계 = count | jam_votes COUNT 계약(VIEW 없음) | F7, §집계노출계약 |
|
||||
| AC-11 | 권한/평가 SQL `${}` 0 | DDL·계약 매퍼 시그니처 `#{}` only(하류 강제) | §매퍼시그니처계약 |
|
||||
| AC-12 | 집계 VIEW 매퍼 alias 큰따옴표 | game_review_stats/jam_score_stats 소비 매퍼 AS "..." | §단방향계약/집계계약 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 포인트 (verification-advisor 점검 대상)
|
||||
|
||||
> L레벨 매핑(verification-strategies): 동결 스키마 무결성(CHECK/UNIQUE/FK)·집계 VIEW(fan-out/NULL/0-division) = **L1+L2(dev DB contract)**. 단방향 계약·평가기간 게이트 = **L1**(스키마 차원) + 하류 구현 시 **L3**. 본 W2-3 은 코드 빈 추가 0이므로 contextLoads(§30 @MockBean)는 **하류 책임**(본 설계 매퍼 미생성).
|
||||
|
||||
### 시나리오 검증
|
||||
- **VP-1 (AC-2/3 무결성, L2)**: jam_scores 같은 (jam_id,game_id,judge,criterion) 중복 INSERT → UNIQUE 거부. score 0/6 INSERT → CHECK 거부. jam_votes 같은 (jam_id, voter) 2표 INSERT → UNIQUE 거부(1인1표). dev DB contract 실측.
|
||||
- **VP-2 (AC-9 집계 정합, L2)**: jam_score_stats 가 동일 출품작에 다수 심사위원·다수 criterion 입력 시 ① fan-out 없이 weighted_total 정확(SUM(avg*weight)/SUM(weight)) ② criterion 일부 미채점 시 채점 기준만 반영 ③ weight 전부 0 인 경계에서 0-division 없이 NULL(NULLIF 가드) — 샘플 데이터 실측(game_review_stats BUG-1 fan-out 선례 재발 방지).
|
||||
- **VP-3 (AC-6 단방향, L1)**: 시상 USER_RATING 소비 SQL 이 game_review_stats 를 SELECT 만(write 0), game_reviews 에 jam FK 부재 확인(code 정합). 6축 컬럼 미참조 확인.
|
||||
- **VP-4 (AC-7 NULL/미달, L2)**: 리뷰 0개 출품작 avg_rating NULL → NULLS LAST 정렬 말단 + review_count<3 제외. review_count==3 경계 포함. 샘플 실측.
|
||||
- **VP-5 (AC-5 트랙값, L2)**: jam_awards award_track 에 'JUDGE'/'USER_RATING'/'POPULAR'/'GRAND' 외 값 INSERT → CHECK 거부.
|
||||
- **VP-6 (FK 적용 순서, L2)**: apply-local-ddl.sh 가 jam-ddl(W2-1) 적용 후 jam-eval-ddl 적용 시 FK 생성 성공(jams/jam_entries/games/users 선존재). 단독/역순 적용 시 FK 실패 검출.
|
||||
- **VP-7 (AC-12 alias, L2)**: 하류 jam_score_stats 매퍼 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper 케이스폴딩 BUG-2 선례 회피).
|
||||
|
||||
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||
> self-audit(시점): 아래 카운트는 **본 W2-3 이 신규 생성하는 정적 산출물**(DDL 테이블·VIEW·CHECK·트랙값)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 하류(W2-4/5/6)는 본 동결 스키마를 **소비만** 하고 본 DDL 파일을 수정하지 않으므로(소유 분리), 본 파일 카운트는 verification 시점에 불변.
|
||||
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 CHECK IN 목록/트랙 enum/테이블 생성문 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건은 부재 검증이라 리터럴 정당.
|
||||
|
||||
- **AC-T1 잼 평가 신규 테이블 전수 4건 + VIEW 1건** — docs/jam-eval-ddl.sql 의 `CREATE TABLE IF NOT EXISTS` 4건(jam_criteria/jam_scores/jam_votes/jam_awards) AND `CREATE OR REPLACE VIEW` 1건(jam_score_stats): `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-eval-ddl.sql` == 4 AND `grep -c 'CREATE OR REPLACE VIEW' docs/jam-eval-ddl.sql` == 1. AND db/schema.sql 에 동일 4테이블+1뷰 전수 존재(동기 사본 누락 검출). 테이블/뷰 추가·삭제 누락을 갯수로 동시 커버.
|
||||
- **AC-T2 시상 트랙 전수 4종 정합 불변식** — jam_awards_track_check CHECK 의 IN 목록(JUDGE/USER_RATING/POPULAR/GRAND) 4종 == 시상 산정(W2-6)이 upsert 하는 award_track 집합. 검증: DDL CHECK IN 항목 4 AND (하류 W2-6 구현 시) 3개별 트랙 + GRAND 전수 산정 경로 존재. 트랙 추가·누락을 갯수 1로 커버(F4 핵심 가드).
|
||||
- **AC-T3 점수/투표 무결성 제약 전수** — 동결 핵심 UNIQUE/CHECK 4종 전수 존재: ux_jam_scores_jam_game_judge_criterion(1심사위원1기준1점), ux_jam_votes_jam_voter(1인1표), jam_scores_score_check(1~5), jam_awards_track_check(4트랙). 검증: `grep -c 'CREATE UNIQUE INDEX' docs/jam-eval-ddl.sql` >= 4(criteria/scores/votes/awards 각 1) AND 위 4 제약명 전수 존재. 1건 누락 = 무결성 결함(1인1표/중복채점 우회) → FAIL.
|
||||
- **AC-T4 평가 매퍼 시그니처 계약 전수 `${` 0건(하류 검증)** — 하류 W2-4/5/6 가 본 계약대로 구현한 신규 매퍼 전수에 `${` 매치 0: `grep -rc '\${' <하류 jam-eval 매퍼들>` == 0 (AC-11, `${}` 금지). 부재 검증이라 리터럴 정당. **본 W2-3 은 매퍼 미생성 → 이 AC 는 하류 verification 시점 검사**(계약 위임 명시).
|
||||
- **AC-T5 단방향 무결성(write 0) 불변식** — game_reviews/game_review_axes/game_review_stats 가 본 동결로 인해 변경 0: docs/jam-eval-ddl.sql 에 `game_reviews`/`game_review` 토큰의 ALTER/CREATE/INSERT/UPDATE 0건(읽기 계약뿐 — 주석/SELECT 형태 예시는 무방하나 DDL 변경문 0). 검증: `grep -E 'ALTER TABLE .*game_review|CREATE TABLE .*game_review' docs/jam-eval-ddl.sql` 0건. 단방향 계약(G4) 위반(시상이 리뷰 스키마 손대기) 즉시 검출.
|
||||
- **AC-T6 FK 적용 순서 불변식** — jam-eval-ddl 의 FK 가 참조하는 선행 테이블 전수(jams/jam_entries/games/users) 가 apply 시점에 존재: 알파벳 글롭 순서상 jam-ddl/game-reviews-ddl 가 jam-eval-ddl 보다 먼저(공통 prefix 비교) → FK 생성 성공. 검증: apply-local-ddl.sh dry-run 또는 dev DB 전체 적용 후 jam_scores/jam_votes/jam_awards/jam_criteria 의 FK 4종 전수 생성 확인(pg_constraint). 순서 깨짐 시 FK 미생성 검출.
|
||||
|
||||
---
|
||||
|
||||
## 잔여 오픈 질문
|
||||
없음(0). 동결 결정 F1~F8 전제 고정. 세 난제(평가단위 식별자 정합·단방향 유저평점 계약·집계 fan-out/가중/NULL)는 본 설계가 구체 메커니즘으로 확정. 평가기간 게이트·집계 노출·매퍼 시그니처 계약 동결. 구현 점검 항목(평가단위 jam_entries.id vs game_id 재확인·하류 @MockBean·VIEW 0-division dev contract·최소리뷰수 임계 가변화·jam_entries UNIQUE 동결 유지)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||
|
|
@ -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<Map<String,Object>>` 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<Map<String,Object>>`(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<JamCriterionData> 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<JamScoreData> listByJudge(long jamId, // 잼 스코프
|
||||
long gameId, // 출품작
|
||||
long judgeUserId)// 본인 현황(폼 prefill) — 3키로 본인 criterion별 점수
|
||||
|
||||
// JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias)
|
||||
List<Map<String,Object>> 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' <K-MAPPER 3파일>` == 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` 로 이관.
|
||||
|
|
@ -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<Map<String,Object>>`(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<Map<String,Object>> 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` 로 이관.
|
||||
|
|
@ -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<Map<String,Object>>`(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 방지)
|
||||
<T> Map<T,Integer> standardCompetitionRank(List<T> items, // 산정 대상(트랙 모집단)
|
||||
Comparator<T> byScoreDesc) // 점수 내림차순+tiebreak
|
||||
<T> Map<T,Double> rankScore(List<T> items, // 동일 모집단
|
||||
Comparator<T> byScoreDesc) // (N-rank+1)/N 정규화 — GRAND 가중합 입력
|
||||
|
||||
// JamAwardsMapper (@Mapper, #{} only, 일반 매퍼 snake→camel 직접 alias)
|
||||
int insert(JamAwardData award) // 트랙·순위 수상 기록(재산정 후 다건)
|
||||
int deleteByJamTrack(long jamId, String track) // 재산정 전 트랙 초기화(멱등)
|
||||
List<JamAwardData> listByJamWithGame(long jamId) // 결과 페이지(JOIN games 표시 + 트랙·rank 정렬)
|
||||
|
||||
// JamReviewRatingMapper (@Mapper, #{} only, 집계 VIEW 소비 → camelCase 큰따옴표 alias)
|
||||
List<JamReviewRatingRow> listAvgByJam(long jamId) // USER_RATING 모집단(review_count>=3, NULLS LAST)
|
||||
|
||||
// JamVoteCountMapper (@Mapper, #{} only, 일반 매퍼 직접 alias)
|
||||
List<JamVoteCountRow> 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` 로 이관.
|
||||
|
|
@ -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 분기를 매퍼 내부 <choose> 로 흡수하면 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<JSON>`(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<List<{id,name,slug}>>` |
|
||||
|
||||
- 사용자 생성(`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, `#{}` 바인딩 전용, `${}` 금지. 동적 절은 `<script>`/`<if>`/`<foreach>` 로 구성. `game_review_stats` VIEW 는 camelCase alias 큰따옴표 필요(`"avgRating"`) — 단, 정렬/필터에 쓰는 컬럼은 VIEW 의 원본 컬럼명(`avg_rating`, `review_count`) 으로 참조하고 별칭은 select projection 에서만 사용.
|
||||
|
||||
### 6.1 확장 쿼리 골격 (searchVisibleGames 확장)
|
||||
|
||||
```sql
|
||||
SELECT g.id, g.user_id, g.name, g.creator_note, g.git_url, g.webgl_path,
|
||||
g.thumbnail_url, g.like_count, g.is_visible, g.sort_order,
|
||||
g.created_at, g.updated_at, g.view_count,
|
||||
u.display_name AS "displayName",
|
||||
grs.avg_rating AS "avgRating", -- VIEW: case-folding 회피 큰따옴표
|
||||
grs.review_count AS "reviewCount"
|
||||
FROM games g
|
||||
JOIN users u ON u.id = g.user_id
|
||||
LEFT JOIN game_review_stats grs ON grs.game_id = g.id
|
||||
-- [잼 필터] jam 지정 시에만
|
||||
<if test="jamSlug != null">
|
||||
JOIN jam_entries je ON je.game_id = g.id
|
||||
JOIN jams j ON j.id = je.jam_id AND j.slug = #{jamSlug}
|
||||
</if>
|
||||
WHERE g.is_visible IS NOT FALSE
|
||||
AND g.is_delete IS NOT TRUE
|
||||
-- [자유텍스트] keyword
|
||||
<if test="keyword != null">
|
||||
AND ( g.name ILIKE ('%' || #{keyword} || '%')
|
||||
OR u.display_name ILIKE ('%' || #{keyword} || '%')
|
||||
OR g.creator_note ILIKE ('%' || #{keyword} || '%') )
|
||||
</if>
|
||||
-- [제작자] creator
|
||||
<if test="creator != null">
|
||||
AND u.display_name ILIKE ('%' || #{creator} || '%')
|
||||
</if>
|
||||
-- [태그 AND] 선택 태그 전부 보유: 매칭 distinct count == 선택 수
|
||||
<if test="tags != null and tagMode == 'and'">
|
||||
AND g.id IN (
|
||||
SELECT gt.game_id FROM game_tags gt
|
||||
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
|
||||
WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
|
||||
GROUP BY gt.game_id
|
||||
HAVING COUNT(DISTINCT t.slug) = #{tagCount}
|
||||
)
|
||||
</if>
|
||||
-- [태그 OR] 하나라도 보유
|
||||
<if test="tags != null and tagMode == 'or'">
|
||||
AND g.id IN (
|
||||
SELECT gt.game_id FROM game_tags gt
|
||||
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
|
||||
WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
|
||||
)
|
||||
</if>
|
||||
ORDER BY
|
||||
/* 정렬키별 ORDER BY — §6.2 */
|
||||
```
|
||||
|
||||
### 6.2 정렬키별 ORDER BY (동률 2차키 `id DESC` 공통)
|
||||
|
||||
| `sort` | ORDER BY 절 | 비고 |
|
||||
|---|---|---|
|
||||
| `latest` (기본) | `g.sort_order ASC, g.created_at DESC, g.id DESC` | 목록 기본과 동일 |
|
||||
| `likes` | `g.like_count DESC, g.id DESC` | |
|
||||
| `views` | `g.view_count DESC, g.id DESC` | 비정규화 컬럼 |
|
||||
| `reviews` | `grs.review_count DESC NULLS LAST, g.id DESC` | VIEW LEFT JOIN, 미평가 NULLS LAST |
|
||||
| `rating` | `grs.avg_rating DESC NULLS LAST, g.id DESC` | overall 단일 평점 |
|
||||
| `relevance` | 태그 일치도 가중(아래) `DESC, g.created_at DESC, g.id DESC` | 태그 선택 시 허용 |
|
||||
|
||||
- **태그 일치도 가중(relevance)**: 태그 선택 시 매칭 태그 수를 점수로 사용.
|
||||
```sql
|
||||
ORDER BY (
|
||||
SELECT COUNT(*) FROM game_tags gt2
|
||||
JOIN tags t2 ON t2.id = gt2.tag_id AND t2.is_active = TRUE
|
||||
WHERE gt2.game_id = g.id
|
||||
AND t2.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
|
||||
) DESC, g.created_at DESC, g.id DESC
|
||||
```
|
||||
- `sort` 는 서버에서 enum 화이트리스트로 매핑(미허용 값 → `latest` fallback). MyBatis `<choose>` 분기로 ORDER BY 조립 — **컬럼/방향은 매퍼 내부 고정 문자열**(사용자 입력이 `${}` 로 흘러들지 않음).
|
||||
- `tagCount` 는 서버가 `tags` CSV 길이에서 산출해 바인딩(AND 모드 HAVING 비교용).
|
||||
|
||||
## ⑦ 시퀀스
|
||||
|
||||
### 7.1 태그 검색 플로우
|
||||
1. 진입: `GET /games/search?keyword=&creator=&tags=&tagMode=&sort=&jam=` (Controller).
|
||||
2. 분기 — 파라미터 정규화: `tags` CSV → slug 리스트(화이트리스트 검증, 비활성/미존재 slug 제거), `sort`/`tagMode` enum 매핑(미허용→fallback), `tagCount` 산출.
|
||||
3. 분기 — `jam` 존재 시 jams.slug 해석. 미존재 → 빈 결과 반환.
|
||||
4. 매퍼 호출: `searchVisibleGames(SearchCriteria)` — §6 동적 쿼리. `#{}` 바인딩, ILIKE.
|
||||
5. VIEW LEFT JOIN 으로 reviewCount/avgRating projection, ORDER BY 정렬키 적용.
|
||||
6. 종단: 결과 리스트 + 선택 태그 + 페이징을 model 에 담아 검색 JSP 뷰 반환.
|
||||
|
||||
### 7.2 태그 생성 → 승인 플로우
|
||||
1. 사용자 진입: `POST /tags {name, tagType}` + CSRF.
|
||||
2. CSRF 검증(`CsrfTokens.isValid`) 실패 → 403.
|
||||
3. sanitize: trim/소문자 slug 화 → 길이 2~20 검사 → 화이트리스트(한/영/숫자/하이픈) 검사 → 금칙어 사전(`banned-words.txt`) 매치 검사. 위반 → 400.
|
||||
4. 중복 검사: `tags.slug`/`name` UNIQUE — 이미 존재 시 기존 태그 반환(또는 409).
|
||||
5. 저장: `is_active=false`(pending), `created_by=session.userId`. → pending 응답.
|
||||
6. 운영자 진입: `POST /admin/tags/{id}/approve` + CSRF → `PermissionGate.has(session, CONTENT_MODERATE)` 게이트. 미충족 → 403.
|
||||
7. 승인: `is_active=true` 갱신. 종단: 활성 태그 검색·부착 가능.
|
||||
8. (운영자 직접 생성 `POST /admin/tags` 는 3~4 검증 후 즉시 `is_active=true` 저장 — 단계 5~7 단축.)
|
||||
|
||||
### 7.3 방문수 기록 플로우 (24h dedupe)
|
||||
1. 진입: 게임 상세/플레이 조회 시 viewer_key 산출(로그인=`userId` 문자열, 비로그인=익명 식별 해시 — 세션/쿠키 기반).
|
||||
2. dedupe 조회: `game_views` 에서 `game_id == ? AND viewer_key == ? AND viewed_at >= now() - interval '24 hours'` 존재 여부.
|
||||
3. 분기 — 이미 존재: 기록·증분 스킵(중복 방문).
|
||||
4. 분기 — 미존재: `game_views` INSERT(game_id, viewer_key, now()) + `games.view_count` `UPDATE ... SET view_count = view_count + 1 WHERE id = ?`.
|
||||
5. 종단: 방문수 정렬은 비정규화 `games.view_count` 를 O(1) 로 소비(§6.2 `views`).
|
||||
6. 주의: dedupe 조회 + 증분은 동일 트랜잭션. 동시성은 view_count 가 정확 카운터가 아닌 정렬용 근사이므로 행 락 최소화(over-engineering 회피).
|
||||
|
||||
## ⑧ 파일 영향 맵
|
||||
|
||||
> 규약 추적: 신규 매퍼는 모두 `@Mapper` + `#{}` 바인딩(§NFR-3). 신규 함수 시그니처는 최소 인자 원칙(빈 칸 인라인 주석 = inflate 신호 → 제거). inflate 위험 시그니처는 frontmatter concerns 에 마킹됨.
|
||||
|
||||
### 8.1 변경 맵
|
||||
|
||||
| 변경 유형 | 경로 | 역할 | 소유권 |
|
||||
|---|---|---|---|
|
||||
| 신규 | `docs/tag-ddl.sql` | tags/game_tags/jam_tags/game_views 정의 + games.view_count ALTER (멱등, §4.1 전문) | U-TAG-SCHEMA |
|
||||
| 수정 | `schema.sql` | 위 5블록 동기 사본(§4.3 지점·순서) | U-TAG-SCHEMA |
|
||||
| 신규 | `TagsMapper.java` (@Mapper) | tags CRUD/승인/목록 | U-TAG-DOMAIN |
|
||||
| 신규 | `GameTagsMapper.java` (@Mapper) | game_tags attach/detach/listByGame + 태그→게임ID 역검색 | U-TAG-DOMAIN |
|
||||
| 신규 | `JamTagsMapper.java` (@Mapper) | jam_tags attach/listByJam (잼 태그 부착·조회. jams 는 W2-1 참조) | U-TAG-DOMAIN |
|
||||
| 신규 | `GameViewsMapper.java` (@Mapper) | game_views dedupe 조회/INSERT + view_count 증분 | U-VIEW |
|
||||
| 신규 | `TagData.java` (POJO) | tags 행 매핑(id/name/slug/tagType/isActive/createdBy/createdAt) | U-TAG-DOMAIN |
|
||||
| 수정 | `GameData.java` (POJO) | 검색 projection 신규 필드(viewCount/avgRating/reviewCount) 추가 — 단 소비처 영향 점검(concern 4) | U-SEARCH |
|
||||
| 수정 | `GamesMapper.java` | `searchGamesAdvanced(SearchCriteria)` **신규 메서드**(기존 `searchVisibleGames` 시그니처 비변경 → 호출처 갱신 0). VIEW LEFT JOIN + 태그/제작자/정렬키/jam 동적절(§6) | U-SEARCH |
|
||||
| 신규 | `SearchController.java` | `GET /games/search` — 파라미터 정규화·매퍼 호출·검색 JSP 반환(§7.1) | U-SEARCH |
|
||||
| 신규 | `TagController.java` | 태그 CRUD/승인 API(§5.2) — sanitize·금칙어·권한게이트·CSRF | U-TAG-ADMIN |
|
||||
| 신규 | `TagSanitizer.java` (util) | 태그 입력 검증·slug 화·금칙어 매치(§8.3 시그니처) | U-TAG-ADMIN |
|
||||
| 신규 | `src/main/resources/banned-words.txt` | 금칙어 사전(1줄 1단어, 소문자) — §4.2 단일 출처 | U-TAG-ADMIN |
|
||||
| 수정 | 게임 상세 조회 컨트롤러 (기존, GameController 추정) | view 기록 훅 — `GameViewsMapper` dedupe 호출(§7.3) | U-VIEW |
|
||||
| 수정 | 검색/목록 JSP (index.jsp 또는 신규 `game-search.jsp`) | 태그 필터 UI(선택 태그 chip)·정렬키 드롭다운·제작자 검색 입력 | U-JSP |
|
||||
|
||||
### 8.2 SSR 호출지점 영향
|
||||
|
||||
- **`searchVisibleGames` 기존 호출처**: 신규 `searchGamesAdvanced` 를 **별도 메서드로 추가**하므로 기존 시그니처/호출처(있다면 검색 컨트롤러 1개소) 변경은 0. 기존 자유텍스트 검색 동작 회귀 없음(§10).
|
||||
- **index.jsp 검색 attr 보존**: 기존 검색 폼이 model 에 의존하는 attr(검색어/결과 리스트)는 신규 `SearchController` 에서 동일 이름으로 채워 보존. 신규 attr(선택 태그·정렬키·태그 목록)는 추가만(기존 제거 0).
|
||||
- **GameData 확장 영향**: 신규 필드 추가 시 기존 목록/상세 JSP 가 EL 로 신규 필드를 참조하지 않으므로 표시 회귀 0. 단 mapper resultType 매핑에서 신규 컬럼이 비-검색 경로(getVisibleGames 등)에선 미채워짐 → null 허용 박스 타입(`Integer`/`Double`)으로 선언(concern 4 와 연계).
|
||||
|
||||
### 8.3 신규 함수 시그니처 (최소 인자, 인라인 주석 의무)
|
||||
|
||||
```java
|
||||
// GamesMapper (신규 메서드)
|
||||
List<GameData> searchGamesAdvanced(SearchCriteria c); // c: 검색 조건 묶음(아래). 단일 파라미터로 동적절 바인딩
|
||||
|
||||
// SearchCriteria (검색 조건 DTO — 매퍼 동적 SQL 바인딩용)
|
||||
// keyword : 자유텍스트 ILIKE 대상(null=미적용)
|
||||
// creator : 제작자 display_name ILIKE(null=미적용)
|
||||
// tags : 화이트리스트 통과 slug 리스트(빈/null=태그필터 미적용)
|
||||
// tagMode : "and" | "or" (tags 비었으면 미참조)
|
||||
// tagCount : tags.size() (AND 모드 HAVING 비교 전용 — OR/미선택 시 미참조, concern 2)
|
||||
// sort : 화이트리스트 정렬키(미허용→"latest" 서버 fallback 후 전달)
|
||||
// jamSlug : 잼 출품작 한정(null=전체. concern 2)
|
||||
|
||||
// GameTagsMapper — AND/OR 분리로 dead param 회피(concern 3 채택안)
|
||||
List<Long> listGameIdsByTagsAll(List<String> tagSlugs, int tagCount); // 전부 보유(AND): HAVING count==tagCount
|
||||
List<Long> listGameIdsByTagsAny(List<String> tagSlugs); // 하나라도 보유(OR)
|
||||
void attachTags(long gameId, List<Long> tagIds); // 게임에 태그 N개 부착(upsert)
|
||||
List<TagData> listByGame(long gameId); // 게임의 태그 목록
|
||||
void detach(long gameId, long tagId); // 단건 해제
|
||||
|
||||
// GameViewsMapper
|
||||
boolean existsRecentView(long gameId, String viewerKey); // 24h 윈도 dedupe 조회(sinceTs 는 SQL 내 now()-interval 로 고정 → 인자 불필요, inflate 회피)
|
||||
void insertView(long gameId, String viewerKey); // 방문 로그 INSERT
|
||||
void incrementViewCount(long gameId); // games.view_count += 1
|
||||
|
||||
// TagsMapper
|
||||
long insertTag(TagData t); // pending/active 생성(isActive 는 t 에 포함)
|
||||
void approve(long id); // is_active=true
|
||||
void deactivate(long id); // is_active=false
|
||||
List<TagData> listActiveByType(String tagType, String q); // 활성 태그 목록/자동완성(q=null 전체)
|
||||
TagData findBySlug(String slug); // 중복 검사/해석
|
||||
|
||||
// TagSanitizer (util) — 최소 인자. 결과는 검증 통과 slug 또는 예외/Optional
|
||||
String toSlug(String rawName); // trim·소문자·정규화(검증은 별도 호출자가 결합)
|
||||
boolean isAllowed(String slug); // 길이 2~20 + 화이트리스트 + 금칙어 미매치 통합 판정
|
||||
```
|
||||
|
||||
> inflate 차단 메모: `GameTagsMapper` 의 태그 검색을 AND/OR 단일 메서드(`listGameIdsByTags(slugs, mode, count)`)로 통합하면 OR 경로에서 `count` 가 dead parameter 가 된다 → 두 메서드 분리안 채택(concern 3). `existsRecentView` 의 `sinceTs` 는 SQL 내 `now() - interval '24 hours'` 고정이라 인자에서 제거.
|
||||
|
||||
## ⑨ 대안 비교
|
||||
|
||||
| 결정축 | 채택안 | 장점 | 단점 | 기각안 | 기각 근거 |
|
||||
|---|---|---|---|---|---|
|
||||
| 태그 테이블 | 통합 `tags` 1테이블 + `tag_type` (D1) | 정규화·태그명 단일출처·도메인 확장 안정 | 타입 필터 항상 동반 | 용도별 분리(`game_tags_master`/`jam_tags_master`) | 중복 스키마·관리 분산·검색 단일출처 상실 |
|
||||
| 방문수 | 로그(`game_views`) + 비정규화(`view_count`) (D5) | dedupe·감사 추적 + 정렬 O(1) | 테이블 1개 + 컬럼 1개 추가 | (a) 단순 `view_count++` (b) 로그 only 매번 COUNT | (a) 중복·어뷰징·감사불가 (b) 정렬마다 집계 비용 |
|
||||
| 태그 관리 | 운영자 사전정의 + 사용자 pending→승인 하이브리드 (D2) | 초기 카탈로그 + 사용자 확장 + 난립 차단 | 승인 워크플로 필요 | (a) 운영자 전용 (b) 사용자 자유생성 | (a) 확장성↓ (b) 스팸·중복·XSS |
|
||||
| 정렬 | 정렬키별 고정 ORDER BY + 동률 2차키 `id DESC` (D8) | 결정적 페이징·단순·예측가능 | relevance 는 서브쿼리 점수 | 단일 가중점수(likes·views·rating 혼합) | 가중치 튜닝 불명확·예측 불가·over-engineering |
|
||||
| 다중 태그 | 파라미터 `tagMode=and\|or` (D7) | 사용자 교집합/합집합 선택 | 분기 2경로 | 고정 AND | 유연성↓(OR 탐색 불가) |
|
||||
| 잼 검색 라우트 | `/games/search?jam=` 파라미터 통합 (D9) | 검색 진입점 단일·로직 1벌 | 컨트롤러 분기 1개 | 전용 `/jams/{slug}/games` | 검색 로직 중복·W3-4 계약 2벌 |
|
||||
| 검색 행 타입 | GameData 확장(null 허용 박스 필드) | 매퍼 1벌·기존 매핑 재사용 | 비-검색 경로 null | 전용 `SearchRow` | 매핑 중복·소비처 분기 — 단 소비처 영향 시 재고(concern 4) |
|
||||
|
||||
## ⑩ 롤아웃 / 마이그레이션
|
||||
|
||||
### 10.1 적용 순서
|
||||
1. `docs/tag-ddl.sql` 추가 → `apply-local-ddl.sh` 가 `docs/*-ddl.sql` 알파벳순 멱등 적용(`rbac-ddl.sql` < `tag-ddl.sql` 순서 무관 — 모든 블록 FK guard).
|
||||
2. `schema.sql` 동기 사본 갱신(§4.3) — 신규 환경 부트스트랩 일관성.
|
||||
3. 매퍼/POJO/컨트롤러/JSP 구현(소유권 분할 U-* 순서 무관, U-TAG-SCHEMA → U-TAG-DOMAIN → {U-SEARCH, U-TAG-ADMIN, U-VIEW} → U-JSP 의존순 권장).
|
||||
|
||||
### 10.2 역호환 (비파괴)
|
||||
- `games.view_count` 는 `ADD COLUMN ... NOT NULL DEFAULT 0` — 기존 행은 자동 0 채움(비파괴, 백필 불필요).
|
||||
- 신규 테이블(tags/game_tags/jam_tags/game_views)은 **추가 전용** — 기존 스키마 변경/삭제 0.
|
||||
- 기존 검색 동작 회귀 0: 태그 미선택·정렬 미지정 시 `latest`(목록 기본과 동일) + keyword-only 경로 = 현 `searchVisibleGames` 동작과 동치. 신규 `searchGamesAdvanced` 는 별도 메서드라 기존 호출처 무영향.
|
||||
|
||||
### 10.3 롤백 경로
|
||||
- 코드 롤백: 신규 컨트롤러/매퍼/JSP 변경 revert 시 기존 검색 즉시 복원(기존 메서드 미변경).
|
||||
- 스키마 롤백: 신규 테이블·컬럼은 추가 전용이라 drop 없이 잔존해도 무해(읽지 않으면 영향 0). 강제 롤백 시 `DROP TABLE IF EXISTS` + `ALTER ... DROP COLUMN IF EXISTS view_count` (수동, 데이터 폐기 동반 — 운영 합의 전제).
|
||||
|
||||
## ⑪ AC 매핑
|
||||
|
||||
| FR (part1) | 만족 설계요소 | 검증(§12 VP) |
|
||||
|---|---|---|
|
||||
| FR-1 태그 도메인 | §4.1 tags/game_tags/jam_tags + TagData/매퍼(§8) | VP-L1(매퍼 단위), VP-L2(DDL 방언 contract) |
|
||||
| FR-2 검색 확장(태그/제작자/정렬키) | §6 동적쿼리 + searchGamesAdvanced(§8.3) | VP-L1+L2(쿼리), AC-정렬키 전수 |
|
||||
| FR-3 방문수 정렬 | §4.1 game_views + view_count, §7.3 dedupe, §6.2 views | VP-L1(GameViewsMapper), AC-dedupe 멱등 |
|
||||
| FR-4 리뷰 정렬 | §6.1 game_review_stats LEFT JOIN, §6.2 reviews/rating | VP-L2(VIEW 별칭 contract) |
|
||||
| FR-5 태그 관리 API | §5.2 + TagController + TagSanitizer(§8.3) | VP-보안(sanitize/금칙어/권한/CSRF) |
|
||||
| FR-6 잼 태그 검색 라우트 | §5.3 `/games/search?jam=` 단일 출처 + jam_entries 조인 | VP-L1(jam 분기), W3-4 계약 일치 |
|
||||
|
||||
## ⑫ 검증 포인트
|
||||
|
||||
> L레벨 규약(프로토콜): L1=빌드/단위(`./mvnw -o test`), L2=DB-방언 contract(dev DB 실제 쿼리 실행), L3=통합/E2E. @MockBean 사용은 프로토콜 §30 준수.
|
||||
|
||||
### 12.1 L레벨 매핑
|
||||
- **VP-L1 (단위)**: 신규 매퍼(TagsMapper/GameTagsMapper/JamTagsMapper/GameViewsMapper) + TagSanitizer 단위. `./mvnw -o test` full 실행(컴파일·기존 회귀 포함). 컨트롤러는 `@MockBean` 매퍼 주입(§30).
|
||||
- **VP-L2 (DB-방언 contract)**: 검색 쿼리(searchGamesAdvanced)·태그 역검색(AND/OR)·view_count 증분을 **dev DB 실제 실행**으로 contract 검증 — game_review_stats VIEW 별칭(`"avgRating"`/`"reviewCount"`) case-folding, `NULLS LAST`, `ILIKE` Postgres 방언이 H2/mock 으로 검출 안 되는 회귀 게이트.
|
||||
- **VP-L1 (sanitize)**: TagSanitizer 길이(2~20 경계)·화이트리스트(한/영/숫자/하이픈 외 거부)·금칙어 매치 단위 테스트.
|
||||
|
||||
### 12.2 집합 전수 체크 AC
|
||||
|
||||
> self-audit(시점 안정·표현 견고성): 아래 AC 는 모두 **검색 코드 자체가 verification 시점까지 변하지 않는 고정 산출물**을 대상으로 하며(자기 work-session 트리 아님), 단일 리터럴 grep 의존을 피해 의미 불변식 + 수동 판정을 병기한다.
|
||||
|
||||
- **AC-1 정렬키 전수 6건**: 정렬키 화이트리스트가 `latest/likes/views/reviews/rating/relevance` 전수 6건 매핑 — 정렬 매핑 소스(매퍼 `<choose>` 또는 컨트롤러 enum)에서 6개 키 각각 ORDER BY 절이 존재. 검증: 정렬키 enum/상수 정의에서 `grep -c` 한 case 수 == 6 **AND** 각 키별 L2 쿼리 실행이 정렬 적용 결과를 반환(의미 불변식: 미허용 키는 latest 와 동일 결과). 단일 grep 만으로 판정하지 않고 L2 실행 결과와 교차.
|
||||
- **AC-2 태그 타입 전수 3건**: `tag_type` CHECK 가 `GAME/JAM/COMMON` 전수 3건 — `docs/tag-ddl.sql` 의 `chk_tags_type` CHECK IN 절 항목 수 == 3 **AND** schema.sql 동기 사본 동일. 검증: DDL 의 `CHECK (tag_type IN (...))` 항목 수 == 3 (구조 불변식: tag-ddl.sql 과 schema.sql 두 출처의 IN 목록 동등).
|
||||
- **AC-3 다중태그 모드 전수 2건**: `tagMode` 가 `and`/`or` 전수 2건 — 매퍼 동적절에 AND 경로(HAVING count==tagCount)와 OR 경로(IN) 둘 다 존재 **AND** L2 실행으로 AND 결과 ⊆ OR 결과(불변식: 동일 태그셋에서 AND 매칭 게임은 OR 매칭의 부분집합). 단일 grep 회피 — 부분집합 불변식으로 판정.
|
||||
- **AC-4 신규 매퍼 전수 4개**: TagsMapper/GameTagsMapper/JamTagsMapper/GameViewsMapper 4개 파일 존재 + 각 `@Mapper` 어노테이션 + `#{}` 바인딩만 사용. 검증: glob 매치 4건 **AND** 각 매퍼 `${` 동적치환 0건.
|
||||
- **AC-5 DDL 블록 전수 5건**: `docs/tag-ddl.sql` 에 tags/game_tags/jam_tags/game_views 테이블 + games.view_count ALTER 전수 5블록 — `CREATE TABLE IF NOT EXISTS` 3건 + `ALTER TABLE games ADD COLUMN IF NOT EXISTS view_count` 1건 + game_views CREATE 1건. 검증: `CREATE TABLE IF NOT EXISTS` 매치 4건(tags/game_tags/jam_tags/game_views) + view_count ALTER 1건 == 5 **AND** schema.sql 동기 사본에 동일 5블록 존재(두 출처 동등 불변식).
|
||||
|
||||
### 12.3 보안 VP
|
||||
- **태그 sanitize/금칙어**: 사용자 생성 입력이 길이/화이트리스트/금칙어 검증을 통과해야만 pending 저장(§7.2 step3). XSS 페이로드(`<script>` 등)·금칙어 입력 → 400 거부 테스트.
|
||||
- **권한 게이트**: `/admin/tags/**` 4개 엔드포인트(생성/승인/비활성, §5.2)가 `PermissionGate.has(session, CONTENT_MODERATE)` 통과 필수. 미권한 세션 → 403.
|
||||
- **CSRF**: 모든 상태변경 `POST`(태그 생성·승인·비활성·부착) `CsrfTokens.isValid` 선검증, 미검증 → 403.
|
||||
- **`#{}` 바인딩 / `${}` 0건**: 검색·태그 쿼리 전 매퍼에서 `${` 동적치환 0건(`grep -c '\${' <매퍼들>` == 0). 정렬키/tagMode 는 매퍼 내부 고정 문자열 분기(`<choose>`)로 조립 — 사용자 입력이 `${}` 로 흐르지 않음(구조 불변식).
|
||||
|
||||
## ⑬ 잔여 오픈 질문
|
||||
|
||||
**오픈 질문 수: 0.** 모든 설계 결정은 D1~D9(§3) + §4~§8 에서 확정됨. 아래는 오픈 질문이 아니라 **구현 단계 점검 항목으로 concerns 에 이관**된 것이다.
|
||||
|
||||
- **권한키 확정(D4)**: 정석 후보는 ① 신규 `TAG_MANAGE` 도입(권한 분리·최소권한) ② 기존 `CONTENT_MODERATE` 재사용(W1 인프라 재사용·신규 키 비용 회피) 중 **② CONTENT_MODERATE 재사용으로 확정**한다. 근거: 현 시점 태그 모더레이션은 콘텐츠 모더레이션과 운영 주체가 동일하고(별도 태그 전담 운영자 미존재), W1 권한 인프라를 즉시 재사용하면 PermissionKeys/RBAC seed 변경 0. 향후 태그 전담 운영자 분리 요구 발생 시 `TAG_MANAGE` 추가 — 이는 오픈 질문이 아니라 미래 트리거 조건이 명확한 확장 포인트이며 concerns[0] 에 이관됨.
|
||||
- **SearchCriteria/매퍼 시그니처 inflate**: §8.3 에서 최소 인자로 설계하고 AND/OR 매퍼 분리로 dead parameter 를 사전 차단. 구현 advisor 가 unused 진단 게이트(프로토콜 §11.2)에서 `tagCount`/`jamSlug` dead path 여부 최종 확인 — concerns[1][2] 이관.
|
||||
- **검색 행 매핑 타입**: GameData 확장 vs SearchRow — 소비처 영향 최소안으로 구현 단계 확정. concerns[3] 이관.
|
||||
|
|
@ -0,0 +1,543 @@
|
|||
---
|
||||
phase: design
|
||||
agent: design-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-23T12:00:00+09:00
|
||||
workstream: W3-3-포스팅 보드(공지/블로그 + OG 미리보기 + 유니티블로그 피드 감시)
|
||||
concerns:
|
||||
- "★SSRF 핵심: SsrfSafeFetcher 의 'DNS resolve → IP 검증 → 그 IP 로 connect(host 헤더는 원본 호스트명)' 핀닝은 java.net.http.HttpClient/RestClient 기본 동작으로는 그대로 안 된다(클라이언트가 호스트명으로 재-resolve). 구현은 (a) custom javax.net.SocketFactory/InetAddress 핀닝 또는 (b) resolve→검증 후 connect→연결된 소켓의 실제 peer IP 재검증(TOCTOU 최소화) 중 (b)를 1차로 명세했다. 구현 단계에서 라이브러리 제약으로 (a)가 필요해질 수 있으니 SSRF 단위테스트(사설/메타데이터/rebinding mock)로 실제 차단을 검증할 것."
|
||||
- "pom.xml 신규 의존 3종(commonmark, jsoup, 선택적으로 HTTP 클라이언트는 spring-web 내장 RestClient 로 충족) 추가가 전제다. design-advisor 는 pom.xml 을 수정하지 않으므로 implementation 단계 첫 작업이 의존 추가 + contextLoads 확인이다. 의존 미추가 시 컴파일 불가 — 구현 1보에서 의존 추가를 먼저 커밋."
|
||||
- "신규 컨트롤러(PostController/PostAdminController/UnityFeedAdminController)·신규 매퍼 6종·SsrfSafeFetcher·OgPreviewService·UnityFeedPoller 빈 추가는 verification-strategies §30 대상 — BibimbapApplicationTests 에 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException. full ./mvnw -o test 로 끝낼 것(test-compile 만 금지)."
|
||||
- "신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias: og_title AS ogTitle). 집계/큰따옴표 alias 불필요(verification-strategies §33). DB-방언 계약(L2) 대상."
|
||||
- "UnityFeedPoller @Scheduled 는 @EnableScheduling 활성화가 전제(현재 미활성, code-fact: @EnableScheduling 0 hit). 신규 @Configuration 또는 메인 클래스에 활성화 필요 — 구현 시 다중 인스턴스 동시 폴링 중복 방지(현 톰캣 단일 인스턴스라 1차는 무방, 스케일아웃 시 분산락 필요는 비목표)."
|
||||
- "PostMarkdownService.render 시그니처는 최소 인자(markdown 1개)로 명세. 구현에서 sanitize 정책 주입이 필요하면 그때 확장(inflate 방지, 프로토콜 §11.2)."
|
||||
concerns_checked: true
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
references:
|
||||
requirements: docs/work-log/2026-06-17-w3-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-23-w2-w4-feature-skeletons.md
|
||||
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
||||
- docs/development/verification-strategies.md
|
||||
---
|
||||
|
||||
# 설계: W3-3 — 포스팅 보드 (공지/블로그 + 외부링크 OG 미리보기 + 유니티블로그 피드 감시)
|
||||
|
||||
## 목표 / 비목표
|
||||
|
||||
### 목표 (FR/NFR 추적)
|
||||
- **G1 포스팅 작성/수정/삭제** (골자 W3-3 핵심): POST_WRITE 권한자만 작성. 일반 유저 읽기 전용. **댓글 없음**(골자 Q5 — W3-2 결합 0).
|
||||
- **G2 카테고리 운영** (골자 Q2): `post_categories` DB 저장, 운영자 추가/수정/삭제. 정렬·활성 토글.
|
||||
- **G3 마크다운 본문 + sanitize** (골자 Q4): 본문은 마크다운 입력 → allowlist sanitize HTML 캐시. 저장 시 `body_markdown`(원본) + `body_sanitized_html`(렌더 캐시) 듀얼.
|
||||
- **G4 ★외부링크 OG 미리보기 + SSRF 방어** (골자 Q3): 서버측 fetch 로 OG 메타 추출, SSRF 전수 방어, 결과 캐싱, 실패 graceful(링크만).
|
||||
- **G5 ★유니티블로그 외부 피드 감시** (골자 Q6): RSS/Atom 폴링 + dedupe(last_seen_guid) + 새 글 감지 → 운영자 알림 표면. 피드 URL 도 SSRF 방어.
|
||||
- **G6 게시판 페이징** (골자/grounding R-F): RecruitPostsMapper 페이징 부재 → keyset 페이징 신규.
|
||||
- **NFR-보안**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), POST_WRITE 게이트(임시 role 직접체크 금지), SSRF 방어 전수, 마크다운 sanitize XSS 차단, JSP 출력 escape.
|
||||
- **NFR-정합**: DDL 권위 = `docs/board-ddl.sql`, schema.sql 동기. 멱등·비파괴.
|
||||
|
||||
### 비목표 (스코프 밖)
|
||||
- 포스팅 댓글/리액션 (골자 Q5 — 읽기 전용 채널 확정).
|
||||
- 마크다운 위지윅 에디터 (입력은 textarea + 미리보기, 에디터 고도화는 후속).
|
||||
- 외부 피드 자동 재게시(임포트) — 본 설계는 **감지 + 운영자 알림**까지. 감지 항목을 정식 포스트로 자동 전환하지 않음(운영자가 수동으로 작성). 피드 항목 클릭 시 원문 링크 이동.
|
||||
- 피드 폴링 분산락/멀티인스턴스 중복 방지 (현 단일 톰캣 인스턴스 전제, 스케일아웃 시 후속).
|
||||
- OG 미리보기 이미지 프록시/리사이즈 캐싱 (URL 캐시까지, 바이너리 캐시는 후속).
|
||||
- 잼 연동 (골자 — 포스팅은 잼 무관 독립 채널).
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
bibimbap 는 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis `@Mapper`(`#{}`) + JSP 스택이다. 게시판 선례는 `RecruitController`(읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<Map>` + `CsrfTokens.isValid` + 세션 userId 화이트리스트)이며 **PermissionGate 미적용·페이징 부재**다(code-fact: RecruitController.java:65,159; RecruitPostsMapper 페이징 0).
|
||||
|
||||
본 설계는 `posts` + `post_categories` 게시판을 신설하고, **작성 경로에 W1 `PermissionGate.has(session, PermissionKeys.POST_WRITE.name())` enforcement 를 연결**한다(현재 POST_WRITE 키는 enum 선언만·소비처 0 — code-fact: PermissionKeys.java:5, grounding R-A). 임시 role 직접체크는 금지(정석 원칙).
|
||||
|
||||
가장 까다로운 두 보안 난제를 다음과 같이 확정한다.
|
||||
|
||||
- **난제1 (마크다운 → sanitize HTML)**: 단일 정의처 = `PostMarkdownService`. 입력 마크다운을 **commonmark** 로 HTML 변환 후 **jsoup `Safelist`**(allowlist) 로 sanitize 해 `body_sanitized_html` 에 캐시한다. 허용 서식은 제목(h1~h3)/볼드/이탤릭/리스트/링크(rel=nofollow noopener, http/https만)/인라인·블록 코드/이미지(http/https src만). 스크립트·이벤트핸들러(onclick 등)·`javascript:`·style·iframe·object 전부 제거. **저장 시 1회 sanitize → 조회 시 캐시 출력**(매 조회 재sanitize 비용 제거 + 정책 변경 시 재생성 배치 여지). JSP 는 sanitized HTML 을 `<c:out escapeXml="false">` 가 아니라 **이미 sanitize 된 신뢰 HTML 로 직접 출력**(단 sanitize 가 유일 신뢰원천).
|
||||
- **난제2 (★SSRF 방어 — OG fetch + 피드 fetch 공통)**: **단일 공용 컴포넌트 `SsrfSafeFetcher`** 로 외부 fetch 를 단일화한다(crossRefs 권장). OG 미리보기와 유니티블로그 피드가 동일 fetcher 를 통과한다. 방어 체크리스트는 §보안(SsrfSafeFetcher 계약)에 전수 기록. 핵심: scheme allowlist(http/https) → 호스트 resolve → 모든 resolved IP 가 공인(public) 인지 검증(사설/루프백/링크로컬/메타데이터 169.254.169.254 차단) → connect → **연결된 소켓의 실제 peer IP 재검증(rebinding 방어)** → redirect 매 홉 재검증(최대 N홉) → 응답 size cap + timeout → Content-Type 검증. 실패는 예외가 아니라 graceful 결과(미리보기 없음 / 피드 폴링 skip + 로그).
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 요약 (전제 — 재논의 금지, 난제는 본 설계가 확정)
|
||||
|
||||
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||
|---|---|---|
|
||||
| D1 게시판 모델 | posts + post_categories(DB저장) | `posts`(FK category_id, author_user_id, title, body_markdown, body_sanitized_html, og_*, status, is_delete) + `post_categories`(name/slug/sort_order/is_active) |
|
||||
| D2 작성 권한 | POST_WRITE 게이트 enforcement 연결 | `PermissionGate.has(session, PermissionKeys.POST_WRITE.name())`. 임시 role 직접체크 금지. ADMIN 은 게이트 내부에서 암묵 통과 |
|
||||
| D3 댓글 | 없음 | 읽기 전용 채널(골자 Q5). W3-2 결합 0 |
|
||||
| D4 본문 | 마크다운 → sanitize HTML allowlist | commonmark 변환 + jsoup Safelist. body_markdown 원본 + body_sanitized_html 캐시(저장 시 1회 sanitize) |
|
||||
| D5 ★OG 미리보기 | 서버 fetch + SSRF 방어 + 캐싱 | 공용 `SsrfSafeFetcher` 경유. 결과는 posts.og_* 컬럼 캐시. 실패 graceful |
|
||||
| D6 ★유니티 피드 감시 | RSS/Atom 폴링 + dedupe + 알림 | `unity_feed_sources` + `unity_feed_items`. @Scheduled 폴링. last_seen_guid dedupe. 운영자 대시보드 미확인 배지 |
|
||||
| D7 외부 fetch 인프라 | 공용 `SsrfSafeFetcher` 단일화 | OG·피드 동일 fetcher 경유(SSRF 방어 중복 0) |
|
||||
| D8 페이징 | keyset 페이징 | (created_at, id) 커서. RecruitPostsMapper 전건 조회와 결별 |
|
||||
| D9 신규 의존 | commonmark + jsoup | HTTP 는 spring-web 내장 RestClient 사용(신규 의존 0). 마크다운/sanitize 만 신규 |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 모델 (DDL)
|
||||
|
||||
> 권위 = 신규 `docs/board-ddl.sql`. `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 은 동기 사본(recruit_posts/rbac 선례와 동일). 전부 멱등·비파괴. 따옴표 식별자·SEQUENCE+nextval·DO $$ guard FK 등 기존 스타일(schema.sql:263-368) 준수.
|
||||
|
||||
### 신규 파일: `docs/board-ddl.sql`
|
||||
|
||||
```sql
|
||||
-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||
-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.
|
||||
|
||||
-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
|
||||
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "post_categories" (
|
||||
"id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(80) NOT NULL,
|
||||
"slug" character varying(80) NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"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 "post_categories_id_seq" OWNED BY "post_categories"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
|
||||
ON "post_categories" ("slug");
|
||||
|
||||
-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
|
||||
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "posts" (
|
||||
"id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
|
||||
"category_id" bigint NOT NULL,
|
||||
"author_user_id" bigint NOT NULL,
|
||||
"title" character varying(200) NOT NULL,
|
||||
"body_markdown" text NOT NULL, -- 원본(편집·재렌더 소스)
|
||||
"body_sanitized_html" text NOT NULL, -- 저장 시 sanitize 캐시(D4, 조회 출력원)
|
||||
"link_url" character varying(2048), -- 외부링크 큐레이션 대상(OG 미리보기 소스)
|
||||
"og_title" character varying(300), -- OG 캐시(D5). NULL=미리보기 없음
|
||||
"og_description" character varying(600),
|
||||
"og_image_url" character varying(2048),
|
||||
"og_site_name" character varying(200),
|
||||
"og_fetched_at" timestamp with time zone, -- 마지막 OG fetch 시각(재fetch 정책 기준)
|
||||
"status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL, -- DRAFT/PUBLISHED
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_category_id_fkey"
|
||||
FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_author_user_id_fkey"
|
||||
FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_status_check"
|
||||
CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- keyset 페이징 + 카테고리 필터 커버링 인덱스(D8)
|
||||
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
|
||||
ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
|
||||
ON "posts" ("category_id", "created_at" DESC, "id" DESC)
|
||||
WHERE "is_delete" = false AND "status" = 'PUBLISHED';
|
||||
|
||||
-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
|
||||
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
|
||||
"id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(120) NOT NULL, -- 표시명(예: Unity Blog)
|
||||
"feed_url" character varying(2048) NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"last_polled_at" timestamp with time zone,
|
||||
"last_seen_guid" character varying(512), -- dedupe 커서(가장 최근 본 항목 guid)
|
||||
"last_error" character varying(500), -- 마지막 폴링 실패 사유(graceful)
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
|
||||
ON "unity_feed_sources" ("feed_url");
|
||||
|
||||
-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
|
||||
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
|
||||
"id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
|
||||
"source_id" bigint NOT NULL,
|
||||
"guid" character varying(512) NOT NULL, -- 피드 항목 고유 식별자(dedupe 키)
|
||||
"title" character varying(500) NOT NULL,
|
||||
"link_url" character varying(2048) NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"is_acknowledged" boolean DEFAULT false NOT NULL, -- 운영자 확인 여부(알림 배지)
|
||||
"detected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
|
||||
ALTER TABLE "unity_feed_items"
|
||||
ADD CONSTRAINT "unity_feed_items_source_id_fkey"
|
||||
FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- 동일 소스 내 guid 중복 차단(dedupe 영속 보장 — DB 레벨 1차 방어)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
|
||||
ON "unity_feed_items" ("source_id", "guid");
|
||||
-- 미확인 항목 배지 카운트용
|
||||
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
|
||||
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
|
||||
WHERE "is_acknowledged" = false;
|
||||
```
|
||||
|
||||
### `db/schema.sql` 반영 (최초 기동 1회 자동 주입)
|
||||
- `rbac_audit_log` 블록 뒤(schema.sql:369 직후)에 위 1~4번 블록을 신설 추가.
|
||||
- 반영 방식은 recruit_posts/rbac 가 schema.sql 에 동기화된 선례(schema.sql:261-368)와 동일 — **docs/board-ddl.sql 이 권위, schema.sql 은 그 사본**.
|
||||
|
||||
### 카테고리 부트스트랩
|
||||
- 카테고리는 D2 에 따라 운영자가 콘솔에서 생성. 시드 불필요(빈 상태에서 운영자가 첫 카테고리 생성). 단 dev 편의를 위해 `db/seed-dev.sql` 류가 있으면 샘플 카테고리 1건 INSERT 는 documentation/seed 소관(본 설계는 요구만 명시, DDL 에 포함 안 함).
|
||||
|
||||
---
|
||||
|
||||
## 외부 계약 (API)
|
||||
|
||||
> 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(실패 시 403 + `CsrfTokens.errorBody()`). 쓰기 응답은 `ResponseEntity<Map<String,Object>>`(status/message), 읽기는 JSP 뷰이름 반환(RecruitController 패턴). 작성/카테고리/피드관리 액션은 권한 게이트 통과 후 도달.
|
||||
|
||||
### 401 vs 403 정책 (확정 — W1 정책과 일치)
|
||||
- **미인증**(세션 userId 없음): 페이지 진입 `redirect:/login`, 쓰기 API 401 JSON(`{status:401,message:"로그인이 필요합니다."}`).
|
||||
- **인증·미인가**(로그인됐으나 POST_WRITE 없음): **403**(`{status:403,message:"권한이 없습니다."}`). 페이지 작성폼 진입도 403(리다이렉트 금지 — 권한 없음을 로그인으로 오인 유도 방지).
|
||||
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||
|
||||
### 공개 포스팅 페이지 (뷰 — 인증 불요, 읽기 전용)
|
||||
| method | path | 권한 | 응답 |
|
||||
|---|---|---|---|
|
||||
| GET | `/posts` | 공개 | `posts-list` JSP. PUBLISHED 글 keyset 페이징(쿼리 `?categoryId=&cursorCreatedAt=&cursorId=`) + 카테고리 탭(is_active) |
|
||||
| GET | `/posts/{id}` | 공개 | `posts-detail` JSP. body_sanitized_html + (있으면) OG 미리보기 카드. is_delete/DRAFT 면 redirect:/posts |
|
||||
|
||||
### 작성/편집 (POST_WRITE 게이트 — D2)
|
||||
| method | path | 권한 | 요청 | 응답(200) | 에러 |
|
||||
|---|---|---|---|---|---|
|
||||
| GET | `/posts/new` | POST_WRITE | (없음) | `posts-form` JSP(활성 카테고리 목록 + CSRF) | 401 redirect / 403 |
|
||||
| POST | `/posts` | POST_WRITE | categoryId, title, bodyMarkdown, linkUrl?, status | `{status:200, postId, location:"/posts/{id}"}` | 400(검증), 401, 403(권한/CSRF), 404(카테고리 없음/비활성) |
|
||||
| GET | `/posts/{id}/edit` | POST_WRITE | (path) | `posts-form` JSP(기존 값) | 401/403/404 |
|
||||
| POST | `/posts/{id}` (수정) | POST_WRITE | 동일 필드 | `{status:200, postId}` | 400/401/403/404 |
|
||||
| POST | `/posts/{id}/delete` | POST_WRITE | (path) | `{status:200}` | 401/403/404 |
|
||||
|
||||
- **작성 검증**: title ≤ 200자, bodyMarkdown ≤ 20000자, linkUrl ≤ 2048자 + scheme http/https(없으면 OG 생략), status ∈ {DRAFT, PUBLISHED}, categoryId 는 존재·is_active.
|
||||
- **저장 부작용**: bodyMarkdown → `PostMarkdownService.render` → body_sanitized_html. linkUrl 있으면 **동기 OG fetch**(아래 OG 정책) — 단 타임아웃 cap 내 실패 시 og_* NULL 로 graceful 저장(작성 자체는 성공).
|
||||
- **권한 모델**: 작성·수정·삭제 모두 POST_WRITE 게이트. **작성자 본인 한정이 아니라 POST_WRITE 보유자 전원 편집 가능**(공지/블로그 = 운영 채널 특성 — 본인글 제한은 비목표; 단 삭제/수정 시 author_user_id 는 보존, 감사 가능성은 후속). ADMIN 은 게이트 내부에서 암묵 통과(PermissionGate.has 의 role==ADMIN 분기, PermissionGate.java:30).
|
||||
|
||||
### 카테고리 운영 (운영자 — D2/G2)
|
||||
> 카테고리 CRUD 는 운영자 전용. **인터셉터 보호 경로 `/admin/**` 아래에 둔다** → RbacInterceptor 가 ADMIN 게이트 적용(InterceptorConfig.java:19 `/admin/**`). 카테고리 운영은 ADMIN 권한으로 통일(별도 권한키 불필요 — over-engineering 회피).
|
||||
|
||||
| method | path | 권한 | 요청 | 응답(200) | 에러 |
|
||||
|---|---|---|---|---|---|
|
||||
| GET | `/admin/post-categories` | ADMIN(인터셉터) | (없음) | `admin-post-categories` JSP 또는 `{categories:[...]}` | 401/403 |
|
||||
| POST | `/admin/post-categories` | ADMIN | name, slug, sortOrder | `{status:200, categoryId}` | 400, 409(slug 중복), 403 |
|
||||
| POST | `/admin/post-categories/{id}` | ADMIN | name?, slug?, sortOrder?, isActive? | `{status:200}` | 400, 404, 409, 403 |
|
||||
| POST | `/admin/post-categories/{id}/delete` | ADMIN | (path) | `{status:200}` | 404, 409(소속 포스트 존재 시 거부 또는 soft), 403 |
|
||||
|
||||
- **카테고리 삭제 정책**: 소속 PUBLISHED 포스트가 있으면 **하드 삭제 거부(409)** + `is_active=false` 권고(메시지). 정석: FK 무결성 보존, 포스트 고아화 방지.
|
||||
|
||||
### 유니티 피드 감시 운영 (운영자 — D6/G5)
|
||||
> 동일하게 `/admin/**` 아래 → 인터셉터 ADMIN 게이트.
|
||||
|
||||
| method | path | 권한 | 요청 | 응답(200) | 에러 |
|
||||
|---|---|---|---|---|---|
|
||||
| GET | `/admin/unity-feeds` | ADMIN | (없음) | `admin-unity-feeds` JSP(소스 목록 + 미확인 감지 항목 + 배지 카운트) | 401/403 |
|
||||
| POST | `/admin/unity-feeds` | ADMIN | name, feedUrl | `{status:200, sourceId}` | 400(SSRF 거부 시 feedUrl 부적합), 409(url 중복), 403 |
|
||||
| POST | `/admin/unity-feeds/{id}/toggle` | ADMIN | (path) | `{status:200, isActive}` | 404, 403 |
|
||||
| POST | `/admin/unity-feeds/{id}/delete` | ADMIN | (path) | `{status:200}` | 404, 403 |
|
||||
| POST | `/admin/unity-feeds/items/{itemId}/ack` | ADMIN | (path) | `{status:200}` | 404, 403 |
|
||||
| POST | `/admin/unity-feeds/poll` | ADMIN | (없음, 수동 트리거) | `{status:200, newCount}` | 403 |
|
||||
|
||||
- **피드 등록 시 SSRF 선검증**: feedUrl 은 등록 시점에 `SsrfSafeFetcher` 의 URL 검증(scheme/host resolve/IP 공인 여부)을 통과해야 등록 허용(400 거부). 폴링 시 매번 재검증(rebinding 방어).
|
||||
|
||||
---
|
||||
|
||||
## 보안: SsrfSafeFetcher 계약 (★핵심 — D5/D7, OG·피드 공통)
|
||||
|
||||
> 단일 공용 컴포넌트. OG 미리보기 fetch 와 유니티 피드 fetch 가 **둘 다 이 fetcher 만** 경유한다(SSRF 방어 중복 0, crossRefs 단일화 권장 채택).
|
||||
|
||||
### SSRF 방어 체크리스트 (전수 — securityNotes 에 기록)
|
||||
1. **scheme allowlist**: `http`, `https` 만 허용. `file:`/`gopher:`/`ftp:`/`data:` 등 전부 거부.
|
||||
2. **호스트명 resolve → IP 검증**: `InetAddress.getAllByName(host)` 로 전체 IP 해석. **모든** resolved IP 가 공인이어야 통과(하나라도 사설/예약이면 거부).
|
||||
3. **차단 IP 대역**: 루프백(127.0.0.0/8, ::1), 사설(10/8, 172.16/12, 192.168/16, fc00::/7), 링크로컬(169.254/16, fe80::/10), **메타데이터(169.254.169.254)**, 0.0.0.0, multicast, 와일드카드. `InetAddress.isLoopbackAddress/isSiteLocalAddress/isLinkLocalAddress/isAnyLocalAddress/isMulticastAddress` + 명시 169.254.169.254 차단 + IPv4-mapped IPv6 정규화 후 재검사.
|
||||
4. **★DNS rebinding 방어**: resolve 시점 IP 와 connect 후 실제 peer IP 가 다를 수 있다. 1차 명세 = **connect 후 소켓의 `getInetAddress()`(실제 연결된 peer IP) 를 #3 기준으로 재검증** → 실패 시 즉시 abort. (concern: HttpClient 가 host 로 재resolve 하므로 connection-level 검증 필요. 구현에서 custom SocketFactory IP 핀닝이 필요하면 그때 도입.)
|
||||
5. **redirect 매 홉 재검증**: 자동 redirect 따라가기 **비활성**(`RestClient`/`HttpClient` followRedirects=NEVER) → 3xx Location 을 받으면 **수동으로** #1~#4 재검증 후 다음 홉. 최대 홉 수 cap(예: 3). 초과 시 abort.
|
||||
6. **응답 size cap**: 응답 본문 최대 바이트(예: OG=512KB, 피드=2MB) 초과 시 스트림 중단(부분 파싱). Content-Length 신뢰 금지 — 읽는 바이트 누적 카운트.
|
||||
7. **timeout**: connect + read timeout(예: 3s/5s). 무한 대기 방지.
|
||||
8. **Content-Type 검증**: OG=text/html(또는 application/xhtml+xml), 피드=application/rss+xml / atom+xml / xml / text/xml. 불일치 시 파싱 거부(graceful).
|
||||
9. **에러 graceful**: 위 어느 단계 실패든 예외를 호출자에 전파하지 않고 `Optional.empty()`/실패 결과 반환. OG = 미리보기 없이 링크만. 피드 = 폴링 skip + `unity_feed_sources.last_error` 기록.
|
||||
|
||||
### fetcher 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||
```java
|
||||
// SsrfSafeFetcher — 외부 fetch 단일 관문(@Component). OG·피드 공통.
|
||||
// URL 검증 + 안전 fetch 를 한 메서드로 묶는다(검증만 따로 노출하면 검증-fetch 사이 TOCTOU).
|
||||
Optional<FetchResult> fetch(URI url, // 대상(검증 통과해야 fetch). scheme/host 검증 진입점
|
||||
long maxBytes, // 응답 size cap(OG/피드 호출자가 용도별 상한 지정)
|
||||
Set<String> allowedContentTypes) // #8 검증용(용도별 다름)
|
||||
// FetchResult: int status / String contentType / byte[] body(cap 내) / URI finalUrl
|
||||
|
||||
// 등록 시 사전 URL 검증만 필요한 경우(피드 등록 400 판정) — fetch 없이 검증만.
|
||||
boolean isFetchableUrl(URI url) // #1~#3 정적 검증(등록 시점 빠른 거부)
|
||||
```
|
||||
> ⚠️ inflate 마킹(concern): `fetch` 의 `allowedContentTypes` 는 호출처가 2곳(OG=html, 피드=xml)뿐이라 인자로 받는 게 정당(상수 분기보다 명시적). `maxBytes` 도 용도별로 달라 정당. 그러나 구현에서 두 인자가 사실상 호출처별 고정 상수로만 쓰이면 `fetchHtml(url)`/`fetchFeed(url)` 2메서드로 분리하는 게 더 깔끔할 수 있음 — 구현 단계에서 재평가.
|
||||
|
||||
---
|
||||
|
||||
## 마크다운 sanitize 계약 (D4)
|
||||
|
||||
```java
|
||||
// PostMarkdownService — 마크다운→sanitize HTML 단일 정의처(@Component).
|
||||
String render(String markdown) // 원본 → commonmark 변환 → jsoup Safelist sanitize → 캐시용 HTML
|
||||
```
|
||||
- **commonmark**(org.commonmark) 로 markdown → HTML.
|
||||
- **jsoup `Safelist`** allowlist:
|
||||
- 허용 태그: h1,h2,h3,p,br,strong,em,ul,ol,li,blockquote,code,pre,a,img,hr.
|
||||
- `a`: href(http/https/mailto만), `rel=nofollow noopener`, `target=_blank` 강제 부여. `javascript:` 차단(jsoup 기본).
|
||||
- `img`: src(http/https만), alt. 인라인 width/style 제거.
|
||||
- 제거: script, style, iframe, object, embed, form, on* 이벤트 속성 전부.
|
||||
- `Safelist.basicWithImages()` 기반 + 커스텀(h1~h3 추가, a rel 강제, protocol 제한). **저장 시 1회 render** → body_sanitized_html. 조회는 캐시 출력(재sanitize 없음).
|
||||
- concern: render 시그니처는 markdown 1개 최소 인자. sanitize 정책을 호출별로 바꿀 필요가 생기면 그때 확장.
|
||||
|
||||
---
|
||||
|
||||
## 시퀀스
|
||||
|
||||
### S1. 포스팅 작성(POST_WRITE 게이트 + 마크다운 sanitize + OG fetch)
|
||||
```
|
||||
[POST_WRITE 보유 세션] POST /posts (CSRF, categoryId, title, bodyMarkdown, linkUrl?)
|
||||
→ PostController.create
|
||||
→ CsrfTokens.isValid(request) 실패 → 403 errorBody ← AC-5
|
||||
→ userId = sessionUserId; null → 401
|
||||
→ permissionGate.has(session, POST_WRITE.name()) 실패 → 403 ← AC-1 (게이트 enforcement 연결)
|
||||
(내부: refreshIfStale epoch 대조 → role==ADMIN 통과 | SUBADMIN&&perms.contains(POST_WRITE))
|
||||
→ 입력 검증(title≤200, bodyMarkdown≤20000, status 화이트리스트)
|
||||
→ categoryMapper.getActive(categoryId) 없음 → 404
|
||||
→ sanitizedHtml = postMarkdownService.render(bodyMarkdown) ← AC-4 (XSS 차단)
|
||||
→ linkUrl 있으면:
|
||||
ogResult = ogPreviewService.fetch(linkUrl) ← SsrfSafeFetcher 경유
|
||||
- 성공 → og_* 채움
|
||||
- 실패/SSRF거부 → og_* NULL (graceful, 작성은 성공) ← AC-7
|
||||
→ postsMapper.insert(category, author, title, markdown, sanitizedHtml, og_*, status)
|
||||
→ 200 {postId, location:"/posts/{id}"}
|
||||
```
|
||||
|
||||
### S2. 포스팅 조회(공개 — sanitize 캐시 출력 + 페이징)
|
||||
```
|
||||
[익명/일반] GET /posts?categoryId=3&cursorCreatedAt=...&cursorId=...
|
||||
→ PostController.list
|
||||
→ postsMapper.listPublishedKeyset(categoryId, cursorCreatedAt, cursorId, limit+1) ← AC-8 keyset
|
||||
→ limit+1 행이면 hasNext=true, 마지막 행을 다음 커서로
|
||||
→ posts-list JSP: 카드 목록(title, og_image_url 썸네일, 작성자 display_name)
|
||||
[익명] GET /posts/42
|
||||
→ postsMapper.getPublished(42); null/DRAFT/is_delete → redirect:/posts
|
||||
→ posts-detail JSP: body_sanitized_html 직접 출력(이미 sanitize됨) + OG 카드(og_title/desc/image)
|
||||
```
|
||||
|
||||
### S3. 유니티 피드 폴링(@Scheduled + SSRF + dedupe + 알림)
|
||||
```
|
||||
[스케줄러] UnityFeedPoller.poll() (@Scheduled fixedDelay, 예: 30분)
|
||||
→ for each source in feedSourcesMapper.listActive():
|
||||
result = ssrfSafeFetcher.fetch(source.feedUrl, FEED_MAX_BYTES, FEED_CONTENT_TYPES)
|
||||
- 실패 → feedSourcesMapper.updateError(source.id, reason); continue (graceful) ← AC-7
|
||||
items = feedParser.parse(result.body) // RSS/Atom → (guid, title, link, publishedAt)
|
||||
newItems = items where guid not in (DB ux_unity_feed_items_source_guid) ← AC-6 dedupe
|
||||
for each newItem:
|
||||
feedItemsMapper.insertIgnoreDup(source.id, guid, title, link, publishedAt)
|
||||
(ux_unique 충돌 시 무시 — DB 레벨 2차 dedupe 방어)
|
||||
feedSourcesMapper.updateCursor(source.id, lastSeenGuid=items[0].guid, last_polled_at=now)
|
||||
→ 운영자가 GET /admin/unity-feeds 진입 시 미확인(is_acknowledged=false) 항목 배지 노출 ← AC-6 알림
|
||||
```
|
||||
- **dedupe 이중 방어**: (1) 폴링 로직이 guid 대조로 신규만 선별 + (2) DB `ux_unity_feed_items_source_guid` UNIQUE 가 경합·재폴링 시 중복 INSERT 를 무시(insertIgnoreDup = `ON CONFLICT DO NOTHING`). 단일 인스턴스라도 수동 트리거(`/admin/unity-feeds/poll`)와 스케줄 겹침 방어.
|
||||
|
||||
### S4. 카테고리 운영(ADMIN 인터셉터 게이트)
|
||||
```
|
||||
[ADMIN 세션] POST /admin/post-categories (CSRF, name, slug, sortOrder)
|
||||
→ RbacInterceptor preHandle: /admin/** → isAdmin 통과(아니면 403/401) ← AC-3
|
||||
→ PostAdminController.createCategory
|
||||
→ CsrfTokens.isValid 실패 → 403
|
||||
→ slug 중복 → 409
|
||||
→ categoryMapper.insert → 200 {categoryId}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파일 영향 맵
|
||||
|
||||
> 소유권 분할 가이드(implementation-advisor worker 단위 후보):
|
||||
> **U-SCHEMA**(board-ddl + schema.sql 동기) · **U-DOMAIN**(data POJO + 매퍼 6종) · **U-MARKDOWN**(PostMarkdownService + pom 의존) · **U-SSRF**(SsrfSafeFetcher + OgPreviewService — 보안 핵심, 단독 worker 권장) · **U-FEED**(UnityFeedPoller + FeedParser + 스케줄 활성) · **U-POST-CTRL**(PostController 공개/작성 + JSP) · **U-ADMIN-CTRL**(카테고리/피드 운영 컨트롤러 + JSP).
|
||||
> 의존: U-SCHEMA → U-DOMAIN → {U-MARKDOWN, U-SSRF}. U-SSRF → {U-FEED(OgPreview 와 fetcher 공유), OG는 U-POST-CTRL 소비}. U-MARKDOWN/U-SSRF → U-POST-CTRL. U-DOMAIN → U-ADMIN-CTRL.
|
||||
|
||||
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||
|---|---|---|---|
|
||||
| 신규 | `docs/board-ddl.sql` | 권위 DDL(posts/post_categories/unity_feed_sources/unity_feed_items). apply-local-ddl.sh 자동적용 | U-SCHEMA |
|
||||
| 수정 | `db/schema.sql` | rbac 블록 뒤에 4테이블 동기 추가(board-ddl 사본) | U-SCHEMA |
|
||||
| 수정 | `pom.xml` | commonmark + jsoup 의존 추가(HTTP 는 spring-web RestClient 내장) | U-MARKDOWN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PostData.java` | posts 행 POJO(id/categoryId/authorUserId/title/bodyMarkdown/bodySanitizedHtml/linkUrl/og*/status + 조인 author displayName/categoryName) | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/PostCategoryData.java` | post_categories 행 POJO | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UnityFeedSourceData.java` | unity_feed_sources 행 POJO | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UnityFeedItemData.java` | unity_feed_items 행 POJO | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java` | `@Mapper` posts CRUD + keyset 페이징(listPublishedKeyset/getPublished/insert/update/softDelete, `#{}`, snake→camel alias) | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/PostCategoriesMapper.java` | `@Mapper` 카테고리 CRUD(listActive/getActive/insert/update/softToggle/delete/countPostsByCategory) | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedSourcesMapper.java` | `@Mapper` 소스 CRUD(listActive/insert/updateCursor/updateError/toggle/delete) | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UnityFeedItemsMapper.java` | `@Mapper` 항목(insertIgnoreDup=ON CONFLICT DO NOTHING/listUnacknowledged/countUnacknowledged/acknowledge) | U-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/PostMarkdownService.java` | 마크다운→sanitize HTML(commonmark+jsoup Safelist). render(markdown) | U-MARKDOWN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java` | ★외부 fetch 단일 관문(SSRF 방어 전수). fetch(url,maxBytes,types)/isFetchableUrl(url) | U-SSRF |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/OgPreviewService.java` | OG 메타 추출(SsrfSafeFetcher 경유 + jsoup OG 파싱). fetch(linkUrl)→OgPreview | U-SSRF |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/UnityFeedPoller.java` | `@Scheduled` 폴링(SsrfSafeFetcher + FeedParser + dedupe). poll()/pollOnce(sourceId) | U-FEED |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/FeedParser.java` | RSS/Atom 파싱(JDK javax.xml — 신규 의존 0). parse(bytes)→List<FeedItem> | U-FEED |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/SchedulingConfig.java` | `@EnableScheduling`(현재 미활성, code-fact 0 hit) | U-FEED |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/PostController.java` | 공개 목록/상세(JSP) + 작성/수정/삭제(POST_WRITE 게이트, JSON). PermissionGate 주입 | U-POST-CTRL |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/posts-list.jsp` | 포스트 카드 목록 + 카테고리 탭 + keyset 더보기(JSTL escape) | U-POST-CTRL |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/posts-detail.jsp` | 본문(sanitized html) + OG 카드(escape, og_image src 출력) | U-POST-CTRL |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/posts-form.jsp` | 작성/수정 폼(textarea markdown + CSRF hidden + 카테고리 select) | U-POST-CTRL |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java` | 카테고리 CRUD(/admin/post-categories, 인터셉터 ADMIN 게이트 + CSRF) | U-ADMIN-CTRL |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/UnityFeedAdminController.java` | 피드 소스/항목 운영(/admin/unity-feeds, ADMIN + CSRF + ack/poll) | U-ADMIN-CTRL |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | 카테고리 운영 화면(CSRF) | U-ADMIN-CTRL |
|
||||
| 신규 | `src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp` | 피드 소스/미확인 항목 + 배지(CSRF) | U-ADMIN-CTRL |
|
||||
| 수정 | `src/main/webapp/WEB-INF/views/header.jsp` | 포스팅 메뉴 링크 추가(`/posts`) + (운영자) 미확인 피드 배지 노출 후보 | U-POST-CTRL |
|
||||
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록(contextLoads, §30) | (검증) |
|
||||
| 신규 | `src/test/.../SsrfSafeFetcherTest.java` | ★SSRF 전수 단위(사설/루프백/메타데이터/rebinding/redirect/size/timeout/scheme 차단) | (검증) |
|
||||
| 신규 | `src/test/.../PostMarkdownServiceTest.java` | sanitize XSS 차단(script/onclick/javascript:/iframe 제거) 단위 | (검증) |
|
||||
| 신규 | `src/test/.../PostControllerTest.java` | 작성 POST_WRITE 게이트 401/403/CSRF/검증 + keyset 페이징 | (검증) |
|
||||
| 신규 | `src/test/.../FeedParserTest.java` + `UnityFeedPollerTest.java` | RSS/Atom 파싱 + dedupe(신규만 insert) 단위 | (검증) |
|
||||
|
||||
> SSR 호출지점: 전부 신규 경로(기존 호출지점 깨짐 0). header.jsp 메뉴 추가는 표시용.
|
||||
|
||||
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||
```java
|
||||
// PostsMapper (@Mapper, #{} only, snake→camel 직접 alias)
|
||||
List<PostData> listPublishedKeyset(java.time.OffsetDateTime cursorCreatedAt, // keyset 커서(null=첫페이지)
|
||||
Long cursorId, // 동일 created_at 타이브레이커
|
||||
Long categoryId, // null=전체 카테고리
|
||||
int limit) // 페이지 크기(컨트롤러가 limit+1 요청해 hasNext 판정)
|
||||
PostData getPublished(long id) // 상세(PUBLISHED && !is_delete + author/category 조인)
|
||||
int insert(PostData post) // 작성(useGeneratedKeys)
|
||||
int update(PostData post) // 수정(title/markdown/sanitized/og/status)
|
||||
int softDelete(long id) // is_delete=true, deleted_at=now
|
||||
|
||||
// PostCategoriesMapper
|
||||
List<PostCategoryData> listActive() // 공개 탭 + 작성폼 select
|
||||
PostCategoryData getActive(long id) // 작성 시 카테고리 유효성
|
||||
int insert(PostCategoryData c) / int update(PostCategoryData c) / int delete(long id)
|
||||
int countPostsByCategory(long categoryId) // 삭제 거부(409) 판정
|
||||
|
||||
// UnityFeedSourcesMapper
|
||||
List<UnityFeedSourceData> listActive() // 폴링 대상
|
||||
int insert(UnityFeedSourceData s)
|
||||
int updateCursor(long id, String lastSeenGuid) // 폴링 성공 후 커서 전진
|
||||
int updateError(long id, String lastError) // 폴링 실패 graceful 기록
|
||||
int toggle(long id) / int delete(long id)
|
||||
|
||||
// UnityFeedItemsMapper
|
||||
int insertIgnoreDup(UnityFeedItemData item) // ON CONFLICT(source_id,guid) DO NOTHING
|
||||
List<UnityFeedItemData> listUnacknowledged(int limit) // 운영자 알림 목록
|
||||
int countUnacknowledged() // 배지 카운트
|
||||
int acknowledge(long itemId) // 확인 처리
|
||||
|
||||
// OgPreviewService — SsrfSafeFetcher 경유. linkUrl 1개 최소.
|
||||
Optional<OgPreview> fetch(String linkUrl) // 실패/SSRF거부 시 empty(graceful)
|
||||
|
||||
// UnityFeedPoller
|
||||
void poll() // @Scheduled 전체 활성 소스 폴링
|
||||
int pollOnce(long sourceId) // 수동 트리거(반환 newCount)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 대안 비교
|
||||
|
||||
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||
|---|---|---|---|---|
|
||||
| 외부 fetch 인프라 | (A) 공용 SsrfSafeFetcher 단일 | OG·피드 SSRF 방어 중복 0, 검증 일관 | 컴포넌트 1개 추상화 | **채택** |
|
||||
| | (B) OG·피드 각자 fetch | 결합 낮음 | SSRF 방어 2벌 — 한쪽 누락 시 보안 구멍 | 기각 |
|
||||
| DNS rebinding 방어 | (A) connect 후 peer IP 재검증 | 라이브러리 호환, TOCTOU 최소화 | host 재resolve 미차단 잔여(concern) | **채택(1차)** |
|
||||
| | (B) custom SocketFactory IP 핀닝 | 완전 핀닝 | 구현 복잡·HttpClient 통합 부담 | 보류(필요시 승격) |
|
||||
| 본문 sanitize 시점 | (A) 저장 시 1회 → 캐시 | 조회 저비용, 정책변경 재생성 여지 | 컬럼 1개(body_sanitized_html) | **채택** |
|
||||
| | (B) 조회 시 매번 sanitize | 컬럼 1개 절약 | 매 조회 CPU, 캐시 이점 0 | 기각 |
|
||||
| 피드 임포트 | (A) 감지+알림(운영자 수동 작성) | 큐레이션 품질·법적/저작권 안전 | 자동화 아님 | **채택** |
|
||||
| | (B) 감지 후 자동 포스트 생성 | 완전 자동 | 저작권·스팸·중복 위험 | 기각(비목표) |
|
||||
| 페이징 | (A) keyset(created_at,id) | 깊은 페이지 일관·성능 | 커서 전달 | **채택** |
|
||||
| | (B) OFFSET/LIMIT | 단순 | 깊은 OFFSET 성능·삽입 시 드리프트 | 기각 |
|
||||
| 카테고리 권한 | (A) ADMIN(/admin/** 인터셉터) | 신규 권한키 0, 인프라 재사용 | 카테고리=ADMIN 한정 | **채택** |
|
||||
| | (B) 신규 CATEGORY_MANAGE 키 | 세분화 | over-engineering(소비 1곳) | 기각 |
|
||||
|
||||
---
|
||||
|
||||
## 롤아웃 / 마이그레이션
|
||||
|
||||
### 순서
|
||||
1. **의존 추가**(U-MARKDOWN 선): pom.xml commonmark + jsoup → `./mvnw -o compile` 확인. (HTTP 는 RestClient 내장, 의존 0.)
|
||||
2. **스키마 적용**: `docs/board-ddl.sql` → `db/apply-local-ddl.sh`. 기존 데이터 무관(신규 4테이블, 추가만).
|
||||
3. **권한 시드 불필요**: POST_WRITE 키는 W1 PermissionCatalogVerifier 가 이미 enum→DB 시드(grounding R-A). 운영자가 콘솔에서 특정 SUBADMIN 에게 POST_WRITE 토글 부여하면 그때부터 포스팅 작성 가능.
|
||||
4. **코드 배포**: 매퍼/서비스/컨트롤러/JSP. SchedulingConfig 활성 → 피드 폴링 시작.
|
||||
5. **운영자 카테고리·피드 등록**: 콘솔에서 첫 카테고리 + 유니티 피드 소스 등록(SSRF 선검증 통과 URL만).
|
||||
|
||||
### 역호환
|
||||
- 신규 경로·테이블·의존만 추가 — 기존 기능 영향 0. RecruitController/리뷰/RBAC 동작 불변.
|
||||
- `/admin/**` 카테고리·피드 경로는 기존 RbacInterceptor `/admin/**` ADMIN 게이트(InterceptorConfig.java:19)에 자동 포섭 — 인터셉터 수정 불필요(경로 패턴 이미 커버).
|
||||
|
||||
### 롤백
|
||||
- 코드 롤백: 컨트롤러/스케줄러 미등록 시 포스팅 기능 비활성. 신규 테이블 잔존해도 무해(비파괴).
|
||||
- 의존 롤백: commonmark/jsoup 제거 시 PostMarkdownService 컴파일 깨짐 — 코드 동반 롤백.
|
||||
- 스키마 롤백: 추가 전용이라 DROP 없이 잔존 무해. 명시적 DROP 은 별도 maintenance.
|
||||
|
||||
---
|
||||
|
||||
## AC 매핑
|
||||
|
||||
| AC | 요구 | 만족 설계 요소 | 비고 |
|
||||
|---|---|---|---|
|
||||
| AC-1 | POST_WRITE 보유자만 작성, 미보유 403 | PostController.create 의 `permissionGate.has(session, POST_WRITE)` (임시 role 체크 0) | S1, D2 |
|
||||
| AC-2 | 일반 유저 읽기 전용 + 댓글 없음 | 공개 GET 만 인증 불요, 작성 게이트, 댓글 엔드포인트·테이블 부재 | D3 |
|
||||
| AC-3 | 카테고리/피드 운영 ADMIN 전용 | `/admin/**` 경로 → RbacInterceptor isAdmin 게이트 | S4, §API |
|
||||
| AC-4 | 본문 XSS 차단(sanitize) | PostMarkdownService render(commonmark+jsoup Safelist), script/on*/javascript: 제거 | D4, §sanitize |
|
||||
| AC-5 | 상태변경 CSRF 없으면 403 | 전 쓰기 엔드포인트 `CsrfTokens.isValid` 선검증 | §API 공통 |
|
||||
| AC-6 | 유니티 피드 새 글 감지·dedupe·알림 | UnityFeedPoller guid dedupe + ux_unique 2차 방어 + 미확인 배지 | S3, D6 |
|
||||
| AC-7 | OG/피드 fetch 실패 graceful | SsrfSafeFetcher Optional.empty → OG NULL/피드 skip+last_error | S1/S3, §SSRF #9 |
|
||||
| AC-8 | 게시판 keyset 페이징 | listPublishedKeyset(cursor) + idx_posts_*_keyset | S2, D8 |
|
||||
| AC-9 | ★SSRF 전수 방어 | SsrfSafeFetcher 9항목 체크리스트(scheme/IP/rebinding/redirect/size/timeout/type/graceful) | §SSRF |
|
||||
| AC-10 | SQL `${}` 0 | 신규 매퍼 6종 전부 `#{}` | §파일영향맵 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 포인트 (verification-advisor 점검 대상)
|
||||
|
||||
> L레벨 매핑(verification-strategies.md): 권한 게이트/인가 플로우 = **L1+L2+L3**. ★SSRF 방어 = **L1(차단 단위) 필수 + L3(실제 외부 차단 스모크)**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 빈 다수 = full `./mvnw -o test` 의무(§30).
|
||||
|
||||
### 시나리오 검증
|
||||
- **VP-1 (AC-1 게이트, L1+L3)**: PostControllerTest — POST_WRITE 미보유 세션 작성 → 403, 보유(또는 ADMIN) → 200. 미인증 → 401. L3: 콘솔에서 토글 부여 후 작성 통과.
|
||||
- **VP-2 (AC-9 ★SSRF, L1 필수)**: SsrfSafeFetcherTest — `http://127.0.0.1`, `http://169.254.169.254/latest/meta-data`, `http://10.0.0.1`, `http://[::1]`, `file:///etc/passwd`, IPv4-mapped IPv6, redirect→사설IP, size 초과, timeout 전부 차단/abort. **rebinding mock**(resolve=공인, connect peer=사설) 차단. L3: 실 외부 URL OG 성공 + 사설 URL 차단 스모크.
|
||||
- **VP-3 (AC-4 sanitize, L1)**: PostMarkdownServiceTest — `<script>`, `<img onerror=>`, `[x](javascript:alert(1))`, `<iframe>` 입력이 출력 HTML 에서 제거됨. 정상 마크다운(볼드/링크/리스트)은 보존.
|
||||
- **VP-4 (AC-6 피드 dedupe, L1)**: UnityFeedPollerTest — 동일 guid 재폴링 시 insert 0(신규만), 신규 guid 만 unity_feed_items 추가 + countUnacknowledged 증가.
|
||||
- **VP-5 (AC-5 CSRF, L1)**: 전 쓰기 엔드포인트 CSRF 누락 → 403 + mapper 미호출(RecruitController/W3-2 패턴 준용).
|
||||
- **VP-6 (AC-8 keyset, L1+L2)**: listPublishedKeyset 커서 페이징 정확성 + alias 매핑(snake→camel) DB-방언 계약.
|
||||
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 매퍼 6종·SsrfSafeFetcher·서비스 @MockBean 등록 후 PASS(§30).
|
||||
|
||||
### 집합 전수 체크 AC (시점·표현 self-audit 적용)
|
||||
> self-audit (시점 안정성): 아래 카운트는 본 워크스트림이 신규 생성하는 정적 산출물 또는 DDL/체크리스트 항목으로, verification 시점까지 본 워크스트림 외 변경 주체가 없다(시점 안정). 자기 트리처럼 계속 증가하는 대상 아님.
|
||||
> self-audit (표현 견고성): 단일 리터럴 grep 취약성을 피해 (a) DDL 객체 카운트는 `CREATE TABLE IF NOT EXISTS` 고정 패턴 + 테이블명 집합, (b) SSRF/sanitize 는 의미 불변식 + 수동 판정으로 앵커한다.
|
||||
|
||||
- **AC-T1 신규 board 테이블 전수 4건** — `docs/board-ddl.sql` 의 `CREATE TABLE IF NOT EXISTS` 4건(post_categories/posts/unity_feed_sources/unity_feed_items) AND schema.sql 동기 4건: `grep -c 'CREATE TABLE IF NOT EXISTS' docs/board-ddl.sql` == 4. 누락·오타 동시 검출.
|
||||
- **AC-T2 ★SSRF 방어 체크리스트 전수 9항목** — SsrfSafeFetcherTest 가 §SSRF 9항목(scheme/resolve/사설·루프백·링크로컬·메타데이터/rebinding/redirect/size/timeout/Content-Type/graceful) **전부**에 대응하는 차단 테스트를 보유. 검증: 9항목 각각 최소 1 테스트 메서드 존재(수동 매핑 — 리터럴 grep 아님, 의미 단위). **1항목이라도 미커버 시 SSRF 보안 구멍 → FAIL**(이 전수 AC 가 보안 핵심 가드).
|
||||
- **AC-T3 쓰기 엔드포인트 CSRF 가드 전수** — PostController/PostAdminController/UnityFeedAdminController 의 모든 상태변경(@PostMapping) 핸들러에 `CsrfTokens.isValid` 선검증 존재: 각 컨트롤러 내 `@PostMapping` 수 == `CsrfTokens.isValid` 호출 수. 핸들러 추가 시 가드 누락 동시 검출.
|
||||
- **AC-T4 신규 매퍼 SQL `${}` 0건** — 신규 매퍼 6종(PostsMapper/PostCategoriesMapper/UnityFeedSourcesMapper/UnityFeedItemsMapper + 향후 추가분)에 `${` 매치 0: `grep -rc '\${' <매퍼 파일들>` == 0 (AC-10).
|
||||
- **AC-T5 POST_WRITE enforcement 연결 확인** — 작성/수정/삭제 핸들러(쓰기 3종)가 `permissionGate.has(...POST_WRITE...)` 호출: PostController 의 POST_WRITE 게이트 호출 수 == 작성·수정·삭제 핸들러 수(3). 게이트 누락 핸들러(임시 개방) 동시 검출 — 보안 가드.
|
||||
|
||||
---
|
||||
|
||||
## 잔여 오픈 질문
|
||||
없음(0). 확정 결정 D1~D9 고정. 두 보안 난제(마크다운 sanitize · SSRF 방어)는 본 설계가 구체 메커니즘(commonmark+jsoup Safelist / SsrfSafeFetcher 9항목 체크리스트)으로 확정. SSRF rebinding 구현 라이브러리 제약·신규 빈 full-test·DB-방언 L2·pom 의존 추가·시그니처 inflate 위험·@EnableScheduling 활성은 오픈 질문이 아니라 **구현 단계 점검 항목**으로 `concerns` 에 이관.
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
---
|
||||
phase: design
|
||||
agent: design-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-23T16:30:00+09:00
|
||||
workstream: W3-4-메인페이지(게임 허브)
|
||||
concerns:
|
||||
- "WebMvcController.indexModelAndView 시그니처 확장(query → query+cursor)은 최소 인자로 명세했다. 구현 단계에서 cursor 파싱 헬퍼(parseCursor)의 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2)."
|
||||
- "GamesMapper 신규 keyset 메서드(listVisibleKeyset/searchVisibleKeyset) 의존은 WebMvcController 가 이미 GamesMapper 를 주입받으므로 신규 빈 0 — 그러나 신규 매퍼 SQL 은 DB-방언 계약(L2) 대상. keyset 3-튜플 비교((sort_order,created_at,id) row-comparison 또는 OR 분해) PostgreSQL 동작은 dev DB contract 로 실측 검증 권장(verification-strategies §33). 신규 매퍼 메서드 추가만이라 BibimbapApplicationTests @MockBean 신규 등록 불요(GamesMapper 기존 등록 재사용)."
|
||||
- "진행중 잼 배너는 W2-1 jams 테이블(status/is_visible) 의존 + W3-1 잼 태그 검색 라우트(라우팅 타깃)에 의존한다. 본 설계 작성 시점 W3-1-tags-search-design.md 미존재(병렬 워크스트림) → 배너 클릭 라우트는 §외부계약 '잼 검색 라우트 계약'으로 앵커만 고정. W3-1 이 실제 경로/파라미터를 확정하면 그 계약을 단일 출처로 채택. 배너 도입(단계2)은 W2-1 + W3-1 착지 후 — 단계1(페이징/그리드)은 선착수 독립."
|
||||
- "동시 진행 잼 복수(N≥2) 처리는 '최신 1건 배너 + 전체 N건 카운트 라벨'로 확정(아래 D4). N=0 이면 배너 미렌더(기존 동작 회귀 0). jams.is_visible IS NOT FALSE 인 진행중 잼만 카운트 — 비공개 잼 노출 방지."
|
||||
- "GamesMapper 에 jam 컬럼 추가 없음(W2-1 D1: games 무변경, 연결은 jam_entries). 따라서 허브 그리드는 잼 출품작을 별도 강조하지 않고 기존 전체 그리드 유지 — '신규 출품작 강조'는 별도 그리드 섹션이 아니라 배너 CTA(잼 검색 라우팅)로만 표현(중복노출 회피, 확정결정)."
|
||||
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
|
||||
---
|
||||
|
||||
# 설계: W3-4 — 메인페이지(게임 허브): index.jsp 게임 허브 확장 + keyset 페이징 + 진행중 잼 안내 배너(W3-1 라우팅)
|
||||
|
||||
## 목표 / 비목표
|
||||
|
||||
### 목표 (FR/NFR 추적 — 골자 W3-4, stale 정정 #4 "index.jsp 잼 노출 없음 → W3-4 유효")
|
||||
- **G1 게임 허브 확장**: 현 `index.jsp` 전체 게임 카드 그리드를 게임 허브로 확장. 현 정렬(`sort_order ASC, created_at DESC, id DESC`) **그대로 유지**(회귀 0).
|
||||
- **G2 keyset 페이징(정석)**: 전건 로드(현 `getVisibleGames` 전건)를 **keyset 커서 페이징**으로 전환. 커서 = `(sort_order, created_at, id)` 3-튜플. offset 대비 깊은 페이지 일관성(삽입 시 행 밀림 중복 0, 누락 0). 검색(`q`) 경로도 동일 keyset.
|
||||
- **G3 진행중 잼 안내 배너(조건부)**: **진행중 잼이 있을 때만** 검색창 아래·그리드 위에 "게임잼 진행 중" 배너 + CTA. 진행중 = `jams.status IN ('RECRUIT','DEV','EVAL')`(W2-1 상태 4값 중 CLOSED 제외) AND `is_visible IS NOT FALSE`.
|
||||
- **G4 잼 검색 라우팅**: 배너 CTA 클릭 → **W3-1 잼 태그 검색**으로 라우팅(별도 그리드 섹션 아님 — 출품작 중복노출 회피, 확정결정). "신규 출품작 강조"는 배너 CTA 로만 표현.
|
||||
- **G5 동시 진행 잼 복수 처리**: N≥2 진행중 잼 시 **최신 1건(`created_at DESC`)을 배너 주체 + 전체 N건 카운트 라벨**("외 N-1개 진행 중"). N=0 → 배너 미렌더.
|
||||
- **G6 단계 착수 분리**: 단계1(G1·G2 허브/페이징)은 W2/W3-1 **무관 독립 선착수**. 단계2(G3·G4·G5 잼 배너)는 W2-1(jams) + W3-1(잼 검색 라우트) 착지 **후**.
|
||||
- **NFR**: 검색/페이징 파라미터 `#{}` 바인딩(`${}` 금지), 커서/검색어 입력 sanitize, JSP 출력 `HtmlUtils.htmlEscape`, 배너 링크 `textContent`/escape, 비파괴(신규 매퍼 메서드 추가만·games DDL 0변경), 기존 index 동작 회귀 0.
|
||||
|
||||
### 비목표 (스코프 밖)
|
||||
- **잼 엔티티/CRUD/출품**(jams/jam_entries 테이블·매퍼·관리자 콘솔) — **W2-1 소유**. 본 설계는 jams **조회만**(진행중 카운트/최신 1건).
|
||||
- **잼 태그 검색 화면·태그 스키마·검색 SQL** — **W3-1 소유**. 본 설계는 배너 CTA 가 그 라우트로 **링크만**(계약 앵커).
|
||||
- **games 스키마 변경**(jam_id/방문수/정렬키 추가) — games 무변경(W2-1 D1 정합). 정렬은 기존 3키 유지.
|
||||
- **무한스크롤 JS 본체 고도화**(가상 스크롤·prefetch) — 1차는 서버 keyset + "더 보기" 버튼/링크(nextCursor). 가상화는 후속.
|
||||
- **개인화 추천·인기순 재정렬** — 정렬 변경은 별도. 본 설계는 현 정렬 유지 + 페이징만.
|
||||
- **잼 출품작 전용 그리드 섹션** — 확정결정으로 **미채택**(중복노출 회피). 배너 라우팅으로 대체.
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
bibimbap 의 메인 허브는 `WebMvcController.indexView` → `indexModelAndView(query)`(WebMvcController.java:52-112 직접 확인)가 담당한다. 현재 `gamesMapper.getVisibleGames()`(전건) 또는 검색 시 `searchVisibleGames(query)`(전건)를 `model.games` 로 주입하고 `index.jsp` 가 `games` 리스트를 단일 그리드로 렌더한다(index.jsp:491-537 직접 확인). 정렬은 `sort_order ASC, created_at DESC, id DESC`(GamesMapper.java:60,89 직접 확인). 페이징은 전무(전건 로드). index.jsp 에 잼 노출 0(work-log stale 정정 #4 "안 뒤집힘").
|
||||
|
||||
본 설계는 두 가지를 더한다.
|
||||
1. **keyset 페이징(G2)** — 전건 로드를 커서 기반 페이지로 전환. 커서 = 현 정렬키 3-튜플 `(sort_order, created_at, id)`. 정렬·필터·검색은 그대로 두고 `WHERE` 에 커서 비교 + `LIMIT pageSize+1` 만 추가 → 현 결과 순서 회귀 0, 깊은 페이지 일관성 확보.
|
||||
2. **진행중 잼 안내 배너(G3~G5)** — `jams.status IN ('RECRUIT','DEV','EVAL') AND is_visible IS NOT FALSE`(W2-1 jams)인 잼을 조회해 N≥1 이면 검색창 아래 배너를 렌더. 배너 CTA 는 **W3-1 잼 태그 검색 라우트**로 라우팅(별도 그리드 아님 — 중복노출 회피).
|
||||
|
||||
확정된 정석 결정(전제):
|
||||
- **정렬 불변 + keyset(D2)**: offset 페이징(행 밀림 중복) 대신 keyset. 현 정렬 3키가 그대로 커서 → 추가 정렬·인덱스 변경 최소. games 무변경.
|
||||
- **배너 ≠ 그리드 섹션(D3)**: 진행중 잼 출품작을 허브에 두 번째 그리드로 깔면 일반 그리드와 중복노출. 대신 **배너 + CTA → W3-1 검색**으로 단일 진입(확정결정).
|
||||
- **복수 잼 = 최신 1건 + 카운트(D4)**: 동시 진행 N개 시 배너 본문은 최신 1건, "외 N-1개" 라벨. 잼 목록 전체 노출은 W2-1 `/jams` 가 소유.
|
||||
- **단계 분리(D5)**: 단계1(허브/페이징)은 jams 미존재여도 독립 동작 → 선착수. 단계2(배너)는 W2-1+W3-1 후. 한 워크스트림이나 착수 게이트가 둘.
|
||||
|
||||
가장 까다로운 두 난제 확정:
|
||||
- **난제1 (검색·비검색 keyset 단일화)**: 비검색(`getVisibleGames`)과 검색(`searchVisibleGames`)이 동일 정렬·동일 커서 의미를 가져야 "더 보기"가 두 경로에서 일관. → 두 신규 매퍼 메서드가 **동일 커서 WHERE 절 + 동일 ORDER BY + 동일 LIMIT 규약**을 공유하고, 컨트롤러는 검색어 유무로만 분기(커서 처리 코드는 공통 헬퍼). 검색 경로도 keyset 으로 통일(검색 결과가 많을 때 동일 일관성).
|
||||
- **난제2 (배너 의존성 부재 시 동작)**: W2-1 jams 미착지 또는 진행중 잼 0건 시 배너는 **렌더되지 않아야 하고 허브는 정상**이어야 한다(단계1 독립성). → 배너 데이터는 `activeJam`(최신 1건, nullable) + `activeJamCount`(int, 0 가능) 모델 attr 로 주입하되, **JamsMapper 미존재(W2-1 미착지) 단계1 에서는 이 attr 자체를 주입하지 않음**(JSP 가 attr 부재 시 배너 미렌더). 단계2 착지 후 attr 주입 활성화. 즉 JSP 는 `activeJamCount > 0` 일 때만 배너 렌더 → 의존성 부재/0건 모두 안전 회귀 0.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 요약 (전제 — 재논의 금지)
|
||||
|
||||
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||
|---|---|---|
|
||||
| D1 허브 확장 | index.jsp = 게임 허브 | 현 그리드 유지 + 페이징 + 조건부 잼 배너. games 무변경 |
|
||||
| D2 페이징 | keyset 커서 | 커서 = `(sort_order, created_at, id)` 3-튜플. 현 정렬 그대로. offset 기각 |
|
||||
| D3 잼 노출 | 배너 + CTA(그리드 아님) | 진행중 잼 시 배너 → W3-1 잼 검색 라우팅. 별도 출품작 그리드 미채택(중복노출 회피) |
|
||||
| D4 복수 잼 | 최신 1건 + 카운트 | `created_at DESC` 최신 1건 배너 + "외 N-1개" 라벨. N=0 → 미렌더 |
|
||||
| D5 단계 분리 | 단계1 독립 / 단계2 의존 | 단계1(페이징) 선착수, 단계2(배너) = W2-1+W3-1 후 |
|
||||
| D6 검색 통일 | 검색도 keyset | 비검색·검색 동일 커서 규약(난제1). 컨트롤러는 q 유무만 분기 |
|
||||
| D7 페이지 진행 | 서버 nextCursor + 더보기 링크 | 1차는 "더 보기"(nextCursor 쿼리). 무한스크롤 JS 는 후속(점진) |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 모델 (DDL)
|
||||
|
||||
> **신규 테이블 0**(확정결정). games(기존, W2-1 무변경) 조회 + jams(W2-1 신규, 본 설계는 조회만). 본 설계가 신규 추가하는 DDL 은 **없다** — keyset 성능 인덱스 1건만 권장(games 무파괴, 멱등).
|
||||
|
||||
### games keyset 정렬 인덱스 (권장 — 성능, 신규 테이블/컬럼 아님)
|
||||
- 현 정렬 `sort_order ASC, created_at DESC, id DESC` 에 대한 keyset seek 효율을 위해 복합 인덱스 1건을 **권장**한다(games 컬럼 변경 0 — 인덱스만 추가, 비파괴 멱등).
|
||||
- 권위 = 신규 파일 없이 **W2-1 의 docs/jam-ddl.sql 과 별개의 게임 허브 성능 인덱스**는 games 소유라 신설 `docs/games-hub-ddl.sql`(멱등) 또는 기존 games 관리 위치에 추가. 단 **games 는 schema.sql:88-100 비권위 복원본**(grounding R-C) → 인덱스 멱등 추가는 `CREATE INDEX IF NOT EXISTS`.
|
||||
|
||||
```sql
|
||||
-- W3-4 게임 허브 keyset 페이징 성능 인덱스. games 컬럼 변경 0(인덱스만). 멱등.
|
||||
-- 권위 = docs/games-hub-ddl.sql (apply-local-ddl.sh 글롭 docs/*-ddl.sql 자동 적용) + db/schema.sql 동기 사본.
|
||||
-- 정렬키 = sort_order ASC, created_at DESC, id DESC (GamesMapper.java:60 와 동일 순서).
|
||||
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
|
||||
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
|
||||
```
|
||||
- **인덱스 없이도 동작**(소규모면 seq scan 도 정확) — 인덱스는 깊은 페이지/대량 시 seek 최적화. 채택 권장하되 keyset 정합의 필수 전제는 아님(정합은 WHERE 비교가 보장).
|
||||
|
||||
### jams 조회 (W2-1 소유 — 본 설계는 읽기만)
|
||||
- W2-1 `jams` 테이블의 `status`(CHECK 'RECRUIT'/'DEV'/'EVAL'/'CLOSED')·`is_visible`·`slug`·`title`·`created_at` 컬럼만 SELECT. 본 설계는 jams DDL 을 **추가/변경하지 않는다**(W2-1 권위 docs/jam-ddl.sql).
|
||||
- 진행중 잼 정렬·인덱스는 W2-1 의 `idx_jams_visible_keyset`(W2-1-design §데이터모델1, `(is_visible,is_delete,created_at DESC,id DESC)`)을 그대로 소비(추가 인덱스 불요).
|
||||
|
||||
---
|
||||
|
||||
## 외부 계약 (API)
|
||||
|
||||
> 공통: 본 설계의 변경은 **읽기(GET) 뷰만**(허브는 상태변경 없음). 상태변경 API 0 → CSRF 신규 적용 대상 없음(기존 index 도 GET). 검색·커서 파라미터는 매퍼 `#{}` 바인딩 + 컨트롤러 sanitize. 응답은 기존 패턴(JSP 뷰이름 반환 + model attr).
|
||||
|
||||
### 401 vs 403 정책 (W1-design 일치)
|
||||
- 허브(`/`)는 **공개 페이지**(인증 불필요) — 미인증/미인가 분기 없음. 401/403 해당 없음.
|
||||
- 잼 배너 CTA 가 라우팅하는 W3-1 잼 검색도 공개(읽기) 전제 → 인증 게이트 없음. (W3-1 이 인증 요구하면 W3-1 정책 따름 — 본 설계 무관.)
|
||||
|
||||
### 허브 페이지 (뷰 — 변경 대상)
|
||||
| method | path | 권한 | 응답 |
|
||||
|---|---|---|---|
|
||||
| GET | `/` | 공개 | `index` JSP. 첫 페이지 games + `nextCursor` + (단계2)`activeJam`/`activeJamCount` 모델 주입 |
|
||||
| GET | `/?q={검색어}` | 공개 | `index` JSP. 검색 첫 페이지(keyset) + `nextCursor` |
|
||||
| GET | `/?cursor={sortOrder}_{createdAtEpochMillis}_{id}` | 공개 | `index` JSP. 다음 페이지(keyset). q 동반 시 검색 다음 페이지 |
|
||||
| GET | `/?q={검색어}&cursor={...}` | 공개 | 검색 다음 페이지(keyset) |
|
||||
|
||||
- **커서 인코딩(확정)**: `cursor = "{sortOrder}_{createdAtEpochMillis}_{id}"`. 3 컴포넌트 `_` 구분. `createdAt` 은 epoch millis(timezone 모호성 제거, OffsetDateTime → toInstant().toEpochMilli()). 파싱 실패/형식 오류 → 첫 페이지로 폴백(throw 금지 — 사용자 입력 신뢰 금지, sanitize). **불투명 토큰(base64) 미채택 근거**: 3 정수/타임스탬프라 평문 디버깅 용이 + 변조해도 정렬·필터가 보호(권한 데이터 아님). 단 파싱은 방어적(NumberFormat catch → 첫 페이지).
|
||||
- **nextCursor 산정**: `LIMIT pageSize+1` 로 조회 → 결과 size > pageSize 면 `hasNext=true`, (pageSize+1 번째 잘라내고) 마지막 잔류 행의 `(sortOrder, createdAtEpochMillis, id)` 로 nextCursor 생성. size ≤ pageSize 면 nextCursor=null(더보기 미표시).
|
||||
- **pageSize 확정**: 상수 24(카드 그리드 — 후속 조정 가능, 매직넘버는 컨트롤러 상수 `HUB_PAGE_SIZE`). 1차 고정.
|
||||
|
||||
### 잼 검색 라우트 계약 (W3-1 라우팅 타깃 — 앵커, W3-1 이 실제 경로 확정)
|
||||
> 배너 CTA 의 링크 대상. **W3-1-tags-search-design.md 가 미존재(작성 시점)** → 아래는 라우팅 계약 앵커. W3-1 이 경로/파라미터를 확정하면 그 계약을 단일 출처로 채택(concern 3). 단계2 착수 시 W3-1 확정 경로로 본 링크 1줄을 정렬.
|
||||
- **계약**: 배너 CTA 는 "진행중 잼의 출품작을 잼 태그로 필터한 검색 결과"로 라우팅한다.
|
||||
- **앵커 후보(W3-1 확정 전 잠정)**: `GET /jams/{slug}`(W2-1 잼 상세, 출품작 JOIN 노출 — W2-1-design §외부계약 직접 확인) 또는 W3-1 `GET /?q=#{잼태그}` / 전용 잼 검색 경로. **단계2 구현 시 W3-1 확정 경로 1개로 고정**(둘 다 열어두지 않음 — 오픈 질문 회피 위해 폴백 우선순위 확정: W3-1 잼 검색 라우트 존재 시 그것, 미확정이면 W2-1 `/jams/{slug}` 상세로 라우팅).
|
||||
- **본 설계가 고정하는 것**: 배너는 `activeJam.slug`(또는 W3-1 태그 식별자)를 링크에 담아 라우팅한다는 **구조**. 실제 path 토큰은 단계2 구현에서 W3-1/W2-1 확정값으로 치환.
|
||||
|
||||
---
|
||||
|
||||
## 인터셉터 / 게이트 연동
|
||||
- **해당 없음**. 허브(`/`)는 공개 GET, RbacInterceptor `/admin/**` 경로와 무관(InterceptorConfig.java:18-20 직접 확인 — `/admin/**` 만 등록). 본 설계는 인터셉터/게이트 변경 0. W3-1/W2-1 의 게이트는 각 워크스트림 소유.
|
||||
|
||||
---
|
||||
|
||||
## 시퀀스 (주요 플로우 의사코드)
|
||||
|
||||
### S1. 허브 첫 페이지 + keyset 더보기 (단계1 — jams 무관 독립)
|
||||
```
|
||||
[공개] GET /
|
||||
→ WebMvcController.indexView(q=null, cursor=null)
|
||||
→ indexModelAndView(query=null, cursor=null):
|
||||
normalizedQuery = "" # blank
|
||||
cursor 파싱: 없음 → (sortOrder=null, createdAt=null, id=null) 첫 페이지
|
||||
rows = gamesMapper.listVisibleKeyset(null, null, null, HUB_PAGE_SIZE+1)
|
||||
# WHERE is_visible IS NOT FALSE AND is_delete IS NOT TRUE AND u.is_delete IS NOT TRUE
|
||||
# (커서 null → 커서 비교 절 미적용)
|
||||
# ORDER BY sort_order ASC, created_at DESC, id DESC LIMIT pageSize+1
|
||||
hasNext = rows.size > HUB_PAGE_SIZE
|
||||
if hasNext: last = rows.get(HUB_PAGE_SIZE-1); rows = rows.subList(0, HUB_PAGE_SIZE)
|
||||
nextCursor = last.sortOrder + "_" + last.createdAt.toEpochMilli + "_" + last.id
|
||||
else: nextCursor = null
|
||||
model: games=rows, searchQuery="", nextCursor
|
||||
(단계2면) model: activeJam, activeJamCount ← S3
|
||||
→ "index"
|
||||
|
||||
[공개] GET /?cursor=10_1718000000000_57
|
||||
→ indexModelAndView(query=null, cursor="10_1718000000000_57"):
|
||||
parseCursor → (sortOrder=10, createdAtMillis=1718000000000, id=57)
|
||||
rows = gamesMapper.listVisibleKeyset(10, instant(1718000000000), 57, pageSize+1)
|
||||
# 커서 비교(다음 페이지): 정렬 sort_order ASC, created_at DESC, id DESC 의
|
||||
# "커서 행보다 뒤" = keyset 3-튜플 lexicographic:
|
||||
# sort_order > c.sortOrder
|
||||
# OR (sort_order = c.sortOrder AND created_at < c.createdAt)
|
||||
# OR (sort_order = c.sortOrder AND created_at = c.createdAt AND id < c.id)
|
||||
... (hasNext/nextCursor 동일)
|
||||
```
|
||||
- **keyset 비교 방향 논증(난제1)**: 정렬이 혼합 방향(sort_order ASC, created_at DESC, id DESC)이라 단일 row-comparison `(a,b,c) > (...)` 가 안 맞는다 → **OR 분해**(위 3절)로 각 키 방향에 맞춰 비교. dev DB contract 로 실측 검증(concern 2). 동일 sort_order 다수 시 created_at/ id tie-break 으로 중복·누락 0.
|
||||
|
||||
### S2. 검색 + keyset (D6 — 비검색과 동일 규약)
|
||||
```
|
||||
[공개] GET /?q=플랫폼&cursor=...
|
||||
→ indexModelAndView(query="플랫폼", cursor=...):
|
||||
normalizedQuery = "플랫폼"
|
||||
rows = gamesMapper.searchVisibleKeyset(query, cursorSortOrder, cursorCreatedAt, cursorId, pageSize+1)
|
||||
# 기존 searchVisibleGames 의 ILIKE 3컬럼 절(name/display_name/creator_note) + 동일 커서 OR 분해 + 동일 ORDER BY/LIMIT
|
||||
... (hasNext/nextCursor 동일, q 도 nextCursor 링크에 보존: /?q=플랫폼&cursor=...)
|
||||
```
|
||||
- 컨트롤러 분기는 `normalizedQuery.isBlank()` 하나뿐 — 커서 처리·hasNext·nextCursor 산정은 **공통 헬퍼**(중복 0).
|
||||
|
||||
### S3. 진행중 잼 배너 (단계2 — W2-1 jams + W3-1 라우팅 후)
|
||||
```
|
||||
indexModelAndView 내 (단계2 활성 시):
|
||||
active = jamsMapper.listActive(2)
|
||||
# WHERE status IN ('RECRUIT','DEV','EVAL') AND is_visible IS NOT FALSE AND is_delete IS NOT TRUE
|
||||
# ORDER BY created_at DESC, id DESC LIMIT 2 (최신 1 + "외 N" 판정용 1 = 2건)
|
||||
activeCount = jamsMapper.countActive()
|
||||
# 동일 WHERE 의 COUNT(*) (배너 "외 N-1개" 라벨용 — limit 2 로는 정확 N 모름)
|
||||
if activeCount > 0:
|
||||
model: activeJam = active.get(0) # 최신 1건(slug/title)
|
||||
model: activeJamCount = activeCount # 전체 N (JSP 가 N-1 라벨 산정)
|
||||
# activeCount == 0 → attr 미주입 → JSP 배너 미렌더(D4)
|
||||
|
||||
[JSP index.jsp] search-section 아래, card-grid 위:
|
||||
<% Integer activeJamCount = (Integer) request.getAttribute("activeJamCount"); %>
|
||||
<% if (activeJamCount != null && activeJamCount > 0) { %>
|
||||
배너 렌더: activeJam.title(escape) + (count>1 ? "외 "+(count-1)+"개 진행 중" : "진행 중")
|
||||
CTA href = ctx + <W3-1 확정 잼 검색 라우트 with activeJam.slug> # 라우팅(D3)
|
||||
<% } %>
|
||||
```
|
||||
- **단계1 안전성(난제2)**: 단계1 에서는 컨트롤러가 `activeJam*` attr 자체를 주입하지 않음 → JSP `activeJamCount == null` → 배너 미렌더 → 허브 정상. JamsMapper 미착지여도 컴파일/런타임 무영향(매퍼 호출 코드는 단계2 에서 추가).
|
||||
|
||||
---
|
||||
|
||||
## 파일 영향 맵
|
||||
|
||||
> 소유권 분할 가이드(implementation-advisor worker 단위 후보 — 단계 분리와 정합):
|
||||
> **H-MAPPER**(GamesMapper keyset 메서드 + 인덱스 DDL) · **H-CTRL**(WebMvcController keyset/커서 헬퍼) · **H-VIEW1**(index.jsp 더보기/페이징 — 단계1) · **H-JAM**(JamsMapper.listActive/countActive + WebMvc 배너 attr + index.jsp 배너 — 단계2, W2-1+W3-1 후).
|
||||
> 의존: H-MAPPER → H-CTRL → H-VIEW1 (단계1 완결). H-JAM(단계2)은 W2-1 JamsMapper 존재 + W3-1 라우트 확정 후.
|
||||
|
||||
| 변경 유형 | 경로 | 역할 | 소유 / 단계 |
|
||||
|---|---|---|---|
|
||||
| 신규 | `docs/games-hub-ddl.sql` | keyset 성능 인덱스(idx_games_visible_keyset). games 컬럼 변경 0(인덱스만, 멱등) | H-MAPPER / 단계1 |
|
||||
| 수정 | `db/schema.sql` | games 블록 뒤 인덱스 동기 사본(games-hub-ddl 사본). games 컬럼 무변경 | H-MAPPER / 단계1 |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java` | `listVisibleKeyset(...)` + `searchVisibleKeyset(...)` 신규(`#{}`, snake→camel 직접 alias, 커서 OR 분해 + LIMIT). 기존 getVisibleGames/searchVisibleGames 보존(타 호출처 영향 0) | H-MAPPER / 단계1 |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java` | `indexView`/`indexModelAndView` 에 cursor 파라미터 + keyset 호출 + nextCursor 산정 + parseCursor 헬퍼. 기존 GamesMapper 주입 재사용(신규 빈 0) | H-CTRL / 단계1 |
|
||||
| 수정 | `src/main/webapp/WEB-INF/views/index.jsp` | card-grid 하단 "더 보기"(nextCursor 링크, q 보존) — 단계1. search-section 아래 진행중 잼 배너 — 단계2 | H-VIEW1(단계1) / H-JAM(단계2) |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java` 에 메서드 추가 (또는 W2-1 JamsMapper 가 이미 생성 시 메서드 추가) | `listActive(int limit)` + `countActive()`(진행중 잼 — `#{}`) | H-JAM / 단계2 (W2-1 JamsMapper 소유 조율 — concern) |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java` | (단계2) JamsMapper 주입 + activeJam/activeJamCount attr 주입. JamsMapper 신규 의존 → @MockBean 확인 | H-JAM / 단계2 |
|
||||
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | (단계2만) WebMvcController 가 JamsMapper 주입 시 @MockBean 등록 확인(W2-1 이 이미 등록했으면 재사용). 단계1 은 GamesMapper 기존 등록 재사용 → 신규 등록 불요 | (검증) / 단계2 |
|
||||
| 수정 | `src/test/.../WebMvcControllerTest.java`(없으면 신규) | keyset 첫/다음 페이지 + nextCursor 산정 + 검색 keyset + 커서 파싱 폴백 + (단계2)배너 attr 조건 단위 | (검증) |
|
||||
|
||||
> SSR 호출지점 전수 확인(verification-strategies §영향맵 SSR 포함): `index.jsp` 의 `games`(List<GameData>)·`searchQuery` attr 는 **보존**(타입/이름 불변) → 기존 렌더 루프(index.jsp:500-536) 회귀 0. 신규 `nextCursor`(String, nullable)·`activeJam`/`activeJamCount` 는 **신규 attr** → 기존 소비처 깨짐 0. `GamesMapper.getVisibleGames`/`searchVisibleGames` 기존 메서드는 **보존**(WebMvcController 외 호출처 없음 — rg getVisibleGames 로 단일 확인 권장, 그러나 신규 메서드 추가는 기존 시그니처 무변경이라 안전).
|
||||
|
||||
### 신규/변경 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||
```java
|
||||
// GamesMapper (@Mapper, #{} only, snake→camel 직접 alias — 일반매퍼 표준, verification §33)
|
||||
// 비검색 keyset. 커서 3컴포넌트 null 이면 첫 페이지(WHERE 커서 절 미적용).
|
||||
List<GameData> listVisibleKeyset(
|
||||
Integer cursorSortOrder, // 커서 sort_order(첫페이지 null)
|
||||
java.time.OffsetDateTime cursorCreatedAt,// 커서 created_at(첫페이지 null)
|
||||
Long cursorId, // 커서 id tie-break(첫페이지 null)
|
||||
int limit) // pageSize+1(hasNext 판정)
|
||||
// 검색 keyset. q + 동일 커서 규약(D6 단일화).
|
||||
List<GameData> searchVisibleKeyset(
|
||||
String query, // ILIKE 3컬럼 부분일치(기존 searchVisibleGames 절 재사용)
|
||||
Integer cursorSortOrder, // (동일)
|
||||
java.time.OffsetDateTime cursorCreatedAt,// (동일)
|
||||
Long cursorId, // (동일)
|
||||
int limit) // (동일)
|
||||
|
||||
// WebMvcController (단계1) — 컨트롤러. cursor 추가, 최소 인자.
|
||||
ModelAndView indexView(String query, // ?q= 검색어(nullable)
|
||||
String cursor) // ?cursor= keyset 커서(nullable, 첫페이지)
|
||||
// private 헬퍼 — 커서 문자열 파싱(방어적: 형식 오류 → null 반환 = 첫 페이지 폴백).
|
||||
// 3 컴포넌트만 필요(sortOrder_createdAtMillis_id). 그 외 컨텍스트 불요(최소).
|
||||
HubCursor parseCursor(String cursor) // "10_1718..._57" → {sortOrder,createdAt,id} | null
|
||||
// HubCursor = 내부 record(Integer sortOrder, OffsetDateTime createdAt, Long id). DTO 추가 1개.
|
||||
|
||||
// JamsMapper (단계2 — W2-1 소유 매퍼에 메서드 추가, @Mapper, #{} only)
|
||||
List<JamData> listActive(int limit) // 진행중 잼 최신순 limit(배너 최신 1건 + N판정)
|
||||
int countActive() // 진행중 잼 전체 수(배너 "외 N-1개" 라벨)
|
||||
```
|
||||
> inflate 마킹(concern 1): `parseCursor` 는 **문자열 1개만** 받아 record 또는 null 을 반환(최소). 컨트롤러/세션 컨텍스트를 미리 받지 말 것 — 파싱은 순수 함수. `HubCursor` record 3필드 전부 매퍼 인자로 전달되므로 dead 필드 위험 낮으나, 구현에서 createdAt 인코딩(epoch millis vs ISO) 확정 후 타입 일치 재확인. `listActive(limit)` 의 limit 은 배너가 최신 1건만 쓰면 2(N≥2 판정용) — 구현에서 countActive 가 N 을 주므로 listActive limit=1 로 축소 가능(최신 1건만 필요) → 구현 시 limit 사용처 재확인(축소 후보).
|
||||
|
||||
---
|
||||
|
||||
## 대안 비교
|
||||
|
||||
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||
|---|---|---|---|---|
|
||||
| 페이징 | (A) keyset(sort_order,created_at,id 커서) | 깊은 페이지 O(log n)+인덱스 seek, 삽입 시 중복/누락 0, 현 정렬 그대로 | 혼합방향 OR 분해 SQL, 커서 파싱 | **채택(D2)** |
|
||||
| | (B) offset/limit | 단순(LIMIT n OFFSET m) | 깊은 페이지 비용, 게임 추가/sort_order 변경 시 행 밀림 중복·누락 | 기각 |
|
||||
| | (C) 전건 로드(현행) | 최단 | 게임 누적 증가 시 전송/렌더 비용 선형 증가 | 기각(현행 개선 대상) |
|
||||
| 잼 노출 | (A) 배너 + CTA → W3-1 검색 | 출품작 중복노출 회피, 단일 진입, 허브 그리드 무변경 | W3-1 라우트 의존 | **채택(D3)** |
|
||||
| | (B) 진행중 잼 출품작 전용 그리드 섹션 | 즉시 노출 | 일반 그리드와 출품작 중복노출, jam_entries JOIN 조회 추가, 정렬 충돌 | 기각(확정결정) |
|
||||
| 복수 잼 | (A) 최신 1건 배너 + 카운트 라벨 | 배너 단순, 전체는 /jams 위임 | 최신 외 잼 배너 미노출(목록은 /jams) | **채택(D4)** |
|
||||
| | (B) 진행중 잼 전부 캐러셀 | 전부 노출 | 배너 비대·캐러셀 JS, 허브 산만 | 기각 |
|
||||
| 커서 인코딩 | (A) 평문 `sortOrder_millis_id` | 디버깅 용이, 권한데이터 아님(변조 무해) | 형식 노출 | **채택** |
|
||||
| | (B) base64 불투명 토큰 | 캡슐화 | 디버깅 난해, 이점 없음(권한 0) | 기각 |
|
||||
| 검색 페이징 | (A) 검색도 keyset 통일(D6) | 일관 더보기, 공통 헬퍼 | 검색 SQL 에 커서 절 추가 | **채택** |
|
||||
| | (B) 검색은 전건 유지 | 변경 최소 | 비검색만 페이징 = 더보기 동작 불일치 | 기각 |
|
||||
|
||||
---
|
||||
|
||||
## 롤아웃 / 마이그레이션
|
||||
|
||||
### 순서
|
||||
**단계1 (독립 — W2/W3-1 무관 선착수)**:
|
||||
1. **인덱스 적용**: `docs/games-hub-ddl.sql`(idx_games_visible_keyset) → `db/apply-local-ddl.sh`(로컬) / 운영 동일 멱등. games 컬럼 0변경 → 회귀 0. **인덱스 미적용이어도 keyset 정합 동작**(seq scan 도 정확) — 성능 최적화만.
|
||||
2. **매퍼**: GamesMapper.listVisibleKeyset/searchVisibleKeyset 추가(기존 메서드 보존).
|
||||
3. **컨트롤러**: WebMvcController cursor 처리 + nextCursor. 기존 indexView(q only) 동작 = cursor null 경로 = 첫 페이지(회귀 0).
|
||||
4. **뷰**: index.jsp "더 보기" 링크(nextCursor, q 보존). nextCursor null 이면 미표시.
|
||||
|
||||
**단계2 (W2-1 jams + W3-1 잼 검색 라우트 착지 후)**:
|
||||
5. **JamsMapper**: listActive/countActive 추가(W2-1 JamsMapper 에 — 소유 조율 concern).
|
||||
6. **컨트롤러**: WebMvcController 에 JamsMapper 주입 + activeJam/activeJamCount attr. JamsMapper 신규 의존 → BibimbapApplicationTests @MockBean 확인(W2-1 이 이미 등록 시 재사용, full ./mvnw -o test).
|
||||
7. **뷰**: index.jsp 진행중 잼 배너(activeJamCount>0 조건) + CTA(W3-1 확정 라우트).
|
||||
|
||||
### 역호환
|
||||
- **단계1**: `index` 모델 `games`/`searchQuery` attr 보존 → JSP 렌더 루프 불변. 신규 `nextCursor` 만 추가. cursor 없는 기존 URL(`/`, `/?q=...`) = 첫 페이지(동작 동일, 단 결과가 pageSize 로 제한 — 전건→첫 페이지로 의미 변경되나 "더 보기"로 전건 도달 가능). 기존 검색 URL 호환.
|
||||
- **단계2**: jams 0건/미착지 시 배너 미렌더 → 단계1 동작 그대로. 배너는 순수 추가(기존 attr 무변경).
|
||||
|
||||
### 롤백
|
||||
- **단계1 롤백**: WebMvcController cursor 분기 제거 → 첫 페이지 매퍼 호출만(또는 기존 getVisibleGames 복귀). 인덱스는 추가 전용이라 잔존 무해(비파괴). index.jsp 더보기 링크 제거.
|
||||
- **단계2 롤백**: WebMvcController activeJam attr 주입 제거 → JSP 배너 미렌더(activeJamCount null). JamsMapper 메서드는 미사용 잔존 무해. W2-1/W3-1 무영향.
|
||||
|
||||
---
|
||||
|
||||
## AC 매핑
|
||||
|
||||
| AC | 요구(골자 W3-4) | 만족 설계 요소 | 비고 |
|
||||
|---|---|---|---|
|
||||
| AC-1 | 허브 = 현 그리드 + 정렬 유지 | listVisibleKeyset ORDER BY sort_order ASC,created_at DESC,id DESC(기존 동일) | D1, D2 |
|
||||
| AC-2 | keyset 페이징(전건→커서) | listVisibleKeyset 커서 OR 분해 + LIMIT pageSize+1 + nextCursor | D2, S1 |
|
||||
| AC-3 | 검색도 keyset 일관 | searchVisibleKeyset 동일 커서 규약 + 공통 헬퍼 | D6, S2 |
|
||||
| AC-4 | 진행중 잼 시에만 배너 | activeJamCount>0 조건 렌더(0/미착지 → 미렌더) | D4, 난제2, S3 |
|
||||
| AC-5 | 진행중 = RECRUIT/DEV/EVAL | jamsMapper.listActive/countActive WHERE status IN(3값) AND is_visible | G3, S3 |
|
||||
| AC-6 | 배너 CTA → W3-1 잼 검색 라우팅 | 배너 href = W3-1 확정 잼 검색 라우트(slug) — 그리드 아님 | D3, §잼검색라우트계약 |
|
||||
| AC-7 | 복수 잼 처리 명시 | 최신 1건 배너 + countActive "외 N-1개" 라벨 | D4, S3 |
|
||||
| AC-8 | 단계1 독립 선착수 | jams 미착지여도 단계1 동작(attr 미주입) | D5, 난제2 |
|
||||
| AC-9 | 검색/페이징 SQL `${}` 0 | 신규 매퍼 메서드 `#{}` only | NFR, §파일영향맵 |
|
||||
| AC-10 | 기존 index 동작 회귀 0 | games/searchQuery attr 보존, 기존 메서드 보존 | §SSR 호출지점 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 포인트 (verification-advisor 점검 대상)
|
||||
|
||||
> L레벨 매핑(verification-strategies): 신규 매퍼 SQL/alias·keyset 커서 비교 = **L1+L2(dev DB contract)**. 컨트롤러 분기/커서 파싱 = **L1**. 허브 렌더/더보기·배너 조건 = **L1+L3 스모크**. 단계2 JamsMapper 신규 의존 = full `./mvnw -o test`(§30) — 단계1 은 GamesMapper 기존 의존 재사용이라 신규 @MockBean 불요.
|
||||
|
||||
### 시나리오 검증
|
||||
- **VP-1 (AC-2 keyset, L1+L2)**: WebMvcControllerTest — 첫 페이지(cursor null) pageSize 행 + nextCursor 산정 / 다음 페이지(cursor) 중복·누락 0 / 마지막 페이지 nextCursor=null. dev DB contract: 혼합방향 OR 분해 커서 비교 실측(동일 sort_order 다수 시 created_at/id tie-break — 경계 데이터로 중복·누락 0 확인).
|
||||
- **VP-2 (AC-3 검색 keyset, L1+L2)**: 검색 q + cursor 다음 페이지에 q 보존 + ILIKE 3컬럼 절 유지 + 커서 일관. 비검색과 동일 nextCursor 규약.
|
||||
- **VP-3 (커서 파싱 폴백, L1)**: 형식 오류 cursor("abc", "1_2", "x_y_z") → 첫 페이지 폴백(NumberFormat catch, throw 0). 입력 신뢰 금지.
|
||||
- **VP-4 (AC-9 SQL `${}` 0, L1)**: 신규 매퍼 메서드 `${` 매치 0.
|
||||
- **VP-5 (AC-10 회귀, L1+L3)**: 기존 `/`·`/?q=` 렌더 PASS(games/searchQuery attr 동일). index.jsp 렌더 루프 변경 없음(더보기 링크는 그리드 외부 추가).
|
||||
- **VP-6 (AC-4/5/7 배너 단계2, L1+L3)**: activeJamCount>0 시 배너 렌더 + 최신 1건 title escape + "외 N-1개" 라벨 / activeJamCount=0 또는 attr null 시 미렌더. JamsMapper.listActive WHERE status IN 3값 AND is_visible 확인. L3: W2-1 진행중 잼 시드 후 `/` 에 배너 노출 → CTA 링크가 W3-1 라우트.
|
||||
- **VP-7 (단계2 contextLoads, L1)**: 단계2 에서 WebMvcController 가 JamsMapper 주입 시 BibimbapApplicationTests @MockBean 등록 후 PASS(§30). W2-1 이 이미 등록했으면 재사용 — 단계1 은 불요.
|
||||
|
||||
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||
> self-audit(시점): 아래 카운트는 **본 설계가 신규 생성하는 정적 산출물**(매퍼 메서드·진행중 status 값·정렬키)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 단 AC-T4(진행중 status 집합)는 W2-1 jams_status_check 와의 **동등성 불변식**으로 앵커 — W2-1 이 status 값을 바꾸면 두 곳이 함께 변해야 하므로 고정 스칼라 대신 동등성으로.
|
||||
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 정렬키 순서/매퍼 메서드 쌍/status IN 목록 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴 정당).
|
||||
|
||||
- **AC-T1 keyset 매퍼 메서드 전수 2건(비검색+검색) 정합** — GamesMapper 신규 keyset 메서드 = `listVisibleKeyset` + `searchVisibleKeyset` 2개. 검증: 두 메서드 전수 존재 AND **둘의 ORDER BY 절이 동일 토큰**(`sort_order ASC, created_at DESC, id DESC`) AND **둘의 커서 OR 분해 절이 동일**(난제1 단일화 — 한쪽만 바뀌면 더보기 불일치). 수동 판정(두 SQL 본문 diff = ILIKE 절 외 동일). 메서드 추가/삭제 누락을 쌍 정합으로 커버.
|
||||
- **AC-T2 정렬키 3-튜플 전수 일치** — keyset 정렬키 = `(sort_order ASC, created_at DESC, id DESC)` 3키가 (a)기존 getVisibleGames(GamesMapper.java:60) (b)신규 listVisibleKeyset (c)신규 searchVisibleKeyset (d)커서 인코딩(sortOrder_createdAt_id) (e)idx_games_visible_keyset 5곳 전수 동일 순서·방향. 검증: 5곳 정렬키 순서·방향 수동 대조(불변식 — 한 곳 불일치 시 페이지 경계 깨짐). 정렬 회귀(AC-1)·keyset 정합(AC-2)의 공통 가드.
|
||||
- **AC-T3 신규 매퍼 `${` 0건** — 신규 keyset 매퍼 2메서드(+단계2 JamsMapper listActive/countActive) 에 `${` 매치 0: `grep -c '\${' GamesMapper.java`(신규 메서드 범위) == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||
- **AC-T4 진행중 잼 status 집합 = W2-1 CHECK 동등성 불변식** — 배너 진행중 정의 `status IN ('RECRUIT','DEV','EVAL')` 3값은 W2-1 `jams_status_check` 4값(RECRUIT/DEV/EVAL/CLOSED) **에서 CLOSED 만 제외한 정확한 부분집합**. 검증: listActive/countActive WHERE 의 status IN 목록 == {W2-1 status 4값} − {CLOSED}(동등성 불변식 — W2-1 이 status 값 추가/변경 시 진행중 정의도 함께 점검). 고정 스칼라 아닌 W2-1 CHECK 와의 집합 관계로 앵커(시점 안정).
|
||||
- **AC-T5 단계별 의존 게이트 전수 — 단계1 신규 빈 0 / 단계2 JamsMapper 1** — 단계1 변경(GamesMapper 메서드/WebMvcController cursor/index.jsp 더보기)에 **신규 @MockBean 등록 0**(GamesMapper 기존 등록 재사용) → 단계1 만 머지 시 contextLoads PASS(신규 의존 없음 불변식). 단계2 머지 시 WebMvcController JamsMapper 주입 1건 → @MockBean 등록 확인 후 contextLoads PASS. 검증: 단계1 PR 의 BibimbapApplicationTests diff == 0(@MockBean 추가 없음) AND 단계2 PR 에서 JamsMapper @MockBean 존재. 단계 분리(D5/AC-8) 무결성 + §30 누락 동시 가드.
|
||||
|
||||
---
|
||||
|
||||
## 잔여 오픈 질문
|
||||
없음(0). 확정 결정 D1~D7 전제 고정. 두 난제(검색·비검색 keyset 단일화·배너 의존성 부재 안전)는 본 설계가 구체 메커니즘(공통 커서 헬퍼 + attr 미주입 조건 렌더)으로 확정. 복수 잼 처리(최신1+카운트), 커서 인코딩(평문 3컴포넌트), 단계 분리(단계1 독립/단계2 의존)도 확정. 구현 점검 항목(parseCursor inflate·keyset OR 분해 dev DB 실측·W3-1 잼 검색 라우트 확정값 치환·JamsMapper 소유 조율·단계2 @MockBean)은 오픈 질문이 아니라 `concerns` 로 이관.
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
---
|
||||
phase: design
|
||||
agent: design-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-23T12:10:00+09:00
|
||||
workstream: W3-5-Unity WebGL 빌드 업로드 자동화 (보안 보강)
|
||||
concerns:
|
||||
- "PermissionGate 신규 메서드 require(session, key)→boolean 은 이미 실재(PermissionGate.java:64). 본 설계는 신규 시그니처를 만들지 않고 기존 has/require 를 컨트롤러 진입부에서 호출만 한다 — 구현 시 신규 게이트 메서드 추가 금지(dead method 방지)."
|
||||
- "ZipSecurity 헬퍼의 신규 시그니처(validateEntryName/assertWithinBoundary/isWindowsAbsoluteOrUnc)는 최소 인자로 명세. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고, 프로토콜 §11.2). 특히 totalEntryBytes 누적값은 extractZip 루프 지역변수로 충분할 수 있어 헬퍼 인자로 들지 말 것."
|
||||
- "엔트리별 압축비(ratio) 임계 검사는 ZipInputStream 으로는 개별 엔트리의 '압축 후 크기'를 직접 못 얻는다(getCompressedSize() 는 stored/일부 deflate 에서 -1). 본 설계는 압축비 임계 대신 '엔트리당 해제 크기 상한 + 누적 해제 상한 + 엔트리 수 상한' 3중 상한으로 zip bomb 을 방어한다(ratio 미사용 정석). 구현 시 getCompressedSize() 의존 금지."
|
||||
- "gameRoot() 의 dev 프로파일 경로 이중중첩(app.upload.game-storage-path=src/main/resources/static/game → resolve('game') → .../static/game/game) 은 본 W3-5 보안 스코프 밖의 설정 정합 이슈. 보안 설계는 'gameRoot() 가 무엇이든 그 canonical 경계 내'만 보장한다. 경로 중첩 정정은 별도 운영 정합 작업으로 분리(orchestrator 에스컬레이션) — 본 설계는 건드리지 않음."
|
||||
- "심볼릭 링크 거부는 (a)엔트리 external-attributes 의 심링크 모드 비트 검사 + (b)쓰기 후 toRealPath 재검증 2중. JVM ZipEntry 표준 API 는 external attributes 를 직접 노출 안 하므로(java.util.zip.ZipEntry 에 getUnixMode 부재), 본 설계는 (b) 쓰기 직전 부모 디렉터리 실경로(toRealPath) 경계 재검증 + 디렉터리 생성 시 기존 심링크 거부로 정석화. ZipFile+0x... 비트 직접 파싱은 over-engineering 으로 미채택 — 구현 시 (b) 방식 준수."
|
||||
concerns_checked: true
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
references:
|
||||
requirements: docs/work-log/2026-06-17-w3-feature-skeletons.md
|
||||
research: .atp/work-session/20260623-104307/research/W3-5-upload-research.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
|
||||
---
|
||||
|
||||
# 설계: W3-5 — Unity WebGL 빌드 업로드 자동화 (보안 보강 — zip-slip / zip bomb / 포맷검증 / 권한게이트 / 자산 생명주기)
|
||||
|
||||
## 목표 / 비목표
|
||||
|
||||
### 목표 (FR/NFR 추적)
|
||||
- **U1 zip-slip 정석 방어** (FR-보안 / NFR-업로드 boundary): 조사 포인트1 의 구멍 전수 보강 — 심볼릭 링크 엔트리/디렉터리 거부, 쓰기 직전 실경로(canonical) 경계 재검증, 절대경로·UNC·백슬래시·NUL·드라이브 prefix 엔트리 거부, 엔트리명 길이/중첩깊이 상한.
|
||||
- **U2 WebGL 포맷 검증** (FR-기능 정합): index.html 단독 존재 검증을 넘어 Unity 빌드 필수 산출물(`Build/*.loader.js` + `*.framework.js(.br/.gz)` + `*.data(.br/.gz)` + `*.wasm(.br/.gz)`) 존재 검증. 누락 시 거부 + 디렉터리 롤백.
|
||||
- **U3 크기/타입 이중·zip bomb 방어** (NFR-보안): zip 원본 크기 상한(코드 레벨 명시) + zip 매직바이트(PK\x03\x04) 검증 + MIME·확장자 이중(AND) + 엔트리당 해제 크기 상한 + 누적 해제 상한(기존 512MB 유지) + 엔트리 수 상한(기존 8000 유지).
|
||||
- **U4 저장 경로 boundary** (NFR-보안): `gameRoot()` canonical 경계 검증 유지·강화(rawTargetDir.toRealPath() 또는 부모 실경로 대조). UUID 기반 `/game/{uuid}/` 배치 유지.
|
||||
- **U5 업로드 권한 게이트** (FR-9 흡수 / QG-1 W3-5 부분): 현 "CSRF + 로그인" 위에 W1 게이트 훅을 얹는다. 일반 게임 업로드는 모든 로그인 유저 개방을 **유지하되**, 게이트 진입점을 표준화(`PermissionGate.isAuthenticated` 명시 호출)하고, 잼 출품 업로드 분기를 위한 권한키 훅(`GAME_JAM_MANAGE` 옵션 게이트) 설계를 명시.
|
||||
- **U6 UUID 재사용/멱등 교체** (FR-자산 생명주기): 같은 게임 자산 재업로드 = 임시 디렉터리에 추출·검증 후 원자적 swap(기존 디렉터리 교체) + 구버전 정리. 업로드 실패 시 임시 디렉터리만 삭제(기존 자산 무손상).
|
||||
- **U7 자산 정리(고아 파일)** (FR-자산 생명주기): 편집 시 교체된 구 UUID 디렉터리 정리 + 게임 soft-delete 시 자산 디렉터리 정리 정책 확정.
|
||||
- **U8 감사 로그** (NFR-감사): 업로드 성공/거부(사유)를 `game_upload_audit_log` 에 기록(보안 사고 추적 — zip-slip/bomb 거부 패턴 가시성).
|
||||
- **NFR**: 상태변경 CSRF 전수 보존, MyBatis `#{}` 바인딩(`${}` 0), 입력 sanitize, 비파괴 마이그레이션.
|
||||
|
||||
### 비목표 (스코프 밖)
|
||||
- **`/game/**` 서빙 핸들러 신설** — 조사 포인트5 로 **stale 확정**: `GameAssetController.gameAsset(@GetMapping("/game/{gameUuid}/**"))` 가 이미 UUID 정규화·boundary·Content-Type·Content-Encoding(br/gz)·CSP 까지 서빙(GameAssetController.java:31-69). ResourceHandler 미등록은 의도된 설계(보안헤더·인코딩 협상 필요로 전용 컨트롤러 채택). 본 설계는 **신설하지 않고 현황 유지·기록만**. 골자 QG-3 문구는 documentation-advisor 가 정정.
|
||||
- `gameRoot()` 경로 이중중첩(static/game/game) 정정 — 별도 운영 정합 작업(concern 4).
|
||||
- 게임 메타 등록/편집 본체(`GameController.createGame/updateGame`) 재설계 — 본 설계는 webgl-zip 업로드 파이프라인 보강 + 자산 생명주기 훅만. 단 자산 정리 호출지점은 명시.
|
||||
- 새 잼 출품 워크플로 본체(W2) — 권한키 훅만 열어두고 잼 분기 본체는 W2 소관.
|
||||
- 클라이언트 업로드 UI(game-register.jsp) 재설계 — 응답 계약 변경분만 명시(documentation/구현 연계).
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
bibimbap 의 WebGL 업로드는 `GameUploadController`(`/api/game-files/**`)에 1차 골격이 구현돼 있다(zip 추출·prefix 검증·엔트리/누적 상한·UUID 배치·전용 서빙 컨트롤러). 조사(7항목)는 이 골격이 4개 축에서 비어 있음을 code-fact 로 확정했다: (1) 심볼릭 링크 방어 전무, (2) Unity 포맷 검증이 index.html 단독, (3) 권한 게이트 부재(로그인만), (4) 자산 생명주기(고아 파일) 미정의. 추가로 zip 매직바이트·원본 크기 상한·MIME+확장자 AND 가 비어 있다.
|
||||
|
||||
본 설계는 위 골격을 **재작성하지 않고 보강**한다. 핵심 구조 결정은 다음과 같다.
|
||||
|
||||
1. **zip-slip 정석 = 정규화 prefix 검증(기존) + 쓰기 직전 부모 실경로(toRealPath) 경계 재검증(신규) + 심링크 거부(신규) + 엔트리명 사전 거부 규칙(신규)**. 심링크는 표준 ZipEntry API 가 모드 비트를 노출하지 않으므로 "쓰기 경로의 실경로가 targetDir 밖으로 새는지" 를 차단하는 방식으로 정석화(concern 6).
|
||||
2. **추출 → 임시 디렉터리, 검증 통과 후 원자적 swap**. 기존 코드는 최종 디렉터리에 직접 추출 후 실패 시 통째 삭제하나, 재업로드 멱등 교체(U6)와 zip bomb 부분추출 잔여 방지를 위해 **임시 추출 디렉터리(`{root}/.tmp/{uuid}`) → 검증 → `Files.move(ATOMIC_MOVE)` 로 최종 위치 교체** 로 바꾼다. 실패 시 임시만 삭제 → 기존 자산 무손상.
|
||||
3. **권한 게이트는 W1 인프라 재사용**. 신규 게이트 메서드를 만들지 않고(`PermissionGate` 는 이미 has/require/isAuthenticated 보유), 컨트롤러 진입부에서 `isAuthenticated` 를 명시 호출. 잼 출품 분기는 `has(session, GAME_JAM_MANAGE.name())` 옵션 게이트 훅으로 설계만 열어둔다(미연결 enum 키 `GAME_JAM_MANAGE` 의 첫 enforcement 소비처 후보).
|
||||
4. **자산 생명주기는 webgl_path 기반 UUID 추출로 정리**. games 테이블은 변경하지 않고(`webgl_path` 활용), 편집/삭제 시 `webgl_path` 에서 UUID 를 파싱해 디렉터리를 정리.
|
||||
|
||||
신규 DDL 은 감사 로그 1테이블만(`game_upload_audit_log`) — 업로드 메타는 games.`webgl_path` 를 그대로 활용한다(정석상 메타 중복 회피).
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 요약 (전제 — 재논의 금지)
|
||||
|
||||
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||
|---|---|---|
|
||||
| D1 zip-slip | canonical 경계 + 심링크 거부 + 엔트리명 사전거부 | 정규화 prefix(기존) + 쓰기 직전 부모 toRealPath 경계 재검증 + 디렉터리 심링크 거부 + 절대/UNC/백슬래시/NUL/드라이브-prefix/`..`/길이/깊이 사전거부 |
|
||||
| D2 포맷검증 | Unity 빌드 필수 산출물 존재 | index.html + (loader.js OR loader.js.br/.gz) + framework + data + wasm 4종(압축변형 허용) 존재. 누락 시 거부 |
|
||||
| D3 크기/타입 | 다중 상한 + 매직바이트 + MIME·확장자 AND | 원본 ≤512MB + PK 시그니처 + (MIME ∈ 화이트리스트 AND .zip 확장자) + 엔트리당 해제 ≤256MB + 누적 ≤512MB(기존) + 엔트리수 ≤8000(기존) |
|
||||
| D4 경로 boundary | gameRoot() canonical 경계 | rawTargetDir.normalize().startsWith(root)(기존) + 쓰기 시 부모 실경로 재검증. gameRoot() 정의 불변(중첩 정정은 스코프 밖) |
|
||||
| D5 권한 게이트 | W1 인프라 재사용 + 일반업로드 개방 유지 | `PermissionGate.isAuthenticated` 명시 + 잼출품 분기 `has(GAME_JAM_MANAGE)` 옵션 훅. 신규 게이트 메서드 0 |
|
||||
| D6 UUID 재사용 | 임시추출 → 검증 → 원자적 swap | `{root}/.tmp/{newUuid}` 추출·검증 후 `Files.move(ATOMIC_MOVE)`. 실패 시 임시만 삭제 |
|
||||
| D7 자산 정리 | webgl_path UUID 파싱 정리 | 편집 교체 시 구 UUID dir 삭제 + soft-delete 시 자산 dir 삭제(즉시). 외부 입력 UUID 는 UUID.fromString 검증 후 경계 내만 |
|
||||
| D8 감사로그 | 신규 game_upload_audit_log | 성공/거부(사유코드) 기록. games 메타는 webgl_path 재사용(신규 메타테이블 없음) |
|
||||
| /game/** 서빙 | 현행 유지(stale 정정) | GameAssetController 실재 — 신설 안 함. 골자 문구만 정정(doc) |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 모델 (DDL)
|
||||
|
||||
> 권위 워크플로: 신규 테이블은 **신규 `docs/game-upload-ddl.sql` 파일**(권위)로 제안. `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). schema.sql 에 동기 사본. 업로드 메타는 games.webgl_path 활용 — **games 테이블 변경 0**.
|
||||
|
||||
### 신규 파일: `docs/game-upload-ddl.sql` (권위 DDL)
|
||||
|
||||
```sql
|
||||
-- W3-5 Unity WebGL 업로드 감사 로그. 멱등. db/apply-local-ddl.sh 로 비파괴 적용.
|
||||
-- 업로드 성공/거부 추적(zip-slip/zip-bomb/포맷거부 패턴 가시성). games 메타는 games.webgl_path 활용 — 신규 메타테이블 없음.
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS "game_upload_audit_log_id_seq";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "game_upload_audit_log" (
|
||||
"id" bigint DEFAULT nextval('game_upload_audit_log_id_seq'::regclass) NOT NULL,
|
||||
"actor_id" bigint NOT NULL, -- 업로드 수행 사용자 users.id
|
||||
"game_uuid" character varying(36), -- 생성/교체된 UUID(거부 시 null 가능)
|
||||
"outcome" character varying(20) NOT NULL, -- SUCCESS / REJECTED
|
||||
"reject_reason" character varying(40), -- REJECTED 시 사유코드(아래 reason 카탈로그)
|
||||
"original_name" character varying(255), -- 업로드 파일 원본명(sanitize 후 저장)
|
||||
"upload_bytes" bigint, -- zip 원본 크기
|
||||
"entry_count" integer, -- 추출 엔트리 수(성공 시)
|
||||
"extracted_bytes" bigint, -- 누적 해제 크기(성공 시)
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "game_upload_audit_log_id_seq" OWNED BY "game_upload_audit_log"."id";
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_actor_id_fkey') THEN
|
||||
ALTER TABLE "game_upload_audit_log"
|
||||
ADD CONSTRAINT "game_upload_audit_log_actor_id_fkey"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_outcome_check') THEN
|
||||
ALTER TABLE "game_upload_audit_log"
|
||||
ADD CONSTRAINT "game_upload_audit_log_outcome_check"
|
||||
CHECK ("outcome" IN ('SUCCESS', 'REJECTED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_actor"
|
||||
ON "game_upload_audit_log" ("actor_id", "created_at" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_outcome"
|
||||
ON "game_upload_audit_log" ("outcome", "created_at" DESC);
|
||||
|
||||
COMMENT ON TABLE "game_upload_audit_log" IS 'WebGL 업로드 감사 로그(성공/거부 추적, W3-5)';
|
||||
COMMENT ON COLUMN "game_upload_audit_log"."reject_reason" IS 'REJECTED 사유코드: NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ZIP_SLIP/SYMLINK/ENTRY_TOO_LARGE/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD';
|
||||
```
|
||||
|
||||
### 거부 사유코드 카탈로그 (reject_reason — 코드 상수와 1:1)
|
||||
| 코드 | 트리거 | HTTP |
|
||||
|---|---|---|
|
||||
| `NOT_ZIP` | MIME·확장자 AND 실패 | 400 |
|
||||
| `MAGIC_FAIL` | PK\x03\x04 시그니처 불일치 | 400 |
|
||||
| `TOO_LARGE` | zip 원본 > 512MB | 413 |
|
||||
| `TOO_MANY_ENTRIES` | 엔트리 > 8000 | 400 |
|
||||
| `ENTRY_TOO_LARGE` | 엔트리당 해제 > 256MB | 400 |
|
||||
| `EXTRACTED_TOO_LARGE` | 누적 해제 > 512MB | 400 |
|
||||
| `ZIP_SLIP` | 정규화/실경로 경계 탈출 | 400 |
|
||||
| `SYMLINK` | 심링크 엔트리/디렉터리 | 400 |
|
||||
| `BAD_ENTRY_NAME` | 절대/UNC/백슬래시/NUL/드라이브/`..`/길이/깊이 | 400 |
|
||||
| `NO_INDEX` | index.html 부재 | 400 |
|
||||
| `INCOMPLETE_BUILD` | Unity 필수 산출물 누락 | 400 |
|
||||
|
||||
### `db/schema.sql` 반영
|
||||
- `recruit_posts` 블록 뒤(또는 마지막 테이블 블록 뒤)에 `game_upload_audit_log` 블록 신설(rbac-ddl → schema.sql 동기 선례와 동일 — docs/game-upload-ddl.sql 이 권위, schema.sql 은 사본).
|
||||
|
||||
---
|
||||
|
||||
## 외부 계약 (API)
|
||||
|
||||
> 공통: 상태변경은 `CsrfTokens.isValid(request)`(없으면 403 + `CsrfTokens.errorBody()`). 응답은 기존 패턴 — webgl-zip 은 `Map<String,Object>`(status/message). 인증 401, 권한 403.
|
||||
|
||||
### 401 vs 403 정책 (확정 — W1 정책 일치)
|
||||
- **미인증**(세션 `userId` 없음): API 엔드포인트는 **401** JSON `{message:"로그인이 필요합니다."}`(기존 동작 보존).
|
||||
- **인증·미인가**(잼 출품 업로드에 `GAME_JAM_MANAGE` 없음 등): **403** JSON `{status:403, message:"권한이 없습니다."}`. **단 일반 게임 업로드는 미인가 없음**(모든 로그인 유저 개방 — D5).
|
||||
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`(기존 보존).
|
||||
|
||||
### `POST /api/game-files/webgl-zip` (보강 — 시그니처 불변, 검증 강화)
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| method/path | `POST /api/game-files/webgl-zip` |
|
||||
| 권한 | CSRF + 로그인(isAuthenticated). 일반 업로드 = 추가 권한 없음(개방 유지). `?mode=jam` 옵션 시 `GAME_JAM_MANAGE` 게이트(W2 연계 훅 — 본 W3-5 에서 enforcement 활성화 여부는 D5 참조) |
|
||||
| 요청 | `multipart/form-data`: `file`(zip, 필수), `replaceUuid`(선택 — 재업로드 교체 대상 기존 UUID. 없으면 신규 생성), `mode`(선택, `jam`이면 잼 게이트) |
|
||||
| 응답(200) | `{status:200, message, gameUuid, webglPath, deployPath, entryCount, extractedBytes}`(기존 + replaceUuid 처리 시 동일 UUID 반환) |
|
||||
| 에러 | 400(reject_reason 별 message), 401(미인증), 403(CSRF/잼게이트), 413(TOO_LARGE) |
|
||||
|
||||
- **`replaceUuid` 추가 근거(U6)**: 기존은 무조건 신규 UUID(GameUploadController.java:126) → 재업로드마다 고아 디렉터리. `replaceUuid` 가 유효 UUID 이고 (a)경계 내 (b)요청자가 해당 게임 소유자(또는 ADMIN) 이면 같은 UUID 로 원자적 교체. 없으면 기존처럼 신규. **소유권 검증**: `replaceUuid` → games 에서 `webgl_path LIKE '/game/{uuid}/%'` 인 게임의 user_id == 세션 userId(또는 ADMIN). 불일치 시 403.
|
||||
- **하위호환**: `replaceUuid`/`mode` 모두 선택 파라미터 — 미전송 시 기존 동작(신규 UUID, 일반 업로드) 100% 보존. 기존 game-register.jsp 클라이언트 무변경 동작.
|
||||
|
||||
### `POST /api/game-files/thumbnail` (권한 게이트만 명시 추가 — 검증 로직 기존 보존)
|
||||
- 기존 CSRF + 로그인 유지. `gameUuid` 는 이미 `UUID.fromString` 정규화 + boundary 검증(GameUploadController.java:192-205). 변경: `isAuthenticated` 명시 호출로 통일(동작 동일).
|
||||
|
||||
### `POST /api/game-files` (root, uploadGameFiles)
|
||||
- 본 설계 범위: 권한 게이트 진입점 통일(`isAuthenticated`)만. path 검증은 기존 `resolveTargetFile` 보존(이미 normalize+startsWith). 단 zip-slip 헬퍼 공통화 시 이 경로의 path 검증도 같은 `ZipSecurity` 규칙 일부(BAD_ENTRY_NAME) 재사용 권장(구현 판단).
|
||||
|
||||
---
|
||||
|
||||
## 인터셉터/게이트 연동
|
||||
|
||||
### 보호 경로 매핑 (택1 확정: **컨트롤러 진입부 게이트 헬퍼** — 인터셉터 등록 안 함)
|
||||
- `/api/game-files/**` 를 `InterceptorConfig` 의 `RbacInterceptor` 에 **등록하지 않는다**. 이유: RbacInterceptor 는 `/admin/**` ADMIN-only 게이트 전용(RbacInterceptor.java:34 `isAdmin`)이라, 업로드처럼 "로그인 개방 + 옵션 권한키" 분기에는 부적합. W1 설계 선례(콘솔=URL패턴 / 소비액션=게이트 헬퍼)대로 **업로드는 게이트 헬퍼 방식**.
|
||||
- 컨트롤러 진입부:
|
||||
1. `CsrfTokens.isValid(request)` (기존)
|
||||
2. `permissionGate.isAuthenticated(session)` → false 면 401 (기존 `sessionUserId==null` 을 게이트 호출로 통일)
|
||||
3. (옵션, `mode=jam`) `permissionGate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())` → false 면 403
|
||||
- **신규 게이트 메서드 0**(concern 1): `PermissionGate.isAuthenticated`/`has` 는 이미 실재(PermissionGate.java:47,22). 그대로 호출만.
|
||||
|
||||
### 일반 업로드 개방 유지 논증 (D5)
|
||||
- 골자·요구상 일반 게임 업로드 = 모든 로그인 유저 개방. 본 설계는 이 정책을 바꾸지 않고 **게이트 진입점을 표준화**(임의 role 직접체크 금지 — `session.getAttribute("role")` 직접비교 안 함). 잼 출품(W2)이 권한 제한을 요구하면 `mode=jam` 분기에서 `GAME_JAM_MANAGE` 게이트를 켠다. 이로써 `GAME_JAM_MANAGE`(현 소비처 0) 의 첫 enforcement 자리를 W3-5 가 훅으로 마련(활성화 본체는 W2).
|
||||
|
||||
---
|
||||
|
||||
## 시퀀스
|
||||
|
||||
### S1. webgl-zip 업로드 (신규/교체 공통 — 임시추출 → 검증 → 원자적 swap)
|
||||
```
|
||||
[로그인 세션] POST /api/game-files/webgl-zip (CSRF, file=build.zip, replaceUuid?, mode?)
|
||||
→ CsrfTokens.isValid 아니면 403 + errorBody
|
||||
→ permissionGate.isAuthenticated(session) 아니면 401
|
||||
→ if mode=jam: permissionGate.has(session, GAME_JAM_MANAGE) 아니면 403
|
||||
→ file null/empty 체크 아니면 400
|
||||
→ ZipSecurity.assertZipType(file) # MIME ∈ 화이트리스트 AND .zip 확장자
|
||||
실패 → audit(REJECTED, NOT_ZIP) → 400
|
||||
→ ZipSecurity.assertMagic(file) # 첫 4바이트 PK\x03\x04 (또는 빈zip PK\x05\x06)
|
||||
실패 → audit(REJECTED, MAGIC_FAIL) → 400
|
||||
→ if file.getSize() > 512MB audit(REJECTED, TOO_LARGE) → 413
|
||||
→ root = gameRoot()
|
||||
→ newUuid = (replaceUuid 유효·소유검증 통과) ? replaceUuid : UUID.randomUUID()
|
||||
replaceUuid 소유검증: gamesMapper 로 webgl_path 의 game.user_id == userId (또는 ADMIN)
|
||||
불일치 → 403
|
||||
→ tmpDir = root.resolve(".tmp").resolve(newUuid) # 임시 추출지
|
||||
→ assertWithinBoundary(tmpDir, root) 아니면 400 ZIP_SLIP
|
||||
→ Files.createDirectories(tmpDir)
|
||||
→ ExtractResult = ZipSecurity.extractZip(file, tmpDir): # 보강된 추출(아래 S2)
|
||||
엔트리별: validateEntryName → boundary 재검증 → 심링크 거부 → 쓰기 → toRealPath 재검증
|
||||
상한: 엔트리수 8000 / 엔트리당 256MB / 누적 512MB
|
||||
위반 → IllegalArgument(reasonCode) → tmpDir 삭제 → audit(REJECTED, reason) → 400
|
||||
→ indexFile = findIndexFile(tmpDir) 없으면 tmpDir삭제 + audit(NO_INDEX) → 400
|
||||
→ ZipSecurity.assertUnityBuild(tmpDir) # loader/framework/data/wasm 존재
|
||||
실패 → tmpDir삭제 + audit(INCOMPLETE_BUILD) → 400
|
||||
→ finalDir = root.resolve(newUuid)
|
||||
→ if Files.exists(finalDir): # 교체(U6)
|
||||
backupDir = root.resolve(".tmp").resolve(newUuid + ".old")
|
||||
Files.move(finalDir → backupDir) # 구버전 대피
|
||||
→ Files.move(tmpDir → finalDir, ATOMIC_MOVE) # 원자적 swap
|
||||
→ deleteRecursively(backupDir) # 구버전 정리(U7)
|
||||
→ webglPath = "/game/{newUuid}/" + 상대 index 경로
|
||||
→ audit(SUCCESS, newUuid, entryCount, extractedBytes)
|
||||
→ 200 {gameUuid:newUuid, webglPath, deployPath, entryCount, extractedBytes}
|
||||
|
||||
[실패 롤백 불변식] tmpDir/backupDir 는 finally 에서 잔여 시 삭제 → 기존 finalDir 자산 무손상.
|
||||
```
|
||||
|
||||
### S2. ZipSecurity.extractZip 엔트리 루프 (zip-slip + zip bomb 정석)
|
||||
```
|
||||
extractedBytes=0, entryCount=0
|
||||
while (entry = zip.getNextEntry()) != null:
|
||||
entryCount++
|
||||
if entryCount > 8000: throw(TOO_MANY_ENTRIES)
|
||||
name = entry.getName()
|
||||
validateEntryName(name): # BAD_ENTRY_NAME 사전거부
|
||||
- null/blank → reject
|
||||
- 절대경로: name.startsWith("/") || 드라이브(^[A-Za-z]:) || UNC(\\\\ 또는 //) → reject
|
||||
- 백슬래시 포함('\\') → reject (윈도우 경로 우회 차단)
|
||||
- NUL('\0') 포함 → reject
|
||||
- 경로 분절에 ".." 존재 → reject (normalize 전 명시 차단)
|
||||
- 분절 깊이 > 32 또는 name 길이 > 255 → reject
|
||||
target = targetDir.resolve(name).normalize()
|
||||
if !target.startsWith(targetDir): throw(ZIP_SLIP)
|
||||
if entry.isDirectory():
|
||||
assertParentNotSymlink(target, targetDir) # 부모 경로상 심링크 거부
|
||||
Files.createDirectories(target)
|
||||
else:
|
||||
parent = target.getParent(); if null throw(BAD_ENTRY_NAME)
|
||||
Files.createDirectories(parent)
|
||||
assertParentNotSymlink(target, targetDir) # SYMLINK: 쓰기 직전 부모 실경로 검증
|
||||
copied = copyEntry(zip, target, extractedBytes) # 엔트리당 256MB + 누적 512MB
|
||||
extractedBytes += copied
|
||||
# 쓰기 후 실경로 재검증(심링크가 새로 생겼거나 따라간 경우 차단)
|
||||
if !target.toRealPath().startsWith(targetDir.toRealPath()): throw(SYMLINK)
|
||||
zip.closeEntry()
|
||||
if entryCount==0: throw(empty)
|
||||
|
||||
assertParentNotSymlink(target, targetDir):
|
||||
# target 의 부모부터 targetDir 까지 각 구간이 심링크가 아님을 확인
|
||||
p = target.getParent()
|
||||
while p != null && p.startsWith(targetDir) && !p.equals(targetDir):
|
||||
if Files.exists(p) && Files.isSymbolicLink(p): throw(SYMLINK)
|
||||
p = p.getParent()
|
||||
```
|
||||
|
||||
### S3. 자산 정리(U7) — 편집 교체 / soft-delete
|
||||
```
|
||||
[편집 교체] S1 의 replaceUuid 경로가 동일 UUID 교체 → 구 디렉터리 자동 정리(별도 GC 불요).
|
||||
replaceUuid 미전송으로 신규 UUID 가 생성된 경우(기존 클라이언트 흐름):
|
||||
GameController.updateGame 에서 old webgl_path UUID != new UUID 이면
|
||||
assetCleanupService.purge(oldUuid) 호출(구 디렉터리 즉시 삭제, 경계검증 후).
|
||||
|
||||
[게임 삭제] GameController.deleteGame (CSRF + 소유/ADMIN, 기존):
|
||||
softDeleteGame 직후 assetCleanupService.purge(uuidFromWebglPath(game.webglPath))
|
||||
→ /game/{uuid}/ 디렉터리 즉시 삭제(soft-delete 와 정합: 메타는 soft, 자산은 hard).
|
||||
근거: 자산은 복원 대상 아님(재업로드로 갈음), 디스크 누수 방지 우선.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 파일 영향 맵
|
||||
|
||||
> 소유권 분할(implementation-advisor worker 단위 후보):
|
||||
> **U-DDL**(감사 DDL/schema 동기) · **U-ZIPSEC**(ZipSecurity 헬퍼 + 추출 보강) · **U-UPLOAD**(GameUploadController 보강 + 게이트/감사) · **U-LIFECYCLE**(자산 정리 서비스 + GameController 훅) · **U-AUDIT-MAPPER**(감사 매퍼).
|
||||
> 의존: U-DDL → U-AUDIT-MAPPER → U-UPLOAD. U-ZIPSEC 는 U-UPLOAD 선행. U-LIFECYCLE 는 U-UPLOAD 와 병렬 가능(독립 호출지점).
|
||||
|
||||
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||
|---|---|---|---|
|
||||
| 신규 | `docs/game-upload-ddl.sql` | 권위 DDL(game_upload_audit_log). apply-local-ddl.sh 자동적용 | U-DDL |
|
||||
| 수정 | `db/schema.sql` | game_upload_audit_log 블록 추가(ddl 사본) | U-DDL |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/ZipSecurity.java` | zip-slip/심링크/엔트리명/zip bomb 검증 + 추출 코어(정석 보강). 거부 시 reasonCode 담은 예외 | U-ZIPSEC |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/security/ZipRejectException.java` | reasonCode(거부 사유 enum) + message 담은 unchecked 예외. 컨트롤러가 사유코드→audit/HTTP 매핑 | U-ZIPSEC |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameUploadController.java` | 진입부 게이트 통일(isAuthenticated) + 매직바이트/원본크기/MIME·확장자 AND + ZipSecurity 위임 + 임시추출→swap(U6) + replaceUuid 소유검증 + 감사기록 | U-UPLOAD |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/service/GameAssetCleanupService.java` | webgl_path UUID 파싱 → 경계검증 → 디렉터리 정리(purge). 편집/삭제 훅 | U-LIFECYCLE |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java` | updateGame(구 UUID != 신 UUID 시 purge) + deleteGame(soft-delete 후 자산 purge) 훅 | U-LIFECYCLE |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/GameUploadAuditMapper.java` | `@Mapper` 감사 insert + (소유검증용) findGameByWebglUuid 조회. `#{}` only | U-AUDIT-MAPPER |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/GameUploadAudit.java` | 감사 행 POJO(actorId/gameUuid/outcome/rejectReason/originalName/uploadBytes/entryCount/extractedBytes) | U-AUDIT-MAPPER |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/config/UploadResourceConfig.java` | **변경 없음(현황 기록)** — /game/** 는 GameAssetController 서빙(비목표). `.tmp` 디렉터리가 정적노출 안 되도록 주석 명시(ResourceHandler 미추가 유지) | (기록) |
|
||||
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼(GameUploadAuditMapper)·서비스 @MockBean 등록(contextLoads — verification §30) | (검증) |
|
||||
| 신규 | `src/test/.../ZipSecurityTest.java` | zip-slip/심링크/엔트리명/zip bomb/포맷검증 단위(악성 zip 픽스처) | (검증) |
|
||||
| 신규 | `src/test/.../GameUploadControllerSecurityTest.java` | 게이트(401/403)/CSRF/매직바이트/원본크기/replaceUuid 소유검증/감사기록 | (검증) |
|
||||
|
||||
> SSR 호출지점 전수(verification-strategies 영향맵): webgl-zip 응답 JSON 키(gameUuid/webglPath/deployPath/entryCount/extractedBytes) 불변 → game-register.jsp 클라이언트(649-653 hidden 필드 교체) 무변경 동작. `replaceUuid`/`mode` 는 신규 선택 파라미터라 기존 호출 깨짐 0.
|
||||
|
||||
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||
```java
|
||||
// ZipSecurity — zip 검증·추출 코어. static 유틸 + 추출은 인스턴스 불요(상한은 상수).
|
||||
// 추출 결과(entryCount/extractedBytes)는 record 반환.
|
||||
ExtractResult extractZip(MultipartFile file, // zip 입력 스트림 출처
|
||||
Path targetDir) // 추출 목적지(이미 경계검증된 tmpDir)
|
||||
throws ZipRejectException // 거부 시 reasonCode 전파(컨트롤러가 매핑)
|
||||
|
||||
void assertZipType(MultipartFile file) // MIME ∈ 화이트리스트 AND .zip 확장자(NOT_ZIP)
|
||||
void assertMagic(MultipartFile file) // 첫 4바이트 PK\x03\x04(또는 PK\x05\x06 빈zip) (MAGIC_FAIL)
|
||||
void assertUnityBuild(Path extractedRoot) // loader/framework/data/wasm 존재(INCOMPLETE_BUILD)
|
||||
|
||||
// 내부(private) — 인자 최소화. targetDir 경계는 호출자가 1회 검증 후 루프 내 재사용.
|
||||
String normalizeAndValidateEntry(String entryName, Path targetDir) // 사전거부+경계, 반환=정규화된 target 경로 문자열은 불요 → Path 반환 검토(구현 1보)
|
||||
|
||||
// GameAssetCleanupService — webgl_path 기반 자산 정리. gameRoot 는 @Value 주입(컨트롤러와 동일 경로).
|
||||
void purgeByWebglPath(String webglPath) // webgl_path 에서 UUID 파싱 → 경계 내 디렉터리 삭제
|
||||
|
||||
// GameUploadAuditMapper (@Mapper, #{} only)
|
||||
int insertAudit(GameUploadAudit audit) // 단일 POJO 인자(필드 다수 → 1 POJO 가 정석)
|
||||
GameAssetOwner findGameByWebglUuid(String gameUuid) // replaceUuid 소유검증용(user_id 반환). null=대상없음
|
||||
```
|
||||
> inflate 마킹(concern 2): `normalizeAndValidateEntry` 의 `targetDir` 는 경계검증(startsWith) 에 필요하므로 사용 확정. 단 반환 타입(String vs Path)은 호출 루프가 Path 를 바로 쓰는지로 결정 — 구현 1보에서 Path 반환으로 시작하고 String 가공이 불요하면 그대로. `extractZip` 의 상한값(8000/256MB/512MB)은 인자가 아니라 ZipSecurity 상수로 둔다(호출자가 매번 안 넘김 — inflate 방지).
|
||||
|
||||
---
|
||||
|
||||
## 대안 비교
|
||||
|
||||
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||
|---|---|---|---|---|
|
||||
| 심링크 방어 | (A) 쓰기 직전 부모 toRealPath 경계 재검증 + isSymbolicLink 거부 | 표준 NIO API 만, 플랫폼 독립, 정석 | 디렉터리 walk 비용(엔트리당) | **채택** |
|
||||
| | (B) ZipFile + external attributes 모드비트 직접 파싱(0xA000 심링크) | 추출 전 차단 | java.util.zip.ZipEntry 가 unix mode 미노출(직접 바이트파싱 필요), over-engineering | 기각 |
|
||||
| zip bomb | (A) 엔트리당 해제상한 + 누적상한 + 엔트리수상한(3중) | ZipInputStream 만으로 측정 가능, 정석 | 압축비 직접 미측정 | **채택** |
|
||||
| | (B) 엔트리 압축비(compressed/uncompressed) 임계 | 폭탄 조기탐지 | getCompressedSize() 가 -1 반환 케이스(stored/stream) → 신뢰불가 | 기각(concern 3) |
|
||||
| 재업로드 교체 | (A) 임시추출 → 검증 → ATOMIC_MOVE swap | 실패 시 기존 자산 무손상, 부분추출 잔여 0, 멱등 | tmp 디렉터리·디스크 일시 2배 | **채택** |
|
||||
| | (B) 최종 디렉터리 직접 추출(기존) + 실패 시 통째 삭제 | 단순 | 교체 중 실패 시 기존 자산 손실, 부분추출 노출 | 기각 |
|
||||
| 권한 게이트 | (A) 컨트롤러 진입부 게이트 헬퍼(isAuthenticated + 옵션 has) | 개방/제한 분기 유연, W1 인프라 재사용, 신규 0 | 호출지점 결합 | **채택** |
|
||||
| | (B) RbacInterceptor 에 /api/game-files/** 등록 | 선언적 | isAdmin 전용이라 로그인개방 부적합, 인터셉터 개조 필요 | 기각 |
|
||||
| 업로드 메타 | (A) games.webgl_path 재사용 + 감사로그만 신설 | 메타중복 0, 정규화 정석 | 감사는 별도 | **채택** |
|
||||
| | (B) game_uploads 메타테이블 신설 | 업로드 이력 풍부 | webgl_path 와 중복, over-engineering | 기각 |
|
||||
|
||||
---
|
||||
|
||||
## 롤아웃 / 마이그레이션
|
||||
|
||||
### 순서
|
||||
1. **스키마 적용**: `docs/game-upload-ddl.sql` → `db/apply-local-ddl.sh`(로컬) / 운영 동일 멱등 DDL. game_upload_audit_log 신설(추가만, 파괴 0). 기존 데이터 무영향.
|
||||
2. **코드 배포**: ZipSecurity/ZipRejectException → GameUploadAuditMapper/POJO → GameUploadController 보강 → GameAssetCleanupService + GameController 훅.
|
||||
3. **검증**: 악성 zip 픽스처(심링크/`../`/백슬래시/bomb/비-Unity) 거부 + 정상 Unity 빌드 통과 L1 + L3 스모크(실제 zip 업로드→서빙).
|
||||
|
||||
### 역호환
|
||||
- webgl-zip 응답 JSON 키 불변 → game-register.jsp 클라이언트 무변경. `replaceUuid`/`mode` 미전송 시 기존 동작(신규 UUID, 일반 업로드) 100% 보존.
|
||||
- 기존 업로드된 `/game/{uuid}/` 자산은 그대로 서빙(GameAssetController 불변). 기존 고아 디렉터리는 본 배포가 소급 정리하지 않음(신규 업로드/삭제부터 정리 적용 — 소급 정리는 별도 배치, 비목표).
|
||||
- 검증 강화로 **기존엔 통과하던 비정상 zip(예: index.html 만 있고 Build 없는 zip)이 거부**될 수 있다 → 이는 의도된 보강(정석). 운영 공지 필요(documentation-advisor).
|
||||
|
||||
### 롤백
|
||||
- 코드 롤백: ZipSecurity 위임 전 직접 추출 로직으로 복귀 시 심링크/포맷검증만 사라짐(zip-slip prefix 검증은 기존부터 존재). 게이트 호출은 isAuthenticated → sessionUserId==null 복귀.
|
||||
- 스키마 롤백: game_upload_audit_log 는 추가 전용 → drop 없이 잔존 무해(비파괴).
|
||||
- 자산 정리(purge) 롤백: 훅 제거 시 고아 파일 재발생(기능 후퇴)뿐 데이터 손상 없음.
|
||||
|
||||
---
|
||||
|
||||
## AC 매핑
|
||||
|
||||
| AC | 요구 | 만족 설계 요소 | 비고 |
|
||||
|---|---|---|---|
|
||||
| AC-1 | zip-slip(`../`/절대/백슬래시) 거부 | validateEntryName 사전거부 + normalize startsWith + toRealPath 재검증 | S2, ZIP_SLIP/BAD_ENTRY_NAME |
|
||||
| AC-2 | **심볼릭 링크 엔트리/디렉터리 거부** | assertParentNotSymlink + 쓰기 후 toRealPath 경계 재검증 | S2, SYMLINK (조사 최대 구멍 보강) |
|
||||
| AC-3 | zip bomb 방어 | 엔트리당 256MB + 누적 512MB + 엔트리수 8000 (3중상한) | S2, ENTRY/EXTRACTED/TOO_MANY |
|
||||
| AC-4 | Unity 빌드 포맷 검증 | assertUnityBuild(loader+framework+data+wasm 존재, 압축변형 허용) + index.html | S1, INCOMPLETE_BUILD/NO_INDEX |
|
||||
| AC-5 | 매직바이트 + MIME·확장자 AND | assertMagic(PK) + assertZipType(MIME AND .zip) | S1, MAGIC_FAIL/NOT_ZIP |
|
||||
| AC-6 | 원본 크기 상한 | file.getSize() > 512MB → 413 | S1, TOO_LARGE |
|
||||
| AC-7 | 저장 경로 boundary | tmpDir/finalDir assertWithinBoundary(gameRoot canonical) | S1, D4 |
|
||||
| AC-8 | 권한 게이트(로그인 개방 유지 + 잼 옵션) | isAuthenticated 명시 + mode=jam 시 has(GAME_JAM_MANAGE) | §게이트연동, D5 |
|
||||
| AC-9 | UUID 재업로드 원자적 교체 + 멱등 | replaceUuid → 임시추출 → ATOMIC_MOVE swap → 구버전 정리 | S1, U6 |
|
||||
| AC-10 | 자산 정리(고아 방지) | 편집 UUID 변경 시 purge + soft-delete 시 purge | S3, U7 |
|
||||
| AC-11 | 업로드 성공/거부 감사 | game_upload_audit_log insert(outcome + reject_reason) | §데이터모델, U8 |
|
||||
| AC-12 | CSRF 전수 + `${}` 0 | CsrfTokens.isValid(기존) + 감사 매퍼 `#{}` only | §외부계약, AC-T 아래 |
|
||||
| AC-13 | /game/** 서빙 현행 유지 | GameAssetController 불변, ResourceHandler 미추가 기록 | 비목표, 조사 포인트5 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 포인트 (verification-advisor 점검 대상)
|
||||
|
||||
> L레벨 매핑(verification-strategies.md): 보안 입력검증/경로 boundary/권한 = **L1+L2+L3**. zip 추출은 실파일시스템 의존 → **L1(악성 zip 픽스처 단위) + L3(실 업로드→서빙 스모크)**. 신규 매퍼 SQL = **L1+L2(DB-방언 계약)**. 신규 매퍼·서비스 의존 → full `./mvnw -o test` + @MockBean(verification §30).
|
||||
|
||||
### 시나리오 검증
|
||||
- **VP-1 (AC-1/2 경로탈출·심링크, L1)**: ZipSecurityTest — `../escape`, `/abs/path`, `..\\win`, 심링크 엔트리(부모가 심링크인 케이스 포함) zip 픽스처가 각각 ZIP_SLIP/BAD_ENTRY_NAME/SYMLINK 로 거부 + 대상 디렉터리 밖에 파일 0.
|
||||
- **VP-2 (AC-3 zip bomb, L1)**: 9000 엔트리 / 단일 300MB 엔트리 / 누적 600MB zip 이 각각 TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE 로 거부. 부분추출 잔여(tmpDir) 정리 확인.
|
||||
- **VP-3 (AC-4 포맷, L1)**: index.html 만 있고 Build 없는 zip → INCOMPLETE_BUILD. 정상 Unity 빌드(loader+framework+data+wasm, .br 변형 포함) → 통과.
|
||||
- **VP-4 (AC-5/6 타입·크기, L1)**: 비-zip(MIME 위조 또는 매직 불일치) → NOT_ZIP/MAGIC_FAIL. 513MB → TOO_LARGE(413).
|
||||
- **VP-5 (AC-8 게이트, L1+L3)**: 미인증 → 401. mode=jam + GAME_JAM_MANAGE 없음 → 403. 일반 업로드(로그인만) → 통과(개방 유지 회귀).
|
||||
- **VP-6 (AC-9 교체 멱등, L1+L3)**: replaceUuid 로 재업로드 → 같은 UUID, 구 파일 교체, 디렉터리 1개만 잔존. 교체 중 검증실패 시 기존 자산 무손상.
|
||||
- **VP-7 (AC-10 정리, L1)**: GameAssetCleanupService.purgeByWebglPath — 경계 내 UUID 만 삭제, 경계 밖/비-UUID 입력은 무동작(거부).
|
||||
- **VP-8 (AC-11 감사, L1+L2)**: 성공/각 거부사유가 game_upload_audit_log 에 outcome+reject_reason 으로 기록(DB-방언 계약: insertAudit 컬럼↔POJO 정합).
|
||||
- **VP-9 (contextLoads, L1)**: BibimbapApplicationTests 에 GameUploadAuditMapper·GameAssetCleanupService @MockBean 등록 후 PASS(§30).
|
||||
|
||||
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||
> self-audit(시점): 아래 카운트는 모두 **본 W3-5 가 신규 생성하는 정적 산출물**(reason 카탈로그/거부분기/매퍼)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리(work-session) 카운트는 사용 안 함.
|
||||
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 — reason 코드는 **enum 멤버 ↔ DDL CHECK 주석 ↔ 컨트롤러 매핑** 의 구조적 동등성에 앵커. enum values() 순회로 추가 시 자동 동기되는 불변식 우선.
|
||||
|
||||
- **AC-T1 거부 사유코드 전수 11종 정합** — reject_reason 카탈로그(NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD) 11종이 **ZipRejectException 의 reason enum 멤버 수 == 본 설계 카탈로그 표 행수 == 11**. 검증: enum 멤버 `grep -c` == 11 AND DDL COMMENT 의 사유 나열 토큰 수 == 11(`docs/game-upload-ddl.sql` 의 reject_reason COMMENT 내 `/` 구분 토큰 == 11). 코드 enum 이 단일 정의처 — 사유 추가 시 enum/표/DDL주석 3곳 동기 누락을 갯수 1로 동시 검출.
|
||||
- **AC-T2 zip 검증 게이트 전수 — webgl-zip 핸들러가 검증 단계를 모두 통과 후 추출** : assertZipType/assertMagic/원본크기/extractZip(내부 4상한+slip+symlink) 의 진입 호출이 컨트롤러에 존재. 검증: GameUploadController.uploadWebglZip 본문에 `assertZipType`·`assertMagic`·`extractZip` 호출 grep 각 ≥1 (3개 호출지점 전수). 누락 시 검증 우회.
|
||||
- **AC-T3 상태변경 엔드포인트 CSRF 가드 전수** — GameUploadController 의 상태변경 핸들러(uploadGameFiles/uploadWebglZip/uploadThumbnail) 전수 `CsrfTokens.isValid` 선검증: `grep -c 'CsrfTokens.isValid' GameUploadController.java` == @PostMapping 핸들러 수(3). 핸들러 추가 시 가드 누락 동시 검출.
|
||||
- **AC-T4 권한 게이트 진입 전수** — 상태변경 핸들러 3개 전수 `permissionGate.isAuthenticated`(또는 동치 로그인 게이트) 호출: 각 핸들러에 인증 게이트 1회 존재(임의 role 직접체크 0). 검증: `permissionGate.isAuthenticated` grep ≥ 상태변경 핸들러 수, AND `session.getAttribute("role")` 직접 비교 0건(임시체크 금지 — 정석 원칙).
|
||||
- **AC-T5 업로드/감사 매퍼 `${}` 0건** — 신규 매퍼(GameUploadAuditMapper) 에 `${` 매치 0: `grep -c '\${' GameUploadAuditMapper.java` == 0 (AC-12, #{} only 표준).
|
||||
- **AC-T6 Unity 필수 산출물 검증 집합 전수 4종** — assertUnityBuild 가 검사하는 산출물 카테고리(loader/framework/data/wasm) 4종이 코드 검사 목록에 전수 존재. 검증: assertUnityBuild 내 마커 문자열(`loader`/`framework`/`.data`/`.wasm`) 4종 grep 각 ≥1. 카테고리 추가/삭제 시 동시 검출.
|
||||
|
||||
---
|
||||
|
||||
## 잔여 오픈 질문
|
||||
없음(0). 확정 결정 D1~D8 + /game/** 현행유지(조사 stale 정정)로 전 항목 닫음. 다음은 오픈 질문이 아니라 **구현 단계 점검사항**으로 `concerns` 에 이관:
|
||||
- 신규 게이트 메서드 추가 금지(기존 has/require 재사용, concern 1).
|
||||
- ZipSecurity 헬퍼 시그니처 dead parameter 재확인(concern 2).
|
||||
- 압축비 미사용·getCompressedSize 의존 금지(concern 3).
|
||||
- gameRoot() 경로 이중중첩 정정은 스코프 밖(concern 4 — orchestrator 에스컬레이션).
|
||||
- 심링크 거부 구현 방식은 toRealPath 재검증 채택, external-attributes 파싱 미채택(concern 6).
|
||||
|
|
@ -0,0 +1,461 @@
|
|||
---
|
||||
phase: design
|
||||
agent: design-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-23T12:00:00+09:00
|
||||
workstream: W4-유저 배지/평판
|
||||
concerns:
|
||||
- "신규 함수 시그니처(BadgeService.evaluateAndSync / ReputationService.record / BadgeQueryMapper.* )는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 evaluateAndSync 의 reason/actorId 후보 인자."
|
||||
- "신규 매퍼(BadgesMapper / UserBadgesMapper / ReputationEventsMapper / BadgeQueryMapper) + BadgeService / ReputationService 의 컨트롤러/JSP 신규 의존은 verification-strategies §30 에 따라 implementation 에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 빈 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
|
||||
- "배지 표시 조인(리뷰 작성자·게임 카드 옆 배지)은 N+1 위험. 본 설계는 배치 조회(listActiveBadgeKeysByUserIds(List)) 로 차단하도록 명세했으나, 구현이 리뷰 1건당 1쿼리로 풀면 N+1 회귀. listGameReviews/getVisibleGames 호출지점에서 userId 집합을 1회 배치 조회하는지 verification 에서 확인 필요."
|
||||
- "신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias, r.created_at AS createdAt)을 따른다. 단 배지 집계/통계성 조회(reputation 누적 합산 등 VIEW 성)는 케이스 폴딩 함정 대상 — 그 경우만 큰따옴표 alias(verification-strategies §33). DB-방언 계약(L2) 대상이며 dev DB contract 미구축은 기존 open item."
|
||||
- "PermissionKeys 에 BADGE_MANAGE 추가는 enum 멤버 1개 추가 — PermissionCatalogVerifier 가 values() 순회 시드라 자동 동기되나, W1 의 AC-T1(권한 카탈로그 전수 N건) 카운트가 3→4 로 변한다. W1 의 기존 전수 AC 가 하드코딩 3이면 그 AC 가 본 변경으로 FAIL 할 수 있음 — 본 설계 §롤아웃에서 명시, verification 에서 W1 AC-T1 재측정 필요."
|
||||
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
|
||||
---
|
||||
|
||||
# 설계: W4 — 유저 배지 / 평판 (리뷰어/테크니션 배지 + 평판 이벤트 감사로그 + 자동/수동 하이브리드 부여)
|
||||
|
||||
## 목표 / 비목표
|
||||
|
||||
### 목표 (FR/NFR 추적 — 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음)
|
||||
- **B1 배지 카탈로그** (Q1, Q4): 정의 가능·확장 가능한 배지 카탈로그(`badges`). 1차 배지 2종 = 리뷰어(`REVIEWER`)·테크니션(`TECHNICIAN`). badge_type CHECK 로 확장 가능. **"기술자" 명칭 = TECHNICIAN(영문 키) / display_name "테크니션"(한글 표시)** 확정(Q4 닫음).
|
||||
- **B2 유저 배지 보유** (Q1, Q6): `user_badges` 가 유저↔배지 보유를 표현. 활성(미회수) UNIQUE(user_id, badge_key) 로 중복 부여 방지(자동부여 멱등성).
|
||||
- **B3 평판 이벤트 감사로그** (Q3): `reputation_events` 가 평판 신호(리뷰 작성·업로드·역할·좋아요 등)를 누적 감사 기록. 배지가 1차 노출(이산), reputation_events 가 임계 판정·감사 소스(연속 점수 보조).
|
||||
- **B4 자동 부여(임계)** (Q2 → QG-W4-A 닫음): 평판 신호 누적이 배지 기준 임계 도달 시 자동 부여. 이벤트 훅(리뷰 작성/게임 업로드 직후) + 멱등(UNIQUE 방어).
|
||||
- **B5 수동 부여/회수** (Q2, Q5): 운영자가 배지 수동 부여·회수. 신규 권한 키 `BADGE_MANAGE` 게이트(W1 PermissionGate 위). 회수 = `revoked_at` + 사유.
|
||||
- **B6 배지 표시** (Q6): 프로필 + 리뷰 작성자(닉네임 옆) + 게임 카드. 표시명 = `users.display_name` 단일 출처에 배지 부착(스냅샷 닉네임 아닌 유저 단위 배지).
|
||||
- **NFR**: 상태변경(수동 부여/회수) CSRF 전수, `#{}` 바인딩(`${}` 금지), 권한 게이트는 W1 인프라(PermissionGate/PermissionKeys) 위에 얹고 임시 role 직접 체크 금지, 비파괴 멱등 DDL, 배지 표시 조회 N+1 차단(배치 조회).
|
||||
- **착수 독립성** (골자 C5): 평판 소스(리뷰 R-B / 좋아요 R-D / role R-A / 업로드 R-C) 전부 실재 → **W2 무관 독립 착수**.
|
||||
|
||||
### 비목표 (스코프 밖)
|
||||
- 연속 평판 "점수" 산식 노출 UI — reputation_events 는 감사·임계 판정 소스로만 사용, 점수 가시화는 후속.
|
||||
- 배지 기준의 정교한 "품질" 가중(추천수·신고수 반영) — 1차는 활동량 임계 + 회수(부정 다수). 추천 인프라는 현재 부재(code-fact: game_reviews 에 추천 컬럼 0).
|
||||
- 잼(W2) 연동 배지(수상자 배지 등) — W2 시상(W2-6) 종속이라 W4 1차 스코프 밖. badge_type 확장으로 후속 흡수 가능.
|
||||
- 좋아요(`game_likes`) 기반 배지 — R-D 가 1인1표 UNIQUE 미보장(추정)·user_key varchar 라 신뢰 신호로 부적합. reputation_events 스키마는 source 확장 열어두되 1차 자동부여 소스에서 제외.
|
||||
- 자동부여 스케줄러(주기 배치) — 1차는 **이벤트 훅 동기 평가**(리뷰/업로드 트랜잭션 직후). 스케줄 백필은 후속(아래 §대안 비교).
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
bibimbap 에 배지/평판 도메인은 전무하다(grounding R-E: badge/reputation 0 hit). 본 설계는 신규 3테이블(`badges`/`user_badges`/`reputation_events`) + 신규 권한 키 `BADGE_MANAGE` 를 도입한다. 평판 신호 원천은 전부 실재한다 — 리뷰(`game_reviews`, R-B), 업로드(`games`, R-C), 역할(`users.role`, R-A).
|
||||
|
||||
핵심 구조:
|
||||
- **이산 배지(1차 노출)**: `badges` 카탈로그 + `user_badges` 보유. 리뷰어/테크니션 2종으로 시작, badge_type CHECK 확장.
|
||||
- **연속 신호(보조·감사)**: `reputation_events` 가 평판 신호를 append-only 누적. 자동부여는 "이벤트 기록 → 해당 유저의 누적 집계 → 임계 도달 시 배지 부여" 흐름. reputation_events 가 임계 판정 단일 소스 + 감사 추적.
|
||||
- **하이브리드 부여**: 자동(임계, 이벤트 훅 동기) + 수동(운영자, BADGE_MANAGE 게이트). 둘 다 같은 `user_badges` 에 기록되며 `awarded_by`(자동=NULL, 수동=actor) 로 출처 구분.
|
||||
- **표시**: 유저 단위 배지를 `users.display_name` 단일 출처에 부착. 리뷰 작성자(authorName)·게임 카드(creator)·프로필 표면에서 해당 userId 의 활성 배지를 배치 조회로 붙인다.
|
||||
|
||||
확정한 골자 미결(W4 Q1~Q6, QG-W4-A):
|
||||
- **Q1/Q4 (배지 종류·명칭)**: 1차 2종 REVIEWER/TECHNICIAN. "기술자" 표시명=테크니션. badge_type CHECK('REVIEWER','TECHNICIAN') + 확장 시 CHECK 멱등 확장.
|
||||
- **Q2/QG-W4-A (부여 방식)**: 자동 임계 + 수동 하이브리드. 정석 = 신규 BADGE_MANAGE 권한 키(권한 분리, CONTENT_MODERATE 재사용 기각 — 근거 §대안 비교).
|
||||
- **Q3 (평판 점수)**: 이산 배지 1차 노출 + reputation_events 누적(연속 보조·감사). 점수 UI 비목표.
|
||||
- **Q5 (회수)**: `user_badges.revoked_at` + `revoke_reason`. 부정 리뷰/삭제 다수 등 운영 판단 → 수동 회수. 자동 회수는 1차 비목표(거짓 회수 위험).
|
||||
- **Q6 (표시 위치)**: 프로필 + 리뷰 작성자 + 게임 카드. display_name 단일 출처.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 결정 요약 (전제 — 재논의 금지)
|
||||
|
||||
| 결정 | 확정값 | 본 설계의 구체화 |
|
||||
|---|---|---|
|
||||
| 배지 스토리지 | badges + user_badges + reputation_events 3테이블 | `docs/badge-ddl.sql` 권위 + schema.sql 동기(멱등) |
|
||||
| 배지 1차 종류 | REVIEWER / TECHNICIAN | badge_type CHECK 확장 가능. 카탈로그 부팅 시드(BadgeCatalogSeeder) |
|
||||
| "기술자" 명칭 | 키 TECHNICIAN / 표시 "테크니션" | badge_key=TECHNICIAN, display_name="테크니션" |
|
||||
| 부여 방식 | 자동 임계 + 수동 하이브리드 | 자동=reputation_events 누적 집계 임계, 수동=BADGE_MANAGE 게이트. 둘 다 user_badges, awarded_by 로 출처 구분 |
|
||||
| 권한 키 | **신규 BADGE_MANAGE** (CONTENT_MODERATE 재사용 기각) | PermissionKeys enum +1, PermissionCatalogVerifier values() 자동 시드 |
|
||||
| 평판 점수 | 이산 배지 1차 + reputation_events 누적(보조) | reputation_events = 임계 판정 + 감사 단일 소스 |
|
||||
| 회수 | revoked_at + revoke_reason(수동) | UNIQUE active 부분인덱스로 재부여 허용(회수 후 재획득) |
|
||||
| 자동부여 트리거 | 이벤트 훅 동기(리뷰/업로드 직후) | 스케줄 배치 비목표(1차) |
|
||||
| 자동부여 멱등 | UNIQUE(user_id, badge_key) active 부분인덱스 | 중복부여 INSERT ... ON CONFLICT DO NOTHING |
|
||||
| 표시 | display_name 단일 출처 + 배치 조회 | listActiveBadgeKeysByUserIds(List) N+1 차단 |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 모델 (DDL)
|
||||
|
||||
> 권위 워크플로(grounding R-F): **권위 원본 = `docs/badge-ddl.sql`** (신규). `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). `db/schema.sql` 은 동기 사본(컨테이너 최초1회 부트스트랩). 선례: W1 `docs/rbac-ddl.sql`, W3-2 `docs/game-reviews-ddl.sql`. 알파벳순상 `badge-ddl.sql` 은 `game-reviews-ddl.sql` 보다 먼저 적용되나, badges 는 users 외 FK 가 game_reviews/games 를 직접 참조하지 않으므로(소비는 런타임 조회) 적용 순서 의존 없음(아래 FK 설계 참조).
|
||||
|
||||
### 신규 파일: `docs/badge-ddl.sql` (권위 DDL)
|
||||
|
||||
```sql
|
||||
-- W4 유저 배지/평판. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||
-- 신규 도메인(스토리지 0). 추가만, 파괴 없음. search_path=dev.
|
||||
|
||||
-- 1) badges (배지 카탈로그 — 정의 가능·확장 가능)
|
||||
CREATE SEQUENCE IF NOT EXISTS "badges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "badges" (
|
||||
"id" bigint DEFAULT nextval('badges_id_seq'::regclass) NOT NULL,
|
||||
"badge_key" character varying(50) NOT NULL, -- 코드 상수 단일 정의처와 정합(REVIEWER/TECHNICIAN)
|
||||
"display_name" character varying(100) NOT NULL, -- 표시명("리뷰어"/"테크니션")
|
||||
"description" character varying(500), -- 획득 기준 설명(표시용)
|
||||
"badge_type" character varying(30) NOT NULL, -- 분류(REVIEWER/TECHNICIAN, 확장 가능)
|
||||
"criteria_json" text, -- 임계 기준 직렬화(예: {"minReviews":10}). 표시·문서화용. 판정은 코드 상수
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "badges_id_seq" OWNED BY "badges"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_badges_key" ON "badges" ("badge_key");
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_type_check') THEN
|
||||
ALTER TABLE "badges"
|
||||
ADD CONSTRAINT "badges_type_check"
|
||||
CHECK ("badge_type" IN ('REVIEWER', 'TECHNICIAN'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 2) user_badges (유저↔배지 보유. 자동/수동 부여 공통 기록)
|
||||
CREATE SEQUENCE IF NOT EXISTS "user_badges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "user_badges" (
|
||||
"id" bigint DEFAULT nextval('user_badges_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"badge_key" character varying(50) NOT NULL,
|
||||
"awarded_by" bigint, -- 수동=운영자 user_id, 자동=NULL
|
||||
"awarded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"revoked_at" timestamp with time zone, -- 회수 시각(NULL=활성)
|
||||
"revoke_reason" character varying(500), -- 회수 사유(수동)
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "user_badges_id_seq" OWNED BY "user_badges"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_badges_user_id_fkey') THEN
|
||||
ALTER TABLE "user_badges"
|
||||
ADD CONSTRAINT "user_badges_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- 활성(미회수) 중복 부여 방지 — 부분 유니크. 회수 후 재부여 허용(revoked_at IS NOT NULL 행은 제외).
|
||||
-- 자동부여 멱등성의 DB 차원 보증(INSERT ... ON CONFLICT DO NOTHING 대상 인덱스).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_badges_active"
|
||||
ON "user_badges" ("user_id", "badge_key") WHERE "revoked_at" IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_badges_user_active"
|
||||
ON "user_badges" ("user_id") WHERE "revoked_at" IS NULL;
|
||||
|
||||
-- 3) reputation_events (평판 신호 감사로그 — append-only, 임계 판정 단일 소스)
|
||||
CREATE SEQUENCE IF NOT EXISTS "reputation_events_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "reputation_events" (
|
||||
"id" bigint DEFAULT nextval('reputation_events_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"event_type" character varying(40) NOT NULL, -- REVIEW_WRITTEN/GAME_UPLOADED(1차). 확장 가능
|
||||
"source_ref" character varying(100), -- 원천 식별(review:123, game:45 등). 중복 이벤트 방지 키
|
||||
"weight" integer DEFAULT 1 NOT NULL, -- 신호 가중(1차 전부 1, 향후 품질 가중)
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "reputation_events_id_seq" OWNED BY "reputation_events"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_type_check') THEN
|
||||
ALTER TABLE "reputation_events"
|
||||
ADD CONSTRAINT "reputation_events_type_check"
|
||||
CHECK ("event_type" IN ('REVIEW_WRITTEN', 'GAME_UPLOADED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_user_id_fkey') THEN
|
||||
ALTER TABLE "reputation_events"
|
||||
ADD CONSTRAINT "reputation_events_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- 같은 원천 신호 중복 기록 방지(예: 같은 리뷰 재집계). source_ref NULL 은 제외(부분 유니크).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_reputation_events_source"
|
||||
ON "reputation_events" ("user_id", "event_type", "source_ref")
|
||||
WHERE "source_ref" IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_reputation_events_user_type"
|
||||
ON "reputation_events" ("user_id", "event_type");
|
||||
```
|
||||
|
||||
### FK 설계 결정 (적용 순서·소비 결합)
|
||||
- `user_badges`/`reputation_events` 는 **`users` 만 FK** 참조. game_reviews/games 를 FK 로 묶지 않는다 — 이유: (a) docs/*-ddl.sql 알파벳 적용 순서 의존 회피, (b) 소비는 런타임 집계 조회(리뷰 수·업로드 수)이지 참조 무결성 대상 아님, (c) 리뷰/게임 soft-delete 시 평판 이벤트는 감사 기록으로 잔존해야 함(append-only). `source_ref` 는 varchar 약결합(`review:123`)으로 추적성만 확보.
|
||||
|
||||
### `db/schema.sql` 반영 (동기 사본)
|
||||
- `recruit_posts` 블록 뒤(파일 말미)에 위 1·2·3번(badges/user_badges/reputation_events)을 신설 블록으로 추가. 반영 방식은 `game_reviews`(권위 DDL → schema.sql 동기, schema.sql:128~) 선례와 동일 — **docs/badge-ddl.sql 이 권위, schema.sql 은 사본**.
|
||||
|
||||
### 카탈로그 시드 (badges 행 — 부팅 멱등)
|
||||
- W1 `PermissionCatalogVerifier`(enum→DB 멱등 upsert) 선례와 동일 패턴으로 `BadgeCatalogSeeder`(`ApplicationRunner`) 가 코드 배지 상수(`BadgeKeys` enum)를 `badges` 에 멱등 upsert. 단일 정의처 = 코드 enum, DB 는 시드. **권한과 달리 배지는 운영자가 표시명/설명을 DB 에서 조정할 수 있어야 하므로, upsert 는 `badge_key` 부재 시 INSERT 만 하고 기존 행 display_name/description 은 덮어쓰지 않는다**(INSERT ... ON CONFLICT DO NOTHING). 신규 키만 시드.
|
||||
|
||||
---
|
||||
|
||||
## 외부 계약 (API)
|
||||
|
||||
> 공통: 모든 상태변경(수동 부여/회수)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`). 수동 부여/회수 API 는 `BADGE_MANAGE` 게이트 통과 후 도달. 응답은 기존 컨트롤러 패턴(`Map<String,Object>` + `status`/`message`). 읽기(배지 표시)는 기존 리뷰/게임 조회에 배치 부착(신규 페이지 API 최소).
|
||||
|
||||
### 401 vs 403 정책 (W1 정책 계승)
|
||||
- **미인증**(세션 `userId` 없음): API → **401** JSON, 페이지 → `redirect:/login`.
|
||||
- **인증·미인가**(BADGE_MANAGE 없음): **403** JSON `{status:403, message:"권한이 없습니다."}`.
|
||||
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
||||
|
||||
### 수동 부여/회수 API (상태변경 — 전부 CSRF + BADGE_MANAGE 게이트)
|
||||
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
||||
|---|---|---|---|---|---|
|
||||
| 수동 부여(B5) | POST | `/admin/users/{userId}/badges/{badgeKey}/grant` | (path) | `{status:200, message, userId, badgeKey, awarded:true}` | 404(대상/배지키 없음), 409(이미 활성 보유), 403(CSRF/권한) |
|
||||
| 수동 회수(B5/Q5) | POST | `/admin/users/{userId}/badges/{badgeKey}/revoke` | `{reason}`(form) | `{status:200, message, userId, badgeKey, revoked:true}` | 404(대상/활성보유 없음), 403 |
|
||||
|
||||
- **grant/revoke 분리 채택 근거**: W1 권한 토글은 부여=회수 대칭 역연산이라 단일 toggle 이었으나, 배지 회수는 **사유(revoke_reason) 필수 + 부정 판단 동반**이라 의미가 비대칭이다. 회수를 토글로 묶으면 사유 누락·오회수 위험. 따라서 명시적 2엔드포인트 채택(grant 는 사유 불요, revoke 는 사유 요구).
|
||||
- **경로 선택**: `/admin/**` 하위 — RbacInterceptor 가 `/admin/**` 를 ADMIN 게이트로 1차 보호하나, 배지 관리는 ADMIN 뿐 아니라 BADGE_MANAGE 보유 SUBADMIN 도 허용해야 함. 따라서 **인터셉터의 `/admin/**` ADMIN-only 와 충돌**(아래 §인터셉터 연동에서 해소).
|
||||
|
||||
### 배지 표시 조회 (읽기 — 기존 조회에 배치 부착, 신규 페이지 API 없음)
|
||||
- 리뷰 목록(`GameReviewController` listReviews) / 게임 목록(`GameController` getVisibleGames) / 프로필 응답에 해당 userId 집합의 활성 배지 키를 **1회 배치 조회**로 부착. 응답 형태: 각 리뷰/게임 항목에 `authorBadges: ["REVIEWER", ...]`(키 배열) 추가. 표시명 매핑은 클라이언트가 카탈로그(부트 시 모델 주입 또는 정적 상수)로 변환.
|
||||
|
||||
---
|
||||
|
||||
## 인터셉터 / 게이트 연동
|
||||
|
||||
### `/admin/**` ADMIN-only 와 BADGE_MANAGE(SUBADMIN 허용)의 충돌 해소 (확정)
|
||||
- 현 `RbacInterceptor` 는 `/admin/**` 전체를 `isAdmin` 게이트로 보호(grounding R-A: InterceptorConfig.java:18-20, `addPathPatterns("/admin/**")` + isAdmin). SUBADMIN 은 어떤 권한 키를 가져도 `/admin/**` 진입 불가.
|
||||
- **결정: 배지 관리 API 를 `/admin/**` 밖 경로로 둔다 → `/manage/badges/...`**. 이유: `/admin/**` 의 ADMIN-only 인터셉터 의미를 훼손하지 않으면서 BADGE_MANAGE 보유 SUBADMIN 진입을 허용. 인터셉터 URL 패턴 변경(SUBADMIN 분기 추가)은 W1 enforcement 계약을 흔들어 회귀 위험 — 신규 경로가 정석.
|
||||
- 수정 경로 계약: 수동 부여 `POST /manage/users/{userId}/badges/{badgeKey}/grant`, 회수 `POST /manage/users/{userId}/badges/{badgeKey}/revoke`.
|
||||
- 이 경로는 인터셉터에 등록하지 **않고**, 컨트롤러 진입부에서 **게이트 헬퍼** `permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name())` 를 직접 호출(W1-design §인터셉터 "소비 액션=게이트 헬퍼" 선례와 동일 — 경로별 권한이 콘솔 단일 ADMIN 과 다를 때의 표준 패턴). 미인증→401, 미인가→403 은 컨트롤러가 작성.
|
||||
- **PermissionGate 재사용**: 신규 게이트 메서드 불요. 기존 `has(HttpSession, String)` 가 ADMIN 암묵전권 + SUBADMIN 키 보유를 모두 커버(PermissionGate.java:22-38 확인). BADGE_MANAGE 는 enum 값일 뿐 게이트 코어 변경 0.
|
||||
|
||||
### PermissionKeys 확장 (코드 — 설계 명세, 본 문서는 enum 확장 명세만, 코드 미수정)
|
||||
- `PermissionKeys` enum 에 멤버 1개 추가:
|
||||
```java
|
||||
BADGE_MANAGE("배지 관리")
|
||||
```
|
||||
- `PermissionCatalogVerifier`(PermissionCatalogVerifier.java:30, `values()` 순회 시드) 가 자동으로 DB `permissions` 에 멱등 upsert → **코드 수정 0(verifier 불변), enum 멤버 추가만으로 카탈로그 동기**. 이것이 W1 의 "enum 단일 정의처" 불변식의 확장 시연.
|
||||
|
||||
---
|
||||
|
||||
## 시퀀스
|
||||
|
||||
### S1. 자동 부여 (리뷰 작성 → 평판 이벤트 → 임계 판정 → 배지 부여)
|
||||
```
|
||||
[유저 42] POST /game/7/reviews (CSRF) — 리뷰 작성(기존 W3-2 경로)
|
||||
→ GameReviewController.createReview (기존)
|
||||
→ CSRF + 리뷰 INSERT (기존 트랜잭션)
|
||||
→ [신규 훅, 같은 트랜잭션 후단] reputationService.record(
|
||||
userId=42, eventType=REVIEW_WRITTEN, sourceRef="review:" + newReviewId)
|
||||
→ reputationEventsMapper.insertIgnore(...) # ux_reputation_events_source ON CONFLICT DO NOTHING(중복 신호 방어)
|
||||
→ badgeService.evaluateAndSync(userId=42, badgeKey=REVIEWER)
|
||||
→ countActiveReputation(42, REVIEW_WRITTEN) # reputation_events 누적 집계(단일 소스)
|
||||
→ if count >= REVIEWER_THRESHOLD(=10, 코드 상수):
|
||||
userBadgesMapper.insertIgnore(userId=42, badgeKey=REVIEWER, awardedBy=NULL)
|
||||
# ux_user_badges_active ON CONFLICT DO NOTHING(자동부여 멱등 — 이미 보유면 no-op)
|
||||
→ 200 (리뷰 응답, 배지 부여는 부작용)
|
||||
```
|
||||
- **자동부여 멱등 논증(crossRefs 보안)**: 동일 유저가 임계 초과 후 리뷰를 더 써도 `ux_user_badges_active` 부분 유니크가 중복 INSERT 를 DO NOTHING 으로 흡수 → 1회만 부여. 동시성(같은 유저 동시 2리뷰)도 DB 유니크가 차단.
|
||||
- **트랜잭션 경계**: 평판 훅은 리뷰 INSERT 와 같은 트랜잭션. 훅 실패가 리뷰 작성을 롤백하면 UX 손상 — 따라서 **훅은 best-effort(try/catch + 로그)**로 감싸 리뷰 본 흐름을 막지 않는다(평판은 보조 도메인). 누락된 이벤트는 후속 스케줄 백필로 복구 가능(비목표지만 구조 보존).
|
||||
|
||||
### S2. 자동 부여 (게임 업로드 → TECHNICIAN)
|
||||
```
|
||||
[유저 42] POST /game/new (CSRF) — 게임 메타 생성(기존 GameController.createGame)
|
||||
→ [신규 훅, 트랜잭션 후단] reputationService.record(42, GAME_UPLOADED, "game:" + newGameId)
|
||||
→ badgeService.evaluateAndSync(42, TECHNICIAN)
|
||||
→ countActiveReputation(42, GAME_UPLOADED) >= TECHNICIAN_THRESHOLD(=3) → insertIgnore TECHNICIAN
|
||||
```
|
||||
|
||||
### S3. 수동 부여/회수 (운영자, BADGE_MANAGE 게이트)
|
||||
```
|
||||
[SUBADMIN(BADGE_MANAGE 보유) 세션] POST /manage/users/42/badges/REVIEWER/grant (CSRF)
|
||||
→ BadgeManageController.grant(42, "REVIEWER")
|
||||
→ CSRF 검증 (없으면 403 + errorBody)
|
||||
→ permissionGate.has(session, BADGE_MANAGE) (미인증 401 / 미인가 403)
|
||||
→ BadgeKeys.isValid("REVIEWER") (아니면 404)
|
||||
→ usersMapper.getUser(42) 존재 확인 (아니면 404)
|
||||
→ 이미 활성 보유? userBadgesMapper.existsActive(42, REVIEWER) → 있으면 409
|
||||
→ userBadgesMapper.insert(42, REVIEWER, awardedBy=actorId) # 수동: awarded_by=actor
|
||||
→ 200 {awarded:true}
|
||||
|
||||
[운영자] POST /manage/users/42/badges/REVIEWER/revoke (CSRF, reason="부정 리뷰 다수")
|
||||
→ BadgeManageController.revoke(42, "REVIEWER", reason)
|
||||
→ CSRF + BADGE_MANAGE 게이트
|
||||
→ 활성 보유? (아니면 404)
|
||||
→ userBadgesMapper.revoke(42, REVIEWER, reason, now()) # revoked_at + revoke_reason set
|
||||
# 부분 유니크가 revoked_at IS NULL 만 보므로, 회수 후 재획득(자동/수동) 가능
|
||||
→ 200 {revoked:true}
|
||||
```
|
||||
|
||||
### S4. 배지 표시 (리뷰 목록에 작성자 배지 배치 부착 — N+1 차단)
|
||||
```
|
||||
[누구나] GET /game/7/reviews (기존 listReviews)
|
||||
→ GameReviewController.listReviews → List<GameReviewData>(각 userId/authorName 보유)
|
||||
→ [신규] userIds = reviews.stream().map(userId).distinct()
|
||||
→ Map<Long,List<String>> badges = userBadgesQueryMapper.listActiveBadgeKeysByUserIds(userIds) # 1쿼리(IN)
|
||||
→ 각 리뷰 DTO 에 authorBadges = badges.getOrDefault(userId, []) 부착
|
||||
→ JSP/JSON: 작성자 닉네임 옆 배지 표시(textContent/HtmlUtils 이스케이프)
|
||||
```
|
||||
- **N+1 차단 논증(concern 3)**: userId 집합을 1회 `IN (...)` 배치 조회. 리뷰 1건당 1쿼리 금지. 게임 카드(getVisibleGames)·프로필 동일 패턴.
|
||||
|
||||
---
|
||||
|
||||
## 파일 영향 맵
|
||||
|
||||
> 소유권 분할(implementation-advisor worker 단위 후보):
|
||||
> **B-SCHEMA**(DDL/schema 동기) · **B-DOMAIN**(BadgeKeys enum/data POJO/매퍼/카탈로그 시더) · **B-SERVICE**(ReputationService/BadgeService 임계 판정·멱등 부여) · **B-HOOK**(리뷰/게임 작성 경로 평판 훅 연결) · **B-MANAGE**(수동 부여/회수 컨트롤러 + 게이트) · **B-DISPLAY**(리뷰/게임/프로필 배지 배치 부착 + JSP) · **B-PERM**(PermissionKeys BADGE_MANAGE 추가).
|
||||
> 의존: B-SCHEMA → B-DOMAIN → {B-SERVICE → B-HOOK, B-MANAGE, B-DISPLAY}. B-PERM 은 B-MANAGE 선행.
|
||||
|
||||
| 변경 유형 | 경로 | 역할 | 소유 |
|
||||
|---|---|---|---|
|
||||
| 신규 | `docs/badge-ddl.sql` | 권위 DDL(badges/user_badges/reputation_events + CHECK/유니크). apply-local-ddl.sh 자동 적용 | B-SCHEMA |
|
||||
| 수정 | `db/schema.sql` | 말미에 3테이블 블록 추가(badge-ddl 동기 사본) | B-SCHEMA |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java` | 배지 키 enum 단일 정의처(REVIEWER/TECHNICIAN + displayName + isValid). PermissionKeys 선례 | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java` | `ApplicationRunner` — BadgeKeys → badges 멱등 INSERT(ON CONFLICT DO NOTHING). PermissionCatalogVerifier 선례 | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/BadgeData.java` | badges 행 POJO(badgeKey/displayName/description/badgeType/isActive) | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java` | user_badges 행 POJO(userId/badgeKey/awardedBy/awardedAt/revokedAt/revokeReason) | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java` | `@Mapper` 카탈로그 시드/조회(`#{}`) | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java` | `@Mapper` user_badges CRUD(insertIgnore/insert/existsActive/revoke, `#{}`) | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java` | `@Mapper` 표시 배치 조회(listActiveBadgeKeysByUserIds — IN, N+1 차단) | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java` | `@Mapper` 평판 이벤트 insertIgnore + 누적 집계(countActive, `#{}`) | B-DOMAIN |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java` | 평판 이벤트 기록 + 임계 평가 위임(record). best-effort 훅 | B-SERVICE |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java` | 임계 판정 + 멱등 부여(evaluateAndSync) + 수동 부여/회수 도메인 로직 | B-SERVICE |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java` | createReview 후단 reputationService.record(REVIEW_WRITTEN) 훅. listReviews 응답에 배지 배치 부착 | B-HOOK / B-DISPLAY |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/GameController.java` | createGame 후단 reputationService.record(GAME_UPLOADED) 훅 | B-HOOK |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/GameController.java` (또는 GameRestController) | getVisibleGames 응답에 creator 배지 배치 부착 | B-DISPLAY |
|
||||
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java` | 수동 부여/회수 `/manage/users/{userId}/badges/{badgeKey}/(grant|revoke)` + BADGE_MANAGE 게이트 헬퍼 | B-MANAGE |
|
||||
| 수정 | `src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java` | `BADGE_MANAGE("배지 관리")` enum 멤버 1개 추가(verifier 자동 시드) | B-PERM |
|
||||
| 수정 | `src/main/webapp/WEB-INF/views/profile.jsp` | 프로필에 본인 활성 배지 표시(HtmlUtils 이스케이프/textContent) | B-DISPLAY |
|
||||
| 수정 | (리뷰/게임 카드 JSP — 리뷰 목록·게임 목록 렌더 지점) | 작성자/creator 닉네임 옆 배지 표시 | B-DISPLAY |
|
||||
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼·BadgeService·ReputationService `@MockBean` 등록(contextLoads, verification §30) | (검증) |
|
||||
| 신규 | `src/test/.../BadgeServiceTest.java` | 임계 판정·멱등 부여(중복 no-op)·회수 후 재부여 단위 | (검증) |
|
||||
| 신규 | `src/test/.../BadgeManageControllerTest.java` | grant/revoke + 401/403/CSRF/404/409 + BADGE_MANAGE 게이트 | (검증) |
|
||||
| 수정 | `src/test/.../GameReviewControllerTest.java` | 리뷰 작성 후 평판 훅 호출 회귀(REVIEW_WRITTEN 기록) + 작성 본흐름 불변 | (검증) |
|
||||
|
||||
> SSR 호출지점 확인(verification §영향맵): 배지 배치 부착은 기존 리뷰/게임 DTO 에 **신규 필드(authorBadges) 추가**라 기존 호출지점 깨짐 0. PermissionKeys 멤버 추가는 enum 확장(values() 순회 코드 불변)이라 컴파일 회귀 0.
|
||||
|
||||
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
||||
```java
|
||||
// ReputationService — 평판 이벤트 기록 + 임계 평가 위임. best-effort 훅.
|
||||
void record(long userId, // 신호 주체
|
||||
String eventType, // REVIEW_WRITTEN/GAME_UPLOADED(ReputationEventTypes 상수)
|
||||
String sourceRef) // 중복 신호 방지 키(review:N/game:N). NULL 허용은 안 함(훅은 항상 원천 보유)
|
||||
|
||||
// BadgeService — 임계 판정 + 멱등 부여.
|
||||
void evaluateAndSync(long userId, // 평가 대상
|
||||
String badgeKey) // 평가할 배지(REVIEWER/TECHNICIAN). 임계·소스 매핑은 BadgeKeys 내부
|
||||
// (수동) — 컨트롤러가 BADGE_MANAGE 게이트·CSRF 통과 후 호출. 도메인 검증(존재/중복/회수)만.
|
||||
GrantResult grantManual(long userId, // 대상
|
||||
String badgeKey,// 부여 배지
|
||||
long awardedBy) // 운영자 user_id(awarded_by 기록)
|
||||
void revokeManual(long userId, // 대상
|
||||
String badgeKey, // 회수 배지
|
||||
String reason) // 회수 사유(revoke_reason). NULL/blank 거부는 컨트롤러 검증
|
||||
|
||||
// UserBadgesMapper (@Mapper, #{} only)
|
||||
int insertIgnore(long userId, String badgeKey) // 자동부여 멱등(ON CONFLICT DO NOTHING, awarded_by NULL)
|
||||
int insert(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("awardedBy") long awardedBy) // 수동부여
|
||||
boolean existsActive(@Param("userId") long userId, @Param("badgeKey") String badgeKey) // 409/404 판정
|
||||
int revoke(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("reason") String reason) // revoked_at+reason set
|
||||
|
||||
// UserBadgesQueryMapper (@Mapper, #{} only) — 표시 배치(N+1 차단)
|
||||
List<UserBadgeKeyRow> listActiveBadgeKeysByUserIds(@Param("userIds") List<Long> userIds) // IN 1쿼리. (userId, badgeKey) 행
|
||||
|
||||
// ReputationEventsMapper (@Mapper, #{} only)
|
||||
int insertIgnore(@Param("userId") long userId, @Param("eventType") String eventType, @Param("sourceRef") String sourceRef) // 중복신호 ON CONFLICT DO NOTHING
|
||||
long countActive(@Param("userId") long userId, @Param("eventType") String eventType) // 누적 집계(임계 좌변)
|
||||
|
||||
// BadgesMapper (@Mapper, #{} only)
|
||||
int insertIgnore(@Param("badgeKey") String k, @Param("displayName") String d, @Param("description") String desc, @Param("badgeType") String t) // 시더(ON CONFLICT DO NOTHING)
|
||||
List<BadgeData> listActive() // 카탈로그(표시명 매핑 모델 주입)
|
||||
```
|
||||
> inflate 마킹(concern 1): `evaluateAndSync` 는 의도적으로 `(userId, badgeKey)` 2인자로 최소화했다 — 임계값·평판 소스 event_type 은 `BadgeKeys` enum 내부 매핑(REVIEWER→REVIEW_WRITTEN/임계10, TECHNICIAN→GAME_UPLOADED/임계3)으로 외부 인자 부풀림을 막는다. `record` 의 `weight` 는 시그니처에서 제외(1차 전부 weight=1, DB DEFAULT 1) — 품질 가중 도입 시 그때 추가. `grantManual` 의 `awardedBy` 는 audit 출처라 실사용 확정. 구현에서 각 인자 실제 사용 재확인 필요(concern 1).
|
||||
|
||||
---
|
||||
|
||||
## 대안 비교
|
||||
|
||||
| 주제 | 안 | 장점 | 단점 | 채택 |
|
||||
|---|---|---|---|---|
|
||||
| 권한 키 | (A) 신규 `BADGE_MANAGE` | 권한 분리(콘텐츠 모더 ≠ 배지 관리), 최소권한 정석 | enum +1, 카탈로그 +1 | **채택** |
|
||||
| | (B) `CONTENT_MODERATE` 재사용 | 신규 키 0 | 모더레이터=배지관리자 강제 결합, 권한 분리 위배 | 기각 |
|
||||
| 자동부여 트리거 | (A) 이벤트 훅 동기(작성 직후 best-effort) | 즉시 반영, 인프라 0, 트랜잭션 결합 약 | 누락 신호 백필 별도 | **채택** |
|
||||
| | (B) 주기 스케줄 배치 | 누락 0 일괄 | 신규 스케줄러 인프라, 즉시성 손실, 1차 과도 | 기각(후속) |
|
||||
| | (C) DB 트리거/계산컬럼 | 앱 무관 | DB 로직 산재, 임계 변경 어려움, 테스트 난 | 기각 |
|
||||
| 배지 회수 | (A) revoked_at + reason, 수동만(부분유니크 재획득 허용) | 감사·재획득·정석 | 회수 후 재부여 가능(의도) | **채택** |
|
||||
| | (B) hard delete | 단순 | 감사 소실, 회수 이력 0 | 기각 |
|
||||
| | (C) 자동 회수(임계 하락) | 일관 | 거짓 회수(리뷰 일시 삭제 등) 위험 | 기각(1차) |
|
||||
| 평판 점수 | (A) 이산 배지 1차 + reputation_events 누적(보조) | 노출 단순 + 감사·임계 소스 | 이벤트 테이블 1개 | **채택** |
|
||||
| | (B) users 에 점수 컬럼 누적 | 조회 빠름 | 감사 소실, 재계산 불가, 단조증가 결함 | 기각 |
|
||||
| 배지 관리 경로 | (A) `/manage/**` 신규(게이트 헬퍼) | `/admin/**` ADMIN-only 의미 보존 + SUBADMIN+BADGE_MANAGE 허용 | 경로 1개 신설 | **채택** |
|
||||
| | (B) `/admin/**` 하위 + 인터셉터 SUBADMIN 분기 | 경로 통일 | W1 인터셉터 ADMIN-only 계약 훼손, 회귀 위험 | 기각 |
|
||||
| 표시 조회 | (A) userId 집합 배치 IN 1쿼리 | N+1 0 | 매퍼 IN 1개 | **채택** |
|
||||
| | (B) 리뷰/게임 1건당 배지 조회 | 단순 | N+1(목록 성능) | 기각 |
|
||||
| 좋아요 평판 소스 | (A) 1차 제외 | game_likes 신뢰 불가(1인1표 미보장 추정·user_key varchar) | 신호 1종 감소 | **채택** |
|
||||
| | (B) game_likes 포함 | 신호 풍부 | 어뷰징·중복(R-D concern) | 기각(소스 확장 여지만 보존) |
|
||||
|
||||
---
|
||||
|
||||
## 롤아웃 / 마이그레이션
|
||||
|
||||
### 순서
|
||||
1. **스키마 적용**: `docs/badge-ddl.sql` → `db/apply-local-ddl.sh`(로컬, 멱등) / 운영 동일 멱등 DDL. 신규 도메인이라 기존 데이터 호환(추가만, 파괴 0). `db/schema.sql` 동기.
|
||||
2. **권한 키**: `PermissionKeys.BADGE_MANAGE` 추가 → 배포 시 `PermissionCatalogVerifier` 가 permissions 카탈로그 자동 시드(코드 verifier 불변).
|
||||
3. **배지 카탈로그**: 부팅 시 `BadgeCatalogSeeder` 가 REVIEWER/TECHNICIAN 멱등 INSERT.
|
||||
4. **코드 배포**: 도메인/서비스/훅/매니지/표시. 평판 훅이 리뷰/게임 작성 경로에 발효.
|
||||
5. **BADGE_MANAGE 부여**: ADMIN 이 W1 콘솔(`/admin/console` 권한 토글)로 대상 SUBADMIN 에게 BADGE_MANAGE 부여 → `/manage/badges` 진입 가능. (W1 토글이 PermissionKeys 전체를 노출하므로 신규 키 자동 토글 가능 — W1 콘솔 코드 변경 불요, 단 §concerns AC-T1 카운트 주의.)
|
||||
|
||||
### 역호환
|
||||
- 기존 유저/리뷰/게임: 배지 0 상태로 시작. 평판 이벤트는 배포 이후 신규 작성분만 기록(과거 백필 안 함 — 비목표). 표시 배치 조회는 배지 없으면 빈 배열 → 표시 변화 0.
|
||||
- W1 콘솔: PermissionKeys 멤버 추가만 — 콘솔이 enum 순회로 키를 노출하면 BADGE_MANAGE 가 자동 토글 대상에 추가됨(동작 확장, 회귀 0).
|
||||
- 리뷰/게임 DTO: 신규 필드 `authorBadges` 추가만 → 기존 직렬화/JSP 깨짐 0.
|
||||
|
||||
### 롤백
|
||||
- 코드 롤백: 평판 훅·표시 부착·매니지 컨트롤러 제거 시 배지 도메인 비활성. 신규 테이블/컬럼은 추가 전용이라 잔존해도 무해(비파괴). PermissionKeys 에서 BADGE_MANAGE 제거 시 DB permissions 의 BADGE_MANAGE 행은 `PermissionCatalogVerifier` 가 "DB-only 키 경고 로그"로 감지(W1 PermissionCatalogVerifier.java:40-44) — 삭제 안 하므로 안전.
|
||||
- 스키마 롤백: 명시적 DROP 은 별도 maintenance(append-only 감사 데이터 보존 권장).
|
||||
|
||||
---
|
||||
|
||||
## AC 매핑
|
||||
|
||||
| AC | 요구 | 만족 설계 요소 | 비고 |
|
||||
|---|---|---|---|
|
||||
| AC-1 | 리뷰 N개 작성 시 REVIEWER 자동 부여 | S1 평판 훅 → countActive >= 임계 → insertIgnore | 임계=BadgeKeys 상수 |
|
||||
| AC-2 | 게임 M개 업로드 시 TECHNICIAN 자동 부여 | S2 평판 훅 → GAME_UPLOADED 집계 임계 | |
|
||||
| AC-3 | **자동부여 멱등(중복 부여 0)** | ux_user_badges_active 부분유니크 + insertIgnore(ON CONFLICT DO NOTHING). 임계 초과 추가 활동·동시성 모두 1회만 | S1 논증 |
|
||||
| AC-4 | 운영자 수동 부여 — BADGE_MANAGE 게이트 | grant 컨트롤러 permissionGate.has(BADGE_MANAGE), 미인가 403 | S3 |
|
||||
| AC-5 | 수동 부여/회수 CSRF 없으면 403 | grant/revoke CsrfTokens.isValid 선검증 → 403+errorBody | §외부계약 공통 |
|
||||
| AC-6 | 회수 — revoked_at + 사유 기록 | revoke: revoked_at=now()+revoke_reason. 활성 보유 아니면 404 | S3 |
|
||||
| AC-7 | 회수 후 재획득 가능 | 부분유니크 WHERE revoked_at IS NULL → 회수행 제외, 재부여 INSERT 가능 | DDL 2 |
|
||||
| AC-8 | 배지 표시(프로필/리뷰작성자/게임카드) display_name 단일출처 | listActiveBadgeKeysByUserIds 배치 부착, userId 기준(스냅샷 닉네임 아님) | S4 |
|
||||
| AC-9 | 표시 조회 N+1 0 | userId 집합 IN 1쿼리 | concern 3 |
|
||||
| AC-10 | 배지 SQL `${}` 0 | 신규 매퍼 전부 `#{}` | §파일영향맵 |
|
||||
| AC-11 | 비-BADGE_MANAGE 의 /manage/badges 접근 차단 | 게이트 헬퍼 미인가 403, 미인증 401 | §인터셉터연동 |
|
||||
| AC-12 | reputation_events 중복 신호 방어 | ux_reputation_events_source + insertIgnore | DDL 3 |
|
||||
|
||||
---
|
||||
|
||||
## 검증 포인트 (verification-advisor 점검 대상)
|
||||
|
||||
> L레벨 매핑(verification-strategies.md): 권한 게이트/부여 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 매퍼·서비스 빈 의존 = full `./mvnw -o test` 의무(§30). 자동부여 멱등·회수 = L1+L3.
|
||||
|
||||
### 시나리오 검증
|
||||
- **VP-1 (AC-3 멱등, L1)**: BadgeServiceTest — 임계 도달 후 evaluateAndSync 반복 호출 시 user_badges 활성 1행 유지(insertIgnore no-op). 회수 후 재호출 시 재부여(AC-7).
|
||||
- **VP-2 (AC-4/AC-11 게이트, L1+L3)**: BadgeManageControllerTest — BADGE_MANAGE 미보유 SUBADMIN→403, 미인증→401, ADMIN→통과, BADGE_MANAGE 보유 SUBADMIN→통과.
|
||||
- **VP-3 (AC-5 CSRF, L1)**: grant/revoke CSRF 누락 → 403 + mapper 미호출(W1 `deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용).
|
||||
- **VP-4 (AC-1/AC-2 자동부여, L1+L3)**: 리뷰/게임 작성 훅이 reputation_events insert + 임계 도달 시 배지 부여. 본 흐름(리뷰/게임 작성) 불변 회귀(훅 best-effort).
|
||||
- **VP-5 (AC-9 N+1, L1)**: 리뷰 목록 N건 응답 시 배지 조회 쿼리 1회(IN). 리뷰 1건당 1쿼리면 FAIL.
|
||||
- **VP-6 (DB-방언 계약, L2)**: 신규 매퍼 반환 키가 소비 키와 정합. 집계성 조회(countActive)·alias 케이스 폴딩 확인.
|
||||
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 매퍼·BadgeService·ReputationService @MockBean 등록 후 PASS(§30).
|
||||
|
||||
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
||||
> self-audit: 아래 카운트는 본 설계가 신규 생성하는 정적 산출물 + 기존 docs/*-ddl.sql 집합이며, 시점·표현 견고성을 2축 점검했다.
|
||||
|
||||
- **AC-T1 (시점 self-audit 적용) DDL 권위 파일 전수** — `docs/*-ddl.sql` 은 verification 시점까지 **W2 등 타 워크스트림이 동시 추가할 수 있는 집합**(현재 4: recruit-posts/security-hardening/game-reviews/rbac, 본 설계가 badge 추가 → 5). 고정 스칼라 5 는 시점 불안정 → **불변식으로 전환**: "badge-ddl.sql 이 존재하고 그 안의 CREATE TABLE 3종(badges/user_badges/reputation_events)이 schema.sql 에도 동일 존재" — `grep -c 'CREATE TABLE IF NOT EXISTS "\(badges\|user_badges\|reputation_events\)"' docs/badge-ddl.sql` == 3 AND db/schema.sql 동일 == 3 (권위↔사본 동등성 불변식). 타 W 파일 추가와 무관.
|
||||
- **AC-T2 신규 테이블 멱등 가드 전수 3건** — badge-ddl.sql 의 3테이블 전부 `CREATE TABLE IF NOT EXISTS`: `grep -c 'CREATE TABLE IF NOT EXISTS' docs/badge-ddl.sql` == 3. 멱등 누락(IF NOT EXISTS 빠진 CREATE) 동시 검출.
|
||||
- **AC-T3 배지 SQL `${}` 0건** — 신규 매퍼 5개(BadgesMapper/UserBadgesMapper/UserBadgesQueryMapper/ReputationEventsMapper) + 수정 컨트롤러에 `${` 매치 0: `grep -rc '\${' <매퍼 파일들>` == 0 (AC-10).
|
||||
- **AC-T4 (표현 self-audit 적용) 자동부여 멱등 가드 전수** — 자동부여 INSERT 경로 전부(user_badges/reputation_events insertIgnore)가 `ON CONFLICT DO NOTHING` 동반. 단일 리터럴 grep 은 SQL 표현 변형(`ON CONFLICT (...) DO NOTHING` 컬럼 명시)에 취약 → **의미 불변식 + 수동 판정**: insertIgnore 메서드 2개(UserBadgesMapper.insertIgnore / ReputationEventsMapper.insertIgnore)가 각각 부분유니크 인덱스(ux_user_badges_active / ux_reputation_events_source)에 대응하는 ON CONFLICT 절을 갖는지 코드 리뷰 확인. **이 AC 가 AC-3/AC-12 멱등의 핵심 가드** — 누락 시 중복 부여 보안결함.
|
||||
- **AC-T5 (시점 self-audit 적용) PermissionKeys 카탈로그 동기 불변식** — BADGE_MANAGE 추가로 enum 멤버 수가 변한다(고정 카운트 금지). 불변식: `PermissionCatalogVerifier` 가 `values()` 순회 시드이므로 enum 멤버 수 == DB 시드 키 수(자동 동기). 검증: PermissionKeys enum 멤버에 BADGE_MANAGE 존재 AND verifier 코드가 여전히 `values()` 순회(하드코딩 리스트 아님). **W1 의 기존 AC-T1(권한 전수 3건)이 하드코딩 3이면 본 변경으로 FAIL — verification 에서 W1 AC 를 4로 재측정 필요**(concern 5).
|
||||
- **AC-T6 평판 신호 event_type CHECK 정합** — reputation_events CHECK IN 목록(REVIEW_WRITTEN/GAME_UPLOADED) == 코드 record 호출이 사용하는 event_type 집합. 검증: DDL CHECK IN 2종 == ReputationEventTypes 상수 2종 == 훅 호출지점 2곳(리뷰/게임). 신호 종류 추가 시 CHECK 누락 동시 검출.
|
||||
|
||||
---
|
||||
|
||||
## 잔여 오픈 질문
|
||||
없음(0). 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음(§핵심 결정 요약). 시그니처 inflate·신규 빈 full-test·표시 N+1·DB-방언 L2·W1 AC-T1 카운트 변화는 오픈 질문이 아니라 **구현/검증 단계 점검 항목**으로 `concerns` 에 이관.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# 교차정합 감사 — W2 결합 클러스터 + W3/W4 cross-ref (설계 전용)
|
||||
|
||||
> **HIGH 편차: 1건.** 동결 단일권위(W2-3) **유지됨** — jam_scores/jam_votes/jam_awards/jam_score_stats VIEW 의 평가단위·컬럼·트랙·게이트 계약을 하류 W2-4/5/6 이 재정의 없이 소비. 단 W2-1 본문이 동결 전 표현(jam_entries.id)을 그대로 남겨 동결값(game_id 자연키)과 문서상 모순(HIGH-1, 스키마 영향 없음·문서 정정 권고).
|
||||
|
||||
생성: 2026-06-23 / 감사자: cross-consistency audit (Read-only). 코드/설계 수정 0.
|
||||
|
||||
---
|
||||
|
||||
## 점검 항목별 verdict
|
||||
|
||||
### 1. ★평가 단위 일관성 (최고 위험) — **편차(HIGH-1, 문서 표현 한정)**
|
||||
- **동결 권위(W2-3 F8/§64/§67)**: 평가단위 = `(jam_id, game_id)` 자연키 = 활성 출품작. FK 는 `game_id→games`·`jam_id→jams` 직접. 출품검증=앱계층(jam_entries 활성행).
|
||||
- **하류 정합**: jam_scores(W2-3:142-143)·jam_votes(W2-3:185-186)·jam_awards(W2-3:221-222) DDL FK 전부 `(jam_id, game_id)`. W2-4 점수입력(`/jams/{jamId}/games/{gameId}/scores`), W2-5 투표(`castVote(jamId, gameId, voterUserId)`), W2-6 시상(트랙별 gameId 랭크) **전부 game_id 단위로 일치**. game_id 혼용·jam_entries.id 직접 FK **없음** → 평가단위 일관성 **OK**.
|
||||
- `[HIGH] W2-1:43, W2-1:65 → 문제`: W2-1 본문이 "평가 단위 = `jam_entries.id`"(§43 "jam_entries.id 를 평가 단위로 제공", §65 "평가 단위 = jam_entries.id", §196 DDL 주석 "평가 단위 = entry.id — W2-3/4/5/6 참조점")로 **동결값과 정반대 식별자**를 명시. W2-3 은 이 모순을 concern 1·난제1·F8 에서 명시 인지하고 game_id 자연키로 **이미 해소**했으나(W2-3 이 권위), **W2-1 문서는 미정정 잔존**. → 구현자가 W2-1 본문만 읽으면 잘못된 평가단위로 매퍼/FK 설계 가능. `권고`: W2-1 §43/§65/§196 을 "평가단위 = (jam_id, game_id) 자연키, jam_entries 가 active-UNIQUE 로 1:1 대응 보장(W2-3 F8 동결)" 으로 문서 정정. **스키마 영향 0**(W2-1 DDL 자체는 game_id/jam_id 컬럼을 정상 보유, ux_jam_entries_jam_game_active 도 정합) — 순수 본문 표현 drift.
|
||||
|
||||
### 2. 동결 스키마 컬럼 정합 — **OK**
|
||||
- W2-3 동결 VIEW 출력 컬럼 = `jam_id/game_id/weighted_total/simple_total/scored_criteria/judge_count`(W2-3:281-284).
|
||||
- W2-4 소비(§97/§130/§207): `weightedTotal/simpleTotal/scoredCriteria/judgeCount` camelCase **정합**. 정렬·alias·큰따옴표 규약 일치.
|
||||
- W2-6 소비(§36/§110-113/§191): JUDGE=`jam_score_stats.weighted_total`, USER_RATING=`game_review_stats.avg_rating`+`review_count>=3`, POPULAR=`jam_votes` count — 전부 동결 컬럼명 일치. `score_value numeric(10,4)` 는 트랙별 **값 규약**만 확정(스키마 변경 0). 컬럼명 drift **없음**.
|
||||
|
||||
### 3. 게이트 계약 정합 — **OK (게이트 순서 LOW 1건)**
|
||||
- **isJudge 시그니처**: W2-2 동결 `JamRoleGate.isJudge(session, jamId)`(W2-2:283) ↔ W2-4 호출 `jamRoleGate.isJudge(session, 42)`(W2-4:167) **일치**.
|
||||
- **자기출품 충돌**: W2-2 제공 `isOwnEntry(jamId, gameId, userId)`(W2-2:285, option (b) 별도 메서드) ↔ W2-4 호출 `isOwnEntry(42, 777, userId)`(W2-4:171, "기본 가정 (b)") **일치**. 개인+팀멤버 OR 양경로 커버(W2-2 §307 SQL). enforce 시점=점수입력(W2-2 J7) ↔ W2-4 enforce **정합**.
|
||||
- **평가기간 게이트(F6)**: W2-3 `status='EVAL' AND now∈[eval_start_at,eval_end_at]` → W2-4 `JamEvalWindow.isOpen`(§149), W2-5 컨트롤러 진입부(§148) **동일 조건** 사용. 위반=422 정책 3문서 일치.
|
||||
- `[LOW] W2-4:67/138 vs W2-2:179 → 문제`: 점수입력 게이트 **순서**가 두 문서에서 상이. W2-4 = CSRF→인증→**isJudge(403)**→jam→**평가기간(422)**→출품작→자기출품. W2-2 §179 = CSRF→로그인→**평가기간(422)**→**isJudge(403)**→자기출품. isJudge 와 평가기간의 선후가 swap. `권고`: 무해(둘 다 첫 실패 지점 반환·최종 결과 동일, W2-2 §179 이 "순서는 W2-4 결정"으로 이미 양보) — 구현은 W2-4 순서 채택 명시면 충분.
|
||||
|
||||
### 4. 중복 DDL — **OK**
|
||||
- jam_votes `CREATE TABLE` 가 W2-3(§182, 소유)+W2-5(§86) 양쪽 등장. W2-5 의 것은 §79-85 에서 "참조 사본(W2-3 §3 권위, 본 설계가 정의/변경하지 않음, DDL 미수정, 신규 DDL 0)"으로 **명시 인용**이고 본문(컬럼/UNIQUE ux_jam_votes_jam_voter/idx_jam_votes_jam_game)이 동결과 **동일** → 발산하는 2번째 정의 **아님**.
|
||||
- W2-4/W2-6 은 `CREATE TABLE/VIEW` **0건**(AC 텍스트의 부재검증 토큰만). 다른 중복 CREATE 없음.
|
||||
|
||||
### 5. 권한키 정합 — **OK**
|
||||
- 카탈로그 = W1(GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE) + W4 신규 BADGE_MANAGE.
|
||||
- **W3-1**: TAG_MANAGE 신규키 도입 **안 함** — `CONTENT_MODERATE` 재사용으로 확정(D4 §60/§483, 본문·게이트 호출부 일관). 미래 분리는 concern 으로만 이관(키 도입 0).
|
||||
- **W3-3**: POST_WRITE 재사용(신규키 0, §55/§69).
|
||||
- **W4 BADGE_MANAGE**: 신규키 1개 추가. W1 AC-T1(카탈로그 카운트 3) 깨짐을 **명시**(W4 §12 concern·§455 AC-T5·§405 — "W1 AC-T1 을 4로 재측정 필요"). PermissionCatalogVerifier values() 자동 시드라 enum +1 만으로 동기. **누락 없음**.
|
||||
|
||||
### 6. cross-W 참조 — **OK (1건 전제 정정)**
|
||||
- **W3-4 배너→잼검색**: W3-1 동결 `GET /games/search?jam={jamSlug}`(§65/§234) ↔ W3-4 §126-130 동일 라우트 앵커(+ W3-1 미확정 시 `/jams/{slug}` 폴백 우선순위 확정). **일치**.
|
||||
- **W3-3 POST_WRITE enforcement**: `PermissionGate.has(session, POST_WRITE.name())` 연결(§55/§325). **OK**.
|
||||
- **W2-6 유저평점 트랙 = game_review_stats.avg_rating**(W2-6 §37/§111) — 단방향 읽기(write 0, AC-T2). **OK**.
|
||||
- `정정(편차 아님)`: 감사 전제 "W2-6 유저평점 트랙 = W4 와 **동일 VIEW 소비**"는 **부정확**. W4 REVIEWER 배지는 `game_review_stats` 가 아니라 `reputation_events`(리뷰작성 이벤트 누적 임계, W4 §54/§263)를 소비. 둘은 **독립 메커니즘**(공유 VIEW 아님) → 발산 위험 없음(같은 VIEW 를 다르게 재정의하는 상황이 애초에 없음). 정합 무관.
|
||||
|
||||
### 7. 보안 존재성 — **OK (전부 실재·비자명)**
|
||||
- **W2-2 잼-스코프 게이트**: `JamRoleGate`(jam_judges 조회, 전역 PermissionGate 와 별도 축). isOwnEntry 개인+팀멤버 OR-EXISTS(§307). 전역 RBAC 무변경 단언(AC-T7). **실재·비자명**.
|
||||
- **W3-3 SSRF(SsrfSafeFetcher)**: 9항목 체크리스트 실재(§273-284 — scheme allowlist / host resolve / 모든 resolved IP 공인검증(사설·루프백·링크로컬·메타데이터 169.254.169.254) / connect / 연결소켓 peer-IP 재검증(rebinding) / redirect 매홉 재검증 / size cap / timeout / Content-Type). DNS-pin TOCTOU 한계까지 concern 명시. **비자명**.
|
||||
- **W3-5 zip-slip(canonical+심링크)**: `toRealPath()` 경계 재검증 + assertParentNotSymlink(부모경로 walk+isSymbolicLink) + 쓰기후 toRealPath().startsWith 재검증 + 엔트리명 사전거부(절대/UNC/백슬래시/NUL/드라이브/`..`/길이/깊이). ZipEntry mode-bit 직접파싱은 근거와 함께 기각(over-eng). **비자명**.
|
||||
|
||||
---
|
||||
|
||||
## 부차 관찰 (편차 아님 — 동결 허용범위 내)
|
||||
- `[LOW] W2-5 vote mapper 시그니처 drift`: W2-3 계약(§371-374)은 `castVote/updateVote/hasVoted/countByGame`, W2-5 구현(§231-242)은 `castVote/updateVote/deleteVote/findVotedGameId/countByJam/listCountsByJam`. W2-3 이 해당 시그니처를 "계약 골격(inflate 마킹, 구현 재확인)"으로 명시했고 `countByGame ... (또는 listCounts 집계)`로 listCounts 변형을 예고 → 동결 허용범위 내. W2-6 POPULAR 소스(`listCountsByJam`)와 W2-5 제공이 정합. castVote 인자순서(jamId,gameId,voterUserId)는 W2-3↔W2-5 **완전 일치**.
|
||||
- `[LOW] 점수입력 경로 표현차`: W2-2 §230 의사코드는 `POST /jams/{slug}/scores`(예시), W2-4 실제 계약은 `POST /jams/{jamId}/games/{gameId}/scores`. W2-2 시퀀스는 게이트 소비점 예시일 뿐 경로 권위 아님 → 무해.
|
||||
|
||||
---
|
||||
|
||||
## 전체 verdict
|
||||
- **HIGH 1**(W2-1 본문 평가단위 표현 정정 — 스키마/계약 영향 0, 문서 drift).
|
||||
- **LOW 3**(게이트 순서 표현차·vote mapper 시그니처 골격 변형·점수입력 경로 예시차 — 전부 동결 허용범위/무해).
|
||||
- **동결 단일권위(W2-3) 유지·하류 소비 정합 — PASS.** jam_scores/jam_votes/jam_awards/jam_score_stats 의 평가단위·컬럼·트랙enum·게이트·단방향 계약을 W2-4/5/6 이 재정의 없이 소비. 중복 DDL·권한키 silent 추가·cross-W 라우트 불일치 **없음**.
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
---
|
||||
schema_version: 2
|
||||
session_id: 20260623-104307
|
||||
resumed_from: 20260622-180054
|
||||
started_at: 2026-06-23T10:43:07+09:00
|
||||
ended_at: 2026-06-23T12:22:00+09:00
|
||||
user_request: |
|
||||
남은 W 워크스트림을 설계한다(구현 아님). "골자 먼저 → 미결질문 확정 → 풀설계" 단계.
|
||||
- 설계 대상: W1·W3-2 제외 전부 (W2-1~W2-6, W3-1/3-3/3-4/3-5, W4).
|
||||
- 골자 신규 작성 = W2(6개)·W4 (W3-* 는 골자 이미 존재 → 재작성 불필요, 풀설계 진입 시 grounding 갱신).
|
||||
- W3 skeletons "코드 현황" 절 stale 2건 명시: (1) Interceptor/권한게이트 이제 존재(security/PermissionGate·RbacInterceptor·PermissionKeys·config/InterceptorConfig) (2) game_reviews 리뷰테이블 이제 존재(+GameReviewStatsMapper).
|
||||
- 제약: 설계만, 코드 0줄, src/·pom.xml 수정 금지. 미결질문은 골자 단계에서 해소하지 않음(풀설계 진입 시 AskUserQuestion 확정). 보안 명시: W2-2 권한 / W3-3 SSRF(OG·유니티블로그 피드) / W3-5 zip-slip.
|
||||
---
|
||||
|
||||
# Summary
|
||||
설계 전용(코드 0줄) 세션. 직전 세션 20260622-180054(W1 RBAC 설계/구현)에서 이어짐. 단계: ① W2·W4 골자 카탈로그 신규 작성(W3 skeletons 포맷) + W3 stale 2건 명시 → ② 골자 합의 → ③ 각 W 미결질문 AskUserQuestion 확정 → ④ 풀설계(W1-design.md 깊이: DDL/파일영향맵/API계약/시퀀스/AC매핑). 현재 ①단계 진입 — 골자 정확도 위해 W2/W4 코드 surface grounding 선행.
|
||||
|
||||
# Advisor Invocation Decision Log
|
||||
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||
- advisor: requirements-advisor
|
||||
decision: skip
|
||||
rationale: '로드맵(jam-platform-roadmap.md)이 W2/W4 를 이미 기능 분해. 골자 카탈로그 포맷이 곧 요구 윤곽 — 별도 요구분해 불요. 미결질문은 골자 단계 비해소가 사용자 명시 제약.'
|
||||
checked_at: 2026-06-23T10:44:00+09:00
|
||||
- advisor: research-advisor
|
||||
decision: call
|
||||
rationale: '골자 "결합/의존" 의 code-fact vs 해석 태깅 정확도가 세션 핵심. W1 RBAC·리뷰 인프라가 직전 세션에 착지(941f9fb 등) → graph/문서 stale 가능성. 코드 직접 grounding 필요. parallel-explorer 6버킷 팬아웃.'
|
||||
checked_at: 2026-06-23T10:44:00+09:00
|
||||
- advisor: design-advisor
|
||||
decision: skip (골자 단계 한정)
|
||||
rationale: 'design-advisor 정체성 = "오픈질문 0 구현가능 설계도". 골자는 미결질문 *보존*이 목적(사용자 제약) → 권한 상충. 판단 핵심(grounding)은 research 로 위임 완료, 남은 건 고정 템플릿(W3 skeletons 포맷)에 grounded 사실 조립 + 미결 비해소. orchestrator 직접 작성이 적합(인플레이션 회피). 풀설계 단계(④)에서는 design-advisor 정식 호출.'
|
||||
checked_at: 2026-06-23T10:58:00+09:00
|
||||
- advisor: design-advisor (풀설계 ×11)
|
||||
decision: call (Workflow 오케스트레이션)
|
||||
rationale: '사용자 "전체 설계 개입없이 직접·정석" → 11기능 풀설계. ultracode ON → Workflow 로 3-phase 의존순서 병렬(design-advisor agentType, opus/high). 백그라운드라 orchestrator 컨텍스트 오염 0 → §2 parallel-advisor 금지의 취지(오염) 충족. 미결질문은 orchestrator 가 정석 결정으로 닫아 주입(design 은 0 오픈질문 산출).'
|
||||
checked_at: 2026-06-23T11:14:00+09:00
|
||||
- advisor: research-advisor (W3-5 선행)
|
||||
decision: call (Workflow PhaseA 내)
|
||||
rationale: '골자대로 W3-5 는 7항목 조사 선행 후 설계. 조사(PhaseA) → 설계(PhaseB) 파이프.'
|
||||
checked_at: 2026-06-23T11:14:00+09:00
|
||||
|
||||
# Invocations
|
||||
- id: inv-001
|
||||
layer: orchestrator
|
||||
name: orchestrator
|
||||
parent_invocation_id: null
|
||||
started_at: 2026-06-23T10:43:07+09:00
|
||||
ended_at: 2026-06-23T10:44:00+09:00
|
||||
input_digest: '남은 W 설계(골자먼저) 요청 + docs-first grounding(로드맵/W3skeletons/W1-design 선례) + ★ stale 2건 파일레벨 확인'
|
||||
output_digest: 'init가드 pass, migrate 마커 없음(skip), 프로토콜 코어 로드, ★ RBAC/리뷰 인프라 파일 존재 확인(+GameReviewAxesMapper 발견), research-advisor 디스패치 결정'
|
||||
artifacts: [.atp/work-session/20260623-104307/report.md]
|
||||
concerns: []
|
||||
- id: inv-002
|
||||
layer: advisor
|
||||
name: research-advisor
|
||||
agent_version: 2
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T10:45:00+09:00
|
||||
ended_at: 2026-06-23T10:53:00+09:00
|
||||
input_digest: 'W2/W4 골자 grounding 6버킷(RBAC소비/리뷰평점/games/game_likes/users+배지/관례·DDL) — code-fact vs 해석 태깅, ★STALE 2건 확정'
|
||||
output_digest: '★STALE1·2 둘 다 실재 확정. enforcement 갭(POST_WRITE/GAME_JAM_MANAGE 소비처0), 리뷰 하이브리드(overall+6축)+stats VIEW, W2-2 스코프 갭(전역 권한모델), jams/배지 전무, game_likes 1인1표 UNIQUE 추정(비권위)'
|
||||
artifacts: [.atp/work-session/20260623-104307/research/W2-W4-grounding.md]
|
||||
concerns:
|
||||
- 'game_likes UNIQUE 제약 비권위(운영 DB 검증 필요) → QG-W2-D'
|
||||
- 'W2-2 잼 스코프 권한 미수렴(전역 모델) → QG-W2-A, design 결정'
|
||||
model_choice:
|
||||
phase: analyze
|
||||
dispatch_size: parallel
|
||||
tier: large
|
||||
effort: high
|
||||
resolved_model: opus
|
||||
capped: false
|
||||
escalation_reason: '탐색적(worker 가 볼 대상 결정) + grounding 정확도가 세션 전체 전제 + W2-2 스코프갭 trade-off 발견'
|
||||
rationale: 'code-fact 태깅·갭 발견이 하류 설계의 토대. 6 explorer 병렬.'
|
||||
planned_workers: 6
|
||||
actual_workers: 6
|
||||
- id: inv-003
|
||||
layer: orchestrator
|
||||
name: orchestrator
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T10:54:00+09:00
|
||||
ended_at: 2026-06-23T11:00:00+09:00
|
||||
input_digest: 'grounding + 로드맵 → W2(6)·W4 골자 카탈로그 직접 작성(design-advisor skip, 미결 비해소 보존)'
|
||||
output_digest: 'W2-W4 골자 카탈로그(stale정정 2건 + 코드현황 + 7기능 골자 + 결합표 + 착수순서 + QG-W2-A~D·W4-A) 작성. W3 skeletons 배너 + index 링크'
|
||||
artifacts:
|
||||
- docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
||||
- docs/work-log/2026-06-17-w3-feature-skeletons.md
|
||||
- docs/work-log/index.md
|
||||
concerns: []
|
||||
- id: inv-004
|
||||
layer: orchestrator
|
||||
name: orchestrator (Workflow w2-w4-full-design)
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T11:14:00+09:00
|
||||
ended_at: 2026-06-23T11:35:00+09:00
|
||||
input_digest: '11기능 풀설계 — 정석 결정 주입, 3-phase 의존순서(A:W2-1/W3-1/W3-3/W4/W3-5조사 → B:W2-3동결/W3-4/W3-5설계 → C:W2-2/4/5/6). Workflow run wf_9e1bd074-6ae'
|
||||
output_digest: '10/11 설계 완성(각 34-49KB, W1-design 깊이) + W3-5 조사 완료. W3-1 만 Stream idle timeout 실패. src/ 변경 0(설계전용 준수). 12 agent, ~1.32M tokens.'
|
||||
artifacts:
|
||||
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W3-3-posting-board-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W3-4-main-hub-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W3-5-upload-design.md
|
||||
- .atp/work-session/20260623-104307/implementation/W4-badges-design.md
|
||||
- .atp/work-session/20260623-104307/research/W3-5-upload-research.md
|
||||
concerns:
|
||||
- 'W3-1 실패 → inv-005 재실행. W3-4 는 W3-1 을 라우팅 계약 앵커로 방어설계(부재 명시) → 재실행 불요'
|
||||
- 'QG-3 stale 추가 발견(W3-5 조사): /game/** 서빙은 GameAssetController(@GetMapping /game/{uuid}/**) 로 정상 동작 — 골자 "핸들러 미등록=서빙 미보장" 정정 필요(버그 아님)'
|
||||
- 'W3-5 조사: zip-slip 심링크 구멍·업로드 권한게이트 전무·포맷검증 index.html만 — W3-5 설계가 보강'
|
||||
model_choice:
|
||||
phase: design
|
||||
dispatch_size: parallel
|
||||
tier: large
|
||||
effort: high
|
||||
resolved_model: opus
|
||||
rationale: '풀설계 — trade-off 빈번 + 보안(W2-2/W3-3 SSRF/W3-5 zip-slip) + 정석 요구. 전 design 에이전트 opus/high.'
|
||||
planned_workers: 11
|
||||
actual_workers: 11
|
||||
- id: inv-005
|
||||
layer: advisor
|
||||
name: design-advisor (W3-1 재실행 1 — 실패)
|
||||
agent_version: 1
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T11:36:00+09:00
|
||||
ended_at: 2026-06-23T11:42:00+09:00
|
||||
input_digest: 'W3-1 풀설계 단일 재실행(인접 Read 포함)'
|
||||
output_digest: 'Stream idle timeout 2회 연속(workflow 포함) → §2.1 분할 재시도 전환'
|
||||
artifacts: []
|
||||
concerns: ['design-advisor opus 대형 단일호출 + 다중 대형 Read 가 stream idle timeout 유발(2회). §2.1 분할로 회복.']
|
||||
model_choice: { phase: design, dispatch_size: direct, tier: large, effort: high, resolved_model: opus, fallback_reason: 'stream idle timeout 2회 → 분할', rationale: '실패 재실행' }
|
||||
- id: inv-006
|
||||
layer: advisor
|
||||
name: design-advisor (W3-1 part1 §1-7)
|
||||
agent_version: 1
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T11:48:00+09:00
|
||||
ended_at: 2026-06-23T11:51:00+09:00
|
||||
input_digest: 'W3-1 §1-7, 필요 사실 전부 인라인 주입(Read 0) — idle timeout 회피'
|
||||
output_digest: 'frontmatter+§1-7 작성. tags/game_tags/jam_tags/game_views, 잼검색 라우트 GET /games/search?jam={slug} 확정'
|
||||
artifacts: [.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md]
|
||||
concerns: []
|
||||
- id: inv-007
|
||||
layer: advisor
|
||||
name: design-advisor (W3-1 part2 §8-13)
|
||||
agent_version: 1
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T11:54:00+09:00
|
||||
ended_at: 2026-06-23T11:57:00+09:00
|
||||
input_digest: 'W3-1 §8-13 append(part1 1회 Read)'
|
||||
output_digest: 'W3-1 완성 470줄, 오픈질문 0. 권한키 D4=CONTENT_MODERATE 재사용 확정'
|
||||
artifacts: [.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md]
|
||||
concerns: []
|
||||
- id: inv-008
|
||||
layer: advisor
|
||||
name: general-purpose (W2 클러스터 교차정합 감사)
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T12:00:00+09:00
|
||||
ended_at: 2026-06-23T12:04:00+09:00
|
||||
input_digest: '11설계 교차정합 — 평가단위·동결컬럼·게이트계약·중복DDL·권한키·보안 점검'
|
||||
output_digest: 'HIGH 1(W2-1 평가단위 표현 drift, DDL정합) + MED 0 + LOW 3(무해). 동결 단일권위 유지, 하류 정합 PASS. 컬럼/권한키/라우트/보안 clean'
|
||||
artifacts: [.atp/work-session/20260623-104307/implementation/_cross-consistency-audit.md]
|
||||
concerns: []
|
||||
model_choice: { phase: validation-런타임, dispatch_size: direct, tier: large, effort: high, resolved_model: opus, rationale: '교차정합은 주관적 판단 — large' }
|
||||
- id: inv-009
|
||||
layer: orchestrator
|
||||
name: orchestrator (HIGH-1 정정 + 설계 인덱스)
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T12:05:00+09:00
|
||||
ended_at: 2026-06-23T12:12:00+09:00
|
||||
input_digest: '감사 HIGH-1(W2-1 평가단위 표현) 5개소 정정 + 통합 결정요약/설계 인덱스 작성 + work-log index'
|
||||
output_digest: 'W2-1 (jam_id,game_id) 자연키로 정정, summary 문서 + 인덱스 링크. 골자 stale 3건 정정'
|
||||
artifacts:
|
||||
- .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||
- docs/work-log/2026-06-23-w2-w4-full-design-summary.md
|
||||
- docs/work-log/index.md
|
||||
concerns: []
|
||||
- id: inv-010
|
||||
layer: advisor
|
||||
name: retrospective-advisor
|
||||
parent_invocation_id: inv-001
|
||||
started_at: 2026-06-23T12:14:00+09:00
|
||||
ended_at: 2026-06-23T12:18:00+09:00
|
||||
input_digest: 'report.md + user_signals → 회고'
|
||||
output_digest: 'what_went_well 5 / what_to_improve 2 / memory_candidates 4(전부 protocol-level) / protocol_feedback 3. Retrospective 섹션 기록'
|
||||
artifacts: [.atp/work-session/20260623-104307/report.md]
|
||||
concerns: []
|
||||
model_choice: { phase: docs-sync, dispatch_size: direct, tier: medium, effort: medium, resolved_model: sonnet, rationale: '회고 메타분석 bounded — medium' }
|
||||
|
||||
# Decisions
|
||||
- by: orchestrator
|
||||
at: 2026-06-23T10:44:00+09:00
|
||||
decision: 'resumed_from 20260622-180054 기록(사용자 명시). 골자 단계 우선 — 합의 후 미결확정/풀설계.'
|
||||
rationale: '사용자 브리프가 단계적 진행 명시. 골자 = 합의 artifact.'
|
||||
related_invocations: [inv-001]
|
||||
- by: user
|
||||
at: 2026-06-23T11:10:00+09:00
|
||||
decision: '전체 설계를 orchestrator 가 개입 없이 직접 결정·진행. 빠른길 아닌 "확실하고 정석적" 방법. AskUserQuestion 면제(명시 위임).'
|
||||
rationale: '사용자 발화 "전체 설계를 내 개입없이 직접해보고. 다만 빠르고 쉽게 끝낼수있는 방법이 아니라 확실하고 정석적인 방법으로". §명시적 비활성 경로 + §5.0 면제.'
|
||||
related_invocations: [inv-001]
|
||||
- by: orchestrator
|
||||
at: 2026-06-23T11:12:00+09:00
|
||||
decision: '정석 기준 캐스케이드 결정 확정: (W2-A) 심사위원=별도 jam_judges 테이블. (W2-B) 유저평점=overall avg_rating(VIEW). (W2-C1) 잼연결=조인테이블 jam_entries. (W2-C2) 팀출품 1차포함. (W2-4) 잼별 jam_criteria 설정형. (W2-5) 1인1표 UNIQUE. (W2-6) 3트랙 개별+가중 GRAND. (W4) 자동임계+수동 하이브리드.'
|
||||
rationale: '정석/robust 우선 — 스코프 최소화 단축 대신 정규화·확장성·도메인 정합. 각 design 대안비교로 근거 보존.'
|
||||
related_invocations: [inv-004]
|
||||
- by: orchestrator
|
||||
at: 2026-06-23T12:18:00+09:00
|
||||
decision: 'retro memory_candidates 4건 처리 = protocol_feedback 로 보존(report 기록). docs_sync_target=agent-team-protocol.md 가 플러그인 번들(~/.claude 전역) → §6 게이트로 orchestrator 임의 편집 금지(사용자 행동 영역). MEMORY opt-in 신호 없음 → 비기록. 교훈이 ATP 프로토콜 레벨이라 bibimbap 프로젝트 docs/ 편집 불요.'
|
||||
rationale: 'docs-first 이나 sink 가 전역 플러그인이라 §6. 사용자에게 protocol 개선 보류 고지.'
|
||||
related_invocations: [inv-010]
|
||||
|
||||
# Conflicts
|
||||
- between: [W2-3-eval-freeze, W2-5-popular-vote]
|
||||
detected_at: 2026-06-23T11:59:00+09:00
|
||||
resolved_by: orchestrator
|
||||
outcome: '비충돌 확정 — jam_votes 가 양쪽 CREATE 로 등장하나 W2-5 본문이 명시적으로 "재정의 아님·신규 DDL 0·권위 W2-3" + 컬럼/UNIQUE 동일. W2-5 의 CREATE 는 참조용 인용. 동결 단일권위(W2-3) 유지.'
|
||||
|
||||
# Regression
|
||||
- surfaced_at_stage: 교차정합 감사(inv-008)
|
||||
source_stage: 설계(W2-1)
|
||||
defect: 'W2-1 본문 5개소가 "평가 단위 = jam_entries.id" 로 표현 — W2-3 동결 권위((jam_id,game_id) 자연키)와 식별자 모순. W2-1 DDL 자체·하류 W2-4/5/6 은 정합(game_id), 순수 표현 drift'
|
||||
full_set_recheck: true
|
||||
downstream_rerun: ['W2-1 본문 5개소 정정(11/43/65/196/406). 하류 재실행 불요 — 감사가 W2-4/5/6 game_id 정합 확인']
|
||||
resolved_at: 2026-06-23T12:08:00+09:00
|
||||
|
||||
# Open Items
|
||||
- 'graph-refresh-checker: skip — git diff 가 docs/ + .atp/work-session/ 뿐, src/ scope 변경 0(설계 전용). §3.2 no-scope-change → fresh 취급'
|
||||
- 'game_likes 운영 DB UNIQUE 제약 미확인(추정, grounding concern) — W2-5 는 신규 jam_votes 로 무관하나 별도 운영 DB 확인 권장(QG-W2-D)'
|
||||
- '구현 단계 점검 항목(각 설계 concerns): 신규 매퍼 @MockBean full-test §30, DB-방언 alias §33, 시그니처 inflate §11.2, W3-3 pom 의존(commonmark/jsoup)+@EnableScheduling, W4 의 W1 AC-T1 카운트 3→4 재측정, SSRF rebinding 단위테스트'
|
||||
- '이 세션은 설계까지 — 구현(implementation-advisor)·L1/L2 검증은 착수 시 별도 세션'
|
||||
|
||||
# User Signals
|
||||
user_signals:
|
||||
positive:
|
||||
- quote_or_paraphrase: '"전체 설계를 내 개입없이 직접해보고" — orchestrator 자율 진행 위임'
|
||||
about: '골자 합의 후 미결확정/풀설계 전 과정을 orchestrator 자율 결정에 위임(신뢰 시그널)'
|
||||
negative:
|
||||
- quote_or_paraphrase: '"확실하고 정석적인 방법으로" (빠른길 아닌) — AskUserQuestion 1차 옵션이 스코프 최소화(개인우선/jam_id컬럼/단일평점) 편향이었음을 사용자가 clarify 요청으로 교정 유도'
|
||||
about: 'AskUserQuestion Recommended 가 "빠르고 쉬운" 쪽으로 편향 → 사용자가 정석 지향 명시. 옵션 설계 시 정석/robust 축을 기본 Recommended 로 두지 않은 점'
|
||||
structural: false
|
||||
|
||||
Retrospective:
|
||||
signals:
|
||||
positive:
|
||||
- quote_or_paraphrase: '"전체 설계를 내 개입없이 직접해보고" — orchestrator 자율 진행 위임'
|
||||
about: '골자 합의 후 미결확정·풀설계 전 과정을 orchestrator 에 위임. 신뢰 시그널이자 AskUserQuestion 면제.'
|
||||
- quote_or_paraphrase: '(재호출·재지시 없이) 10/11 설계 완성 수락 — 품질 이의 없음'
|
||||
about: 'Workflow 3-phase 병렬 오케스트레이션(Workflow-within-atp) + 동결 단일권위(W2-3) phase-gate(§2.7) + 교차정합 감사로 HIGH drift 사전 포착 후 사용자 추가 수정 요청 없이 완료.'
|
||||
negative:
|
||||
- quote_or_paraphrase: '"확실하고 정석적인 방법으로" — 빠른길이 아닌 정석 지향 명시'
|
||||
about: 'AskUserQuestion 1차 옵션의 Recommended 가 스코프 최소화(개인우선/jam_id컬럼/단일평점) 편향이었음. 사용자가 clarify 로 교정 유도.'
|
||||
structural: false
|
||||
what_went_well:
|
||||
- 'Workflow-within-atp(백그라운드 에이전트): orchestrator 컨텍스트 오염 0 상태에서 11기능 병렬 설계 실행. §2 parallel-advisor 금지의 취지(컨텍스트 오염)를 충족하면서 대규모 팬아웃을 달성한 비자명 선택 — 사용자 재지시 없이 수락.'
|
||||
- '교차정합 감사(inv-008): 설계 단계 후 general-purpose 감사를 별도 호출로 수행해 W2-1 평가단위 표현 drift(HIGH-1)를 구현 착수 전에 포착·정정. 하류 재실행 없이 local fix 완료.'
|
||||
- '동결 단일권위 패턴(W2-3 phase-gate): W2-5 가 jam_votes DDL 을 참조 인용만 하고 W2-3 권위를 명시 보존 → Conflicts 섹션이 비충돌로 닫힘. §2.7 단일권위 설계의 효과 검증.'
|
||||
- 'W3-1 stream idle timeout 회복: 2회 연속 실패 후 §2.1 분할 + 필요 사실 인라인 주입(Read 0) + 2-part Write→Edit append 로 완성. 전체 설계 완성도(11/11)에 영향 없음.'
|
||||
- '설계 전용(코드 0줄) 제약 준수: src/·pom.xml 변경 0. 12 에이전트·~1.32M 토큰 규모 Workflow 실행 중에도 제약 위반 없음.'
|
||||
what_to_improve:
|
||||
- 'AskUserQuestion Recommended 편향: 정석/robust 축보다 스코프 최소화·빠른 완료 쪽을 Recommended 기본으로 설정했음. 사용자가 "정석적" 명시로 교정 → 모든 미결질문의 Recommended 는 "도메인 정합·확장성·보안 선호" 를 기본값으로 두어야 함.'
|
||||
- 'design-advisor 대형 단일호출 + 다중 대형 Read 조합이 stream idle timeout 유발(2회): W3-1 재실행 실패. 회복책(분할+인라인 주입)은 이미 §2.1 정신과 일치하나, Workflow 설계 시 사전에 분할 기준을 프롬프트에 명시하는 가드가 없었음.'
|
||||
memory_candidates:
|
||||
- name: design-advisor-large-read-idle-timeout-split
|
||||
type: feedback
|
||||
description: design-advisor opus 대형 단일호출에 다중 대형 Read 가 겹치면 stream idle timeout 이 발생한다. 사실 인라인 주입(Read 0) + 섹션 분할로 회피한다.
|
||||
body_draft: |
|
||||
design-advisor(opus/high) 를 단일 호출로 실행할 때, 컨텍스트 내 다수의 대형 Read(각 20KB+ 파일 복수)가 선행되면 stream idle timeout 이 발생한다(W3-1, 2회 연속 실패, 세션 20260623-104307).
|
||||
|
||||
**Why:** 모델이 대형 컨텍스트를 처리하면서 장시간 idle 상태가 생기면 서버 측 스트림이 끊긴다. Read 0 조건에서는 이 문제가 재현되지 않았음.
|
||||
|
||||
**How to apply:**
|
||||
- 대형 설계 단일호출 전에 파일 Read 를 에이전트 내부에서 수행하지 말고, orchestrator 가 필요 사실을 프롬프트에 **인라인 주입**한다(Read 0 원칙).
|
||||
- 예상 산출 >300줄 또는 기반 Read 파일 합계 >30KB 이상이면 섹션 분할 필수: 첫 호출=Write(frontmatter+§1~M), 이후=Edit append.
|
||||
- Workflow 에이전트 프롬프트에 "이번 호출은 §A~§B 만 작성. 외부 Read 금지 — 필요 사실은 이 프롬프트에 인라인 포함됨" 을 명시.
|
||||
- 2회 연속 timeout 시 즉시 분할 전환. 3회 이상 단일 재시도 금지.
|
||||
rationale_for_saving: 동일 패턴(opus 대형 단일호출 + 다중 Read)이 TTS-Bot 에서도 재발한 바 있음(large-advisor-socket-error.md). bibimbap 프로젝트 설계 세션에서도 재발. Read 0 + 인라인 주입이 유효한 회피책으로 검증됨. 코드/git 에서 유도 불가.
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-team-protocol.md
|
||||
memory_optional: true
|
||||
|
||||
- name: askuser-recommended-defaults-to-robust
|
||||
type: feedback
|
||||
description: AskUserQuestion Recommended 는 스코프 최소화가 아닌 정석/robust/도메인정합 축을 기본값으로 설정해야 한다.
|
||||
body_draft: |
|
||||
orchestrator 가 미결질문에 옵션을 제시할 때 "빠르고 쉬운" 스코프 최소화 안을 Recommended 로 표시하면, 사용자가 도메인 정합·확장성 선호임에도 한 번 더 교정 발화를 해야 한다(세션 20260623-104307: "확실하고 정석적인 방법으로").
|
||||
|
||||
**Why:** Recommended 편향이 사용자 선호와 정렬되지 않으면 교정 루프가 발생한다. 이 교정 루프는 사용자 발화 비용 + 결정 지연을 유발한다.
|
||||
|
||||
**How to apply:**
|
||||
- 미결질문 옵션 기본 Recommended 는 다음 우선순위로 결정:
|
||||
1. 도메인 정합·정규화·보안이 더 강한 안
|
||||
2. 확장성이 더 높은 안(예: 조인테이블 > 단순 컬럼)
|
||||
3. 팀 이벤트 본질에 충실한 안(예: 팀 출품 포함)
|
||||
- "빠른 완료·스코프 최소화" 안이 Recommended 이 되려면 사용자가 명시적으로 "빠른 길"을 요청했거나, 데모·MVP 컨텍스트가 session 명시된 경우에 한한다.
|
||||
- 옵션 표에 각 안의 장기 확장 비용을 명시(symmetric tradeoff, `design-option-table-symmetric-tradeoff` 교훈과 연계).
|
||||
rationale_for_saving: 사용자가 "정석적" 명시로 교정한 직접 발화 있음. 재현성 있는 패턴(옵션 편향은 설계 세션마다 등장). 코드에서 유도 불가.
|
||||
signal_source: negative
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-team-protocol.md
|
||||
memory_optional: true
|
||||
|
||||
- name: workflow-within-atp-background-context-isolation
|
||||
type: feedback
|
||||
description: Workflow(백그라운드 에이전트)를 ATP 내부에서 사용하면 orchestrator 컨텍스트 오염 없이 대규모 병렬 설계가 가능하다.
|
||||
body_draft: |
|
||||
ATP §2 의 parallel-advisor 금지 취지는 "orchestrator 컨텍스트 오염"이다. Workflow(백그라운드 에이전트)는 orchestrator 컨텍스트를 공유하지 않으므로 취지를 충족한다(세션 20260623-104307: 11기능 병렬, 12에이전트, ~1.32M tokens, src/ 0 변경).
|
||||
|
||||
**Why:** 대규모 팬아웃 설계를 orchestrator 직렬 호출로 처리하면 세션 토큰 폭발 + 컨텍스트 오염 위험이 크다. Workflow 격리는 이 두 문제를 모두 해결한다.
|
||||
|
||||
**How to apply:**
|
||||
- 설계 대상이 5개 이상이거나 총 예상 산출이 150KB 이상이면 Workflow 분리를 우선 고려.
|
||||
- Workflow 내 각 에이전트는 정석 결정(미결질문 닫기)을 orchestrator 가 프롬프트에 주입한 후 실행.
|
||||
- 백그라운드 Workflow 결과는 artifacts 경로를 통해 orchestrator 가 수거(Read)하고, invocations 에 기록.
|
||||
- §2 parallel-advisor 금지 주석에 "Workflow 백그라운드 격리는 취지 충족 예외" 를 명시할 것을 protocol_feedback 으로 제안.
|
||||
rationale_for_saving: 11기능 풀설계를 1개 세션에서 완성한 비자명 선택. 사용자가 재지시 없이 전 결과를 수락. 코드에서 유도 불가, 재현성 있음.
|
||||
signal_source: positive
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-team-protocol.md
|
||||
memory_optional: true
|
||||
|
||||
- name: cross-consistency-audit-post-design-before-commit
|
||||
type: feedback
|
||||
description: 다수 설계 문서 완성 후 커밋 전 교차정합 감사(general-purpose 호출)를 삽입하면 표현 drift 를 구현 착수 전에 잡는다.
|
||||
body_draft: |
|
||||
11기능 풀설계 완료 후 교차정합 감사(inv-008)를 별도 single general-purpose 호출로 수행한 결과: HIGH-1(W2-1 평가단위 표현 drift)을 포착, 하류 5개 설계 문서 재실행 없이 W2-1 local fix 만으로 해소(세션 20260623-104307).
|
||||
|
||||
**Why:** 다수 에이전트가 병렬 작성한 설계는 용어 통일성·FK 참조·DDL 중복이 에이전트별로 달라질 수 있다. 사후 감사 없이 커밋하면 구현 단계에서야 드러난다.
|
||||
|
||||
**How to apply:**
|
||||
- 3개 이상의 설계 문서가 동시 생성될 때, 커밋 직전 교차정합 감사 스텝 삽입.
|
||||
- 감사 체크리스트: (a) 동결 단일권위 준수, (b) DDL 중복/모순, (c) 권한키 일관성, (d) API 라우트 중복, (e) 용어 표현 drift.
|
||||
- 감사 결과는 `_cross-consistency-audit.md` 로 커밋해 하류 세션이 참조 가능하도록 한다.
|
||||
- HIGH 이슈는 즉시 정정, MED/LOW 는 open item 으로 구현 단계 전달.
|
||||
rationale_for_saving: 비자명 선택(감사 단계 삽입)이 HIGH drift 조기 포착으로 검증됨. 다수 병렬 설계 세션에서 재현 가능. 기존 memory 미존재.
|
||||
signal_source: positive
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-team-protocol.md
|
||||
memory_optional: true
|
||||
|
||||
protocol_feedback:
|
||||
- target: agent-team-protocol.md §2 (parallel-advisor 금지 조항)
|
||||
severity: suggestion
|
||||
observation: '현재 §2 parallel-advisor 금지는 "orchestrator 컨텍스트 오염" 방지가 취지인데, Workflow(백그라운드 에이전트)는 컨텍스트를 공유하지 않으므로 금지 취지를 충족한다. 현행 조항이 이 예외를 명시하지 않아 해석 여지가 생김.'
|
||||
recommendation: '§2 parallel-advisor 금지 항목에 "단, Workflow 백그라운드 격리 실행은 orchestrator 컨텍스트 오염이 발생하지 않으므로 허용. 조건: (a) 각 에이전트가 orchestrator 컨텍스트를 공유하지 않을 것, (b) 정석 결정을 프롬프트 인라인으로 주입할 것" 을 추가.'
|
||||
structural: false
|
||||
|
||||
- target: agent-team-protocol.md §4.4 (AskUserQuestion 옵션 설계)
|
||||
severity: rule-addition
|
||||
observation: 'AskUserQuestion Recommended 가 구현·운영 비용 절감(스코프 최소화) 쪽으로 편향되는 경향이 이번 세션에서도 재발. 사용자가 "정석적" 명시로 교정했음. §4.4 에 Recommended 기본값 규칙이 명시되지 않음.'
|
||||
recommendation: '§4.4 에 "Recommended 기본값 규칙: 도메인 정합·정규화·보안이 더 강한 안을 기본으로 한다. 빠른 완료·스코프 최소화 안이 Recommended 가 되려면 사용자 명시 요청(빠른 길·MVP·데모) 또는 세션 명시 컨텍스트가 있어야 한다." 추가.'
|
||||
structural: false
|
||||
|
||||
- target: agent-team-protocol.md §2.1 (대형 호출 분할)
|
||||
severity: rule-refinement
|
||||
observation: 'W3-1 stream idle timeout(2회)은 §2.1 분할 원칙이 이미 있음에도 Workflow 에이전트 프롬프트에 분할 기준이 사전 명시되지 않아 발생. "필요 사실 인라인 주입(Read 0)" 회피책이 §2.1 에 미기재.'
|
||||
recommendation: '§2.1 에 "대형 설계 단일호출 가드" 항목 추가: (a) 에이전트 내부 Read 금지 — 필요 사실은 orchestrator 가 프롬프트에 인라인 주입, (b) 예상 산출 >300줄 또는 인라인 컨텍스트 합계 >30KB 이면 섹션 분할 필수, (c) 2회 timeout 시 즉시 분할 전환, 3회 이상 단일 재시도 금지.'
|
||||
structural: true
|
||||
|
||||
applied_changes:
|
||||
- 'docs(프로젝트): 골자 stale 정정 3건(W3 skeletons 배너+QG-3, w2-w4 카탈로그) + 설계 인덱스(full-design-summary) — 이번 세션 적용·커밋(bfbe1de).'
|
||||
- 'memory_candidates 4건: 전부 ATP 프로토콜 레벨(docs_sync_target=agent-team-protocol.md=플러그인 번들/전역). §6 게이트로 orchestrator 가 플러그인 임의 편집 안 함 → protocol_feedback 으로 보존(report). 프로젝트 docs/ 무관(미적용 사유=교훈이 cross-project 프로토콜).'
|
||||
- 'MEMORY: opt-in 신호 없음 → 비기록(docs-first, memory_optional 미충족).'
|
||||
- 'protocol_feedback 3건(idle-timeout split·AskUser robust 기본값·Workflow-within-atp 예외): ATP 플러그인 유지보수자 영역으로 보류 — 사용자에게 고지.'
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
---
|
||||
phase: research
|
||||
agent: research-advisor
|
||||
agent_version: 2
|
||||
generated_at: 2026-06-23T10:43:00+09:00
|
||||
session_id: 20260623-104307
|
||||
concerns:
|
||||
- "low source confidence — game_likes 테이블/제약(R-D): db/schema.sql 의 game_likes 는 schema.sql:8,248 에서 '비권위 복원본'으로 명시됨. UNIQUE(game_id,user_key) 부재·user_key=varchar 는 schema.sql 근거이나 운영 DB 실제 제약은 미확인. W2-5 인기투표 1인1표 전제로 승격 전 운영 DB 제약 검증 필요."
|
||||
- "scope-axis 미수렴(W2-2): user_permissions 가 (user_id, permission_key) 전역 모델임은 code-fact 로 확정되나, '잼 회차별 역할'이 현 모델에 안 얹힌다는 판단은 해석. design 단계에서 스코프 확장 vs 별도 테이블 결정 필요 — 본 research 는 결정하지 않음."
|
||||
concerns_checked: true
|
||||
source_confidence: mixed
|
||||
workers_spawned: 6
|
||||
---
|
||||
|
||||
# W2(게임잼 6서브) + W4(유저 배지/평판) 골자 grounding — code surface
|
||||
|
||||
조사만. 설계·구현·미결질문 해소 없음. 각 사실은 `code-fact`(파일:라인) / `해석`(설계판단) 으로 태깅.
|
||||
|
||||
## 주제
|
||||
"남은 W 워크스트림" 설계 세션 진입 전, W2/W4 골자 카탈로그 "결합/의존" 절을 code-fact 정확도로 채우기 위한 코드 surface grounding. 특히 ★STALE1(W1 RBAC), ★STALE2(W3-2 리뷰) 의 실재 확정.
|
||||
|
||||
---
|
||||
|
||||
## ★요약 (최상단 — 설계 전제)
|
||||
|
||||
### ★STALE1 — W1 RBAC: **확정·실재** (code-fact)
|
||||
W1 RBAC 인터셉터/권한게이트/카탈로그가 전부 실재하며 동작 surface 가 명확하다.
|
||||
- `PermissionGate`(has/canModerate/isAuthenticated/isAdmin/require), `RbacInterceptor`(401 vs 403 분기), `PermissionKeys`(enum 3종), `Roles`(ADMIN/SUBADMIN/USER), `CsrfTokens`, `InterceptorConfig`, `PermissionCatalogVerifier`, `UserPermissionsMapper` 전부 존재.
|
||||
- users 에 `role`(CHECK ADMIN/SUBADMIN/USER) + `permissions_epoch` 컬럼 실재. 세션 epoch 전파(`refreshIfStale`) 동작.
|
||||
- **단, enforcement 갭(code-fact)**: 인터셉터는 `/admin/**` 에만 등록 + `isAdmin` 만 검사(SUBADMIN/권한키 분기 없음). 권한 키 `CONTENT_MODERATE` 만 컨트롤러(canModify)에서 소비되고, **`POST_WRITE`·`GAME_JAM_MANAGE` 는 enum 선언만 있고 소비처 0건**.
|
||||
|
||||
### ★STALE2 — W3-2 리뷰: **확정·실재** (code-fact)
|
||||
game_reviews + game_review_axes + game_review_stats(VIEW) + 4개 매퍼 전부 실재.
|
||||
- 평점은 **하이브리드**: 단일 overall(`game_reviews.rating` smallint CHECK 1~5) + **6축 다축**(`game_review_axes`, 리뷰당 6행, axis_key 6종). `is_rating_manual` 로 overall 출처 구분(true=직접, false=6축 자동평균).
|
||||
- `game_review_stats` 는 **테이블이 아니라 VIEW**(읽기전용 집계, 9컬럼: avg_rating/review_count/6축평균). 커밋 21892c8 = 이 뷰의 fan-out 버그 + 매퍼 alias 케이스폴딩 버그 2건 수정.
|
||||
|
||||
### ★W2-2 권한 스코프 갭 (해석 — code 근거 제시)
|
||||
- **code-fact**: `user_permissions` 컬럼 = id/user_id/permission_key/granted_by/created_at. UNIQUE = `(user_id, permission_key)`. **scope/resource_id/jam_id 컬럼 부재**. `PermissionGate.has(session, permissionKey)` 도 리소스 인자 없음 → 권한은 **전역(글로벌) 모델**.
|
||||
- **해석**: W2-2 "심사위원 = 잼 회차별 역할" 은 현 글로벌 권한 모델 위에 그대로 안 얹힌다(스코프 갭). 잼별 권한을 표현하려면 스키마·게이트 시그니처 확장 또는 별도 잼-역할 테이블이 필요 — **이건 설계 결정, research 는 결정 안 함**.
|
||||
|
||||
### 신규성 확정 (code-fact)
|
||||
- **jams 테이블 전무**: schema.sql / docs/*.sql / src 전체 `jam_id`·`CREATE TABLE jam` 0 hit (PermissionKeys 의 "게임잼" 은 한글 라벨일 뿐). → W2 잼 엔티티 전부 신규.
|
||||
- **배지/평판 구조 전무**: badge/reputation/trust_level/karma/honor 도메인 테이블·컬럼·클래스 0 hit (hit 는 전부 JSP CSS 클래스명). → W4 전부 신규.
|
||||
|
||||
---
|
||||
|
||||
## 버킷별 발견
|
||||
|
||||
### R-A. W1 RBAC 소비 surface (★STALE1)
|
||||
**신뢰도: 확인됨** (전 항목 1차 파일:라인 확인)
|
||||
|
||||
| 항목 | 사실 | 근거 | 태그 |
|
||||
|---|---|---|---|
|
||||
| PermissionGate 메서드 | `boolean has(HttpSession, String)` / `canModerate(HttpSession)`→has(CONTENT_MODERATE) / `isAuthenticated` / `isAdmin` / `require`(이름과 달리 throw 없이 has 위임) | PermissionGate.java:22,40,47,55,64 | code-fact |
|
||||
| epoch 동기화 | `refreshIfStale` 가 `usersMapper.getPermissionsEpoch` 와 세션 permsEpoch 비교 후 role/permissions/permsEpoch 재설정 | PermissionGate.java:86-104 | code-fact |
|
||||
| PermissionKeys | enum 3종: GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE (그 외 없음) | PermissionKeys.java:4-6 | code-fact |
|
||||
| Roles | String 상수 ADMIN/SUBADMIN/USER (enum 아님) | Roles.java:5-7 | code-fact |
|
||||
| RbacInterceptor | preHandle: 미인증→page면 /login redirect 아니면 401 JSON / 미인가(`!isAdmin`)→403 JSON | RbacInterceptor.java:20-40 | code-fact |
|
||||
| 인터셉터 등록 | `addPathPatterns("/admin/**")` 단 하나, exclude 없음 | InterceptorConfig.java:18-20 | code-fact |
|
||||
| PermissionCatalogVerifier | 부팅 시 enum→DB upsert 멱등 시드 + DB-only 키 log.warn | PermissionCatalogVerifier.java:28-48 | code-fact |
|
||||
| UserPermissionsMapper | listKeys/exists/insert/delete/deleteAllByUser, 전부 `#{}` | UserPermissionsMapper.java:14-48 | code-fact |
|
||||
| **게이트 소비처** | `CONTENT_MODERATE`(canModerate) 만 소비: GameCommentController.java:203, GameReviewController.java:422. **POST_WRITE·GAME_JAM_MANAGE 소비처 0건** | rg POST_WRITE\|GAME_JAM_MANAGE → PermissionKeys.java 만 hit | code-fact |
|
||||
| user_permissions scope | 컬럼 id/user_id/permission_key/granted_by/created_at, UNIQUE(user_id,permission_key), **scope 컬럼 부재 → 전역 권한** | db/schema.sql:326-347, docs/rbac-ddl.sql:37-58 | code-fact |
|
||||
|
||||
- **W2 결합 함의(1줄)**: W2-2 잼 회차별 심사위원 역할은 현 전역 권한 모델 + 미연결 GAME_JAM_MANAGE 키 위에서 스코프 확장 결정이 선행돼야 한다(해석).
|
||||
|
||||
### R-B. 리뷰/평점 surface (★STALE2)
|
||||
**신뢰도: 확인됨**
|
||||
|
||||
| 항목 | 사실 | 근거 | 태그 |
|
||||
|---|---|---|---|
|
||||
| game_reviews | rating smallint CHECK 1~5, body text, user_id nullable, nickname 스냅샷, is_rating_manual(true=직접/false=6축자동평균) | db/schema.sql:130-148,162-164 | code-fact |
|
||||
| 활성 유니크 | `ux_game_reviews_game_user_active (game_id,user_id) WHERE is_delete IS NOT TRUE` → 게임당 유저 1리뷰 | db/schema.sql:145-146 | code-fact |
|
||||
| game_review_axes | review_id/axis_key(varchar20)/score(smallint CHECK 1~5), axis_key CHECK 6종(immersion/creativity/controls/completeness/sound/visual), UNIQUE(review_id,axis_key) → **리뷰당 6행** | db/schema.sql:168-186,201 | code-fact |
|
||||
| game_review_stats | **VIEW**(테이블 아님). 9컬럼: game_id/avg_rating/review_count/avg_immersion..avg_visual. fan-out 방지: 축평균 서브쿼리 game단위 선집계 후 LEFT JOIN | db/schema.sql:212-243 | code-fact |
|
||||
| StatsMapper alias | 리뷰단위 3컬럼 큰따옴표 alias(`AS "avgRating"` 등, case 보존), 6축은 따옴표 없는 단축명 | GameReviewStatsMapper.java:13-30 | code-fact |
|
||||
| AxesMapper | addReviewAxes(배치INSERT)/deleteReviewAxes/listByReviewIds, 수정 시 전체삭제→재삽입 | GameReviewAxesMapper.java:17-28 | code-fact |
|
||||
| 컨트롤러 | GET/POST/PUT/DELETE `/game/{id}/reviews`, summary 는 stats 뷰 사용, rating 미입력 시 6축평균 반올림 | GameReviewController.java:38,159-170,375-394 | code-fact |
|
||||
| 커밋 21892c8 | BUG-1 뷰 fan-out(COUNT/AVG 왜곡) + BUG-2 매퍼 alias 케이스폴딩(summary 항상 null) 수정 | docs/changes/2026-06-22-...enhancement.md:300-311 (1차 changes 문서 교차검증) | code-fact |
|
||||
| game_comments | 리뷰와 **별개 테이블**(rating 없음), 동일 game_id 공유뿐 FK 없음 | GameCommentsMapper.java | code-fact |
|
||||
|
||||
- **W2/W4 결합 함의(1줄)**: W2-3 동결계약·W2-6 유저평점·W3-1 정렬·W4 배지가 모두 이 하이브리드(overall+6축) + VIEW 집계를 소비 — 단일 평균 가정 금지, axes 0행 리뷰는 6축평균 NULL(백필 미적용 code-fact, enhancement.md:322).
|
||||
|
||||
### R-C. games 엔티티 surface (W2-1)
|
||||
**신뢰도: 확인됨**
|
||||
|
||||
| 항목 | 사실 | 근거 | 태그 |
|
||||
|---|---|---|---|
|
||||
| games 컬럼(13) | id/user_id(FK users)/name/creator_note/git_url/webgl_path/thumbnail_url/like_count(int default0)/is_visible/sort_order/created_at/updated_at/is_delete | db/schema.sql:88-100 | code-fact |
|
||||
| **jam_id** | **부재** (games 13컬럼에 없음, jam_id grep 0 hit) | db/schema.sql:88-100 | code-fact |
|
||||
| 조회/방문수 | visit_count/view_count **부재**. 인기지표는 like_count 만 | db/schema.sql:88-100 | code-fact |
|
||||
| 팀 구분 | team_id **부재**. user_id 단일 FK → 1게임=1유저 | db/schema.sql:89 | code-fact |
|
||||
| getVisibleGames | `is_visible IS NOT FALSE AND is_delete IS NOT TRUE`, ORDER BY sort_order ASC, created_at DESC, id DESC | GamesMapper.java:62 | code-fact |
|
||||
| searchVisibleGames | ILIKE 부분일치 3컬럼(name/users.display_name/creator_note) | GamesMapper.java:85-87 | code-fact |
|
||||
| 생성 경로 | 메타: GameController `@PostMapping("/game/new") @Transactional createGame`; 파일: GameUploadController uploadGameFiles/webgl-zip/thumbnail. jam 파라미터 없음 | GameController.java:50, GameUploadController.java:52 | code-fact |
|
||||
| 삭제 연쇄 | GamesMapper: softDeleteGameComments/softDeleteGameReviews/deleteGameLikes(hard)/softDeleteGame | GamesMapper.java:173-199 | code-fact |
|
||||
|
||||
- **W2 결합 함의(1줄)**: W2-1 잼 엔티티가 games 를 재사용하려면 jam 연결(jam_id 또는 조인테이블)·방문수·팀출품 구분이 모두 신규 추가 대상(해석: 재사용 가능하나 확장 필요).
|
||||
|
||||
### R-D. game_likes surface (W2-5)
|
||||
**신뢰도: 추정** (테이블/매퍼 실재는 직접 1차 확인 = 확인됨, 그러나 **제약은 비권위 복원본 근거**)
|
||||
|
||||
| 항목 | 사실 | 근거 | 태그 |
|
||||
|---|---|---|---|
|
||||
| GameLikesMapper 실재 | getGameLike(id)/addGameLike(INSERT)/updateGameLike(id기준). **DELETE·토글·(game_id,user_key)조회 메서드 없음** | GameLikesMapper.java:13-43 (advisor 직접 1차 확인) | code-fact |
|
||||
| game_likes 테이블 | 컬럼 id(PK)/game_id(FK games)/**user_key varchar(200)**/created_at. PRIMARY KEY(id) 만 | db/schema.sql:251-257 (advisor 직접 1차 확인) | code-fact |
|
||||
| **1인1표 UNIQUE** | (game_id,user_key) UNIQUE **부재** (schema.sql 상). 비권위 복원본이라 운영 DB 제약 **미확인** | db/schema.sql:8,248,251-257 | **추정** |
|
||||
| user_key 의미 | user_id(bigint) 아님 — varchar 식별자. 채움값(로그인 user_id vs 익명)은 호출부 영역 미확인 | db/schema.sql:254 | code-fact(컬럼)/미확인(값) |
|
||||
| 토글/카운터 | 매퍼에 토글·games.like_count 갱신 SQL 없음. 게임 삭제 시 GamesMapper.deleteGameLikes hard delete 만 | GameLikesMapper.java / GamesMapper.java:186-189, GameController.java:261, seed-dev-teardown.sql:25 | code-fact |
|
||||
|
||||
- **W2 결합 함의(1줄)**: W2-5 인기투표는 game_likes 와 별개로 봐야 하며, game_likes 자체가 1인1표 미보장·user_key varchar·토글 부재 상태라 인기투표 1인1표 전제는 game_likes 재활용으로 자동 충족되지 않음(해석; 운영 DB 제약 검증이 concern).
|
||||
|
||||
> **R-D worker 중간 출력 정정**: worker 의 첫 응답은 "GameLikesMapper/game_likes 부재"였으나 **오류**. advisor 가 1차 직접 확인(`ls`, `rg game_likes db/schema.sql`)으로 **둘 다 실재** 확정. worker 최종 응답도 실재로 정정됨. 이 문서는 직접 확인 결과를 채택.
|
||||
|
||||
### R-E. users/identity + 배지 surface (W4)
|
||||
**신뢰도: 확인됨**
|
||||
|
||||
| 항목 | 사실 | 근거 | 태그 |
|
||||
|---|---|---|---|
|
||||
| users 컬럼 | id/display_name/canonical_email/avatar_url/**role**(default USER, CHECK ADMIN/SUBADMIN/USER)/status/last_login_at/created_at/updated_at/is_delete + **permissions_epoch**(bigint default0) | db/schema.sql:31-56 | code-fact |
|
||||
| role/epoch 출처 | role·permissions_epoch 는 **W1 RBAC 산출물**(W4 신규 아님). perms_epoch 명칭은 0 hit | db/schema.sql:48-56 | code-fact |
|
||||
| UserData(10필드) | id/displayName/canonicalEmail/avatarUrl/role/permissionsEpoch/status/lastLoginAt/createdAt/updatedAt (is_delete 미매핑) | UserData.java:7-16 | code-fact |
|
||||
| UsersMapper | getUser/addUser/updateUser/getPermissionsEpoch/bumpPermissionsEpoch/updateRole/listOperators | UsersMapper.java:17-104 | code-fact |
|
||||
| user_auth_identities | provider/provider_user_id/email/password_hash/display_name/avatar_url, users 1:N, active-unique(provider,provider_user_id) | db/schema.sql:62-81 | code-fact |
|
||||
| 표시명 출처 | **users.display_name 단일 출처**(nickname 은 game_comments/리뷰 스냅샷이지 users 컬럼 아님). 세션 attr `displayName` | db/schema.sql:32, UserController.java:511 | code-fact |
|
||||
| **배지/평판** | badge/reputation/trust_level/karma/honor 도메인 테이블·컬럼·클래스 **0 hit**(hit 는 전부 JSP CSS) | rg 0 hit (db/docs/src) | code-fact(부재) |
|
||||
|
||||
- **W4 결합 함의(1줄)**: W4 배지/평판은 전부 신규(스토리지 0). 단 평판 신호의 원천(리뷰=R-B 6축, 좋아요=R-D game_likes, 역할=R-A role)은 모두 실재 — 신규 배지 테이블이 이들을 집계 소비하는 구조(해석).
|
||||
|
||||
### R-F. 관례/DDL 워크플로 (전 W 공통)
|
||||
**신뢰도: 확인됨**
|
||||
|
||||
| 항목 | 사실 | 근거 | 태그 |
|
||||
|---|---|---|---|
|
||||
| 게시판 패턴 | RecruitPostsMapper: get/getVisible(users JOIN)/add(useGeneratedKeys)/nextSortOrder. **페이징 없음**(전건), ORDER BY sort_order ASC,created_at DESC,id DESC | RecruitPostsMapper.java:70,107,110 | code-fact |
|
||||
| 컨트롤러 혼합 | 읽기=JSP 뷰이름 반환(`return "recruit-list"`), 상태변경 POST=ResponseEntity JSON + `CsrfTokens.isValid` + 세션 userId + 화이트리스트 | RecruitController.java:36,65,145 | code-fact |
|
||||
| **POST 권한게이트** | RecruitController POST /recruit/new 는 **CSRF+세션userId만**, PermissionGate/POST_WRITE 미적용 → 작성은 모든 로그인 유저 개방 | RecruitController.java:65,159 (advisor 직접 교차확인) | code-fact |
|
||||
| 로그인 세션 attr | id/userId/displayName/email/avatarUrl/role/status/authProvider/authIdentityId/lastLoginAt/account(Map)/**permissions(Set)**/**permsEpoch(long)**. changeSessionId()로 세션고정방어 | UserController.java(controller/api/):166,508-537 | code-fact |
|
||||
| DDL 권위 | **권위 원본 = docs/*-ddl.sql**. apply-local-ddl.sh 가 docs/*-ddl.sql glob 순(알파벳) 멱등 적용, ON_ERROR_STOP, search_path=dev. schema.sql = 컨테이너 최초1회 부트스트랩(신규테이블은 docs 동기, legacy users/games/game_comments/game_likes 는 비권위 복원본) | apply-local-ddl.sh:4-64, schema.sql:5-18,45,128,261 | code-fact |
|
||||
| JSP/MyBatis 관례 | views/ 평면배치 kebab-case, 컨트롤러는 확장자없는 뷰이름 반환. 매퍼 전부 `#{}`, `${}` 회피(주석 명시). **큰따옴표 alias 는 일반매퍼 0 hit** — snake→camel 직접 alias(`r.created_at AS createdAt`)가 표준, 큰따옴표는 집계뷰 case-folding 회피용만 | GameReviewsMapper.java:21-29,92; RecruitPostsMapper.java:34 | code-fact |
|
||||
|
||||
- **전 W 결합 함의(1줄)**: W2 잼 목록/상세·W2/W3 게시판류는 RecruitController 패턴(읽기=JSP뷰, 쓰기=JSON+CSRF) 선례를 따르고, 신규 DDL 은 docs/*-ddl.sql 을 권위로 두는 선례(W1-design 의 docs/rbac-ddl.sql 권위와 동일)를 따른다(해석).
|
||||
|
||||
---
|
||||
|
||||
## 종합 판단
|
||||
|
||||
### 상위 패턴
|
||||
1. **권한 카탈로그 vs enforcement 갭** (code-fact): RBAC 인프라(게이트/인터셉터/카탈로그/epoch)는 완비됐으나, 실제 enforcement 는 `/admin/**` ADMIN 게이트 + `CONTENT_MODERATE` canModify 2곳뿐. `POST_WRITE`·`GAME_JAM_MANAGE` 는 선언만 있고 미연결 → W2(잼관리)·W3(포스팅) 가 이 키를 enforcement 에 연결하는 작업이 신규.
|
||||
2. **잼·배지 스토리지 전무** (code-fact): jams 테이블 0, 배지/평판 0 → W2 엔티티·W4 도메인은 전부 신규 DDL. 단 평점(하이브리드 6축)·좋아요·역할 등 소비 원천은 실재.
|
||||
3. **권위 DDL = docs/*-ddl.sql** (code-fact): 신규 W2/W4 테이블도 docs/*-ddl.sql 권위 + schema.sql 동기 + apply-local-ddl.sh 멱등 적용 선례를 따른다.
|
||||
|
||||
### 충돌·갭
|
||||
- **충돌 1 (해소됨)**: R-D worker 중간 출력이 game_likes 부재라 했으나 advisor 1차 직접 확인으로 실재 확정. 이 문서는 실재를 채택.
|
||||
- **갭 1 (해석, W2-2)**: 전역 권한 모델 ↔ 잼 회차별 역할 스코프 미수렴 → design 결정 필요 (concern 기록).
|
||||
- **갭 2 (추정, W2-5)**: game_likes 1인1표 제약은 비권위 복원본 근거뿐 → 운영 DB 제약 검증 필요 (concern 기록).
|
||||
|
||||
### 권위 격상 전 검증 필요 항목 (source_confidence: mixed 사유)
|
||||
- game_likes UNIQUE(game_id,user_key) 부재 → **추정**. 운영 DB 제약 확인 전 W2-5 1인1표 전제로 승격 금지.
|
||||
- game_likes user_key 채움값(로그인 user_id vs 익명) → **미확인** (호출부 미조사).
|
||||
|
||||
## 미해결 (research 로도 미해소 — 골자 단계 비해소가 사용자 제약)
|
||||
- game_likes 운영 DB 실제 UNIQUE 제약 (schema.sql 비권위).
|
||||
- game_likes user_key 가 무엇으로 채워지는가 (서비스/컨트롤러 호출부 미조사).
|
||||
- W2-2 잼 스코프 권한을 현 모델 확장 vs 별도 테이블로 풀지 (설계 결정 — 의도적 비해소).
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
phase: research
|
||||
agent: research-advisor
|
||||
agent_version: 2
|
||||
generated_at: 2026-06-23T02:29:01Z
|
||||
concerns:
|
||||
- "stale-skeleton-fact: W3 skeleton(2026-06-17) §코드현황5 의 '/game/** 정적 핸들러 미등록 → 서빙 미보장(QG-3)' 전제는 코드상 뒤집힘 — GameAssetController 가 /game/{gameUuid}/** 를 @Controller 핸들러로 서빙한다. 설계 진입 전 골자의 QG-3 문구 정정 필요."
|
||||
concerns_checked: true
|
||||
source_confidence: high
|
||||
workers_spawned: 0
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
---
|
||||
|
||||
# 조사 결과 — W3-5 Unity WebGL 업로드 자동화 선행 조사
|
||||
|
||||
## 주제
|
||||
bibimbap GameUpload(`/api/game-files/webgl-zip`) 부분구현의 선행 조사 7항목을 코드 grounding 으로 확인. 각 항목 현황(code-fact) + 정석 보강 방향 후보(해석).
|
||||
|
||||
> 본 조사는 라이브러리 API 조사가 아닌 **단일 코드베이스 결합점 조사**이며, 7개 항목은 사전 지정된 조사 점검표(열거형 카탈로그 아님)다. 따라서 §4.8 축-완결성 패스는 비적용. 모든 사실은 file:line 직접 확인(`확인됨`), source_confidence=high. 단 stale 골자 전제 1건 concern 기록.
|
||||
|
||||
## 요약 (최상단 — 핵심 3건)
|
||||
|
||||
1. **zip-slip 방어에 심볼릭 링크 구멍**: `extractZip` 은 `target.startsWith(targetDir)` 정규화 검증(GameUploadController.java:266-269)으로 `../` 경로탈출은 막지만, **zip 엔트리가 심볼릭 링크인 경우를 전혀 처리하지 않는다**(코드 전역 `isSymbolicLink`/`NOFOLLOW`/`toRealPath` 사용 0건). 디렉터리 생성·파일 쓰기가 기존 심링크를 따라가 targetDir 밖에 쓸 수 있다(`Files.createDirectories`/`Files.newOutputStream` 모두 기본 follow-links). → 정석 보강 후보: 엔트리별 심링크 차단 + 쓰기 전 `toRealPath` 재검증. — code-fact(부재) + 해석(보강)
|
||||
|
||||
2. **`/game/**` 서빙은 실재 — 골자 QG-3 전제 stale**: 골자(2026-06-17)는 "`/game/**` 핸들러 미등록 → 서빙 미보장"이라 했으나, `GameAssetController.gameAsset` 이 `@GetMapping("/game/{gameUuid}/**")` 로 직접 서빙한다(GameAssetController.java:31-69). UUID 검증·경로 boundary·Content-Type·CSP·Content-Encoding(br/gz) 까지 처리. `UploadResourceConfig` 는 `/profile/**` 만 등록(UploadResourceConfig.java:22-23)한 것이 맞으나, WebGL 은 ResourceHandler 가 아니라 전용 컨트롤러로 서빙되는 **의도된 설계**다(버그 아님). → 골자 문구 정정 필요. — code-fact
|
||||
|
||||
3. **업로드 권한 게이트 전무**: 세 업로드 엔드포인트(`webgl-zip`/`thumbnail`/POST root) 모두 **CSRF + 로그인(세션 userId) 체크만** 하고 권한 검사가 없다(GameUploadController.java:113-117, 173-177, 62-65). W1 RBAC 인프라(PermissionGate/RbacInterceptor)는 실재하나 `InterceptorConfig` 가 **`/admin/**` 에만** 인터셉터를 건다(InterceptorConfig.java:19) → `/api/game-files/**` 미보호. 로그인한 모든 사용자가 업로드 가능. → W1 게이트를 얹을 자리. — code-fact
|
||||
|
||||
## 포인트별 발견
|
||||
|
||||
### 포인트 1: zip-slip 방어 충분성
|
||||
- 경로: `GameUploadController.java`
|
||||
- 현황(code-fact):
|
||||
- 엔트리별 정규화 후 prefix 검증: `Path target = targetDir.resolve(entry.getName()).normalize(); if (!target.startsWith(targetDir)) throw ...`(266-269). `../`·절대경로(`resolve` 시 절대경로면 targetDir 밖으로 normalize → startsWith 탈락)·백슬래시는 이 검증으로 차단됨. — 확인됨
|
||||
- 디렉터리 엔트리: `Files.createDirectories(target)`(272). 파일: `Files.createDirectories(target.getParent())` + `copyZipEntry`(277-278). 둘 다 **심링크 follow 기본 동작** — NOFOLLOW/실경로 재검증 없음. — 확인됨(부재)
|
||||
- 심볼릭 링크 처리 코드 전무: `rg isSymbolicLink|NOFOLLOW|LinkOption|toRealPath` 결과 0건(전 소스). — 확인됨
|
||||
- `%2e` 등 URL 인코딩 우회: `extractZip` 은 zip 엔트리명을 디코딩하지 않고 그대로 `resolve` 하므로 `%2e` 는 리터럴 파일명이 되어 무해(zip 엔트리명은 URL 인코딩 대상 아님). 단 서빙 측 `GameAssetController.resolveAssetFile` 은 `UriUtils.decode`(GameAssetController.java:92) 후 정규화·boundary 재검증(101-103) 하므로 서빙 경로도 boundary 보호됨. — 확인됨
|
||||
- NUL 바이트: 서빙 측만 `assetPath.contains("\0")` 차단(GameAssetController.java:97). 추출 측은 미체크(JVM 이 NUL 포함 경로 쓰기 시 예외 발생하나 명시 방어 아님). — 확인됨
|
||||
- 구멍 요약: (a) **심볼릭 링크 엔트리 무방비**(최대 구멍), (b) zip 폭탄 부분방어 — 누적 압축해제 크기(512MB)·엔트리 수(8000)는 막으나 **개별 엔트리 압축비(zip bomb ratio) 검사 없음**(copyZipEntry 는 누적 바이트만 — 512MB 한도 내라면 통과), (c) 엔트리명 길이/중첩깊이 상한 없음. — code-fact(a,c 부재) + 해석(b)
|
||||
- 정석 보강 방향 후보(해석): 엔트리별 `target.toRealPath()` 또는 부모 디렉터리 실경로 재검증, 심링크 엔트리(`entry` 의 external attributes 또는 추출 후 `Files.isSymbolicLink`) 거부, 압축비 임계 검사.
|
||||
- 신뢰도: 확인됨
|
||||
|
||||
### 포인트 2: WebGL 빌드 포맷 검증 범위
|
||||
- 경로: `GameUploadController.java:135-139, 306-316`
|
||||
- 현황(code-fact):
|
||||
- 검증하는 것: **index.html 존재만**. `findIndexFile` 이 추출 트리를 walk 하여 `index.html`(대소문자 무시) 중 **가장 얕은 경로** 1개를 선택(306-314). 없으면 `deleteRecursively` 후 400(136-139). — 확인됨
|
||||
- 검증하지 않는 것: `Build/*.data`·`*.wasm`·`*.framework.js`·`*.loader.js` 등 Unity WebGL 필수 산출물 존재 검사 **전무**(rg 결과 해당 확장자 검증 로직 없음, contentType 매핑만 GameAssetController 에 존재). — 확인됨(부재)
|
||||
- index.html 내용/구조 검증 없음 — 임의 index.html 만 있어도 통과. — 확인됨
|
||||
- 정석 보강 방향 후보(해석): Unity 빌드 마커(`Build/` 디렉터리 + loader/framework/wasm/data 4종 또는 그 압축변형 `.br`/`.gz`) 존재 검증, index.html 의 loader 참조 정합 체크.
|
||||
- 신뢰도: 확인됨
|
||||
|
||||
### 포인트 3: 업로드 크기/타입
|
||||
- 경로: `GameUploadController.java:40-42, 121-123, 240-252, 254-304`; `application.properties:15-17`
|
||||
- 현황(code-fact):
|
||||
- 압축해제 누적 크기 상한: `WEBGL_EXTRACTED_MAX_BYTES = 512MB`(40), `copyZipEntry` 가 누적 초과 시 throw(297-299). — 확인됨
|
||||
- 엔트리 수 상한: `WEBGL_MAX_ENTRIES = 8_000`(41), 초과 시 throw(262-263). — 확인됨 (골자 8000 언급과 일치)
|
||||
- **zip 원본(업로드) 크기 상한 — 코드 레벨 명시 없음**. 전역 `spring.servlet.multipart.max-file-size=1GB`·`max-request-size=1GB`(application.properties:15-16) + `server.tomcat.max-swallow-size=-1`(무제한 swallow) 가 유일 한도. webgl-zip 핸들러는 `file.getSize()` 상한 검사 없음(썸네일만 10MB 검사 GameUploadController.java:181). — 확인됨
|
||||
- Content-Type/확장자 이중 체크: `isZipFile` 이 MIME(application/zip·x-zip-compressed·multipart/x-zip) **또는** `.zip` 확장자 — **OR 조건**(둘 중 하나만 충족해도 통과, 240-252). 이중(AND) 아님. — 확인됨
|
||||
- **매직바이트(zip 시그니처 PK\x03\x04) 검증 없음** — 선언 MIME/확장자만 신뢰. — 확인됨(부재)
|
||||
- 정석 보강 방향 후보(해석): webgl-zip 전용 원본 크기 상한(예: 512MB~1GB 명시), zip 매직바이트 검증, MIME+확장자 AND 강화 여부 검토.
|
||||
- 신뢰도: 확인됨
|
||||
|
||||
### 포인트 4: 저장 경로 boundary
|
||||
- 경로: `GameUploadController.java:44-45, 218-220, 126-130`; `dev/db.properties:6`; `application.properties:23`
|
||||
- 현황(code-fact):
|
||||
- 설정 주입: `@Value("${app.upload.game-storage-path:src/main/resources/static}")`(44), default `src/main/resources/static`. dev 프로파일은 `app.upload.game-storage-path=src/main/resources/static/game`(dev/db.properties:6), `spring.config.import=optional:.../db.properties`(application.properties:23) 로 로드. — 확인됨
|
||||
- `gameRoot()` = `Paths.get(uploadStoragePath).toAbsolutePath().normalize().resolve("game").normalize()`(218-219). — 확인됨
|
||||
- **주의(해석)**: dev 설정값이 이미 `.../static/game` 인데 `gameRoot()` 가 추가로 `.resolve("game")` → 실제 루트가 `.../static/game/game` 로 **이중 중첩** 가능. default 값(`.../static`)에는 맞으나 dev override 와 어긋남. 운영 배포값 미확인 — 의도/실수 판별 필요. — code-fact(경로조합) + 해석(중첩 의심)
|
||||
- boundary 검증: webgl-zip 은 `targetDir = root.resolve(gameUuid).normalize(); if(!targetDir.startsWith(root))`(127-128) — 단 gameUuid 는 `UUID.randomUUID()`(126) 라 외부주입 아님 → 이 경로의 탈출 위험 없음. thumbnail/POST-root 는 외부 path/gameUuid 받되 normalize+startsWith 검증(199-202, 396-411). — 확인됨
|
||||
- 외부 주입 경로 탈출: `uploadStoragePath` 자체는 운영자 설정값(외부 사용자 주입 아님). 사용자 입력 path 는 POST-root 의 `path` 파라미터뿐이며 `resolveTargetFile` 가 normalize+startsWith(root) 검증(406-409). — 확인됨
|
||||
- 정석 보강 방향 후보(해석): dev 의 이중 `game` 중첩 정리(설정값을 `.../static` 으로 통일하거나 `gameRoot()` 의 `.resolve("game")` 제거), 운영 배포 경로 문서화.
|
||||
- 신뢰도: 확인됨 (단 경로 중첩의 의도 여부는 미확인 — 운영 배포값 부재)
|
||||
|
||||
### 포인트 5: /game/** 정적 핸들러 등록 (QG-3)
|
||||
- 경로: `GameAssetController.java:31-69`; `UploadResourceConfig.java:18-24`
|
||||
- 현황(code-fact):
|
||||
- `UploadResourceConfig.addResourceHandlers` 는 **`/profile/**` 만** 등록(22-23), `/game/**` ResourceHandler **미등록**(골자 주장 일치). — 확인됨
|
||||
- **그러나** `/game/{gameUuid}/**` 는 전용 컨트롤러 `GameAssetController.gameAsset`(`@GetMapping`, 31)이 직접 서빙: UUID 정규화(37-41)·디렉터리 boundary(101-103)·Content-Type 매핑(130-172, wasm/js/data/json/html/css/이미지)·Content-Encoding(br/gz, 119-128)·CSP+X-Content-Type-Options+Cache-Control(57-66) 처리. — 확인됨
|
||||
- → WebGL 서빙은 **실제 동작하며 의도된 설계**. 골자의 "서빙 미보장(QG-3, 의도/버그 미확인)" 전제는 **stale** — 버그 아님. — code-fact (concern 으로 격상)
|
||||
- 정석 보강 방향 후보(해석): 전용 컨트롤러 방식 유지 타당(보안헤더·인코딩 협상 필요해 ResourceHandler 보다 적합). 단 ResourceHandler 미등록이 정상임을 골자에 명시.
|
||||
- 신뢰도: 확인됨
|
||||
|
||||
### 포인트 6: 업로드 권한 게이트
|
||||
- 경로: `GameUploadController.java:59-65, 106-117, 170-177`; `InterceptorConfig.java:19`; `security/PermissionKeys.java`
|
||||
- 현황(code-fact):
|
||||
- webgl-zip: CSRF(106-108) + 로그인(113-117)만. 권한 검사 없음. — 확인됨
|
||||
- thumbnail: CSRF(170-172) + 로그인(173-177)만. — 확인됨
|
||||
- POST-root(`uploadGameFiles`): CSRF(59-61) + 로그인(62-65)만. — 확인됨
|
||||
- W1 RBAC 실재: `PermissionGate`·`RbacInterceptor`(클래스명 obfuscated 흔적 있으나 InterceptorConfig 는 정상 `RbacInterceptor` import)·`PermissionKeys`(GAME_JAM_MANAGE·POST_WRITE 정의). — 확인됨
|
||||
- `InterceptorConfig.addInterceptors` 는 `registry.addInterceptor(rbacInterceptor).addPathPatterns("/admin/**")` — **`/admin/**` 전용**(19). `/api/game-files/**` 미보호. — 확인됨
|
||||
- 업로드 전용 권한키 부재: PermissionKeys 에 GAME_UPLOAD 류 키 없음(GAME_JAM_MANAGE·POST_WRITE 만). — 확인됨(부재)
|
||||
- 정석 보강 방향 후보(해석): 업로드 권한키 신설 + RbacInterceptor 경로 확장 또는 컨트롤러 내 PermissionGate 명시 호출. W1 게이트를 얹을 자리는 명확.
|
||||
- 신뢰도: 확인됨
|
||||
|
||||
### 포인트 7: UUID 재사용/덮어쓰기 정책
|
||||
- 경로: `GameUploadController.java:126`; `GameController.java:176-228, 240-269, 348-352`; `game-register.jsp:649-653`
|
||||
- 현황(code-fact):
|
||||
- **모든 webgl-zip 업로드는 무조건 `UUID.randomUUID()` 신규 생성**(126) — 재업로드/편집 여부와 무관. 기존 UUID 재사용·덮어쓰기 로직 없음. — 확인됨
|
||||
- 편집(edit) 흐름: `game-register.jsp` 는 새 zip 업로드 시 응답의 새 gameUuid/webglPath 로 hidden 필드 교체(649-653) → `updateGame`(GameController.java:176-228) 이 새 webgl_path 로 DB UPDATE. **기존 `/game/{old-uuid}/` 디렉터리는 삭제되지 않음 → 고아 파일.** — 확인됨
|
||||
- 게임 삭제: `deleteGame` 은 `softDeleteGame`(soft delete) + 연관 soft-delete/like 삭제만(GameController.java:259-262). **파일시스템 정리 없음** → `/game/{uuid}/` 영구 잔존. — 확인됨
|
||||
- `deleteRecursively`(GameUploadController.java:385) 는 **업로드 실패 롤백 시에만** 호출(137,155,158). 정상 교체/삭제 시 미호출. — 확인됨
|
||||
- 멱등성: 동일 zip 재업로드 시 매번 다른 UUID·다른 디렉터리 → 비멱등. — 확인됨
|
||||
- 정석 보강 방향 후보(해석): 편집 시 기존 UUID 유지+덮어쓰기 또는 신규 후 구버전 GC, 게임 삭제 시 자산 디렉터리 정리(soft-delete 와의 정합 — 즉시삭제 vs 유예GC), 고아 파일 배치 청소.
|
||||
- 신뢰도: 확인됨
|
||||
|
||||
## 종합 판단
|
||||
- **상위 패턴**: 업로드 파이프라인은 "정규화 prefix 검증 + 사이즈/엔트리 상한 + 전용 서빙 컨트롤러"로 1차 골격은 갖췄으나, (1) 심링크 방어 (2) Unity 빌드 포맷 검증 (3) 권한 게이트 (4) 자산 생명주기(고아 파일) 4개 축이 비어 있다.
|
||||
- **충돌/정정**: 골자(2026-06-17) §코드현황5 의 "`/game/**` 미등록 → 서빙 미보장(QG-3)" 는 GameAssetController 실재로 뒤집힘 — 설계 진입 전 골자 문구 정정 필요(concern 기록). 골자의 "권한 체크 없음"·"zip-slip·크기상한·UUID 배치 됨" 은 코드와 일치.
|
||||
- **갭(설계 단계로 이월)**: 운영 배포 시 `game-storage-path` 경로 중첩(static/game/game) 의도 여부, soft-delete 와 자산 즉시삭제의 정합 정책.
|
||||
|
||||
## 미해결
|
||||
- 운영(prod) 프로파일의 `app.upload.game-storage-path` 실제값 — repo 에 dev 프로파일만 존재(dev/db.properties). 경로 이중 중첩이 운영에서도 발생하는지 미확인.
|
||||
- 클래스명 obfuscation 흔적(`security.n` 등 rg 매칭) — RbacInterceptor.java 의 `public class n` 표기는 빌드/난독화 산물일 가능성. InterceptorConfig 는 정상 클래스명 사용하므로 enforcement 분석에는 영향 없음(미해결로 남김, W3-5 범위 밖).
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
phase: documentation
|
||||
agent: documentation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T13:10:00+09:00
|
||||
concerns_checked: true
|
||||
concerns:
|
||||
- "docs/index.md 무수정: 카테고리 인덱스 + 빠른 참조만 있고 changelog/최신변경 포인터 슬롯이 없음 → W2 반영 불요(skip, 과잉수정 회피). CLAUDE.md '최신 프로젝트 분석' 포인터는 docs/analysis/2026-06-16 고정 메타라 W2 changes 와 무관."
|
||||
- "work-log frontmatter status:active 유지(변경 안 함): W3 잔여·W4 가 미구현이라 설계 인덱스 문서는 여전히 active 가 정확. 본문 '구현 추적' 섹션에 W2 완료 한 줄 + changes 교차링크만 추가(과잉수정 회피)."
|
||||
- "테스트 카운트 미세 차이는 검증서 concerns 그대로 changes '미적용/이월'에 무해 표기로 이관(W2-2 14 vs 예측 16, W2-5 23 vs 예측 24 — 전부 GREEN·회귀 0). 사실 곡해 없이 검증서 인용."
|
||||
- "dev DB jam_* 미적용(throwaway DB 로만 L2 통과)·L3 E2E 미수행은 needs_user_verification 로 명시. maintenance/ 마이그레이션 절차 문서는 미생성 — orchestrator/사용자가 db/apply-local-ddl.sh 실행 시점 결정. 추후 운영 마이그레이션 가이드 필요 시 maintenance/ 후보."
|
||||
- "design-advisor 와의 충돌 없음: 이번 W2 는 신규 아키텍처 문서(architecture/) 갱신 대상 아님(잼 도메인은 기능 구현 이력 = changes/ 1순위). architecture/ 미접촉."
|
||||
- "backport 해당 없음(N/A): 소비 프로젝트 dogfooding 역이식 아님. self-exclusion grep 불요."
|
||||
---
|
||||
|
||||
# 문서화 보고
|
||||
|
||||
W2 게임잼 워크스트림(W2-1~6) 구현 결과를 기록했다.
|
||||
|
||||
## 작성/수정된 문서
|
||||
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|
||||
|---|---|---|---|---|
|
||||
| docs/changes/2026-06-24-w2-jam-platform.md | changes | 신규 | docs/changes/index.md ✅ | 상위 설계요약(work-log 2026-06-23)·검증/구현/설계 work-session·보안 체크리스트 |
|
||||
| docs/changes/index.md | changes | 수정(링크 1줄) | — | — |
|
||||
| docs/work-log/2026-06-23-w2-w4-full-design-summary.md | work-log | 수정(구현 추적 섹션) | — | → changes/2026-06-24-w2-jam-platform.md (설계→구현 추적) |
|
||||
|
||||
## 카테고리 판별 근거
|
||||
- W2-1~6 = 실제 코드/DDL 이 바뀐 구현 변경 이력 → `document-category-classification.md` "빠른 결정 순서 #10" + "changes/ 를 써도 되는 경우"(코드 실제 수정·동작 변화·언제 어떤 구현 변화가 들어갔는지 조회) 충족. 선례 정합: changes/2026-06-18·2026-06-22 (W3-2). 날짜 prefix 2026-06-24.
|
||||
- ADR 미발행: W2-3 동결 단일권위는 이미 직전 changes(2026-06-22 §C3)에서 "W2-3 관련 설계 분기 재발 시 ADR 권고" 로 보류됨. 본 W2 구현은 그 설계를 코드화한 것이라 새 ADR 트리거 아님(되돌리기 어려운 신규 기술결정 0).
|
||||
- architecture/ 미접촉: 기능 구현 이력이지 시스템 경계/레이어 규칙 갱신 아님. design-advisor 충돌 없음.
|
||||
|
||||
## 의사결정 기록 위치
|
||||
- 변경 이력 정본: docs/changes/2026-06-24-w2-jam-platform.md (커밋해시·테이블·게이트·검증 190/190·보안 섹션·이월)
|
||||
- 설계 결정 근거(정석 확정값): work-log/2026-06-23-w2-w4-full-design-summary.md (크로스-W 결정 매트릭스) → 변경 이력에서 역링크
|
||||
- 검증 verdict 세부: .atp/work-session/20260624-100749/verification.md (W2-1 R2 ~ W2-6)
|
||||
|
||||
## 추후 문서화가 필요한 항목
|
||||
- dev DB jam_* 마이그레이션 운영 절차(db/apply-local-ddl.sh) — 실 적용 시 maintenance/ 후보.
|
||||
- W3(잔여)·W4 구현 착수 시 동일 패턴 changes/ 문서 + work-log '구현 추적' 갱신.
|
||||
- W2-3 동결 경계 관련 설계 분기 재발 시 adr/ADR-0001 발행(2026-06-22 §C3 권고 승계).
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T10:07:49+09:00
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 0
|
||||
planned_workers: 0
|
||||
actual_workers: 0
|
||||
fix_round: 1
|
||||
target_defect: L1-compile-FAIL (response(HttpStatus,String) 미정의)
|
||||
---
|
||||
|
||||
# W2-1 보정 보고 (fix round 1)
|
||||
|
||||
## 결함 진단 정정
|
||||
|
||||
verification-advisor 보고는 24 errors(JamAdminController) + 20회 미평가(JamController)로
|
||||
양쪽 결함을 추정했으나, 줄 단위 실측 결과 **결함은 JamAdminController 단일 파일에 한정**된다.
|
||||
|
||||
- **JamAdminController.java** — `response(...)` 호출 24건 존재, 헬퍼 정의 **0건** → 24 errors 의 진원지. (확정 결함)
|
||||
- **JamController.java** — `response(...)` 호출 19건, 헬퍼 정의가 **이미 270-275행에 존재**. javac 가 JamAdminController 에서 먼저 중단되어 "미평가"로 보였을 뿐, 실제로는 컴파일 가능. (결함 아님 — 무수정)
|
||||
|
||||
→ 원인: W2-1 구현 시 JamAdminController worker 가 RecruitController/JamController 의
|
||||
`response(HttpStatus,String)` 응답 패턴을 호출했으나 해당 컨트롤러에 헬퍼 정의를 빠뜨림.
|
||||
|
||||
## 수정 파일 목록
|
||||
|
||||
| 파일 | 변경 유형 | 변경 내용 |
|
||||
|---|---|---|
|
||||
| src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java | modify | private `response(HttpStatus,String)` 헬퍼 1개 추가 (363-368행) |
|
||||
|
||||
- JamController.java: **무수정** (헬퍼 기존 존재, 결함 없음)
|
||||
- 신규 util 파일: **미생성** (단일 메서드 복제로 충분, 공유 util 추출은 scope 밖 + 리팩토링이라 보류)
|
||||
- 그 외 W2-1 파일: 무수정
|
||||
|
||||
## response() 헬퍼 최종 시그니처 + 정의 위치
|
||||
|
||||
단일 시그니처. 오버로드 **불필요**.
|
||||
|
||||
```java
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
```
|
||||
|
||||
- 정의 위치: JamAdminController.java **363-368행** (`requireJamManage` 다음, `sessionUserId` 앞)
|
||||
- import 추가 불필요: `HttpStatus`(15), `ResponseEntity`(16), `LinkedHashMap`(27), `Map`(28) 모두 기존 import 됨.
|
||||
|
||||
## RecruitController 기존 패턴과의 정합 여부 → 복제 (신규정의 아님)
|
||||
|
||||
세 컨트롤러의 헬퍼 본문이 **바이트 단위로 동일**함을 확인:
|
||||
|
||||
| 컨트롤러 | 정의 위치 | 본문 |
|
||||
|---|---|---|
|
||||
| RecruitController | 186-190행 | status.value() + message → ResponseEntity.status(status).body(body) |
|
||||
| JamController | 270-275행 | (동일) |
|
||||
| JamAdminController(추가) | 363-368행 | (동일 — 복제) |
|
||||
|
||||
→ 정본 패턴(쓰기 응답 = `ResponseEntity<Map<String,Object>>{status,message}`)을 **그대로 복제**.
|
||||
W2-1 설계 외부계약(쓰기=ResponseEntity<Map>{status,message,...})과도 정합.
|
||||
|
||||
## 호출부 43건 전수 시그니처 정합 확인 결과
|
||||
|
||||
(verification 보고의 "44건"은 24+20 추정치. 실측 호출 = 24 + 19 = 43건.)
|
||||
|
||||
- JamAdminController: 호출 24건 — 92,96,100,104,108,121,124,158,195,200,204,208,212,216,229,232,270,275,279,282,312,341,355,358행
|
||||
- JamController: 호출 19건 — 116,120,123,127,130,138,144,158,180,184,187,191,220,224,228,231,234,239,241행
|
||||
- **43건 전부 `response(HttpStatus.*, "문자열리터럴")` 2인자 형태** — `rg "response\(" | rg -v "response\(HttpStatus\."` 결과 0건(정의 라인 제외).
|
||||
- 정의 시그니처 `(HttpStatus status, String message)` 와 전수 일치. 오버로드 필요 호출 0건.
|
||||
|
||||
### 추가 데이터를 반환하는 OK 응답은 헬퍼 미사용 (정상)
|
||||
jamId/slug/entryId/jamTeamId/visible 등을 함께 싣는 성공 응답은 모두 인라인
|
||||
`Map<String,Object>` + `ResponseEntity.ok(body)` 로 이미 작성됨 — 헬퍼 호출 아님.
|
||||
따라서 2인자 헬퍼 하나로 43건 전수 충족. 오버로드 불요 재확인.
|
||||
|
||||
## 보존 확인 (scope 엄수)
|
||||
|
||||
- 게이트 `requireJamManage` 6핸들러 정합: 무수정 (AC-T3 PASS 유지)
|
||||
- CSRF 검증 `CsrfTokens.isValid`: 무수정
|
||||
- 매퍼 호출(`#{}` 바인딩): 무수정
|
||||
- 핸들러 비즈니스 로직: 무수정
|
||||
- 변경 = 헬퍼 메서드 1개 추가뿐.
|
||||
|
||||
## 자가 검증 (정적, 실행 안 함)
|
||||
|
||||
1. 산출물 존재: `.atp/work-session/20260624-100749/implementation/W2-1-fix1.md` ✓
|
||||
2. frontmatter 필수 필드(phase/agent/agent_version/generated_at/concerns/concerns_checked) ✓
|
||||
3. concerns 검토 완료(빈 리스트 — 결함 단일·정합 전수 확인) ✓
|
||||
4. 컴파일 가능성: response 호출 43건 전수 정의와 시그니처 일치, 미정의 심볼 0. `git status --short` 로 수정 범위 = JamAdminController 단일 파일 확인.
|
||||
- **L1 컴파일 검증(`mvnw compile`) 실행은 verification-advisor 영역** — 미실행.
|
||||
|
||||
## Verification 을 위한 힌트
|
||||
|
||||
- 재검증 게이트: L1 = `./mvnw -o compile` (BUILD SUCCESS 기대, 직전 24 errors → 0 기대)
|
||||
- 이번 변경 영향 테스트: JamAdminControllerTest.java (response 바디 status/message 키 검증 시 영향)
|
||||
- acceptance criteria: design.md "검증 포인트" 참조 (게이트/CSRF/매퍼는 직전 PASS 유지 기대)
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T10:55:00+09:00
|
||||
workstream: W2-1-게임잼 엔티티/라이프사이클
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 21
|
||||
planned_workers: 21
|
||||
actual_workers: 21
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
unused_diagnostics: 0 # 정적 unused-import 스캔 0건 + 각 worker 자가확인. 통합 LSP/린터 실측은 verification 영역.
|
||||
references:
|
||||
design: .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
|
||||
ownership: .atp/work-session/20260624-100749/implementation/ownership.md
|
||||
---
|
||||
|
||||
# 구현 보고 — W2-1 게임잼 엔티티/라이프사이클
|
||||
|
||||
설계 정본을 그대로 구현. 임의 변경 0(아래 "설계와의 차이"의 2건은 설계 계약을 깨지 않는 구현 디테일 명시).
|
||||
|
||||
## 변경 목록 (설계 파일영향맵 = 19파일, 신규 16 + 수정 3)
|
||||
|
||||
| 파일 | worker | 결과 요약 |
|
||||
|---|---|---|
|
||||
| docs/jam-ddl.sql | migration-writer w-001 | 신규. CREATE TABLE 5건, SEQ 5, ADD CONSTRAINT 14, INDEX 8. 멱등(IF NOT EXISTS/DO $$ guard) |
|
||||
| db/schema.sql | migration-writer w-001 | 수정. 371행~ 잼 블록 append. docs/jam-ddl.sql 와 SEQ/CONSTRAINT/INDEX 갯수 동일(동기 사본). 기존 1~368행 무변경 |
|
||||
| data/JamData.java | code-writer w-002 | 신규. jams 18필드 POJO(getIsVisible/getIsDelete) |
|
||||
| data/JamEntryData.java | code-writer w-003 | 신규. jam_entries 8필드 + JOIN 표시 3필드(gameName/thumbnailUrl/entrantName) |
|
||||
| data/JamTeamData.java | code-writer w-004 | 신규. jam_teams 6필드 + memberCount |
|
||||
| jam/JamStatus.java | code-writer w-005 | 신규. enum 4멤버 + isValid/from(unknown→null) |
|
||||
| jam/JamLifecycle.java | code-writer w-006 | 신규. @Component isAllowed(전이그래프) + isPeriodReady(좁은 3인자 + JamData 오버로드) |
|
||||
| jam/JamSlugs.java | code-writer w-006 | 신규. generate(정규화/한글보존/72절단) + withSuffix(충돌재시도) |
|
||||
| mapper/JamsMapper.java | code-writer w-007 | 신규. 9메서드. listVisibleKeyset 은 <script>+row-comparison keyset. ${} 0 |
|
||||
| mapper/JamEntriesMapper.java | code-writer w-008 | 신규. insert/listByJam(JOIN games+COALESCE entrantName)/exists. ${} 0 |
|
||||
| mapper/JamTeamsMapper.java | code-writer w-009 | 신규. insert/getById/listByJam(memberCount 서브쿼리). ${} 0 |
|
||||
| mapper/JamTeamMembersMapper.java | code-writer w-010 | 신규. insert/exists. ${} 0 |
|
||||
| mapper/JamStatusLogMapper.java | code-writer w-011 | 신규. insert(5파라미터 @Param). ${} 0 |
|
||||
| controller/JamAdminController.java | code-writer w-012 | 신규. 6핸들러 전수 requireJamManage(=6) + 5쓰기 CSRF. slug 충돌 재시도 |
|
||||
| config/InterceptorConfig.java | code-writer w-013 | 수정. .excludePathPatterns("/admin/jams/**") 추가(D4-A) |
|
||||
| webapp/.../admin-jam-list.jsp | code-writer w-014 | 신규. 생성폼 + status/visibility/delete 액션. meta _csrf + htmlEscape |
|
||||
| controller/JamController.java | code-writer w-015 | 신규. list(keyset)/detail/submitEntry/submitTeam/addMember. 쓰기 3 CSRF. resolveEntrant 인라인 |
|
||||
| webapp/.../jam-list.jsp | code-writer w-016 | 신규. keyset 더보기(URLEncoder) + htmlEscape |
|
||||
| webapp/.../jam-detail.jsp | code-writer w-017 | 신규. 출품/팀생성 폼(_csrf hidden) + htmlEscape, innerHTML 미사용 |
|
||||
| test/.../BibimbapApplicationTests.java | code-writer w-018 | 수정. 신규 5매퍼 @MockBean 등록(기존 12빈 보존) |
|
||||
| test/.../JamLifecycleTest.java | code-writer w-019 | 신규. 전이그래프 허용/거부/동일/null + 기간정합 8메서드 |
|
||||
| test/.../JamAdminControllerTest.java | code-writer w-020 | 신규. 게이트 401/403/redirect + CSRF + 전이감사 10메서드(순수 Mockito) |
|
||||
| test/.../JamControllerTest.java | code-writer w-021 | 신규. keyset/출품 소유·멤버·중복·상태 + CSRF 14메서드 |
|
||||
|
||||
## Bash 단계 (advisor 직접)
|
||||
- 세션 디렉토리 생성: mkdir .atp/work-session/20260624-100749/implementation → OK
|
||||
- 집합 전수 AC 정적 점검(테스트 실행 아님): AC-T1 테이블5/5 · AC-T2 상태4값(enum4=jams_check4=log_check4) · AC-T3 핸들러6=requireJamManage6 · AC-T4 매퍼5 ${} 0 · AC-T5 XOR+type CHECK 존재 · AC-T6 @MockBean Jam 5 → 전부 PASS
|
||||
- DDL 동기 비교: docs/jam-ddl.sql ↔ schema.sql 잼블록 SEQ 5/5, ADD CONSTRAINT 14/14, INDEX 8/8 일치
|
||||
- unused-import 정적 스캔 16 Java 파일 → 0건
|
||||
- git status: 설계 파일영향맵과 정확히 일치(수정3+신규16), 범위 밖 변경 0
|
||||
|
||||
## 설계와의 차이 (계약 미파괴 — 구현 디테일 명시)
|
||||
1. **상태전이 응답 키 `status`→`jamStatus`**: 설계 API 계약 표는 상태전이 200 응답을 `{status:toStatus}`로 적었으나, 동일 응답 Map 에 공통 `status`=HTTP 200(value) 와 잼 상태를 같은 키로 넣으면 후자가 HTTP value 를 덮어쓴다. w-012 가 데이터 손실 방지 위해 잼 상태를 `jamStatus` 키로 분리. **권장 정합**: verification 의 JamAdminControllerTest 가 `jamStatus`=전이상태 + `status`=200 을 검증하도록 작성됨(일관). 클라(admin-jam-list.jsp)는 reload 방식이라 응답 키 의존 없음. 설계 의도(상태 반환)는 충족, 키 이름만 충돌 회피.
|
||||
2. **JamLifecycle.assertPeriodReady → isPeriodReady 좁힘(concern 1 반영)**: 설계 시그니처는 `assertPeriodReady(JamData jam, JamStatus to)`였으나 concern 1(dead parameter 방지)에 따라 ① 좁은 `isPeriodReady(JamStatus to, OffsetDateTime evalStartAt, OffsetDateTime evalEndAt)`(EVAL→start, CLOSED→end 두 인자 실사용) + ② 컨트롤러 편의용 `isPeriodReady(JamStatus to, JamData jam)` 오버로드(jam 의 두 getter 위임 실사용)로 구현. 예외 throw 대신 boolean(컨트롤러 ResponseEntity 패턴 정합). dead parameter 0.
|
||||
|
||||
> 위 2건 외 DDL/엔티티/매퍼 SQL/API 경로/시퀀스/keyset/게이트 enforcement 는 설계 정본 그대로.
|
||||
|
||||
## concerns 처리 결과 (설계 6개 concern)
|
||||
- **concern 1 (시그니처 inflate)**: 해소. JamLifecycle.isPeriodReady 좁힘(위 #2). JamController.resolveEntrant 는 별도 헬퍼 추출 안 하고 submitEntry 인라인 유지(grep resolveEntrant private 0 — dead parameter 회피).
|
||||
- **concern 2 (신규 컨트롤러/매퍼 의존 + @MockBean)**: 해소. BibimbapApplicationTests 에 신규 5매퍼 @MockBean 등록(grep Jam @MockBean = 5). contextLoads 보존. **단, full ./mvnw -o test 실측은 verification 영역(미실행)**.
|
||||
- **concern 3 (DB-방언 L2: snake→camel alias / keyset)**: 매퍼 5개 모두 직접 alias(큰따옴표 0), listVisibleKeyset 은 PostgreSQL row-comparison `(created_at,id) < (...)` + <script> NULL 분기. **dev DB contract 실측은 verification 영역**.
|
||||
- **concern 4 (games↔jam 활성 자연키)**: jam_entries 의 ux_jam_entries_jam_game_active(활성 UNIQUE) DDL 그대로. games 무변경 확인(git status 에 games 관련 변경 0).
|
||||
- **concern 5 (자동전이 스케줄러)**: 본 구현은 수동 전이 enforcement 만. @Scheduled 빈 미도입 → BibimbapApplicationTests context 영향 0(신규 빈은 매퍼 5 + JamLifecycle/PermissionGate 기존). 범위 준수.
|
||||
- **concern 6 (slug 충돌 재시도)**: 해소. JamSlugs.generate + withSuffix, JamAdminController.create 가 DuplicateKeyException catch 시 withSuffix(base,n) 재시도(최대 ~20).
|
||||
|
||||
## Verification 을 위한 힌트
|
||||
- acceptance criteria: design.md §검증 포인트(VP-1~7) + §집합 전수 AC(AC-T1~T6) 참조.
|
||||
- 영향받는 테스트 파일: JamLifecycleTest(VP-2), JamAdminControllerTest(VP-1/2/5), JamControllerTest(VP-3/4/5), BibimbapApplicationTests(VP-7 contextLoads).
|
||||
- **반드시 full `./mvnw -o test`** (verification-strategies §30): 신규 컨트롤러/매퍼 의존 → test-compile 만으로 부족. contextLoads + 신규 테스트 컴파일·실행 판정.
|
||||
- **dev DB contract(L2)** 권장 실측: listVisibleKeyset row-comparison, jam_entries XOR/type CHECK 위반 INSERT 거부, snake→camel alias 키 일치(VP-6).
|
||||
- 미실측 리스크: ① keyset row-comparison SQL 의 PostgreSQL 실행 정합(매퍼 코드는 명세대로) ② JSP 의 게임상세 링크 경로 `/game/{gameId}`(게임 허브 실제 라우트와 대조 — gameId null 시 폴백 처리됨, 깨짐 없음) ③ datetime-local 입력의 OffsetDateTime 파싱(컨트롤러 parseOffset try/catch→422 처리).
|
||||
|
||||
## 미해결/리스크
|
||||
- 없음(설계 범위 내 전부 구현). 위 "Verification 힌트"의 미실측 3건은 verification-advisor 가 full test + dev DB contract 로 판정.
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T00:00:00+09:00
|
||||
fix: W2-2-fix1 (누락 테스트 2건 보완)
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 0
|
||||
planned_workers: 2
|
||||
actual_workers: 0
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
unused_diagnostics: 0
|
||||
test_compile: pass
|
||||
---
|
||||
|
||||
# 구현 보고 — W2-2-fix1 (누락 신규 테스트 2건 작성)
|
||||
|
||||
## 누락 진단 재확인
|
||||
W2-2 설계 파일영향맵 L275-276 의 신규 테스트 2건이 직전 구현에서 누락됨.
|
||||
원인: 소유 태그 `(검증)` 을 verification-advisor 작성으로 오독. 테스트 작성은 구현 산출물(verification 은 실행만, Write 없음). 본 fix 가 이 2 파일만 작성.
|
||||
|
||||
## 변경 목록
|
||||
| 파일 | worker | 결과 요약 |
|
||||
|---|---|---|
|
||||
| `src/test/java/com/pandoli365/bibimbap/security/JamRoleGateTest.java` | advisor 직접 | 신규. isJudge 4건 + isOwnEntry 4건 = 8 테스트 |
|
||||
| `src/test/java/com/pandoli365/bibimbap/controller/JamJudgeAdminControllerTest.java` | advisor 직접 | 신규. 지정/해제/조회 게이트·CSRF·404·409·성공 = 16 테스트 |
|
||||
|
||||
## 테스트 케이스 ↔ VP 매핑
|
||||
|
||||
### JamRoleGateTest (VP-2, VP-3)
|
||||
- `isJudgeReturnsTrueForAssignedUser` — VP-2 지정 유저 true
|
||||
- `isJudgeReturnsFalseForUnassignedUser` — VP-2 미지정 false
|
||||
- `isJudgeReturnsFalseWhenUnauthenticated` — VP-2 미인증 false (mapper 미호출 검증)
|
||||
- `isJudgeReturnsFalseForNullSession` — VP-2 보강(null 세션 방어, mapper 미호출)
|
||||
- `isOwnEntryTrueForPersonalEntry` — VP-3 ① 개인출품 true
|
||||
- `isOwnEntryTrueForTeamMemberEntry` — VP-3 ② 팀멤버출품 true
|
||||
- `isOwnEntryFalseForOthersEntry` — VP-3 ③ 타인/타팀 false
|
||||
- `isOwnEntryFalseForInactiveEntry` — VP-3 ④ 비활성(is_delete) false
|
||||
|
||||
### JamJudgeAdminControllerTest (VP-1, VP-4, VP-5, VP-6)
|
||||
- `assignReturns401WhenUnauthenticated` — VP-1 미인증 401
|
||||
- `assignReturns403WhenLacksPermission` — VP-1 무키 403
|
||||
- `assignRejectsMissingCsrfBeforeMapperAccess` — VP-5 지정 CSRF 누락 403 + mapper 미호출
|
||||
- `assignReturns404WhenJamMissing` — 404 잼 없음
|
||||
- `assignReturns404WhenTargetUserMissing` — 404 대상 유저 없음
|
||||
- `assignReturns409WhenAlreadyJudge` — VP-6 멱등 재지정 409 (insert 미호출)
|
||||
- `assignInsertsJudgeWhenAuthorized` — VP-1/VP-4 통과·누구나 지정 성공
|
||||
- `removeReturns403WhenLacksPermission` — VP-1 해제 무키 403
|
||||
- `removeRejectsMissingCsrfBeforeMapperAccess` — VP-5 해제 CSRF 누락 403 + mapper 미호출
|
||||
- `removeReturns404WhenNotAssigned` — 해제 404(지정 안 됨)
|
||||
- `removeDeletesJudgeWhenAuthorized` — 해제 성공 removed:true
|
||||
- `listReturns401WhenUnauthenticated` — VP-1 조회 미인증 401
|
||||
- `listReturns403WhenLacksPermission` — VP-1 조회 무키 403
|
||||
- `listReturnsJudgesWhenAuthorized` — 조회 성공(judges 목록)
|
||||
|
||||
> 게이트 통과의 ADMIN / SUBADMIN+키 구분: 컨트롤러는 PermissionGate 만 소비하므로(role 직접체크 없음, J3-A) `gate.isAuthenticated` + `gate.has(GAME_JAM_MANAGE)` 반환값으로 표현(JamAdminControllerTest 동형). role 분기 L3 스모크는 verification 영역.
|
||||
|
||||
## Bash 단계 (advisor 직접)
|
||||
- `./mvnw -o -q test-compile` (JAVA_HOME=openjdk@21) → EXIT=0, 에러 0. 두 신규 테스트 컴파일 성공.
|
||||
- `-Dmaven.compiler.showWarnings=true` 재컴파일 → 경고 0건(unused import/변수 없음).
|
||||
- 테스트 **실행은 하지 않음**(verification 영역). test-compile 은 빌드/타입체크라 advisor 영역.
|
||||
|
||||
## 시그니처 정합 자가확인 (정적 전수)
|
||||
| 소비 대상 | 실제 시그니처(실측) | 테스트 정합 |
|
||||
|---|---|---|
|
||||
| `JamRoleGate(JamJudgesMapper, JamEntriesMapper)` 생성자 | 확인 | ✓ |
|
||||
| `JamRoleGate.isJudge(HttpSession, long)` → boolean | 세션 userId attr `"userId"`, null→false | ✓ |
|
||||
| `JamRoleGate.isOwnEntry(long, long, long)` → boolean | jamEntriesMapper 위임 | ✓ |
|
||||
| `JamJudgesMapper.exists/insert/delete/listByJam` | exists(long,long)→bool, insert(long,long,long)→int, delete(long,long)→int, listByJam(long)→List | ✓ |
|
||||
| `JamEntriesMapper.isOwnEntry(long,long,long)` → boolean | @Param 3종 | ✓ |
|
||||
| `JamJudgeAdminController(JamsMapper, UsersMapper, JamJudgesMapper, PermissionGate)` | 확인 | ✓ |
|
||||
| 핸들러 `assign(long,long,HttpServletRequest,HttpSession)` / `remove(long,long,req,session)` / `list(long,session)` | ResponseEntity<Map<String,Object>> 반환 | ✓ |
|
||||
| `PermissionGate.isAuthenticated/has` | isAuthenticated(session), has(session,String) | ✓ |
|
||||
| `UsersMapper.getUser(long)` → UserData | 확인 | ✓ |
|
||||
| `JamsMapper.getById(long)` → JamData | 확인 | ✓ |
|
||||
| `CsrfTokens.HEADER_NAME / SESSION_ATTRIBUTE / errorBody()` | JamAdminControllerTest 동형 사용 | ✓ |
|
||||
| POJO setter: JamData.setId/setTitle/setStatus, UserData.setId, JamJudgeData.setUserId/setDisplayName/setAssignedBy | 전수 존재 | ✓ |
|
||||
| PermissionKeys.GAME_JAM_MANAGE | enum 상수 존재 | ✓ |
|
||||
|
||||
전례 보강: W2-1 1차의 `response()` 누락 컴파일 FAIL 재발 방지 — 컨트롤러 ResponseEntity 반환·헬퍼 흐름을 실측 후 테스트가 동일 흐름 가정. test-compile EXIT=0 으로 정합 입증.
|
||||
|
||||
### MockitoExtension STRICT_STUBS 점검
|
||||
- JamJudgeAdminControllerTest: `@ExtendWith(MockitoExtension.class)` → strict stubbing. 각 테스트의 stub 전수가 핸들러 흐름(requireJamManage→CSRF→getById→getUser→exists→insert / requireJamManage→CSRF→delete / requireJamManage→listByJam)에서 모두 소비되도록 over-stub 0 으로 구성. UnnecessaryStubbingException 위험 0.
|
||||
- JamRoleGateTest: plain `mock()`(MockitoExtension 미사용) → strict stub 비적용, unused stub 무관.
|
||||
|
||||
## 설계와의 차이 (worker 계획 vs 실제)
|
||||
- **전환 사유**: 파일 수 2 < 8 AND 예상 줄수 합산 약 410 < 500 → advisor 직접 실행 선택(병렬 worker 이득 미미). 더 중요한 근거: 두 테스트의 컴파일 정합이 핵심 리스크(W2-1 1차 FAIL 전례)라 시그니처 전수 정합을 advisor 가 직접 통제하는 편이 안전.
|
||||
- `planned_workers: 2`(code-writer 1파일 1worker 원칙상 계획) → `actual_workers: 0`.
|
||||
- 선택 파일: JamRoleGateTest.java, JamJudgeAdminControllerTest.java.
|
||||
|
||||
## BibimbapApplicationTests @MockBean 현황(VP-8/AC-T6 — 본 fix 무수정 확인)
|
||||
- `JamJudgesMapper`(L74), `JamRoleGate`(L89), `JamEntriesMapper`(L71) 전수 이미 @MockBean 등록됨(직전 W2-2 구현이 등록). contextLoads 보존 — 본 fix 가 추가 등록 불요.
|
||||
|
||||
## concerns 검토
|
||||
빈 리스트. 본 fix 는 테스트 2건 작성만이며 설계 VP-1~6 을 그대로 실현. main 코드/스키마/기존 테스트 무수정(제약 준수). 설계와의 충돌·비현실 발견 없음.
|
||||
|
||||
## Verification 을 위한 힌트
|
||||
- acceptance criteria = design.md 검증 포인트 VP-1~8 + AC-T1~T7.
|
||||
- 본 fix 가 채운 VP: VP-1(게이트), VP-2(isJudge), VP-3(isOwnEntry), VP-5(CSRF), VP-6(멱등), VP-4 일부(누구나 지정 성공).
|
||||
- L2(dev DB contract): VP-3 ②팀멤버/④비활성, VP-6 UNIQUE 거부, VP-7 alias 매핑 — isOwnEntry/listByJam SQL 실측은 verification 의 L2 게이트 소관(단위 테스트는 매퍼 위임 정합만 커버).
|
||||
- AC-T 전수 게이트(AC-T1 DDL 제약수, AC-T2 핸들러 게이트수, AC-T3 CSRF수, AC-T4 isOwnEntry 양경로, AC-T5 `${` 0, AC-T6 @MockBean, AC-T7 전역 RBAC 무변경)는 grep/구조 검증 — verification 영역.
|
||||
- 영향받는 테스트 파일(신규): 위 변경 목록 2건. 기존 테스트 무영향(신규 파일 추가만).
|
||||
- full `./mvnw -o test` 의무(설계 concern 2, §30): 신규 매퍼/게이트 @MockBean contextLoads 포함 — verification 이 실행.
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T11:05:00+09:00
|
||||
workstream: W2-2-심사위원 역할 권한(jam_judges + JamRoleGate)
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 7
|
||||
planned_workers: 7
|
||||
actual_workers: 7
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
unused_diagnostics: 0 # -Xlint:all 재컴파일 + 수동 교차검증, W2-2 파일 warning 0
|
||||
test_compile: BUILD SUCCESS # ./mvnw -o test-compile, MAVEN_EXIT=0 (실행 아님)
|
||||
---
|
||||
|
||||
# 구현 보고: W2-2 심사위원 역할 권한 (jam_judges + JamRoleGate)
|
||||
|
||||
권위 설계도: `.atp/work-session/20260623-104307/implementation/W2-2-judge-role-design.md` (오픈질문 0, 정본 그대로 구현, 임의 변경 0).
|
||||
|
||||
## 변경 목록
|
||||
|
||||
| 파일 | worker | 결과 요약 |
|
||||
|---|---|---|
|
||||
| docs/jam-judge-ddl.sql (신규) | migration-writer w-001 | 권위 DDL. jam_judges(id/jam_id/user_id/assigned_by/created_at) + FK 3종(jams/users/users) + ux_jam_judges_jam_user UNIQUE + idx_jam_judges_jam. 멱등(SEQUENCE/TABLE IF NOT EXISTS, DO $$ pg_constraint guard, CREATE UNIQUE INDEX IF NOT EXISTS) — jam-ddl 선례 동형 |
|
||||
| db/schema.sql (수정) | migration-writer w-001 | 파일 끝(558~596행) jam_judges 블록 동기 사본 추가. 헤더 주석 game_reviews/jam 블록 선례 동형. docs 본문과 바이트 일치(diff 0). jams 블록(374~) 뒤 → FK 순서 정합 |
|
||||
| data/JamJudgeData.java (신규) | code-writer w-002 | POJO 6필드(id/jamId/userId/assignedBy/createdAt/displayName) + getter/setter. JamEntryData 동형(Lombok 미사용) |
|
||||
| mapper/JamJudgesMapper.java (신규) | code-writer w-003 | @Mapper insert/delete/exists/listByJam. #{} only, §33 alias 가드(POJO 비인용 alias, 큰따옴표 0). exists=EXISTS boolean(UserPermissionsMapper 선례) |
|
||||
| mapper/JamEntriesMapper.java (수정) | code-writer w-004 | isOwnEntry(jamId,gameId,userId) 1건 추가. 기존 3메서드 보존. USER+TEAM 양경로 OR-EXISTS. #{} only |
|
||||
| security/JamRoleGate.java (신규) | code-writer w-005 | @Component 잼 스코프 게이트(별도 축). isJudge(session,jamId)/isOwnEntry(jamId,gameId,judgeUserId). 두 매퍼 생성자 주입. 요청당 직접 조회(epoch/캐시 0). sessionUserId PermissionGate 동형 |
|
||||
| controller/JamJudgeAdminController.java (신규) | code-writer w-006 | @Controller. assign(POST)/remove(POST)/list(GET) 3핸들러. requireJamManage 전수 진입 게이트 + 상태변경 CSRF 2건. DuplicateKeyException catch→409. JamAdminController 헬퍼 동형 |
|
||||
| webapp/.../admin-jam-list.jsp (수정) | code-writer w-007 | 심사위원 관리 섹션 + data-action 위임 핸들러(judges/judge-assign/judge-remove). fetch GET 목록 + post 지정/해제. 클라 렌더 textContent only(innerHTML 0). 기존 폼/스크립트 보존 |
|
||||
| test/.../BibimbapApplicationTests.java (수정) | advisor 직접 | @MockBean 2건 추가(JamJudgesMapper, JamRoleGate). contextLoads 빈 의존 해소 (설계 §VP-8/AC-T6, concern 2) |
|
||||
|
||||
## Bash 단계 (advisor 직접)
|
||||
- `./mvnw -o test-compile` (JAVA_HOME=/opt/homebrew/opt/openjdk@21/...) → **BUILD SUCCESS, MAVEN_EXIT=0**. main+test 전 소스 컴파일 통과(신규 @MockBean 포함).
|
||||
- `./mvnw -o clean compile -Dmaven.compiler.compilerArgument=-Xlint:all` → **BUILD SUCCESS, 전체 WARNING 0, W2-2 파일 unused 0**. unused 진단 별도 게이트 통과.
|
||||
- AC-T1~T7 + §33 정적 grep 게이트 전수 실행 → 전부 기대값 일치(아래 §검증 echo).
|
||||
- **테스트 실행·DB 적용·git commit 안 함**(verification/수동/orchestrator 영역).
|
||||
|
||||
## 설계와의 차이
|
||||
없음. planned_workers(7) == actual_workers(7). 설계 파일영향맵·시그니처·DDL·API 계약·시퀀스를 정본 그대로 구현.
|
||||
|
||||
세부 결정(설계 범위 내, 임의 변경 아님):
|
||||
- **목록 조회 UI = 클라이언트 fetch GET**: 설계 §외부계약이 목록 조회를 JSON API(`GET /admin/jams/{jamId}/judges`)로 명시했고, W2-1 console() 핸들러(model 에 jams/csrfToken 만 주입)를 수정하면 안 되므로, JSP 는 비동기 fetch 로 목록을 로드(SSR 추가 데이터 0). console() 무수정 = W2-1 검증 동작(6핸들러 게이트) 보존.
|
||||
- **JamRoleGate @MockBean 채택**: 설계 concern 2 가 "@Component 면 @MockBean 또는 실제빈+의존매퍼 MockBean" 명시. @MockBean 채택(W1 PermissionGate 동형). 의존 매퍼(JamJudgesMapper/JamEntriesMapper)도 @MockBean 이라 양립.
|
||||
|
||||
## W2-1 기존 파일 수정 여부 + 근거
|
||||
1. **JamEntriesMapper.java (수정)** — 설계 §파일영향맵 명시(K-MAPPER, crossRefs). isOwnEntry 메서드 **1건 추가만**, 기존 insert/listByJam/exists 3메서드 한 글자도 변경 0(자기출품 충돌 판정 = W2-2 소관). W2-1 호출지점 깨짐 0(신규 메서드).
|
||||
2. **admin-jam-list.jsp (수정)** — 설계 §파일영향맵 명시(K-ADMIN, crossRefs). 심사위원 섹션·버튼·스크립트 핸들러 **추가만**. 기존 생성 폼/목록 테이블/transitionStatus·toggleVisibility·removeJam 스크립트 보존(grep 7매치 확인).
|
||||
3. **BibimbapApplicationTests.java (수정)** — 설계 §파일영향맵 (검증) 명시. @MockBean 2건 추가만(contextLoads 보존 의무).
|
||||
4. **db/schema.sql (수정)** — 설계 §데이터모델 명시. jam_judges 블록 추가만(전역 RBAC/jams/jam_entries 블록 무변경).
|
||||
|
||||
**JamController/JamAdminController/InterceptorConfig/W2-1 5매퍼는 무수정** — 설계 J3-A(InterceptorConfig 의 `/admin/jams/**` exclude 가 `/admin/jams/{id}/judges` 트리 커버 → 추가 수정 불요)에 따라 건드리지 않음. W2-1 검증 동작(requireJamManage·CSRF·#{}) 회귀 0.
|
||||
|
||||
## concerns 처리 결과
|
||||
설계 frontmatter concerns 5건은 본 구현이 모두 해소(설계가 결정으로 고정한 항목):
|
||||
- **concern 1 (시그니처 inflate)**: isJudge(session,jamId)/isOwnEntry(jamId,gameId,judgeUserId)/매퍼 시그니처 전부 최소 인자, dead parameter 0. -Xlint:all unused 0 + 수동 교차검증(JamRoleGate 두 매퍼 7회 사용, Controller 4 주입필드 전부 사용).
|
||||
- **concern 2 (신규 빈 @MockBean / full test)**: JamJudgesMapper + JamRoleGate @MockBean 등록. test-compile BUILD SUCCESS. **full ./mvnw -o test 실행은 verification-advisor 몫**(본 advisor 실행 금지) — VP-8 로 이관.
|
||||
- **concern 3 (§33 alias)**: JamJudgesMapper resultType=POJO(JamJudgeData) → snake→camel 직접 alias, **큰따옴표 alias 0건**(grep 확인). isOwnEntry/exists boolean(EXISTS) 매핑.
|
||||
- **concern 4 (자기출품 충돌 = 계약, enforce=W2-4)**: 본 W2-2 는 JamRoleGate.isOwnEntry + JamEntriesMapper.isOwnEntry **헬퍼·계약만 제공**. 지정 시점 충돌 미차단(J7). W2-4 점수입력 소비점은 verification 시 crossRefs 재확인.
|
||||
- **concern 5 (배포 순서 의존)**: InterceptorConfig 무수정(J3-A). W2-1 의 `/admin/jams/**` exclude 선배포 의존 — 롤아웃 순서 문서화(설계 §롤아웃). 코드 결함 아님.
|
||||
|
||||
## Verification 을 위한 힌트
|
||||
- acceptance criteria 는 design.md 의 "검증 포인트" + "집합 전수 체크 AC" 참조(아래 verbatim echo).
|
||||
- 이번 변경으로 영향받는/신규 테스트 파일(설계 §파일영향맵 (검증) — 본 advisor 미작성, verification 작성 대상):
|
||||
- src/test/.../JamJudgeAdminControllerTest.java (신규 — VP-1/4/5/6)
|
||||
- src/test/.../JamRoleGateTest.java (신규 — VP-2/3)
|
||||
- src/test/.../BibimbapApplicationTests.java (수정 완료 — VP-8/AC-T6 contextLoads)
|
||||
- full `./mvnw -o test` 의무(설계 §30/concern 2). JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home 사용(시스템 java 없음).
|
||||
- DB-방언 L2 실측 권장: isOwnEntry OR-EXISTS, JamJudgesMapper EXISTS boolean, listByJam JOIN users alias (dev DB contract).
|
||||
|
||||
## 자가확인 정적 게이트 결과 (verification echo 근거)
|
||||
- AC-T1: docs ADD CONSTRAINT=3, CREATE UNIQUE INDEX=1 / schema.sql jam_judges ADD CONSTRAINT=3, UNIQUE INDEX=1 → PASS
|
||||
- AC-T2: @Post/@GetMapping 핸들러=3, requireJamManage=4(정의1+호출3) → 전수 게이트 PASS
|
||||
- AC-T3: CsrfTokens.isValid=2(상태변경 2핸들러) → PASS
|
||||
- AC-T4: entrant_type 'USER'=1, 'TEAM'=1 → 양경로 PASS
|
||||
- AC-T5: JamJudgesMapper+JamEntriesMapper `${`=0 → PASS
|
||||
- AC-T6: JamJudgesMapper @MockBean=1, JamRoleGate @MockBean=1 → PASS
|
||||
- AC-T7: docs 전역테이블(users/user_permissions/permissions) ALTER/CREATE=0 → 전역 RBAC 무변경 PASS
|
||||
- §33: JamJudgesMapper 큰따옴표 alias=0 → PASS
|
||||
|
||||
## 미해결 / 리스크
|
||||
- DB 미적용(NOT APPLIED). `db/apply-local-ddl.sh` 로 jam-judge-ddl 적용은 수동 영역(jam-ddl < jam-judge-ddl 알파벳 순 → FK 선행 jams 자동 보장).
|
||||
- 테스트 코드(JamJudgeAdminControllerTest/JamRoleGateTest) 미작성 — verification-advisor 작성·실행 대상.
|
||||
- W2-4 점수입력의 isJudge/isOwnEntry 소비점은 W2-4 워크스트림에서 검증(crossRefs).
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T11:45:00+09:00
|
||||
workstream: W2-3-잼 평가 동결 스키마(심사점수/인기투표/시상집계 + jam_score_stats VIEW)
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 1
|
||||
planned_workers: 1
|
||||
actual_workers: 1
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
test_compile_exit: 0
|
||||
unused_diagnostics: 0 # Java 소스 변경 0건(매퍼/컨트롤러 미생성) → dead param/unused 발생 불가
|
||||
references:
|
||||
design: .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md
|
||||
ownership: .atp/work-session/20260624-100749/implementation/ownership.md
|
||||
---
|
||||
|
||||
# 구현 보고 — W2-3 잼 평가 동결 스키마
|
||||
|
||||
## 변경 목록
|
||||
| 파일 | worker | 결과 요약 |
|
||||
|---|---|---|
|
||||
| docs/jam-eval-ddl.sql (신규, 194줄) | migration-writer w-001 | 권위 DDL. 설계 96~291줄 verbatim. 4테이블+1VIEW+9FK+4CHECK+4UNIQUE+4보조인덱스 |
|
||||
| db/schema.sql (수정, 596→792줄, +196) | migration-writer w-001 | jam_judges 블록 뒤 동기 사본 append. 본문 byte-identical(diff 0) |
|
||||
|
||||
> 설계 파일 영향 맵(441~445줄)이 확정한 본 W2-3 산출물 = **정확히 이 2건**. 매퍼/POJO/컨트롤러/JSP/테스트는 하류 W2-4/5/6 소유(447~453줄) → 본 advisor 생성 0(아래 §설계와의 차이 참조).
|
||||
|
||||
## Bash 단계 (advisor 직접)
|
||||
- `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-eval-ddl.sql` → **4** (AC-T1 PASS)
|
||||
- `grep -c 'CREATE OR REPLACE VIEW' docs/jam-eval-ddl.sql` → **1** (AC-T1 PASS)
|
||||
- `grep -c 'CREATE UNIQUE INDEX' docs/jam-eval-ddl.sql` → **4** (AC-T3 PASS, >=4)
|
||||
- schema.sql 동기 사본: jam_criteria/jam_scores/jam_votes/jam_awards 각 1 + jam_score_stats VIEW 1 → **전수 존재** (AC-T1 동기 누락 검출 PASS)
|
||||
- `grep -cE 'ALTER TABLE .*game_review|CREATE TABLE .*game_review' docs/jam-eval-ddl.sql` → **0** (AC-T5 단방향 write 0 PASS)
|
||||
- verbatim 정합: `diff`(jam-eval-ddl 본문 vs schema.sql 신규 블록) → **0줄**(권위=사본 드리프트 0)
|
||||
- append 경계: 596줄 `ON "jam_judges" ("jam_id");`(W2-2 끝) → 599줄 평가 헤더. 기존 블록 무변경.
|
||||
- `./mvnw -o test-compile` (JAVA_HOME=openjdk@21) → **EXIT=0**, test-classes 산출 확인(기존 코드 회귀 0). 실행 안 함(verification 영역).
|
||||
- `git status --short`: `M db/schema.sql`, `?? docs/jam-eval-ddl.sql` — src/ 변경 0.
|
||||
|
||||
## §33 인용 alias 적용 결과 (W2-3 핵심)
|
||||
- **jam_score_stats VIEW 컬럼 alias = 순수 소문자 snake_case 비인용** (`AS jam_id`, `AS game_id`, `AS weighted_total`, `AS simple_total`, `AS scored_criteria`, `AS judge_count`). camelCase 문자 0 → Postgres 소문자 폴딩이 일어나도 **식별자 불변**(이미 소문자) → 케이스폴딩 버그 **불가능**.
|
||||
- §33의 camelCase 큰따옴표 강제는 **하류 매퍼 SELECT alias 단계**에 적용(설계 337/365줄, VP-7): VIEW 컬럼 snake_case → 매퍼가 `SELECT weighted_total AS "weightedTotal"` 로 인용. 본 W2-3 은 매퍼 미생성 → 이 인용은 W2-4 verification 시점 검사(설계 위임).
|
||||
- 선례 game_review_stats 는 컬럼을 `AS "snake_case"`(인용)로, jam_score_stats 는 비인용 snake_case 로 둠 — **둘 다 소문자라 동작 동일**, §33 위반 아님(인용/비인용 무관, 폴딩 영향 0).
|
||||
- **fan-out 패턴(§33 가드)**: jam_score_stats 는 `WITH per_criterion AS (... GROUP BY jam_id,game_id,criterion_key)` 로 jam_scores 를 **출품작·기준 단위 선집계** 후 `LEFT JOIN jam_criteria`. 부모(평가)에 자식(축점수) 직접 JOIN 안 함 → COUNT/AVG 왜곡 없음. game_review_stats 선집계 선례 동형. 0-division: `NULLIF(SUM(COALESCE(c."weight",1.0)),0)` 가드 그대로. 미채점 criterion 은 per_criterion 행 미생성으로 자동 제외.
|
||||
|
||||
## 동결 자연키/컬럼 정합 (하류 소비 계약 고정)
|
||||
- **평가단위 = (jam_id, game_id) 자연키** (F8). jam_scores/jam_votes/jam_awards 전부 jam_id+game_id 직접 컬럼. FK = game_id→games, jam_id→jams(jam_entries.id surrogate 아님). ux_jam_entries_jam_game_active(schema.sql:521) 가 1:1 보장.
|
||||
- **동결 컬럼 변경 0**: 설계 DDL 블록을 한 글자도 변경 없이 구현(diff 0). jam_criteria(jam_id,criterion_key,display_name,sort_order,weight) / jam_scores(jam_id,game_id,judge_user_id,criterion_key,score) / jam_votes(jam_id,game_id,voter_user_id) / jam_awards(jam_id,game_id,award_track,rank,score_value) — 하류 W2-4/5/6 소비 계약 고정.
|
||||
- **무결성 제약 전수**(AC-T3): 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(JUDGE/USER_RATING/POPULAR/GRAND) — 전수 존재.
|
||||
- **트랙 4종 정합**(AC-T2): award_track CHECK IN 4종 == 시상 산정 트랙 집합. F4 가드 고정.
|
||||
|
||||
## 설계와의 차이
|
||||
**계획=실제 1 worker, planned==actual. 그러나 orchestrator 지시문 "필수준수 3·4"(신규 Test.java 작성·@MockBean 등록) 미적용 — 사유 기록:**
|
||||
|
||||
- 설계 권위(파일 영향 맵 441~455줄 + 비목표 §43-46 + concern 2)가 본 W2-3 범위를 **스키마+계약 동결만**으로 확정. 매퍼/POJO/컨트롤러/JSP/**테스트**는 전부 "하류 W2-4/5/6 소유 — 본 설계 미생성". 본 advisor 가 Java 신규 생성 시 ① 설계 파일영향맵 위반 ② 하류 소유 침범 ③ 동결 스키마 소비 계약 선점 — orchestrator 금지문("파일영향맵 밖 신규 파일 금지", "동결 변경 금지")과도 충돌.
|
||||
- 따라서 본 W2-3 은 **매퍼/컨트롤러 0개 생성** → 등록할 신규 @MockBean 0, 작성할 *Test.java 0. BibimbapApplicationTests 무변경(18빈 그대로) → contextLoads 회귀 없음.
|
||||
- orchestrator "필수준수 3·4" 는 매퍼/컨트롤러가 생성되는 워크스트림(W2-1/W2-2 류) 전제의 누적 교훈이다. 본 W2-3 처럼 매퍼/컨트롤러 0 산출 워크스트림에는 적용 대상이 없다(빈/테스트 산출물 없음).
|
||||
- 검증은 DB contract(L2 — VP-1~7 dev DB 실측) 차원으로 verification-advisor 가 수행(설계 §검증포인트 519줄: contextLoads @MockBean 은 매퍼 생성 하류 책임으로 명시).
|
||||
- **그 외 동결 설계 대비 차이 0**: DDL/VIEW/제약/alias verbatim, W2-1/W2-2 기존 파일 수정 0(append only, 기존 블록 무변경).
|
||||
|
||||
## Verification 을 위한 힌트
|
||||
- acceptance criteria 는 design.md(W2-3-eval-freeze-design.md) "검증 포인트"(VP-1~7) + "집합 전수 체크 AC"(AC-T1~T6) 참조.
|
||||
- 본 변경으로 영향받는 테스트: **없음**(Java 0 변경). 신규 검증은 dev DB contract(L2) 실측 — jam-eval-ddl 적용 후 무결성/집계 VIEW.
|
||||
- L2 dev DB contract 권장: VP-1(UNIQUE/CHECK 거부), VP-2(jam_score_stats fan-out/NULL/0-division 샘플 실측), VP-5(트랙값 CHECK), VP-6(FK 적용 순서 — jam-ddl 후 jam-eval-ddl), VP-7(하류 매퍼 alias 는 W2-4 시점).
|
||||
- DB 적용 미수행(NOT APPLIED) — verification 시 `db/apply-local-ddl.sh` 실행은 verification-advisor 판단(advisor 는 적용 금지).
|
||||
|
||||
### 설계 "검증 포인트" verbatim echo
|
||||
- **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" verbatim echo
|
||||
- **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뷰 전수 존재(동기 사본 누락 검출). 테이블/뷰 추가·삭제 누락을 갯수로 동시 커버. **[advisor 사전확인: ddl 4/1, schema 4/1 — PASS]**
|
||||
- **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 핵심 가드). **[advisor 사전확인: CHECK IN 4종 ddl/schema 각 1매치 — PASS, W2-6 산정경로는 하류]**
|
||||
- **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. **[advisor 사전확인: UNIQUE INDEX 4, 4제약명 전수 존재 — PASS]**
|
||||
- **AC-T4 평가 매퍼 시그니처 계약 전수 `${` 0건(하류 검증)** — 하류 W2-4/5/6 가 본 계약대로 구현한 신규 매퍼 전수에 `${` 매치 0: `grep -rc '\${' <하류 jam-eval 매퍼들>` == 0 (AC-11, `${}` 금지). 부재 검증이라 리터럴 정당. **본 W2-3 은 매퍼 미생성 → 이 AC 는 하류 verification 시점 검사**(계약 위임 명시). **[advisor: 본 W2-3 매퍼 0 → 하류 W2-4/5/6 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) 위반(시상이 리뷰 스키마 손대기) 즉시 검출. **[advisor 사전확인: grep 0건 — PASS]**
|
||||
- **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 미생성 검출. **[advisor 사전확인: jam-ddl < jam-eval-ddl < jam-judge-ddl 알파벳 순서 + game-reviews-ddl('g')도 선행 — 정적 PASS, FK 실생성은 L2 dev DB]**
|
||||
|
||||
## grounding 불일치 / concerns / 기존 파일 수정 / 미해결
|
||||
- **grounding 불일치**: 없음. 설계 인용 앵커 전부 실측 정합(game_review_stats fan-out/인용 선례, ux_jam_entries_jam_game_active, GameReviewStatsMapper alias, jam-ddl 멱등 스타일, apply-local-ddl.sh 알파벳 글롭, schema.sql jam_judges 끝 596줄, BibimbapApplicationTests @MockBean 18). ownership.md 에 상세.
|
||||
- **concerns 처리**: 설계 concerns 5건 전부 본 W2-3 구현 범위에서 검토 완료 — (1)평가단위 식별자: (jam_id,game_id) 자연키 채택 확정, jam_entries.id 직접 FK 미사용(설계 F8 그대로). (2)신규 매퍼 @MockBean: 본 W2-3 매퍼 0 → 하류 책임(설계 위임). (3)0-division/alias: NULLIF 가드 + VIEW snake/매퍼 camelCase 인용 2단(§33 정합). (4)최소리뷰수 N=3: 시상 산정 상수(W2-6 위치, 본 동결은 스키마만). (5)jam_entries UNIQUE 동결 유지: schema.sql:521 무변경 확인. **본 advisor 신규 concerns 0**.
|
||||
- **W2-1/W2-2 기존 파일 수정 여부**: db/schema.sql 만 수정 — jam_judges 블록 끝(596줄) 뒤 순수 append, 기존 W2-1(jams/entries/teams) + W2-2(jam_judges) 블록 **무변경**(설계 296줄 "jams/jam_entries 블록 뒤 신설" 근거). docs/*-ddl.sql 기존 파일 무변경. src/ 무변경 → W2-1/2 게이트·CSRF·#{} 동작 회귀 0.
|
||||
- **미해결**: 없음. 동결 스키마 단일권위 확정. 하류 W2-4/5/6 이 본 스키마 변경 없이 소비.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T12:10:00+09:00
|
||||
workstream: W2-4-심사위원 평가(점수 입력/집계 소비)
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 7
|
||||
planned_workers: 11
|
||||
actual_workers: 7
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
unused_diagnostics: 0
|
||||
test_compile: BUILD SUCCESS
|
||||
---
|
||||
|
||||
# 구현 보고 — W2-4 심사위원 평가
|
||||
|
||||
> 설계 권위: `.atp/work-session/20260623-104307/implementation/W2-4-judge-scoring-design.md` (오픈질문 0, audit PASS). W2-3 동결 스키마(jam_criteria/jam_scores + jam_score_stats VIEW) 소비. 신규 DDL 0.
|
||||
|
||||
## 변경 목록
|
||||
|
||||
| 파일 | 담당 | 결과 요약 |
|
||||
|---|---|---|
|
||||
| `src/main/java/com/pandoli365/bibimbap/data/JamCriterionData.java` | advisor 직접 | 신규. criterionKey/displayName/sortOrder/weight(BigDecimal) POJO |
|
||||
| `src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java` | advisor 직접 | 신규. criterionKey/score/judgeUserId/updatedAt POJO |
|
||||
| `src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java` | advisor 직접 | 신규. static isOpen(JamData, OffsetDateTime) — EVAL+구간 경계포함 판정, NULL=false |
|
||||
| `src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java` | code-writer w-B1 | 신규. listByJam(jamId) SELECT, snake→camel 직접 alias, `${}` 0 |
|
||||
| `src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java` | code-writer w-B2 | 신규. upsertScore(ON CONFLICT 4키) + listByJudge. snake→camel alias, `${}` 0 |
|
||||
| `src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java` | code-writer w-B3 | 신규. listStatsByJam VIEW SELECT. **큰따옴표 alias 5건** + 결정론 ORDER BY |
|
||||
| `src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java` | code-writer w-C1 | 신규. submitScores(POST+PUT)/myScores/summary/scoringForm. 3중게이트+CSRF |
|
||||
| `src/main/webapp/WEB-INF/views/jam-scoring.jsp` | code-writer w-C2 | 신규. 채점 폼. HtmlUtils.htmlEscape 전수 + CSRF(meta/hidden/헤더) + textContent + prefill |
|
||||
| `src/test/java/com/pandoli365/bibimbap/controller/JamScoringControllerTest.java` | code-writer w-D1 | 신규. 19 테스트. 게이트순서 전수 + AC-T5 5분류 + upsert never 10건 |
|
||||
| `src/test/java/com/pandoli365/bibimbap/jam/JamEvalWindowTest.java` | code-writer w-D2 | 신규. 10 테스트. 경계포함/status≠EVAL/NULL 4종 |
|
||||
| `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | advisor 직접 | 수정. 신규 3매퍼 @MockBean 등록(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) |
|
||||
|
||||
신규 11파일 중 8 신규 + 1 수정. 신규 DDL 0 (W2-3 동결 소비, AC-T4/AC-15).
|
||||
|
||||
## planned_workers 11 vs actual_workers 6 (worker 계획 vs 실제)
|
||||
- 계획 11(파일 11개 1:1). 실제 spawn = 6 code-writer(w-B1/B2/B3/C1/C2/D1/D2 중 7? 재계산 아래).
|
||||
- **실제 spawn 정정**: code-writer worker = JamCriteriaMapper(B1) + JamScoresMapper(B2) + JamScoreStatsMapper(B3) + JamScoringController(C1) + jam-scoring.jsp(C2) + JamScoringControllerTest(D1) + JamEvalWindowTest(D2) = **7 worker**. frontmatter `actual_workers` 를 7로 정정.
|
||||
- advisor 직접 = JamCriterionData + JamScoreData + JamEvalWindow(배치 A 3) + BibimbapApplicationTests(D3) = 4파일.
|
||||
- **전환 사유(계량 근거)**: 배치 A 3파일은 boilerplate POJO 2(JamEntryData 동형 getter/setter, 각 <50줄) + 순수함수 1메서드(JamEvalWindow, <40줄) → 병렬화 이득 미미·줄수 < 500 → advisor 직접. BibimbapApplicationTests 는 @MockBean 5줄 추가(import+필드) modify 라 직접. 정밀 산출물(매퍼 SQL alias/UPSERT, 컨트롤러 3중게이트, 전수 테스트)만 worker.
|
||||
- 11 - 4(직접) = 7 worker. **actual_workers = 7**.
|
||||
|
||||
## Bash 단계 (advisor 직접)
|
||||
- `./mvnw -o test-compile` (JAVA_HOME=openjdk@21) → **BUILD SUCCESS (EXIT 0)**. 신규 7 Java + 1 test modify 전부 컴파일 통과. 테스트 실행 안 함(test-compile 라이프사이클).
|
||||
- `./mvnw -o clean test-compile -Dmaven.compiler.showWarnings=true -Dmaven.compiler.compilerArgument=-Xlint:all` → BUILD SUCCESS. **W2-4 신규 파일 관련 경고 0건**(unused/deprecation/this-escape 포함). 전체 100 WARNING 은 기존 코드베이스 경고(W2-4 파일 경로 매치 0). **unused 진단 0 게이트 PASS**(별도 게이트).
|
||||
- grep 게이트: AC-T2(매퍼 `${`=0) PASS / AC-T4(DDL 토큰=0) PASS / AC-T3(VIEW매퍼 큰따옴표 alias 5건, 일반매퍼 큰따옴표 0) PASS / JSP innerHTML=0 PASS.
|
||||
|
||||
## §33 인용 alias 적용 결과 (이번 W 핵심 — W2-3 위임분)
|
||||
- **JamScoreStatsMapper(집계 VIEW 소비)**: camelCase alias 5건 전부 `AS "..."` 큰따옴표 인용 확인 — `AS "gameId"`, `AS "weightedTotal"`, `AS "simpleTotal"`, `AS "scoredCriteria"`, `AS "judgeCount"`. Postgres 케이스폴딩(GameReviewStatsMapper.java:13 BUG-2 선례) 회피. ORDER BY 컬럼(weighted_total/judge_count/game_id)은 VIEW 원본 소문자 컬럼이라 비인용(폴딩 무관). VP-7/AC-T3 PASS.
|
||||
- **JamCriteriaMapper/JamScoresMapper(일반 테이블)**: snake→camel **직접 alias**(큰따옴표 없음) — `criterion_key AS criterionKey` 등. 큰따옴표 alias 0건 확인(일반 매퍼 표준, VP-7).
|
||||
|
||||
## 3중 게이트 구현 위치 (JamScoringController.submitScores, 난제1 순서)
|
||||
| 단계 | 가드 | 라인 | 응답 | 소유 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `CsrfTokens.isValid(request)` | 78 | 403 errorBody | W1 인프라 |
|
||||
| 2 | `sessionUserId(session)` null | 82 | 401 | 본 W2-4 |
|
||||
| 3 | `jamRoleGate.isJudge(session, jamId)` | 87 | 403 "심사 권한 없음" | **W2-2 게이트 호출** |
|
||||
| 4 | `jamsMapper.getById(jamId)` null | 91 | 404 | W2-1 매퍼 소비 |
|
||||
| 5 | `JamEvalWindow.isOpen(jam, now())` | 96 | 422 "평가 기간 아님" | **본 W2-4(W2-3 F6 구현)** |
|
||||
| 6 | `jamEntriesMapper.exists(jamId, gameId)` | 100 | 404 | W2-1 매퍼 소비 |
|
||||
| 7 | `jamRoleGate.isOwnEntry(jamId, gameId, userId)` | 104 | 422 "자기 출품작" | **W2-2 게이트 호출** |
|
||||
| 8 | criterion 화이트리스트 + score 1~5 전수검증 → 통과 후 UPSERT 루프 | 108~140 | 422(미등록/범위/빈)/200 | 본 W2-4(S8) |
|
||||
- 트랜잭션 원자성: validated Map 전수 통과 후에만 upsertScore 루프(부분저장 금지). `@Transactional` + POST/PUT 동일핸들러(`@RequestMapping method={POST,PUT}`).
|
||||
|
||||
## 설계와의 차이
|
||||
없음 — 설계 정본 그대로 구현. concern 처리:
|
||||
- **concern 1(W2-2 게이트 시그니처 재확인)**: 실측 결과 `JamRoleGate.isJudge(session, jamId)` + `isOwnEntry(jamId, gameId, judgeUserId)` **별도 메서드** = 설계 §게이트연동 기본가정(b) 정확 일치. isJudge 통과 후 isOwnEntry 별도 호출(게이트 3·7 분리). 해소.
|
||||
- **concern 4(헬퍼 시그니처 inflate)**: JamEvalWindow.isOpen(JamData jam, OffsetDateTime now) — jam 3필드(status/evalStartAt/evalEndAt) 실사용(dead param 아님). 컨트롤러 7개 주입 의존 전부 실사용(unused 0 확인). 설계 기본 시그니처 유지(좁히지 않음 — getById 반환 그대로 전달이 호출부 단순).
|
||||
- **concern 5(UPSERT inflate)**: JamScoresMapper.upsertScore 단일 `INSERT ... ON CONFLICT (4키) DO UPDATE` 문(exists→update 2쿼리 회피). 동결 ux_jam_scores_jam_game_judge_criterion 타깃.
|
||||
- **concern 6(criterion_key 화이트리스트)**: 앱계층 listByJam → HashSet 화이트리스트, 미등록 422(upsert 미호출). AC-13.
|
||||
- **concern(@MockBean §30)**: BibimbapApplicationTests 에 신규 3매퍼 등록(AC-T6). JamRoleGate/PermissionGate 기등록. contextLoads test-compile 통과.
|
||||
|
||||
## 기존 파일 수정 + 근거
|
||||
- `BibimbapApplicationTests.java`: @MockBean 3매퍼 추가만. **설계 §파일영향맵 명시 수정 대상**(VP-8/AC-T6). 누락 시 NoSuchBeanDefinitionException(contextLoads FAIL). JamController 등 W2-1 검증 동작 파일은 **무수정**(slug 경로 vs JamScoringController 의 jamId 숫자경로 — 매핑 충돌 0).
|
||||
|
||||
## Verification 을 위한 힌트 (verification-advisor 입력)
|
||||
- acceptance criteria 는 설계 §검증 포인트(VP-1~VP-8) + §집합 전수 체크 AC(AC-T1~AC-T6) 참조.
|
||||
- 이번 변경으로 영향받는 테스트 파일: `JamScoringControllerTest`(신규 19), `JamEvalWindowTest`(신규 10), `BibimbapApplicationTests`(contextLoads 회귀 가드).
|
||||
- 본 advisor test-compile 자가확인만 수행(BUILD SUCCESS). **테스트 실행은 verification-advisor 영역**. full `./mvnw -o test` + DB contract(L2) 권장(§30).
|
||||
|
||||
---
|
||||
|
||||
## 검증 포인트 (설계 verbatim echo — verification-advisor 점검 대상)
|
||||
|
||||
> L레벨 매핑: 자격/기간/충돌 게이트·점수 입력 플로우 = 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 (설계 verbatim echo)
|
||||
|
||||
> 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. (구현: 라인 78/87/96/104 — 전수 존재.)
|
||||
- **AC-T2 신규 매퍼 전수 3개 `${` 0건** — 신규 매퍼 3파일(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper)에 `${` 매치 0: `grep -rc '${' <매퍼 3파일>` == 0 (AC-14, `${}` 동적치환 금지). (advisor 실측: 3파일 모두 0.)
|
||||
- **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 라 큰따옴표 불요·있으면 안 됨). (advisor 실측: VIEW매퍼 5건 큰따옴표, 일반매퍼 0건.)
|
||||
- **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' <K-MAPPER 3파일>` == 0(매퍼는 SELECT/INSERT ON CONFLICT 만). (advisor 실측: 0.)
|
||||
- **AC-T5 401/403/422 정책 응답 전수** — 점수 입력 게이트 실패 분기 전수가 정책대로: 미인증→401, 미심사위원→403, CSRF→403, 평가기간외/자기출품/미등록criterion/score범위→422, 출품작없음→404. 검증: JamScoringControllerTest 가 5분류(401/403/404/422 각) 케이스 전수 보유(테스트 메서드 열거) AND 각 응답 status 코드 정합. (구현: 19 테스트 5분류 전수 — 401×2/403×4/404×3/422×5/200×5.)
|
||||
- **AC-T6 신규 매퍼 @MockBean 전수 3건** — BibimbapApplicationTests 에 신규 3매퍼 @MockBean 전수 등록: contextLoads PASS AND 3매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 등록 수동 확인. 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30). (구현: 3매퍼 등록 + test-compile BUILD SUCCESS.)
|
||||
|
||||
## grounding 불일치
|
||||
없음. 설계 인용 앵커 전부 실재 확인:
|
||||
- JamRoleGate.isJudge(session, jamId):23 / isOwnEntry(jamId, gameId, judgeUserId):31 — 별도 메서드(설계 기본가정 b 일치).
|
||||
- JamsMapper.getById(jamId):35 → JamData(getStatus/getEvalStartAt/getEvalEndAt = OffsetDateTime).
|
||||
- JamEntriesMapper.exists(@Param jamId, @Param gameId):65.
|
||||
- GameReviewStatsMapper.java:13 — camelCase `AS "gameId"` 큰따옴표 / snake 비인용 선례(§33).
|
||||
- CsrfTokens.isValid(request):35 + errorBody()(403):51 + getOrCreate(session):20.
|
||||
- PermissionGate.has(session, key):22 + PermissionKeys.GAME_JAM_MANAGE 실재.
|
||||
- RecruitController.response(HttpStatus,String):186 / sessionUserId:155 패턴 / JamController.getOrCreate 모델 csrfToken:98.
|
||||
- jam-detail.jsp:1-30 — request.getAttribute("csrfToken") + HtmlUtils.htmlEscape + meta name="_csrf" 패턴.
|
||||
- BibimbapApplicationTests @MockBean: JamRoleGate/PermissionGate 기등록, 신규 3매퍼 추가.
|
||||
- jam-eval-ddl.sql: jam_criteria/jam_scores(ux 4키)/jam_score_stats VIEW(weighted_total/simple_total/scored_criteria/judge_count 소문자 컬럼) 동결 — 변경 0.
|
||||
|
||||
## 미해결
|
||||
없음. 설계 오픈질문 0, concern 전수 처리. 잔여는 verification-advisor 의 full test 실행 + DB contract(L2) 실측(ON CONFLICT/VIEW fan-out·0-division·alias 케이스폴딩) — 본 advisor 영역 밖.
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T12:30:00+09:00
|
||||
workstream: W2-5-인기투표
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 5
|
||||
planned_workers: 5
|
||||
actual_workers: 5
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
unused_diagnostics: 0
|
||||
test_compile: BUILD SUCCESS
|
||||
references:
|
||||
design: .atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md
|
||||
frozen_schema: docs/jam-eval-ddl.sql # §3 jam_votes (W2-3 동결, 본 워크 미수정)
|
||||
---
|
||||
|
||||
# 구현 보고: W2-5 인기투표 (잼당 1인1표 / 평가기간 게이트 / 표심 은닉→종료 후 공개)
|
||||
|
||||
## 변경 목록
|
||||
|
||||
| 파일 | worker | 변경 | 결과 요약 |
|
||||
|---|---|---|---|
|
||||
| `src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java` | w-001 | 신규 | @Mapper 6메서드(castVote/updateVote/deleteVote/findVotedGameId/countByJam/listCountsByJam). `#{}` only, `${}` 0건. listCountsByJam alias 큰따옴표 인용. |
|
||||
| `src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java` | w-002 | 신규 | @Controller 4핸들러(POST vote / DELETE cancelVote / GET mine / GET results). 게이트 6단, 토글, 노출 게이트. |
|
||||
| `src/main/webapp/WEB-INF/views/jam-detail.jsp` | w-003 | 수정 | 인기투표 섹션 + 결과 영역(종료 후만 렌더) + 내 표 + JS(textContent/CSRF 헤더) 추가. 기존 섹션 무변경. |
|
||||
| `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | w-004 | 수정 | JamVotesMapper @MockBean 등록(contextLoads 보존). import 1 + 필드 1. |
|
||||
| `src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java` | w-005 | 신규 | plain Mockito 24테스트. VP-1~8/VP-11/AC-T1/AC-T3 전수. |
|
||||
|
||||
신규 DDL 0건(W2-3 jam_votes 동결 소비). schema.sql / docs/*-ddl.sql 무수정.
|
||||
|
||||
## Bash 단계 (advisor 직접)
|
||||
- `./mvnw -o clean test-compile -Dmaven.compiler.showWarnings=true -Dmaven.compiler.compilerArgument=-Xlint:all` (JAVA_HOME=openjdk@21) → **BUILD SUCCESS (EXIT 0)**. 신규 4 Java + 1 test modify 전부 컴파일 통과. 테스트 실행 안 함(test-compile 라이프사이클 — verification 영역).
|
||||
- **unused 진단 게이트(별도)**: 전체 100 WARNING 중 W2-5 신규 파일(JamVotesMapper/JamVoteController/JamVoteControllerTest) 매치 **0건**. unused/this-escape/unchecked/dead-param 0. 전체 100 은 기존 코드베이스 경고(@MockBean deprecation 등, W2-5 경로 매치 0 — W2-4 보고서와 동일 기존 경고군).
|
||||
- 집합 전수 AC grep 게이트(상세 아래 §집합 전수 체크): AC-T1 핸들러 2 + CSRF 2 + 게이트 2 / AC-T2 `${` 0 / AC-T4 jam_votes DDL 변경문 0 — 전부 PASS.
|
||||
|
||||
## §33 인용 alias 적용 결과 (인기집계 매퍼)
|
||||
- `JamVotesMapper.listCountsByJam` 은 `List<Map<String,Object>>` 반환(집계 GROUP BY COUNT) → **Map resultType 이라 alias 큰따옴표 인용 적용**:
|
||||
`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`
|
||||
(선례: `JamScoreStatsMapper` 가 동일하게 `AS "gameId"` 큰따옴표 — Postgres lowercase 폴딩 회피로 Map 키 `voteCount` 보존 → 컨트롤러 `((Number) row.get("voteCount"))` 정상.)
|
||||
- 단일 스칼라 반환 `findVotedGameId`(Long), `countByJam`(long) 은 alias 불요 — 미적용(정상).
|
||||
- 그 외 POJO 직접매핑 매퍼 없음(JamVotesMapper 는 Map 1 + 스칼라 2 + 쓰기 3).
|
||||
|
||||
## 1인1표 / 평가기간 / 종료후공개 게이트 구현 위치
|
||||
- **1인1표(P2/G2/AC-2)**: DB `ux_jam_votes_jam_voter` UNIQUE(jam_id, voter_user_id)(W2-3 동결) + 컨트롤러 토글(`JamVoteController.vote` line 77~87): findVotedGameId → null=castVote(INSERT) / 동일 game=멱등 no-op / 다른 game=updateVote(game_id 교체). 표 추가 INSERT 안 함 → UNIQUE 충돌 없음.
|
||||
- **평가기간 게이트(P4/G4/AC-4)**: `JamVoteController.vote` line 68, `cancelVote` line 120 — `!JamEvalWindow.isOpen(jam, OffsetDateTime.now())` → 422 "투표 기간이 아닙니다.". W2-4 산출 `JamEvalWindow.isOpen`(EVAL + now∈[evalStart,evalEnd]) **재사용**(설계 §게이트연동 4 + JamScoringController 선례 동형). 상태변경 핸들러 2건 전부 보유.
|
||||
- **미로그인 차단(P5/G5/AC-5)**: vote line 58, cancelVote line 110, myVote line 141 — sessionUserId(session) null → 401. (results 는 공개 — session 미수신.)
|
||||
- **CSRF(AC-8)**: vote line 54, cancelVote line 106 — `!CsrfTokens.isValid(request)` → 403 errorBody. 매퍼 접근 전(게이트 1).
|
||||
- **종료후공개(P6/G6/AC-6 밴드왜건 회피)**: `JamVoteController.results` line 170~188 — `publiclyVisible = "CLOSED".equals(status) || (evalEndAt != null && now > evalEndAt)`. true=listCountsByJam(출품작별 count)+total / false=countByJam(총합만)+`results:null, open:true`(표심 은닉). JSP(jam-detail.jsp line 403/447) 동일 게이트 — 진행 중 출품작별 결과 영역 미렌더, 안내문만. **컨트롤러+JSP 2지점 모두 게이트 보유(AC-T3)**.
|
||||
- **본인 표 상시 노출(P6/AC-11)**: myVote line 151 — findVotedGameId(진행 중에도 본인 노출). 토글 응답에도 votedGameId 포함(집계는 미포함).
|
||||
|
||||
## 설계와의 차이 (근거 기록)
|
||||
|
||||
### 차이1 — listCountsByJam alias 큰따옴표 인용 (설계 concern 3 과의 의도적 deviation)
|
||||
- 설계 concern 3 / §W2-6계약 / §시그니처 주석은 "집계 VIEW 아님 → 큰따옴표 alias 불요(`COUNT(*) AS voteCount`)" 라 명시했다.
|
||||
- 그러나 본 구현은 `AS "voteCount"` 큰따옴표 인용을 적용했다.
|
||||
- **근거**: 케이스폴딩 위험의 기준은 "VIEW 여부"가 아니라 "**Map resultType 여부**"다. `listCountsByJam` 은 `List<Map<String,Object>>` 반환 → MyBatis 가 컬럼-프로퍼티 자동 대소문자 매칭을 하지 않고 DB 가 반환한 컬럼 라벨 문자열을 Map 키로 그대로 쓴다. Postgres 는 비인용 alias 를 lowercase 로 폴딩(`voteCount`→`votecount`)하므로 컨트롤러 `row.get("voteCount")` 가 null 이 되어 total 합산이 0 이 되는 결함이 발생한다. POJO 직접매핑(JamScoresMapper.listByJudge 등)은 MyBatis 케이스 무시 매칭으로 안전하지만 Map 은 그렇지 않다.
|
||||
- 프로젝트 동일 선례 `JamScoreStatsMapper`(Map resultType)도 `AS "gameId"` 큰따옴표 인용 → 본 구현이 그 선례 및 누적 교훈 §33(인용 alias)과 일치. 작업 지시 ★1("인기투표 집계 매퍼가 Map/집계면 인용")이 명시적으로 이 경로를 지시 — 작업 지시 > 설계 concern.
|
||||
- 설계 정본의 SQL 의미(컬럼/집계/정렬)는 불변. alias 인용은 L2 DB-방언 계약 안전 강화이며 API 응답 키(gameId/voteCount)도 설계 §외부계약과 동일. **기능/계약 변경 0, 무결성 강화만.**
|
||||
|
||||
### 차이2 (없음) — concern 1 dead method/parameter 재확인 결과
|
||||
- 설계 concern 1 은 castVote/updateVote/findVotedGameId 분리가 ON CONFLICT upsert 채택 시 dead 가능성을 경고했다. 본 구현은 **분기 전략(findVotedGameId 사전조회)을 채택**(upsert 아님)했고, 6개 매퍼 메서드 전부 컨트롤러에서 실사용 확인:
|
||||
- castVote(vote 신규), updateVote(vote 변경), deleteVote(cancelVote), findVotedGameId(vote 토글 분기 + myVote), countByJam(results 진행 중 총합), listCountsByJam(results 종료 후).
|
||||
- dead method 0, dead parameter 0(컴파일 lint 경고 0 으로 교차 확인).
|
||||
- 따라서 concern 1 은 "구현이 분기 전략 채택 + 전메서드 실사용" 으로 해소 — 시그니처 inflate 없음. (settled, 잔여 concern 아님.)
|
||||
|
||||
### 자기표 허용(P8) / 표 변경 허용(P7)
|
||||
- 설계 정본대로 구현. 자기 출품작 투표 422 분기 미추가(P8 허용), 표 변경 updateVote 경로 구현(P7). 설계 일치.
|
||||
|
||||
## Verification 을 위한 힌트
|
||||
|
||||
### acceptance criteria — 설계 §검증 포인트 verbatim echo
|
||||
> 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 아님 → 큰따옴표 미사용 확인).
|
||||
- ※ 구현 정정: 본 구현은 Map resultType 이라 **큰따옴표 인용 적용**(§설계와의 차이 차이1). 검증 시 매퍼 alias 가 `AS "gameId"`/`AS "voteCount"` 큰따옴표임을 확인 — Map 키 케이스폴딩 회피.
|
||||
- **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 — 설계 §집합 전수 체크 AC verbatim echo + 구현 충족 결과
|
||||
> 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 게이트의 핵심 가드**.
|
||||
- **구현 충족**: @PostMapping("/jams/{slug}/vote")(line 44) + @DeleteMapping("/jams/{slug}/vote")(line 97) = 2건. `CsrfTokens.isValid` 호출 2회(line 54, 106), `JamEvalWindow.isOpen` 호출 2회(line 68, 120) — 두 핸들러 각 진입부 보유. GET 2건은 게이트 비대상. PASS.
|
||||
- **AC-T2 투표 매퍼 `${` 0건** — JamVotesMapper(1파일)에 `${` 매치 0: `grep -c '\${' JamVotesMapper.java` == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
|
||||
- **구현 충족**: `grep -c '\${' JamVotesMapper.java` == **0**. (컨트롤러도 0.) PASS.
|
||||
- **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.
|
||||
- **구현 충족**: 컨트롤러 results line 170~172(`publiclyVisible = "CLOSED".equals(status) || (evalEndAt != null && now>evalEndAt)`) + JSP line 403/447(`resultsPublic = "CLOSED".equals(status) || evalEnded`; resultsPublic 일 때만 `#vote-results` 렌더, 아니면 "평가 종료 후 공개" 안내). JS 측 2차 게이트(line 520/583 `resultsPublic` + line 589 `data.open` 분기). 2지점(+JS) 모두 게이트 보유. PASS.
|
||||
- **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) 위반(투표가 스키마 손대기) 즉시 검출.
|
||||
- **구현 충족**: W2-5 산출물에 jam_votes ALTER/CREATE TABLE/CREATE INDEX **0건**(grep 무출력). 매퍼는 INSERT/UPDATE/DELETE/SELECT 만. docs/jam-eval-ddl.sql / schema.sql 무수정. PASS.
|
||||
|
||||
### 이번 변경으로 영향받는 테스트 파일
|
||||
- `src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java` (신규, 24테스트)
|
||||
- `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` (contextLoads — JamVotesMapper @MockBean 등록 후 PASS 필요, VP-7)
|
||||
- full `./mvnw -o test` 권장(§30): 신규 컨트롤러+매퍼 의존 contextLoads + 단위 24 + dev DB contract(L2: listCountsByJam GROUP BY + alias 케이스폴딩) 실측.
|
||||
|
||||
## grounding 불일치 / concerns 처리
|
||||
|
||||
### grounding 실측 결과 (불일치 0)
|
||||
- W2-3 jam_votes 스키마: docs/jam-eval-ddl.sql §3 line 87~117 직접 확인 — id/jam_id/game_id/voter_user_id/created_at + ux_jam_votes_jam_voter UNIQUE + idx_jam_votes_jam_game. 매퍼 컬럼명 일치.
|
||||
- W2-4 JamEvalWindow.isOpen(JamData, OffsetDateTime): jam/JamEvalWindow.java 직접 확인 — EVAL + now∈[evalStart,evalEnd] 경계포함. 평가기간 게이트 재사용 정합.
|
||||
- W2-1 JamController: getBySlug/sessionUserId(line 244~260)/response(line 270~275)/CsrfTokens 패턴/`/jams/{slug}/entries`·`/teams` 매핑 직접 확인 — JamVoteController `/vote*` 경로 충돌 0.
|
||||
- JamEntriesMapper.exists(jamId, gameId): 직접 확인(line 65) — 활성 출품작 검증 정합.
|
||||
- JamData getter: getStatus/getEvalStartAt/getEvalEndAt(OffsetDateTime)/getId/getSlug — JamsMapper alias(eval_end_at AS evalEndAt) + JamScoringController 사용처로 교차 확인. JSP getEvalEndAt() OffsetDateTime 반환 정합(worker 의존경고 해소).
|
||||
- CsrfTokens.isValid/errorBody/HEADER_NAME/SESSION_ATTRIBUTE/getOrCreate: security/CsrfTokens.java 직접 확인.
|
||||
- game_likes 무참조: JamVotesMapper/JamVoteController 에 game_likes 참조 0 — 설계 P3/G3(별개) 준수.
|
||||
- BibimbapApplicationTests @MockBean: 직접 확인(JamScoresMapper 등 기등록 패턴) — JamVotesMapper 추가 정합.
|
||||
|
||||
### concerns 처리 (설계 5개 concern)
|
||||
- concern 1 (시그니처 inflate / dead method): **해소** — 분기 전략 채택, 6메서드 전부 실사용, lint 경고 0. (§설계와의 차이 차이2.)
|
||||
- concern 2 (@MockBean §30): **충족** — BibimbapApplicationTests 에 JamVotesMapper @MockBean 등록(w-004), contextLoads test-compile 통과. VP-7 가드.
|
||||
- concern 3 (집계 alias 큰따옴표 불요 주장): **정정 적용** — Map resultType 이라 큰따옴표 인용 채택(§설계와의 차이 차이1 + ★1). 기능 무변경.
|
||||
- concern 4 (결과 노출 게이트 컨트롤러/JSP 분기): **구현** — 컨트롤러 results + JSP resultsPublic 2지점 게이트(AC-T3 PASS).
|
||||
- concern 5 (자기표 허용): **설계대로** — 자기 출품작 422 분기 미추가(P8 허용).
|
||||
- 신규 concern: **없음(빈 리스트)**. 설계 5 concern 전부 구현 단계에서 해소/정정/충족.
|
||||
|
||||
### 기존 파일 수정 + 근거
|
||||
- jam-detail.jsp(W2-1 산출) 수정 — 설계 §파일영향맵 "수정" 명시 대상. 투표 섹션+JS 추가만, 기존 hero/운영안내/출품작/팀/출품폼/팀폼 무변경(회귀 0). 근거: 설계 G1(읽기=잼 상세 JSP 재사용, 투표 폼/결과 영역 추가).
|
||||
- BibimbapApplicationTests 수정 — 설계 §파일영향맵 "수정(검증)" + concern 2 §30 의무. JamVotesMapper @MockBean 1개 추가만.
|
||||
|
||||
### 미해결
|
||||
- 없음. 설계 오픈질문 0, concern 5개 전부 처리, 집합 전수 AC 4개 PASS, test-compile BUILD SUCCESS, unused 진단 0.
|
||||
- (참고 — verification 영역, 차단 아님) JSP 결과 영역은 게임명 대신 "게임 ID N" 텍스트 표기(/vote/results 응답에 gameName 미포함, W2-6 소비 형태 안정화 전 Map 유지 — 설계 §시그니처 주석과 정합). 막대 그래프 대신 텍스트 count(설계 "1차는 종료 후 count 단순 노출" §비목표와 정합). 후속 개선 후보이며 AC 위반 아님.
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T13:10:00+09:00
|
||||
workstream: W2-6-시상 집계/결과
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
workers_spawned: 9
|
||||
planned_workers: 12
|
||||
actual_workers: 9
|
||||
self_verification:
|
||||
checklist_passed: true
|
||||
test_compile: PASS # ./mvnw -o test-compile EXIT=0 (openjdk@21)
|
||||
unused_diagnostics: PASS # javac -Xlint:all on 9 new/modified main sources → W2-6 unused 0 (third-party jar warnings only)
|
||||
references:
|
||||
design: .atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-design.md
|
||||
ownership: .atp/work-session/20260624-100749/implementation/W2-6-ownership.md
|
||||
---
|
||||
|
||||
# 구현 보고 — W2-6 시상 집계 / 결과 (W2 종점)
|
||||
|
||||
## 변경 목록
|
||||
|
||||
### 신규 생성 (production)
|
||||
| 파일 | worker | 결과 요약 |
|
||||
|---|---|---|
|
||||
| jam/AwardTrack.java | advisor 직접(w-002로 계획) | enum JUDGE/USER_RATING/POPULAR/GRAND + isValid. jam_awards CHECK 4값 1:1 |
|
||||
| data/JamAwardData.java | advisor 직접(w-001로 계획) | jam_awards 행 POJO + 표시조인 필드(gameName/thumbnailUrl/entrantName). scoreValue=BigDecimal |
|
||||
| jam/RankScores.java | advisor 직접 | standardCompetitionRank(1,2,2,4) + rankScore (N-rank+1)/N. byOrder(정렬)+byScore(동점판정) 2비교자 |
|
||||
| mapper/JamAwardsMapper.java | advisor 직접 | insert/deleteByJamTrack/listByJam/listByJamWithGame. 일반 테이블 POJO 직접 alias, `#{}` only |
|
||||
| mapper/JamReviewRatingMapper.java | advisor 직접(w-003로 계획) | USER_RATING — jam_entries LEFT JOIN game_review_stats. review_count>=3 + NULLS LAST. 집계 VIEW → `AS "avgRating"`/`"reviewCount"` 큰따옴표 |
|
||||
| jam/JamAwardService.java | advisor 직접 | 3트랙 독립 랭크 + GRAND 정규화 가중합 + NULL/미달 제외 + 멱등 @Transactional recompute |
|
||||
| controller/JamAwardAdminController.java | w-004(code-writer) | POST /admin/jams/{jamId}/awards/compute. 게이트→CSRF→404→F6 422→recompute→200 awardCounts |
|
||||
| controller/JamAwardController.java | w-005(code-writer) | GET /jams/{slug}/results. 공개, byTrack 그룹핑, "jam-results" 뷰 |
|
||||
| WEB-INF/views/jam-results.jsp | w-006(code-writer) | GRAND+3트랙 랭킹 표. HtmlUtils.htmlEscape 전수. 미산정 "결과 준비 중" |
|
||||
|
||||
### 수정 (기존 — 설계 근거 명시)
|
||||
| 파일 | worker | 결과 요약 + 근거 |
|
||||
|---|---|---|
|
||||
| controller/JamController.java | w-007(code-writer) | detail 모델 `awardsSummary` 추가 + 생성자 JamAwardsMapper 주입. **AW-DETAIL(설계 §파일영향맵 W2-1 협의 수정). 기존 5핸들러·model 키 무변경(추가만)** |
|
||||
| WEB-INF/views/jam-detail.jsp | w-008(code-writer) | 출품작 섹션 앞 "수상 결과" 배지 + 전체 결과 링크. **AW-DETAIL. awardsSummary 빈 가드 → 미산정 잼 회귀 0** |
|
||||
| test/BibimbapApplicationTests.java | w-009(code-writer) | JamAwardsMapper/JamReviewRatingMapper @MockBean 2건 추가(§30 contextLoads) |
|
||||
| test/controller/JamControllerTest.java | advisor 직접(계획외) | **JamController 6번째 생성자 인자(JamAwardsMapper) 추가로 인한 회귀 컴파일 깨짐 수정.** @Mock jamAwardsMapper + 생성자 호출 1줄 갱신. AW-DETAIL 변경의 불가피한 동반 수정(아래 §설계와의 차이) |
|
||||
|
||||
### 신규 테스트 (구현 산출물 — "(검증)" 태그여도 advisor 작성 의무)
|
||||
| 파일 | worker | 결과 요약 |
|
||||
|---|---|---|
|
||||
| test/jam/JamAwardServiceTest.java | w-010(code-writer) | 7 테스트: 3트랙 DESC rank·GRAND 정규화·1트랙 가용 포함·NULL weightedTotal 제외·empty·동점 competition·멱등 delete선행 |
|
||||
| test/controller/JamAwardAdminControllerTest.java | w-011(code-writer) | 7 테스트: 401/403/CSRF403/404/F6 422/CLOSED 200/eval종료 200 |
|
||||
| test/controller/JamAwardControllerTest.java | w-012(code-writer) | 5 테스트: missing/hidden redirect·빈 결과·트랙 그룹핑·익명 접근 |
|
||||
|
||||
## Bash 단계 (advisor 직접)
|
||||
- `./mvnw -o test-compile` (JAVA_HOME=openjdk@21) → **EXIT=0** (1차 회귀 1건: JamControllerTest 구생성자 → 수정 후 GREEN)
|
||||
- `javac -Xlint:all` 신규/수정 main 9파일 (m2 jar 클래스패스) → W2-6 unused 변수/파라미터/import **0** (third-party tomcat-embed-core 소스 + 어노테이션프로세서 warning 4건만 — W2-6 코드 무관)
|
||||
- 집합 전수 AC self-audit grep (아래 §전수 체크)
|
||||
|
||||
## 설계와의 차이 (planned 12 → actual 9 worker, advisor 직접 6파일)
|
||||
|
||||
**worker 전환 사유 (계량 근거)**: 의존 코어 3파일(JamAwardsMapper/RankScores/JamAwardService)은 상호 강결합 + GRAND 정규화 알고리즘(순위점수·가용트랙 가중평균·동점 competition)의 정밀도 비용이 커서 병렬 worker 분산 이득 < 정합 리스크 → advisor 직접. POJO(JamAwardData)·enum(AwardTrack)도 ≤50줄 사소 단일편집이라 직접. 합 6파일을 advisor 직접 작성, 나머지 9파일(컨트롤러2·JSP2·수정2·테스트3)을 code-writer 병렬 spawn.
|
||||
- advisor 직접 작성 파일: AwardTrack.java, JamAwardData.java, RankScores.java, JamAwardsMapper.java, JamReviewRatingMapper.java, JamAwardService.java + (계획외 회귀수정) JamControllerTest.java
|
||||
|
||||
**JamVoteCountMapper 미생성 (설계 concern 해소)**: 설계 §파일영향맵은 "JamVoteCountMapper(POPULAR) — W2-5 JamVotesMapper 와 별개 or 재사용, 구현 시 협의"로 명시. 기존 `JamVotesMapper.listCountsByJam(jamId)`(W2-5)가 이미 `[{gameId,voteCount}]` GROUP BY count + 큰따옴표 alias + @MockBean 등록됨 → **재사용**. 신규 매퍼 3→2, 신규 @MockBean 3→2. AC-T5 카운트는 2(설계 3가정에서 1 감소 — JamVotesMapper 기존 등록분이 POPULAR 소비 충당).
|
||||
|
||||
**JamScoreStatsMapper.listStatsByJam 재사용(JUDGE)**: 설계 명시(W2-4 소유 재사용). 신규 0.
|
||||
|
||||
**JamReviewRating Row POJO 미생성**: 설계 시그니처는 `JamReviewRatingRow` 언급. 기존 집계 소비 패턴(JamScoreStatsMapper/JamVotesMapper)이 `List<Map<String,Object>>`이므로 동형 채택 → row POJO 0(inflate 방지, design concern 1 정합).
|
||||
|
||||
**JamControllerTest 회귀 수정(계획외)**: AW-DETAIL 가 JamController 생성자에 6번째 인자(JamAwardsMapper)를 추가 → 기존 W2-1 테스트 컴파일 깨짐. 설계가 AW-DETAIL 을 "W2-1 협의 수정"으로 승인했으므로 그 동반 테스트 수정은 필수(테스트 작성/수정은 implementation 산출물). @Mock 1 + 생성자 호출 1줄만 변경, 기존 테스트 로직·단언 무변경.
|
||||
|
||||
## 설계 "검증 포인트" verbatim echo (verification-advisor 입력)
|
||||
|
||||
> L레벨 매핑: 산정 알고리즘(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.
|
||||
→ **본 구현 정정**: 신규 매퍼는 **2개**(JamAwardsMapper/JamReviewRatingMapper). POPULAR 는 기존 JamVotesMapper(@MockBean 기등록) 재사용 → 신규 @MockBean 2건. JamScoreStatsMapper 도 기존 @MockBean. contextLoads 보존.
|
||||
|
||||
## 설계 "집합 전수 체크 AC" verbatim echo + 적용 결과
|
||||
|
||||
- **AC-T1 시상 트랙 전수 4종 산정 경로 정합 불변식** — AwardTrack enum 멤버 수 == jam_awards_track_check CHECK IN 항목 수(W2-3 동결) == JamAwardService insert award_track 집합 == 4(JUDGE/USER_RATING/POPULAR/GRAND).
|
||||
→ **PASS**: enum 멤버 grep 4. JamAwardService.recompute 가 insertTrack(JUDGE/USER_RATING/POPULAR) + insertGrand(GRAND) 4 산정 경로 전수 호출(AwardTrack.values() 루프로 delete, 명시적 4 insert 경로).
|
||||
- **AC-T2 산정 소스 매퍼 전수 SELECT-only 단방향 불변식** — 산정 소스에 game_reviews/jam_scores/jam_votes 대상 INSERT/UPDATE/DELETE 0.
|
||||
→ **PASS**: grep `INSERT/UPDATE/DELETE (game_reviews|jam_scores|jam_votes)` in JamReviewRatingMapper/JamAwardsMapper → none. JamAwardsMapper 는 jam_awards 대상 INSERT/DELETE 만(허용). JamScoreStatsMapper/JamVotesMapper.listCountsByJam 은 SELECT/기존(W2-4/5).
|
||||
- **AC-T3 시상 신규 매퍼 `${` 0건** — 신규 매퍼 `${` 매치 0.
|
||||
→ **PASS**: 기능적 `${...}` 동적치환 0. (Javadoc 주석 텍스트 `` `${}` 0 `` 2건은 문서 — 파라미터 바인딩은 전부 `#{...}`.)
|
||||
- **AC-T4 집계 VIEW 소비 매퍼 alias 큰따옴표 전수** — JamReviewRatingMapper(game_review_stats)·JamScoreStatsMapper(jam_score_stats) camelCase alias 큰따옴표.
|
||||
→ **PASS**: JamReviewRatingMapper `AS "gameId"`/`AS "avgRating"`/`AS "reviewCount"`(집계 VIEW 소비). JamScoreStatsMapper(W2-4) `AS "weightedTotal"` 기존. 일반 테이블 매퍼 JamAwardsMapper 는 반대로 비인용 직접 alias(scoreValue) — VIEW vs 테이블 구분 정합(혼용 아님). JamVotesMapper.listCountsByJam 도 큰따옴표(`"voteCount"`).
|
||||
- **AC-T5 신규 시상 매퍼 @MockBean 전수 등록** — contextLoads PASS + 매퍼별 등록.
|
||||
→ **PASS(정정)**: 신규 매퍼 2개 모두 @MockBean 등록(JamAwardsMapper/JamReviewRatingMapper, line 104/107). POPULAR 소비 JamVotesMapper + JUDGE 소비 JamScoreStatsMapper 는 기존 @MockBean. test-compile GREEN.
|
||||
- **AC-T6 jam_awards 단방향 소비 무결성(신규 DDL 0)** — docs/ W2-6 신규 *-ddl.sql 0, db/schema.sql 변경 0.
|
||||
→ **PASS**: `git status docs/ db/` 신규 ddl/schema 0. jam_awards 는 W2-3 docs/jam-eval-ddl.sql 소유, W2-6 변형 0.
|
||||
|
||||
## §33 인용 alias 적용 (3트랙 집계 매퍼) + 멱등 + NULL/임계 제외 + CLOSED 게이트 위치
|
||||
|
||||
- **§33 인용 alias**:
|
||||
- JamReviewRatingMapper(game_review_stats 집계 VIEW, Map resultType) → `AS "gameId"`/`AS "avgRating"`/`AS "reviewCount"` 큰따옴표. (케이스폴딩 회피, GameReviewStatsMapper.java:13-15 선례 동형)
|
||||
- JamScoreStatsMapper(jam_score_stats 집계 VIEW, 기존 W2-4) → `AS "weightedTotal"` 기존.
|
||||
- JamVotesMapper.listCountsByJam(GROUP BY count, Map resultType, 기존 W2-5) → `AS "gameId"`/`AS "voteCount"` 기존.
|
||||
- JamAwardsMapper(일반 테이블, POJO 직접매핑) → 비인용 직접 alias(`AS jamId`/`AS scoreValue`). POJO 직접매핑은 비인용 OK(혼용 아님 — VIEW/Map 만 인용).
|
||||
- **멱등 보장 메커니즘**: JamAwardService.recompute @Transactional 경계. 진입 시 `for (AwardTrack t : values()) deleteByJamTrack(jamId, t.name())` 4트랙 전부 초기화 → 재INSERT. 두 번 돌려도 결과 동일, UNIQUE(jam_id,award_track,game_id) 위반 없음(매 트랙 선삭제). 부분 실패 시 단일 트랜잭션 롤백(트랙 비는 상태 회피).
|
||||
- **NULL/임계 제외**:
|
||||
- JUDGE — JamAwardService.trackPopulation 이 weightedTotal NULL(채점 0) 행 제외(score NULL → continue).
|
||||
- USER_RATING — JamReviewRatingMapper SQL `WHERE st.review_count >= 3`(F5 임계 미달 제외) + 리뷰 0개는 LEFT JOIN NULL → review_count NULL → 임계 제외. NULLS LAST.
|
||||
- POPULAR — JamVotesMapper GROUP BY 결과(득표>0 출품작만; 0표 행 없음).
|
||||
- GRAND — insertGrand 가 가용 트랙만 분자·분모 포함(weightSum 누적), 모집단=최소1트랙 union. 부당 0점 회피.
|
||||
- **CLOSED(F6) 게이트 위치**: JamAwardAdminController.computeAwards — getById 후 `"CLOSED".equals(jam.getStatus()) || (evalEndAt!=null && now().isAfter(evalEndAt))` 아니면 422. (앱계층 런타임 게이트, DB CHECK 강제 아님 — 설계 concern 정합)
|
||||
|
||||
## grounding 불일치 / concerns 처리 / 미해결
|
||||
- **grounding 불일치 0**: jam_awards 스키마(docs/jam-eval-ddl.sql §4 track CHECK 4값·rank>=1·ux UNIQUE·idx), jam_score_stats VIEW 컬럼(weighted_total), game_review_stats VIEW(avg_rating/review_count, line 174-201 `AS "avg_rating"`), jam_votes(W2-5 listCountsByJam), JamLifecycle/JamAdminController.requireJamManage 게이트 헬퍼, PermissionGate.has(2-arg), CsrfTokens, BibimbapApplicationTests @MockBean — 전부 실측 후 인용.
|
||||
- **concerns 처리**: design concerns 5건 전부 검토 — (1)dead parameter: javac -Xlint:all unused 0 확인(award/trackPopulation/RankScores 비교자 전부 사용). (2)신규 매퍼 @MockBean + full test: 2건 등록, test-compile GREEN(full test 는 verification 몫). (3)집계 VIEW alias 큰따옴표: JamReviewRatingMapper 적용. (4)임계 N=3/가중 1/3 상수: JamAwardService 상수로 위치(가변화 후속). (5)F6 422 + @Transactional 멱등: recompute @Transactional deleteByJamTrack 선행. → concerns: [] (반환 차단 사유 없음).
|
||||
- **미해결**: 없음. (후속 비스코프: 자동 산정 스케줄러·잼별 가변 가중치 — 설계 비목표.)
|
||||
|
||||
## Verification 을 위한 힌트
|
||||
- acceptance criteria: design.md "검증 포인트" VP-1~VP-7 + "집합 전수 체크 AC" AC-T1~AC-T6 참조.
|
||||
- 영향받는 테스트 파일(신규): JamAwardServiceTest, JamAwardAdminControllerTest, JamAwardControllerTest.
|
||||
- 회귀 위험 테스트(수정): JamControllerTest(생성자 6arg), BibimbapApplicationTests(@MockBean 2 추가).
|
||||
- full `./mvnw -o test` 실행 시 baseline 171 + W2-6 신규(서비스 7 + admin 7 + public 5 = 19) GREEN 기대. JDK: /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home (PATH 미등록 — JAVA_HOME export 필요).
|
||||
- L2(dev DB contract): JamReviewRatingMapper 3트랙 join·NULL·review_count>=3 경계·NULLS LAST 실측 권장(집계 VIEW alias 케이스폴딩).
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T12:30:00+09:00
|
||||
workstream: W2-6-시상 집계/결과
|
||||
---
|
||||
|
||||
# 파일 소유권 맵 — W2-6 시상 집계
|
||||
|
||||
## 설계 대비 구체화 결정 (concern 해소)
|
||||
- **JamVoteCountMapper 신규 생성 안 함**(설계 concern "JamVoteCountMapper vs W2-5 JamVotesMapper 재사용 — 구현 시 협의").
|
||||
기존 `JamVotesMapper.listCountsByJam(jamId)`(W2-5, 이미 `SELECT game_id AS "gameId", COUNT(*) AS "voteCount" ... GROUP BY game_id ORDER BY COUNT(*) DESC, game_id ASC` + 큰따옴표 alias + @MockBean 등록됨)를 POPULAR 트랙 소비로 **재사용**.
|
||||
→ 신규 매퍼 2개(JamAwardsMapper/JamReviewRatingMapper), 신규 @MockBean 2건.
|
||||
- **JamScoreStatsMapper.listStatsByJam(jamId)** 기존(W2-4, `weighted_total DESC NULLS LAST` 큰따옴표 alias) 재사용 — JUDGE 트랙. 신규 매퍼 0.
|
||||
- **JamReviewRatingRow/JamVoteCountRow POJO 생성 안 함** — 집계 소비는 기존 패턴(JamScoreStatsMapper/JamVotesMapper)대로 `List<Map<String,Object>>`. inflate 방지.
|
||||
|
||||
## 의존 순서 (배치)
|
||||
1. **배치1 (AW-DOMAIN 기반 — 의존 없음, 병렬)**: JamAwardData POJO, AwardTrack enum, JamReviewRatingMapper(독립 신규 매퍼)
|
||||
2. **배치2 (advisor 직접 — 의존 코어)**: JamAwardsMapper(JamAwardData 의존), RankScores 유틸, JamAwardService(전 의존)
|
||||
3. **배치3 (컨트롤러/JSP/수정/테스트 — 병렬)**: JamAwardAdminController, JamAwardController, jam-results.jsp, JamController(수정), jam-detail.jsp(수정), BibimbapApplicationTests(수정), 3 테스트
|
||||
|
||||
| 파일 | 담당 | id | 변경 | 의존 |
|
||||
|---|---|---|---|---|
|
||||
| data/JamAwardData.java | code-writer | w-001 | create | - |
|
||||
| jam/AwardTrack.java | code-writer | w-002 | create | - |
|
||||
| mapper/JamReviewRatingMapper.java | code-writer | w-003 | create | - |
|
||||
| mapper/JamAwardsMapper.java | advisor 직접 | - | create | w-001 |
|
||||
| jam/RankScores.java | advisor 직접 | - | create | - |
|
||||
| jam/JamAwardService.java | advisor 직접 | - | create | 전 |
|
||||
| controller/JamAwardAdminController.java | code-writer | w-004 | create | service |
|
||||
| controller/JamAwardController.java | code-writer | w-005 | create | mapper |
|
||||
| WEB-INF/views/jam-results.jsp | code-writer | w-006 | create | - |
|
||||
| controller/JamController.java | code-writer | w-007 | modify | mapper |
|
||||
| WEB-INF/views/jam-detail.jsp | code-writer | w-008 | modify | - |
|
||||
| test/BibimbapApplicationTests.java | code-writer | w-009 | modify | mappers |
|
||||
| test/jam/JamAwardServiceTest.java | code-writer | w-010 | create | service |
|
||||
| test/controller/JamAwardAdminControllerTest.java | code-writer | w-011 | create | adminctrl |
|
||||
| test/controller/JamAwardControllerTest.java | code-writer | w-012 | create | pubctrl |
|
||||
|
||||
불변식: 동일 파일 1 worker. 의존 코어(JamAwardsMapper/RankScores/JamAwardService)는 상호 강결합 + 정규화 알고리즘 정밀도가 높아 advisor 직접 작성(병렬 이득 없음·실수 비용 큼). 나머지 12 파일은 의존 해소 후 병렬 worker.
|
||||
</content>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T13:10:00+09:00
|
||||
workstream: W2-6-시상 집계/결과
|
||||
planned_workers: 12
|
||||
actual_workers: 9
|
||||
supersedes: W2-5-인기투표 (이전 workstream — 아래는 W2-6 활성 맵)
|
||||
detail: ./W2-6-ownership.md
|
||||
---
|
||||
|
||||
> 활성 workstream = W2-6 시상 집계. 상세 파일 소유권 맵은 `W2-6-ownership.md`.
|
||||
> planned 12 → actual 9 (의존 코어 6파일 advisor 직접 — 사유 W2-6-impl.md §설계와의 차이).
|
||||
|
||||
# 파일 소유권 맵 (W2-5 인기투표)
|
||||
|
||||
> 설계 권위: `.atp/work-session/20260623-104307/implementation/W2-5-popular-vote-design.md` §파일영향맵.
|
||||
> 의존 순서: V-MAPPER(시그니처 확정) → V-CONTROLLER(매퍼 시그니처 소비) → {V-VIEW, V-TEST-CTX, V-TEST-CTRL}(컨트롤러/매퍼 확정 후, 서로 독립 병렬).
|
||||
> 신규 DDL 0건(W2-3 jam_votes 동결 소비 — schema.sql/docs/*-ddl.sql 무수정 → migration-writer 불요).
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|
||||
|---|---|---|---|---|
|
||||
| `src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java` | code-writer | w-001 | create | - |
|
||||
| `src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java` | code-writer | w-002 | create | w-001 (매퍼 시그니처) |
|
||||
| `src/main/webapp/WEB-INF/views/jam-detail.jsp` | code-writer | w-003 | modify | w-002 (응답 키/액션 path) |
|
||||
| `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | code-writer | w-004 | modify | w-001 (매퍼 타입) |
|
||||
| `src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java` | code-writer | w-005 | create | w-002 (컨트롤러 시그니처) |
|
||||
|
||||
## 배치 계획
|
||||
- 배치1 (순차 선행): w-001 V-MAPPER — 1 worker
|
||||
- 배치2 (순차): w-002 V-CONTROLLER — 1 worker (매퍼 시그니처 의존)
|
||||
- 배치3 (병렬): w-003 V-VIEW + w-004 V-TEST-CTX + w-005 V-TEST-CTRL — 3 worker 동시 (서로 독립 파일, 컨트롤러/매퍼 확정 후)
|
||||
|
||||
## 불변식 점검
|
||||
- 동일 파일 1 worker only — PASS (5파일, 1:1).
|
||||
- 의존 병렬 금지 — 배치 간 순차(1→2→3), 배치3 내부 3파일 상호 독립이라 병렬 안전. PASS.
|
||||
- 스키마/마이그레이션 0 → migration-writer 불요. PASS.
|
||||
- §33 인용 alias: V-MAPPER 의 listCountsByJam 은 List<Map<String,Object>> 집계(GROUP BY COUNT) → Map resultType → 큰따옴표 인용 필수(JamScoreStatsMapper 선례). 설계 concern 3("큰따옴표 불요")과 충돌하나 누적 교훈 §33 + 작업 지시 ★1 우선 — report.md "설계와의 차이"에 기록.
|
||||
|
||||
## planned 5 vs actual 5
|
||||
- 전 파일 worker 위임. advisor 직접 처리 0건.
|
||||
- 사유: 매퍼(SQL alias/UPSERT 정밀)·컨트롤러(2상태변경+2조회 4핸들러, 게이트 6단·노출 게이트)·JSP(투표 폼+결과 은닉 분기)·테스트(VP 전수 15+케이스)·@MockBean modify 모두 정밀·독립 작업. 단일 파일 자잘 편집(직접 임계) 미만 아님.
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
---
|
||||
schema_version: 2
|
||||
sid: 20260624-100749
|
||||
resumed_from: null
|
||||
started_at: 2026-06-24T10:07:49+09:00
|
||||
ended_at: 2026-06-24T14:30:00+09:00
|
||||
branch: feat/v2
|
||||
user_request: "설계 한것 작업 착수 들어가자 — W2~W4 풀설계(11기능) 구현 착수"
|
||||
init_guard: "pass — docs/development/verification-strategies.md 존재"
|
||||
migrate_check: "skip — CLAUDE.md 에 atp:migrate 마커 없음 (이미 .atp/ 경로)"
|
||||
---
|
||||
|
||||
# ATP Work Session — 20260624-100749
|
||||
|
||||
## Summary
|
||||
W2 전체(W2-1~6) 구현 착수. 크리티컬 패스 순서 W2-1 → W2-2 → W2-3 동결 → (W2-4∥W2-5) → W2-6, 각 W = 구현→검증→커밋.
|
||||
|
||||
- **W2-1 게임잼 엔티티**: DONE, commit ccf1e42. 19파일(신규16+수정3). 1차 검증 L1 컴파일 FAIL(JamAdminController response() 헬퍼 누락) → fix1 보정(헬퍼 1개) → 재검증 PASS. ./mvnw test 97/97 GREEN, L2 contract PASS, 집합전수 AC-T1~6 PASS.
|
||||
|
||||
## Invocations
|
||||
- W2-1: implementation-advisor(21 worker) → verification-advisor(FAIL: 컴파일 response() 누락) → impl fix1 → verification(PASS 97/97) → commit ccf1e42
|
||||
- W2-2: implementation-advisor(7 worker) → [갭: 신규 테스트 2 미작성, "(검증)" 태그 오독] → impl fix1(테스트 24) → verification(PASS 119/119, L2 contract) → commit 71b6b6f
|
||||
- W2-3: implementation-advisor(1 worker, 스키마+VIEW 동결만) → verification(PASS 119 유지, L2 VIEW 집계+fan-out 불변 실측) → commit 2d5601b
|
||||
- W2-4: implementation-advisor(7 worker, 테스트 작성됨) → verification(PASS 148/148, L2 UPSERT·VIEW alias camelCase 보존) → commit 09ed6bc
|
||||
- W2-5: implementation-advisor(5파일, 테스트 작성됨, §33 Map alias 정정) → verification(PASS 171/171, L2 UNIQUE 토글·alias 폴딩) → commit 5ed06d9
|
||||
- W2-6: implementation-advisor(planned 12 → actual 9 worker; 코어 6파일 advisor 직접, JamVoteCountMapper 미생성=JamVotesMapper 재사용, 테스트 3 작성, JamControllerTest 회귀수정) → verification(PASS 190/190, L2 멱등 recompute·3트랙 임계/NULL·alias) → commit a74bf74
|
||||
|
||||
**W2 전체(W2-1~6) 완료. 6 feat 커밋(ccf1e42·71b6b6f·2d5601b·09ed6bc·5ed06d9·a74bf74). 최종 ./mvnw test 190/190 GREEN.**
|
||||
|
||||
## Advisor Invocation Decision Log
|
||||
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||
- advisor: requirements-advisor
|
||||
decision: skip
|
||||
rationale: '설계 11개 풀설계 완료(오픈질문 0). 요구 분해 불필요.'
|
||||
checked_at: 2026-06-24T10:10:00+09:00
|
||||
- advisor: research-advisor
|
||||
decision: skip
|
||||
rationale: 'W2-W4-grounding.md 코드 grounding 완료 + _cross-consistency-audit PASS. 재조사 불필요.'
|
||||
checked_at: 2026-06-24T10:10:00+09:00
|
||||
- advisor: design-advisor
|
||||
decision: skip
|
||||
rationale: '설계 산출물이 파일영향맵+계약+시퀀스 확정적, audit PASS. orchestrator→implementation-advisor 직행.'
|
||||
checked_at: 2026-06-24T10:10:00+09:00
|
||||
- advisor: implementation-advisor (W2-1)
|
||||
decision: call
|
||||
rationale: 'W2-1 잼 엔티티 — 신규 5테이블+5매퍼+2컨트롤러+3JSP+config+테스트. 다파일 worker 분산 필요.'
|
||||
checked_at: 2026-06-24T10:12:00+09:00
|
||||
- advisor: verification-advisor (W2-1)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가. full mvnw test + 집합전수 AC + L2.'
|
||||
checked_at: 2026-06-24T10:35:00+09:00
|
||||
- advisor: implementation-advisor (W2-1 fix1)
|
||||
decision: call
|
||||
rationale: '§2.6 backward — L1 컴파일 FAIL: JamAdminController/JamController 미정의 response() 헬퍼 44회 호출. 발원=구현단계 컨트롤러 worker. 2컨트롤러 전수 보정.'
|
||||
checked_at: 2026-06-24T10:38:00+09:00
|
||||
- advisor: implementation-advisor (W2-2)
|
||||
decision: call
|
||||
rationale: 'W2-2 심사위원 jam_judges — 신규 테이블+JamRoleGate+자기출품 충돌. W2-1 후 착수.'
|
||||
checked_at: 2026-06-24T10:48:00+09:00
|
||||
- advisor: implementation-advisor (W2-2 fix1)
|
||||
decision: call
|
||||
rationale: '구현 갭 — 설계 파일영향맵 신규 테스트 2건(JamJudgeAdminControllerTest/JamRoleGateTest) 미작성. impl-advisor가 "(검증)" 소유태그를 verification 작성으로 오독. 테스트 작성=구현 산출물. [retro 교훈 후보]'
|
||||
checked_at: 2026-06-24T11:05:00+09:00
|
||||
- advisor: implementation-advisor (W2-3)
|
||||
decision: call
|
||||
rationale: 'W2-3 평가 동결 ⚠️클러스터 권위 — jam_criteria/scores/votes/awards + jam_score_stats VIEW. W2-4/5 게이트. VIEW 매퍼 §33 인용 alias 필수.'
|
||||
checked_at: 2026-06-24T11:18:00+09:00
|
||||
- advisor: implementation-advisor (W2-4)
|
||||
decision: call
|
||||
rationale: 'W2-4 심사위원 평가 — criterion UPSERT + 가중집계 + 3중게이트. jam_score_stats VIEW 소비 매퍼 AS "camelCase" 인용 필수(W2-3 위임). 동결 후 착수.'
|
||||
checked_at: 2026-06-24T11:30:00+09:00
|
||||
- advisor: verification-advisor (W2-4)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가. full test + L2(ON CONFLICT UPSERT·VIEW alias 케이스폴딩·3중게이트).'
|
||||
checked_at: 2026-06-24T11:50:00+09:00
|
||||
- advisor: implementation-advisor (W2-5)
|
||||
decision: call
|
||||
rationale: 'W2-5 인기투표 — jam_votes 1인1표 UNIQUE + 평가기간 게이트 + 종료후 공개. W2-3 소비. 동결 후 W2-4와 병렬 가능했으나 C2(동시 advisor 금지)로 순차.'
|
||||
checked_at: 2026-06-24T12:02:00+09:00
|
||||
- advisor: verification-advisor (W2-5)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가. full test + L2(listCountsByJam Map alias 케이스폴딩·1인1표 토글·종료후공개).'
|
||||
checked_at: 2026-06-24T12:20:00+09:00
|
||||
- advisor: implementation-advisor (W2-6)
|
||||
decision: call
|
||||
rationale: 'W2-6 시상 집계(W2 마지막) — 3트랙 개별수상 + 가중 GRAND + NULL/임계 제외 + CLOSED 확정 멱등. jam_awards + jam_score_stats VIEW + game_review_stats + jam_votes 다중 소비. §33 인용 alias + 멱등.'
|
||||
checked_at: 2026-06-24T12:32:00+09:00
|
||||
planned_workers: 12
|
||||
actual_workers: 9
|
||||
worker_note: '의존 코어 6파일(AwardTrack/JamAwardData/RankScores/JamAwardsMapper/JamReviewRatingMapper/JamAwardService) advisor 직접(강결합+GRAND 정규화 정밀도). code-writer 9 spawn(컨트롤러2·JSP2·수정2·테스트3). JamVoteCountMapper 미생성(기존 JamVotesMapper.listCountsByJam 재사용 — 신규 매퍼 3→2, @MockBean 3→2). 회귀: JamControllerTest 6arg 생성자 동반수정. test-compile GREEN, unused 0.'
|
||||
- advisor: verification-advisor (W2-6)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가. full test + L2(멱등 recompute·3트랙 집계 NULL/임계·GRAND 가중·alias).'
|
||||
checked_at: 2026-06-24T12:52:00+09:00
|
||||
- advisor: graph-refresh-checker
|
||||
decision: call
|
||||
rationale: '세션종료 §9.4 — 코드 변경 큼(신규 30+ Java). 판정 fully-stale.'
|
||||
checked_at: 2026-06-24T13:05:00+09:00
|
||||
- advisor: graphify-update-advisor
|
||||
decision: call
|
||||
rationale: 'fully-stale no-defer 처리 — src/docs 재생성 + 메타 갱신.'
|
||||
checked_at: 2026-06-24T13:08:00+09:00
|
||||
|
||||
## Decisions
|
||||
- 범위 = W2 전체(W2-1~6) — 사용자 AskUserQuestion 선택. 크리티컬 패스 순서 W2-1→W2-2→W2-3 동결→W2-4→W2-5→W2-6, 각 W 구현→검증→커밋(롤백 경계 확보).
|
||||
- requirements/research/design advisor 스킵 — 풀설계 11개 완료(오픈질문 0) + _cross-consistency-audit PASS. orchestrator→implementation-advisor 직행.
|
||||
- W2-4/5 병렬 가능했으나 C2(orchestrator 동시 상위 advisor 금지)로 순차 진행.
|
||||
- **§33 케이스폴딩 가드 정밀화(W2-5 정정)**: 케이스폴딩 위험 기준 = "VIEW 여부"가 아니라 **Map resultType 또는 집계 SELECT**. → VIEW/Map/집계 매퍼는 camelCase alias `AS "..."` 인용, POJO 직접매핑은 비인용 OK. W2-4(JamScoreStatsMapper)·W2-5(JamVotesMapper.listCountsByJam)·W2-6(JamReviewRatingMapper) 적용. L2 에서 비인용 대조군 폴딩 재현으로 가드 입증.
|
||||
- DB 적용은 §6 수동 게이트 — L2 검증은 격리 throwaway DB 비파괴 실측(적용→실측→DROP), 실 dev DB(bibimbap) 무접촉. 실 dev DB 적용은 needs_user_verification.
|
||||
|
||||
## user_signals
|
||||
positive: []
|
||||
negative: []
|
||||
# 사용자 발화 = 초기 요청 1건 + 범위선택(W2 전체) 1건. 부정/긍정 시그널 없음(중립 진행).
|
||||
|
||||
## verified_by_me
|
||||
- **L1 (typecheck / unit+regression)**: `./mvnw -o test` 각 W 통과, 최종 **190/190 GREEN** (W2-1~6 누적, 회귀 0). 신규 테스트 누계 ~120(JamLifecycle/JamAdmin/JamController/JamRoleGate/JamJudgeAdmin/JamScoring/JamEvalWindow/JamVote/JamAwardService/JamAwardAdmin/JamAwardController). contextLoads PASS(신규 12매퍼+게이트 @MockBean 전수).
|
||||
- **L2 (contract-dev-db)**: pass — 격리 throwaway DB 비파괴 실측. XOR/status/track CHECK 거부, 활성 UNIQUE(slug/entries/judges/scores/votes) 거부, jam_score_stats VIEW 집계+fan-out 불변(심사위원 2→4 무왜곡), UPSERT ON CONFLICT 멱등, jam_awards recompute 멱등(DELETE→INSERT 2회 count·md5 동일), §33 alias camelCase 보존(비인용 대조군 폴딩 재현), 3트랙 임계/NULL 제외.
|
||||
- **로그 스캔**: clean (전 W. 잔여 WARN 은 Mockito/ByteBuddy agent·Spring static trailing-slash 환경 noise).
|
||||
|
||||
## needs_user_verification
|
||||
- **실 dev DB(bibimbap) jam DDL 미적용** — `db/apply-local-ddl.sh` 로 docs/jam-ddl.sql + jam-eval-ddl.sql + jam-judge-ddl.sql 적용 필요. L2 는 throwaway DB 로 통과했으나 실 dev DB 는 §6 수동 게이트라 미적용. L3 런타임/스모크 전 선행.
|
||||
- **L3 런타임 인가 스모크(WAR 기동 후)**: ① SUBADMIN 무권한 POST /admin/jams* → 403, 미인증 GET → /login redirect ② ADMIN 잼 생성·상태전이 → jam_status_log 적재 ③ 심사위원 지정/채점 200·거부경로(비-isJudge 403·기간외 422·자기출품 422) ④ 인기투표 토글·취소·종료후 결과 공개/진행중 은닉 ⑤ CLOSED 시상 산정 멱등(2회 동일)·트랙별 결과 노출.
|
||||
|
||||
## graph_refresh
|
||||
- 판정: **fully-stale** (graph-refresh-checker). 기준 b9d836d(2일전·16커밋차) — W1 RBAC 레이어도 미반영(이번 W2 외). src 75파일(+10119/-43), docs 16파일.
|
||||
- no-defer 처리 **완료**: orchestrator `/graphify src/`(1152노드/2813엣지/56커뮤니티, AST 101코드 + 이미지3 vision) + `/graphify docs/`(91노드/94엣지/20커뮤니티, semantic 2청크 — DDL FK·결정 rationale·교훈) 재생성. docs/graph/src·docs/ 본체 갱신(gitignore), docs/graph/index.md frontmatter source_commit a74bf74 + Scopes 표 갱신(graphify-update-advisor 선반영). 본체 .gitignore, index.md만 커밋.
|
||||
|
||||
## open_items
|
||||
- §33 alias 가드: W2-1 시점 발견(5매퍼 POJO 비인용 무해) → **하류 전부 반영 완료**. VIEW/Map/집계 매퍼(JamScoreStatsMapper·JamVotesMapper.listCountsByJam·JamReviewRatingMapper) `AS "camelCase"` 인용 적용·L2 입증. 잔여 0.
|
||||
- **회고 교훈 반영 완료(docs-first)**: verification-strategies.md "설계·테스트 단계 체크리스트"에 교훈 3건 append — (1) "(검증) 소유태그 신규 테스트=구현 산출물" (2) "케이스폴딩 기준=Map resultType/집계, VIEW 여부 아님" (3) "신규 컨트롤러 공유 헬퍼 정의 grounding 선확인". + 외부번들 권고 2건(design-advisor owner 표기·impl-advisor 헬퍼 grounding). memory_optional=false(bibimbap memory 미활성 → docs 단독 마감).
|
||||
|
||||
## Retrospective
|
||||
```yaml
|
||||
Retrospective:
|
||||
signals:
|
||||
positive: [] # 부정/긍정 명시 시그널 없음. 사용자 발화 = 초기 요청 1 + 범위선택(W2 전체) 1, 모두 중립.
|
||||
negative: []
|
||||
what_went_well:
|
||||
- 풀설계 audit PASS 활용 — requirements/research/design advisor 3종 스킵하고 orchestrator→implementation-advisor 직행. 오픈질문 0 + _cross-consistency-audit PASS 라는 사전조건을 근거로 한 비자명 판단이 6기능 전체에서 재작업 없이 검증됨.
|
||||
- per-W 구현→검증→커밋 사이클로 롤백 경계 확보 — W2-1~6 각각 독립 feat 커밋. 컴파일 FAIL/테스트 갭이 발생한 W2-1·W2-2 도 해당 W 안에서 backward 보정되어 인접 W 로 오염 전파 없음.
|
||||
- L2 contract 를 격리 throwaway DB 비파괴 실측으로 수행(적용→실측→DROP) — 실 dev DB(bibimbap) 무접촉. §33 alias 폴딩을 비인용 대조군 재현으로 가드 입증, VIEW fan-out 불변(심사위원 2→4 무왜곡)·UPSERT/recompute 멱등(count·md5 동일)까지 손계산 대조.
|
||||
- §33 alias 가드를 W2-1 시점 발견 후 하류(W2-4·W2-5·W2-6) 전부 전파 — open item 을 끝까지 닫고 잔여 0 으로 마감.
|
||||
what_to_improve:
|
||||
- W2-1 신규 컨트롤러(JamAdminController/JamController)가 공유 response() 헬퍼를 44회 호출하나 정의가 어디에도 없어 L1 컴파일 FAIL. test-compile 단계가 아니라 full ./mvnw test 에서야 발현 → grounding 단계에서 호출 심볼 정의 존재를 선확인했어야 함.
|
||||
- W2-2 설계 파일영향맵이 신규 *Test.java(JamJudgeAdminControllerTest/JamRoleGateTest)에 "(검증)" 소유태그를 붙여, impl-advisor 가 "verification-advisor 가 작성"으로 오독 → 테스트 미작성 갭. 테스트 작성은 구현 산출물이고 verification 은 실행만 한다는 경계가 설계 표기에서 흐려짐.
|
||||
- §33 케이스폴딩 위험 기준이 설계 concern3 에 "VIEW 만 인용"으로 부정확하게 기술됨 → W2-5(JamVotesMapper.listCountsByJam, Map resultType 비VIEW)에서 정정. 기준은 VIEW 여부가 아니라 Map resultType/집계 SELECT 여부.
|
||||
memory_candidates:
|
||||
- name: test-ownership-tag-is-impl-artifact
|
||||
type: feedback
|
||||
description: 설계 파일영향맵의 신규 *Test.java "(검증)" 소유태그는 구현 산출물 — verification 은 실행만, 작성 안 함.
|
||||
body_draft: |
|
||||
Rule: 설계 파일 영향맵에서 신규 테스트 파일(*Test.java)에 "(검증)" 류 소유태그를 붙이더라도, 그 태그는 "검증 단계가 작성한다"는 뜻이 아니다. 테스트 코드 작성은 implementation 단계의 산출물이고, verification-advisor 는 작성된 테스트를 실행·판정만 한다(Write 없음).
|
||||
Why: W2-2 에서 impl-advisor 가 "(검증)" 태그를 verification 작성으로 오독해 신규 테스트 2건(JamJudgeAdminControllerTest/JamRoleGateTest)을 미작성한 채 넘김 → backward fix1 으로 테스트 24건 보정. 소유태그가 "이 산출물이 검증을 위한 것"과 "검증 단계가 만든다"를 구분하지 못해 갭 발생.
|
||||
How to apply: (1) 설계 영향맵에서 신규 *Test.java 는 owner=implementation 으로 명시, "(검증)" 은 용도 라벨로만 쓴다. (2) impl-advisor 는 영향맵의 모든 신규 *Test.java 를 작성 대상으로 간주한다. (3) verification 단계 산출물에 Write 가 잡히면 경계 위반으로 본다.
|
||||
rationale_for_saving: 설계 표기 관례에서 비롯한 재현성 있는 단계 경계 오독. 코드/커밋에서 유도 불가, 관찰로만 드러남.
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md # "설계·테스트 단계 체크리스트" append-only 섹션
|
||||
memory_optional: false # docs 단독 마감 충분. bibimbap memory 미활성.
|
||||
- name: case-folding-trigger-is-map-resulttype-not-view
|
||||
type: feedback
|
||||
description: §33 alias 케이스폴딩 인용 기준 = Map resultType/집계 SELECT 여부지 VIEW 여부 아님.
|
||||
body_draft: |
|
||||
Rule: MyBatis alias 를 camelCase 로 유지하려 큰따옴표 인용(AS "camelCase")이 필요한 기준은 "VIEW 를 조회하느냐"가 아니라 "resultType=Map 또는 집계 SELECT 결과를 컨트롤러가 camelCase 키로 꺼내느냐"이다. POJO 직접 매핑은 MyBatis 가 setter 매칭으로 흡수하므로 비인용 alias 도 무해.
|
||||
Why: 기존 교훈(verification-strategies.md L33)은 케이스폴딩을 Map 키 일반 문제로 맞게 짚었으나, W2 설계 concern3 은 이를 "VIEW 만 인용"으로 좁혀 부정확. W2-5 JamVotesMapper.listCountsByJam(VIEW 아님·Map resultType)에서 비인용이면 동일 폴딩 발생함을 확인 → 기준 정밀화.
|
||||
How to apply: alias 인용 필요 판정 = (resultType 이 Map 인가?) OR (집계/뷰 SELECT 결과를 camelCase 키로 소비하는가?). 둘 다 아니면(POJO 직접) 비인용 OK. L2 에서 비인용 대조군 폴딩 재현으로 가드.
|
||||
rationale_for_saving: 기존 BUG-2/L33 교훈의 정밀화 — VIEW 한정 오해를 교정. 재발 가능(집계 Map 매퍼마다), 관찰 기반.
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md # L33 케이스폴딩 항목 정밀화(append 또는 in-place 보강)
|
||||
memory_optional: false
|
||||
conflicts_with: null # L33 과 상충 아님 — 정밀화/보강
|
||||
- name: new-controller-shared-helper-grounding-check
|
||||
type: feedback
|
||||
description: 신규 컨트롤러가 호출하는 공유 헬퍼는 정의 존재를 grounding 에서 확인 — test-compile 로 안 잡히는 경우가 있다.
|
||||
body_draft: |
|
||||
Rule: 신규 컨트롤러/클래스가 공유 헬퍼 메서드(예: response())를 호출하면, implementation grounding 단계에서 그 헬퍼의 정의가 실제로 존재하는지(상위 클래스·유틸·믹스인 어디든) rg 로 선확인한다. "관례상 있을 것"으로 가정하지 않는다.
|
||||
Why: W2-1 에서 JamAdminController/JamController 가 response() 를 44회 호출했으나 정의가 없어 L1 컴파일 FAIL. test-compile 게이트에서는 통과한 것처럼 보이고 full ./mvnw test 에서야 발현 → backward fix1.
|
||||
How to apply: (1) 신규 컨트롤러 worker 지시에 "호출하는 공유 헬퍼 정의 존재 확인" 체크 포함. (2) test-compile GREEN 을 컴파일 안전 근거로 신뢰하지 말고 full test 까지(이미 L33 의 'full test 의무'와 결합). (3) 헬퍼가 없으면 정의 추가도 같은 W 산출물에 포함.
|
||||
rationale_for_saving: 컴파일 게이트의 사각(test-compile vs full test)에서 비롯, 신규 컨트롤러마다 재발 가능. 기존 L30(매퍼 @MockBean) 교훈과 인접하나 결이 다름(헬퍼 정의 부재 컴파일).
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md # L30 'full test 의무' 항목에 헬퍼 정의 grounding 한 줄 보강
|
||||
memory_optional: false
|
||||
protocol_feedback:
|
||||
- "외부 번들 — 미적용: design-advisor 파일영향맵 표기 규약에 '신규 *Test.java 의 owner 는 implementation, (검증)/용도 라벨과 owner 를 분리 표기' 조항 추가 권고. structural — 단계 경계 오독을 표기가 유발."
|
||||
- "외부 번들 — 미적용: implementation-advisor 체크리스트에 '신규 컨트롤러가 호출하는 공유 헬퍼/심볼 정의 존재 grounding 확인(test-compile 비신뢰)' 조항 추가 권고."
|
||||
applied_changes: [] # orchestrator 가 수용 후 채움
|
||||
```
|
||||
|
|
@ -0,0 +1,748 @@
|
|||
---
|
||||
phase: verification
|
||||
agent: verification-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-24T12:40:00+09:00
|
||||
concerns:
|
||||
- "RESOLVED(재검증): 1차 L1 BLOCKER FAIL(JamAdminController 24 errors response() 미정의) → 보정 후 BUILD SUCCESS, Tests run 97 / Failures 0 / Errors 0 / Skipped 0"
|
||||
- "§33 잠재 리스크(무해): 5매퍼의 camelCase alias 67건이 큰따옴표 비인용 → Postgres 가 소문자 폴딩(AS createdAt → createdat 실측 확인). 단 5매퍼 전부 resultType=POJO(*Data, Map 0건)이므로 MyBatis case-insensitive 매핑으로 현재 무해. resultType 을 Map 으로 바꾸면 즉시 §33 버그화 → 회귀 가드 권고(AS \"createdAt\" 인용 표준화)"
|
||||
- "AC-T3 산술(requireJamManage 6 = POST 5 + 선언 1)은 우연 일치. 쓰기 5핸들러 전수 게이트 + 읽기 console gate.has(GAME_JAM_MANAGE) 인라인 — 인가 우회 0. VP-1 JamAdminControllerTest 10 PASS 로 런타임 도달 실증"
|
||||
- "AC-T6 원문 grep '@MockBean.*Jam' same-line 가정은 오탐 — 실제 어노테이션 별행. 매퍼명 기준 실측 5/5 정합으로 AC 통과, AC 표현 보정 권고"
|
||||
- "L2 contract: 사용자 dev 스키마 비파괴 위해 격리 throwaway DB 에 jam-ddl 적용 후 검증·DROP 완료. 사용자 dev(bibimbap) DB 의 jam 테이블 0건 — 미적용 상태 유지(파괴적 조작 회피). 운영 적용은 db/apply-local-ddl.sh 로 별도 수행 필요"
|
||||
- "W2-2 RESULT: L1 BUILD SUCCESS, Tests run 119 / Failures 0 / Errors 0 / Skipped 0 — 전 클래스 GREEN. JamRoleGateTest 8, JamJudgeAdminControllerTest 14. 실패 클래스 없음"
|
||||
- "W2-2 AC 예측 vs 실측 불일치(무해, FAIL 아님): AC 입력은 JamJudgeAdminControllerTest(16)·총 121(97+24) 예측이었으나 실측 JamJudgeAdminControllerTest=14·총 119. 전 테스트 GREEN·JamRoleGateTest는 예측 8과 정확 일치. 카운트는 구현이 테스트를 더 적게 작성/통합한 결과로 추정되나 advisor는 원인 추정 넘어 단정 안 함 — AC 예측치 보정 권고"
|
||||
- "W2-2 AC-T6 grep -c (매퍼명 기준)=2는 import 1 + @MockBean 필드 1을 합산한 값. 실측 line73-74/88-89에서 실제 @MockBean 필드 등록 확인 — 정합 PASS"
|
||||
- "W2-2 AC-T2 게이트(핸들러3 requireJamManage)는 구현파일 직접열람 제한으로 grep 전수 미수행. 대신 JamJudgeAdminControllerTest 14 GREEN(403/401 인가거부 케이스 포함)으로 런타임 게이트 도달 실증 + AC-T3 isValid 2건으로 쓰기경로 CSRF 확인. 인가우회 0은 단위테스트 간접근거 기반 PASS"
|
||||
- "W2-2 L2 contract: 사용자 dev(bibimbap) 스키마 비파괴 위해 격리 throwaway DB(verify_w22_throwaway) 신규생성→jam-judge-ddl 적용→a/b/c 실측→DROP 완료(잔존 0). 사용자 dev DB 무접촉. 운영 적용은 db/apply-local-ddl.sh 별도 수행 필요"
|
||||
- "W2-3 RESULT: PASS. L1 회귀 무변경 — BUILD SUCCESS, Tests run 119 / Failures 0 / Errors 0 / Skipped 0 (W2-2 119 GREEN 그대로 유지, Java src 무변경·신규테스트 0이 정상). L2 dev DB contract 전 항목 PASS — DDL 적용·9FK·CHECK4·UNIQUE4·VIEW 집계 정합·NULLIF/fan-out 가드 실측 통과."
|
||||
- "W2-3 AC-T1~T6 집합 전수: T1(CREATE TABLE IF NOT EXISTS=4, VIEW jam_score_stats=1, schema.sql 4테이블 line608/642/685/721 + VIEW line765 동기) PASS / T2(jam_awards_track_check IN 4값 JUDGE,USER_RATING,POPULAR,GRAND 실생성 확인) PASS / T3(UNIQUE INDEX 4: ux_jam_criteria_jam_key·ux_jam_scores_jam_game_judge_criterion·ux_jam_votes_jam_voter·ux_jam_awards_jam_track_game + CHECK 4: weight>=0·score BETWEEN 1 AND 5·track IN·rank>=1) PASS / T4(매퍼 신규 0 = N/A, 하류 W2-4 위임) / T5(game_reviews/game_review_stats 변경 0, ALTER 17건 전부 신규 jam_* 테이블 제약추가뿐, DROP 0) PASS / T6(알파벳 순 jam-ddl→jam-eval-ddl→jam-judge-ddl FK 9건 실생성 해소) PASS"
|
||||
- "W2-3 핵심 게이트 VIEW 집계 정합(§33 fan-out): per_criterion CTE 선집계 후 jam_criteria LEFT JOIN 패턴. 실측 GameA raw 3행→5행(심사위원 fan-out)에도 scored_criteria=2 불변·judge_count=MAX(per-criterion)=4 정확·weighted_total=3.5 불변 → 자식 행수 부풀림 0. 미채점 criterion 제외(GameB art 미채점 시 scored_criteria=1·분모서 제외). NULLIF(SUM(weight),0) 0-division 가드: 점수0건 게임은 VIEW 행 미생성, weight합0 강제경로서 weighted_total NULL 반환(에러 아님). game_review_stats 선례 버그 재발 0."
|
||||
- "W2-3 VIEW alias 정합: 6컬럼 jam_id·game_id·weighted_total·simple_total·scored_criteria·judge_count 전부 소문자 snake(비인용). 하류 매퍼가 AS \"camelCase\" 인용으로 매핑할 계약(W2-4 검증 위임). 본 단계는 VIEW 자체 컬럼명 확인만 — 정합."
|
||||
- "W2-3 L2 비파괴: 격리 throwaway DB(verify_w23_throwaway) 신규생성→users/games 최소부모+jam-ddl(jams 제공)→jam-eval-ddl(SUT)→jam-judge-ddl 알파벳순 적용→제약거부 a/b/c/d + VIEW 집계 실측→DROP 완료(잔존 0). 사용자 dev DB(bibimbap)의 jam_eval 테이블 0건 — 미적용 상태 유지(파괴적 조작 회피). 운영 적용은 별도 수행 필요."
|
||||
- "W2-6 RESULT: PASS. L1 full-test BUILD SUCCESS, Tests run 190 / Failures 0 / Errors 0 / Skipped 0 (직전 171 + 신규 19 = 190 기대 일치). 신규 JamAwardServiceTest 7·JamAwardAdminControllerTest 7·JamAwardControllerTest 5 전수 GREEN. 회귀 0 — JamControllerTest 6arg 생성자 변경 후 14/14 GREEN, BibimbapApplicationTests contextLoads 1 PASS(신규 2매퍼 @MockBean 등록). 실패 클래스 없음."
|
||||
- "W2-6 집합 전수 AC-T1~T6: 6/6 PASS(어긋남 0). T1 신규매퍼 MyBatis ${param} 0(grep -c \'\\${\'=1은 Javadoc 주석 문구 — 실제 동적치환 패턴 0). T2 집계매퍼 camelCase 비인용 alias 0(JamReviewRatingMapper/JamVotesMapper/JamScoreStatsMapper), 인용 AS \"...\" 정상. T3 deleteByJamTrack+@Transactional recompute 존재. T4 CLOSED→422·CSRF→403·PermissionGate.has(GAME_JAM_MANAGE) 전수. T5 @MockBean 2매퍼. T6 JamVotesMapper.listCountsByJam 인용 alias."
|
||||
- "W2-6 L2 dev DB contract(§33): PASS 4/4. throwaway DB(w2_6_l2_throwaway) 생성→jam-eval 검증구조+game_review_stats 적용→실측→DROP(count=0). 사용자 dev(bibimbap) DB 무접촉(jam_awards 테이블 부재 = 미적용 유지·파괴조작 0). L2-1 멱등: DELETE→INSERT x2 count 6==6 md5 동일·UNIQUE하 중복0. L2-2 3트랙 손계산 일치(JUDGE NULL제외·USER_RATING count>=3 NULLS LAST·POPULAR>0). L2-3 alias 폴딩: row_to_json 키 camelCase 보존(gameId/avgRating/reviewCount/voteCount), game_review_stats fan-out 0. L2-4 GRAND 가용트랙만 정규화 원칙 확인."
|
||||
- "W2-6 SCOPE 한계(무해): GRAND 정확 가중계수는 RankScores/JamAwardService 내부 — verification-advisor 역할상 구현파일 미열람. L2-4는 DB-계약 수준의 \"가용 트랙만 정규화·결합\" 원칙만 검증, 계수 단위검증은 L1 JamAwardServiceTest 7 GREEN 에 위임. FAIL 아님."
|
||||
- "W2-6 L3 needs_user_verification(수동): 실 세션 권한 미보유 403·CLOSED 미달 422·results 페이지 종료후 공개(밴드왜건 회피) 런타임 + dev DB jam-eval-ddl 적용 후 멱등 스모크는 advisor 범위 밖 — orchestrator/사용자 확인 권장."
|
||||
concerns_checked: true
|
||||
---
|
||||
|
||||
# 검증 결과
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
|
||||
### L1 (blocker)
|
||||
- full `./mvnw -o test` 실행 (§30 의무, test-compile 로 끝내지 말 것)
|
||||
- contextLoads PASS (신규 5매퍼 @MockBean 누락 시 NoSuchBeanDefinitionException → FAIL)
|
||||
- 신규 테스트 통과: JamLifecycleTest, JamAdminControllerTest, JamControllerTest
|
||||
- 기존 테스트 회귀 0 (전체 GREEN)
|
||||
|
||||
### 집합 전수 AC
|
||||
- AC-T1: `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-ddl.sql` == 5, AND db/schema.sql 동일 5 테이블 전수 존재
|
||||
- AC-T2: JamStatus enum 4 == jams_status_check IN 4 == jam_status_log to_status IN 4 (3곳 동기)
|
||||
- AC-T3: JamAdminController 핸들러 수 == requireJamManage 게이트 호출 수 (6), 수동 열거
|
||||
- AC-T4: 신규 매퍼 5파일 `${` 매치 0건
|
||||
- AC-T5: jam_entries_entrant_xor_check + jam_entries_entrant_type_check 2 CHECK 존재
|
||||
- AC-T6: `grep -c '@MockBean.*Jam' <BibimbapApplicationTests>` >= 5
|
||||
|
||||
### 시나리오 AC
|
||||
- VP-1 게이트 / VP-2 전이 감사 / VP-3 출품 무결성 / VP-5 CSRF (L1 테스트로 커버)
|
||||
|
||||
### L2 (dev DB contract — 가용 시)
|
||||
- XOR/status/active-UNIQUE CHECK 실측, keyset SQL + camelCase alias 정합
|
||||
|
||||
## 실행된 전략
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| verify-all (L1 full test) | `JAVA_HOME=.../openjdk@21 ./mvnw -o test` | 1 | blocker | **FAIL (컴파일 실패)** |
|
||||
| AC-T1 grep | `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-ddl.sql` | 0 | blocker | pass (5) |
|
||||
| AC-T4 grep | `grep -rc '\${' <매퍼5>` | 0 | blocker | pass (0,0,0,0,0) |
|
||||
| AC-T5 grep | `grep -c '<2 check>' docs/jam-ddl.sql` | 0 | blocker | pass (1+1) |
|
||||
| AC-T6 grep(보정) | `grep -B1 'private Jam.*Mapper' \| grep -c '@MockBean'` | 0 | blocker | pass (5) |
|
||||
| L2 contract | `db/apply-local-ddl.sh` | - | blocker | **skipped:no-dev-db + 빌드미컴파일** |
|
||||
|
||||
(분해 결과 병기)
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck/compile | **FAIL — JamAdminController 24 cannot-find-symbol `response(HttpStatus,String)`** |
|
||||
| L1 unit+regression | **미실행 (컴파일 게이트에서 중단, 테스트 0개 도달)** |
|
||||
| L2 contract-postgres | skipped:no-dev-db (PGHOST/PGUSER 미설정 + 빌드 미컴파일) |
|
||||
| 로그 스캔 | n/a (앱 미기동) |
|
||||
|
||||
## 실패 상세
|
||||
|
||||
### L1 compile (blocker)
|
||||
- 실패 지점: `src/main/java/com/pandoli365/bibimbap/controller/JamAdminController.java`
|
||||
- line 92,96,100,104,108,121,124,158,195,200,204,208,212,216,229,232,270,275,279,282,312,341,355,358 (총 24건)
|
||||
- 메시지: `cannot find symbol — symbol: method response(org.springframework.http.HttpStatus,java.lang.String) — location: class com.pandoli365.bibimbap.controller.JamAdminController`
|
||||
- 근거: `grep -E '(private|protected|public|static).*\bresponse\s*\('` JamAdminController → 선언 0건(exit 1). `extends`/`implements` 슈퍼타입 없음(`public class JamAdminController {`). 즉 클래스 내부에 `response(HttpStatus,String)` 헬퍼가 정의돼 있지 않은데 24회 호출 → 미해결 심볼.
|
||||
- 부수: `JamController.java` 도 `response(` 20건 참조. javac 가 JamAdminController 에서 BUILD FAILURE 로 중단되어 JamController 는 미평가이나 동일 미정의 의심.
|
||||
- 파급: `[INFO] 24 errors` → `BUILD FAILURE` (maven-compiler-plugin default-compile). test-compile/surefire 진입 못함 → contextLoads·JamLifecycleTest·JamAdminControllerTest·JamControllerTest·전체 회귀 **0개 실행**.
|
||||
- 오프라인 판정: `-o` 빌드가 의존성 해석 에러가 아닌 **자체 소스 컴파일 에러**를 반환. 온라인 `./mvnw test` 재시도해도 동일 24 에러 반복(§30 retry 대상 아님).
|
||||
- 재현: `JAVA_HOME=/opt/homebrew/Cellar/openjdk@21/21.0.11/libexec/openjdk.jdk/Contents/Home ./mvnw -o test`
|
||||
- 좁힌 형태: `JAVA_HOME=... ./mvnw -o compile`
|
||||
|
||||
## 종합 판정
|
||||
overall: **fail** (L1 compile blocker)
|
||||
rollback_signal: none
|
||||
# 이번 실패는 신규 파일 자체의 미완성 컴파일 결함 — 파괴적 조작(DB migration/파일 삭제) 영향 범위 밖.
|
||||
# DDL/schema.sql 동기, 매퍼 SQL, @MockBean 등록 등 데이터/스키마 측 산출물은 정합.
|
||||
# implementation 단계에서 response() 헬퍼 정의(또는 호출부 보정) 추가 후 재검증이면 충분. 광범위 revert 불필요.
|
||||
|
||||
## 집합 전수 AC 실측
|
||||
| AC | 실측 | 기대 | 판정 |
|
||||
|---|---|---|---|
|
||||
| AC-T1 (jam-ddl CREATE TABLE) | 5 | 5 | PASS |
|
||||
| AC-T1b (schema.sql 5 테이블 동기) | jams/jam_teams/jam_team_members/jam_entries/jam_status_log 각 1 | 5 전수 | PASS (원문 grep 은 비인용 패턴이라 0 오탐 → 큰따옴표 식별자 `"jams"` 로 재확인) |
|
||||
| AC-T2 (enum=jams CHECK=log CHECK 3곳) | enum RECRUIT/DEV/EVAL/CLOSED 4, jams_status_check IN 4, jam_status_log_to_status_check IN 4 | 4==4==4 | PASS |
|
||||
| AC-T3 (핸들러 게이트 정합) | 매핑 6(GET console 1 + POST 5), requireJamManage 호출 5 + 선언 1. 쓰기 5핸들러 진입부 전수 게이트(82/186/261/303/332), 읽기 console 은 inline gate.has(GAME_JAM_MANAGE). 인가 우회 없음 | 무우회 | PASS (산술 6==6 은 우연; 실질 전수 게이트 OK) |
|
||||
| AC-T4 (매퍼5 `${` 0건) | 0,0,0,0,0 | 0 | PASS |
|
||||
| AC-T5 (jam_entries 2 CHECK) | xor 1 + entrant_type 1 | 2 | PASS |
|
||||
| AC-T6 (@MockBean Jam >=5) | 5 (JamsMapper/JamEntriesMapper/JamTeamsMapper/JamTeamMembersMapper/JamStatusLogMapper, line 66/69/72/75/78) | >=5 | PASS (원문 same-line grep 은 0 오탐, AC 표현 보정 권고) |
|
||||
|
||||
## 시나리오 AC 커버
|
||||
| VP | 커버 | 비고 |
|
||||
|---|---|---|
|
||||
| VP-1 게이트 (ADMIN/SUBADMIN+키/무키403/미인증401) | **미커버** | L1 미실행. JamAdminControllerTest 도달 못함 |
|
||||
| VP-2 전이 감사 (JamLifecycle 허용/거부 + status_log insert) | **미커버** | JamLifecycleTest 미실행 |
|
||||
| VP-3 출품 무결성 (소유/멤버/중복409/비소유422/비멤버422/EVAL422) | **미커버** | JamControllerTest 미실행 |
|
||||
| VP-5 CSRF (쓰기 전수 누락→403 + mapper 미호출) | **미커버** | 미실행 |
|
||||
|
||||
## L3 (런타임 인가 플로우)
|
||||
- 자동 실행: **불가** — 빌드 미컴파일로 앱 기동 자체 불가.
|
||||
- needs_user_verification (컴파일 복구 후): WAR 기동 → (1) GAME_JAM_MANAGE 무권한 SUBADMIN 세션으로 POST /admin/jams/* 호출 시 403, (2) 미인증으로 GET /admin/jams 시 /login redirect, (3) ADMIN 세션 전이 후 jam_status_log 적재 확인.
|
||||
|
||||
## L2 (dev DB contract)
|
||||
- skipped:no-dev-db — PGHOST/PGPORT/PGDATABASE/PGUSER 전부 미설정, 그리고 빌드 미컴파일로 contract 검증 대상(매퍼 반환 Map alias 정합 등) 부재. db/apply-local-ddl.sh + psql 바이너리는 존재. 원격/seed 지정 후 재호출 시 (a)XOR/status/active-UNIQUE CHECK 거부 (b)keyset SQL camelCase alias 정합 실측 가능.
|
||||
|
||||
## 로그 스캔
|
||||
- n/a — 애플리케이션 미기동 (빌드 FAIL).
|
||||
|
||||
## Acceptance 매칭
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| L1 full test 통과 | verify-all (mvnw -o test) | **FAIL (compile)** |
|
||||
| contextLoads / 신규3테스트 / 회귀0 | surefire | 미실행 (게이트 차단) |
|
||||
| AC-T1~T6 집합 전수 | grep cmd | 전 PASS |
|
||||
| VP-1/2/3/5 시나리오 | L1 테스트 | 미커버 (L1 미실행) |
|
||||
| L2 DB contract | apply-local-ddl.sh | skipped:no-dev-db |
|
||||
|
||||
---
|
||||
|
||||
# 재검증 (R2 — 보정 후) — 2026-06-24T11:05:00+09:00
|
||||
|
||||
> 1차 검증(상단)에서 L1 컴파일 BLOCKER FAIL(JamAdminController 24 errors, `response(HttpStatus,String)` 헬퍼 미정의) 판정.
|
||||
> implementation 보정 완료 후 본 R2 재검증 수행. 1차 검증서는 보존(상단), 본 섹션 append.
|
||||
|
||||
## R2 실행된 전략
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| verify-all (L1 full test) | `JAVA_HOME=.../openjdk@21 ./mvnw -o test` | 0 | blocker | **PASS** |
|
||||
| AC-T1 grep | `grep -c 'CREATE TABLE IF NOT EXISTS' docs/jam-ddl.sql` | 0 | blocker | pass (5) |
|
||||
| AC-T1b schema 동기 | `grep -iE 'CREATE TABLE.*<t>' db/schema.sql` x5 | 0 | blocker | pass (5/5) |
|
||||
| AC-T2 enum/CHECK 동기 | `grep` JamStatus enum + jams/ log CHECK IN | 0 | blocker | pass (4==4==4, schema 동기) |
|
||||
| AC-T3 게이트 열거 | `grep -c requireJamManage / @PostMapping / gate.has` | 0 | blocker | pass (무우회) |
|
||||
| AC-T4 매퍼5 `${` | `grep -c '\${' <매퍼5>` | 0 | blocker | pass (0,0,0,0,0) |
|
||||
| AC-T5 entries 2 CHECK | `grep` xor + entrant_type (ddl+schema) | 0 | blocker | pass (2, 양측 동기) |
|
||||
| AC-T6 @MockBean Jam | `grep -A1 '@MockBean' \| grep -c 'Jam.*Mapper'` | 0 | blocker | pass (5) |
|
||||
| L2 contract-postgres | 격리 임시DB + jam-ddl 적용 + CHECK/UNIQUE/keyset 실측 | 0 | blocker | **pass** |
|
||||
|
||||
(분해 결과 병기)
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck/compile | **PASS** — BUILD SUCCESS, 컴파일 에러 0 (1차 24 errors 완전 해소) |
|
||||
| L1 unit+regression | **PASS** — Tests run: 97, Failures: 0, Errors: 0, Skipped: 0 |
|
||||
| L2 contract-postgres | **PASS** (격리 DB 실측: CHECK/UNIQUE/keyset 전수 정상) |
|
||||
| 로그 스캔 | clean (benign WARN 1건 — static resource trailing slash, W2-1 무관) |
|
||||
|
||||
## R2 L1 테스트 카운트 (실측 인용)
|
||||
```
|
||||
[INFO] Tests run: 97, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
- 신규 테스트 전수 통과:
|
||||
- `JamControllerTest` Tests run: 14, F0 E0 S0
|
||||
- `JamAdminControllerTest` Tests run: 10, F0 E0 S0
|
||||
- `JamLifecycleTest` Tests run: 8, F0 E0 S0
|
||||
- `BibimbapApplicationTests` Tests run: 1, F0 E0 S0 (contextLoads — 신규 5매퍼 @MockBean 정합)
|
||||
- 회귀 0: PermissionGateTest(7)/AdminConsoleControllerTest(13)/GameCommentControllerTest(18)/GameReviewControllerTest(21)/UserControllerCsrfTest(5) 전부 GREEN.
|
||||
- 실패 클래스: **없음**.
|
||||
|
||||
## R2 집합 전수 AC 실측 (보정 grep)
|
||||
| AC | 실측 | 기대 | 판정 |
|
||||
|---|---|---|---|
|
||||
| AC-T1 (jam-ddl CREATE TABLE) | 5 (line 8/49/77/104/157) | 5 | PASS |
|
||||
| AC-T1b (schema.sql 5 테이블 동기, 큰따옴표 식별자) | jams/jam_teams/jam_team_members/jam_entries/jam_status_log 각 1 (line 381/422/450/477/530) | 5 전수 | PASS |
|
||||
| AC-T2 (enum 4 == jams CHECK 4 == log to_status CHECK 4) | enum RECRUIT/DEV/EVAL/CLOSED(4), jams_status_check IN 4, jam_status_log_to_status_check IN 4. schema.sql 도 동기(408/549) | 4==4==4 | PASS |
|
||||
| AC-T3 (쓰기핸들러 게이트 전수 + console inline) | requireJamManage 6(POST5+선언1), GET console gate.has(GAME_JAM_MANAGE). 인가 우회 0 | 무우회 | PASS |
|
||||
| AC-T4 (매퍼5 `${` 0건) | 0,0,0,0,0 | 0 | PASS |
|
||||
| AC-T5 (jam_entries 2 CHECK) | xor 1 + entrant_type 1 (ddl 135/139, schema 508/512) | 2 | PASS |
|
||||
| AC-T6 (@MockBean Jam 매퍼) | 5 (JamsMapper/JamEntriesMapper/JamTeamsMapper/JamTeamMembersMapper/JamStatusLogMapper, line 66/69/72/75/78) | 5 (>=5) | PASS |
|
||||
|
||||
## R2 시나리오 AC 커버 (이번엔 실제 도달 — L1 테스트 GREEN)
|
||||
| VP | 커버 | 근거 테스트 |
|
||||
|---|---|---|
|
||||
| VP-1 게이트 (ADMIN/SUBADMIN+키 통과·무키403·미인증401) | **커버 PASS** | JamAdminControllerTest 10 PASS (런타임 도달) |
|
||||
| VP-2 전이 감사 (허용/거부 전이 + status_log insert) | **커버 PASS** | JamLifecycleTest 8 PASS |
|
||||
| VP-3 출품 무결성 (소유/멤버/409/422) | **커버 PASS** | JamControllerTest 14 PASS |
|
||||
| VP-5 CSRF (쓰기 누락→403 + mapper 미호출) | **커버 PASS** | JamAdminControllerTest / JamControllerTest 내 CSRF 케이스 (전체 GREEN) |
|
||||
|
||||
## R2 L2 (dev DB contract) — 실측 PASS
|
||||
`bibimbap-db` 컨테이너(Up healthy) 가용. **사용자 dev 스키마 비파괴 원칙**상 격리 throwaway DB(`jam_l2_verify_*`)에 users/games stub + `docs/jam-ddl.sql` 전체 적용(ON_ERROR_STOP=1, 에러 0) 후 contract 실측, 검증 후 DROP. 사용자 dev(bibimbap) DB 의 jam 테이블은 0건 유지(미적용).
|
||||
|
||||
| contract | 케이스 | 결과 |
|
||||
|---|---|---|
|
||||
| status CHECK | jams.status='BOGUS' INSERT | **거부** (jams_status_check) |
|
||||
| XOR CHECK | USER+양쪽채움 / USER+양쪽NULL / TEAM+user_id | **3종 전부 거부** (jam_entries_entrant_xor_check) |
|
||||
| XOR 정상 | USER+user_id only | 통과(OK) |
|
||||
| 활성 UNIQUE | 동일(jam_id,game_id) active 2건 | **거부** (ux_jam_entries_jam_game_active) |
|
||||
| 활성 UNIQUE soft-delete | 1건 is_delete=true 후 동일키 재등록 | 허용(OK) — partial UNIQUE WHERE is_delete IS NOT TRUE 정상 |
|
||||
| slug 활성 UNIQUE | 동일 slug active 중복 | **거부** (ux_jams_slug_active) |
|
||||
| keyset row-comparison | `(created_at,id) < (cursor)` ORDER BY created_at DESC, id DESC | **실행 정상** — cursor 기준 더 오래된 행만 반환, idx_jams_visible_keyset 정합 |
|
||||
| camelCase alias | `AS createdAt` 폴딩 실측 → `createdat` (비인용 소문자 폴딩 §33 패턴) | 5매퍼 전부 resultType=POJO(*Data, Map 0건) → MyBatis case-insensitive 매핑으로 **현재 무해** (concerns 기록) |
|
||||
|
||||
## R2 로그 스캔
|
||||
clean — 실패 신호 0. 유일 WARN: `ResourceHandlerUtils: Appended trailing slash to static resource location` (Spring 정적 리소스 기동 경고, W2-1 변경과 무관한 기존 부트 동작).
|
||||
|
||||
## R2 종합 판정
|
||||
overall: **PASS** (L1 BUILD SUCCESS + 97/97 GREEN + 집합전수 AC-T1~6 전 PASS + L2 contract 실측 PASS)
|
||||
rollback_signal: none (실패 없음)
|
||||
|
||||
### L3 런타임 인가 (자동 불가 → needs_user_verification)
|
||||
L1/L2 GREEN 으로 컴파일·DB계약·단위 인가는 커버. 실제 WAR 기동 E2E 인가는 자동 미수행(앱 미기동):
|
||||
1. GAME_JAM_MANAGE 무권한 SUBADMIN 세션으로 POST /admin/jams/* → 403 기대
|
||||
2. 미인증 GET /admin/jams → /login redirect 기대
|
||||
3. ADMIN 세션 상태 전이 후 jam_status_log 적재 확인
|
||||
|
||||
## R2 Acceptance 매칭
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| L1 full test 통과 | verify-all (mvnw -o test) | **PASS (97/97)** |
|
||||
| contextLoads / 신규3테스트 / 회귀0 | surefire | **PASS** |
|
||||
| AC-T1~T6 집합 전수 | grep cmd (보정) | **전 PASS** |
|
||||
| VP-1/2/3/5 시나리오 | L1 테스트 (런타임 도달) | **커버 PASS** |
|
||||
| L2 DB contract | 격리DB + jam-ddl 실측 | **PASS** |
|
||||
| L3 런타임 인가 E2E | WAR 기동 | needs_user_verification |
|
||||
|
||||
|
||||
---
|
||||
|
||||
# W2-2 검증 결과 (심사위원 역할 / jam_judges) — append 2026-06-24T11:12:00+09:00
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
- VP-1 지정게이트: ADMIN/SUBADMIN+GAME_JAM_MANAGE 통과·SUBADMIN무키 403·미인증 401. 지정/해제/조회.
|
||||
- VP-2 isJudge: 지정 true·미지정 false·미인증 false.
|
||||
- VP-3 ★자기출품 충돌 isOwnEntry: 개인출품 true·팀멤버출품 true·타인/타팀 false·비활성 false.
|
||||
- VP-4 누구나 지정: USER role 대상 지정 200(전역 role 검사 없음)·미존재 userId 404.
|
||||
- VP-5 CSRF: 지정/해제 CSRF 누락 → 403 + mapper 미호출.
|
||||
- VP-6 멱등: 이미 지정 (jamId,userId) 재지정 → 409.
|
||||
- AC-T1~T7 집합 전수 (grep -c 그대로).
|
||||
|
||||
## 실행된 전략
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| verify-all (L1 full) | `JAVA_HOME=…openjdk@21… ./mvnw -o test` | 0 | blocker | **pass** |
|
||||
| L2 contract-postgres | 격리 throwaway DB + jam-ddl + jam-judge-ddl 실측 | 0 | blocker | **pass** |
|
||||
| AC-T1~T7 집합전수 | `grep -c …` (원문) | 0 | blocker | **전 pass** |
|
||||
|
||||
분해:
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck (BUILD) | **pass** — BUILD SUCCESS |
|
||||
| L1 unit+regression | **pass** — Tests run 119 / Failures 0 / Errors 0 / Skipped 0 |
|
||||
| L1 contextLoads (@MockBean JamJudgesMapper+JamRoleGate) | **pass** — NoSuchBeanDefinitionException 없음 |
|
||||
| L1 신규 JamRoleGateTest | **pass** — 8/8 |
|
||||
| L1 신규 JamJudgeAdminControllerTest | **pass** — 14/14 (※AC 예측 16, 실측 14 — 아래 concern) |
|
||||
| L2 contract-postgres (a)(b)(c) | **pass** (격리DB, 검증 후 DROP) |
|
||||
| 로그 스캔 | clean — 실패신호 0, WARN 5건 전부 ByteBuddy agent + Spring 정적리소스 (W2-2 무관) |
|
||||
|
||||
## L1 상세 (전 클래스 GREEN)
|
||||
- BUILD SUCCESS. 집계: **Tests run: 119, Failures: 0, Errors: 0, Skipped: 0**. (직전 W2-1 재검증 97 → +22)
|
||||
- 클래스별 (잼 관련): JamRoleGateTest 8/8, JamJudgeAdminControllerTest 14/14, JamAdminControllerTest 10/10, JamControllerTest 14/14, JamLifecycleTest 8/8, PermissionGateTest 7/7, BibimbapApplicationTests 1/1(contextLoads).
|
||||
- 실패 클래스: **없음**.
|
||||
|
||||
## 시나리오 VP-1~6 커버 (L1 테스트 도달 + L2 보강)
|
||||
| VP | 커버 경로 | 판정 |
|
||||
|---|---|---|
|
||||
| VP-1 지정게이트(403/401/통과, 지정/해제/조회) | JamRoleGateTest 8 + JamJudgeAdminControllerTest 14 GREEN | **PASS (커버)** |
|
||||
| VP-2 isJudge(t/f/미인증f) | JamRoleGateTest GREEN | **PASS (커버)** |
|
||||
| VP-3 isOwnEntry(개인t/팀멤버t/타인f/타팀f/비활성f) | JamJudgeAdminControllerTest GREEN + **L2(b) DB 실측 6/6 정합** | **PASS (커버+DB실증)** |
|
||||
| VP-4 누구나 지정(USER 200 / 미존재 404) | JamJudgeAdminControllerTest GREEN | **PASS (커버)** |
|
||||
| VP-5 CSRF 누락 403 + mapper 미호출 | JamJudgeAdminControllerTest GREEN (AC-T3 isValid 2건 실증) | **PASS (커버)** |
|
||||
| VP-6 멱등 재지정 409 | JamJudgeAdminControllerTest GREEN + **L2(a) UNIQUE 중복 INSERT 거부 실측** | **PASS (커버+DB실증)** |
|
||||
|
||||
## 집합 전수 AC-T1~T7 (실측 vs 기대)
|
||||
| AC | grep 실측 | 기대 | 판정 |
|
||||
|---|---|---|---|
|
||||
| AC-T1 ddl ADD CONSTRAINT | 3 | 3 | **PASS** |
|
||||
| AC-T1 ddl CREATE UNIQUE INDEX | 1 | 1 | **PASS** |
|
||||
| AC-T1 schema.sql 동기 | jam_judges + 3 FK(jam/user/assigned_by) + ux_jam_judges_jam_user UNIQUE 존재 (db/schema.sql:566-594) | 동일 제약 | **PASS** |
|
||||
| AC-T2 핸들러 3 requireJamManage 진입(인가우회 0) | JamJudgeAdminController CsrfTokens.isValid 2(쓰기) + GET 조회 — 게이트 진입 실증은 JamJudgeAdminControllerTest 14 GREEN(403/401 케이스) | 3 게이트 / 우회 0 | **PASS** |
|
||||
| AC-T3 CsrfTokens.isValid | 2 | 2 (지정/해제, GET 제외) | **PASS** |
|
||||
| AC-T4 isOwnEntry entrant_type USER/TEAM 양경로 | USER=1, TEAM=1 | 각 1 | **PASS** |
|
||||
| AC-T5 `${` JamJudgesMapper, JamEntriesMapper | 0, 0 | 0, 0 | **PASS** |
|
||||
| AC-T6 @MockBean JamJudgesMapper+JamRoleGate 등록 | line73-74 `@MockBean private JamJudgesMapper`, line88-89 `@MockBean private JamRoleGate` 실측 정합 | 양 매퍼 등록 | **PASS** |
|
||||
| AC-T7 전역 RBAC 테이블 무변경 | 0 | 0 | **PASS** |
|
||||
|
||||
## L2 DB Contract (격리 throwaway DB — 비파괴, 검증 후 DROP 완료)
|
||||
방식: bibimbap-db 컨테이너 내 `verify_w22_throwaway` 신규 DB → 최소 parent(users/games/jams/jam_teams/jam_team_members/jam_entries) + 실파일 docs/jam-judge-ddl.sql 적용 → contract 실행 → DROP(잔존 0 확인). 사용자 dev(bibimbap) 스키마 무접촉.
|
||||
- **(a) ux_jam_judges UNIQUE**: (jam_id=1000,user_id=10) 1차 INSERT OK → 동일 키 2차 INSERT `ERROR: duplicate key value violates unique constraint "ux_jam_judges_jam_user"` (멱등 가드 = VP-6 409 DB 근거). DELETE 후 재INSERT OK(역할 회수→재지정 모델 실증). **PASS**
|
||||
- **(b) isOwnEntry OR-EXISTS**: 개인(9001,u1)=t, 팀멤버(9002,u2)=t, 팀오너(9002,u1)=t, 타인개인(9001,u3)=f, 비멤버(9002,u3)=f, 삭제건(9003,u3)=f → 6/6 기대 정합. **PASS**
|
||||
- **(c) alias snake→camel**: jam_judges 컬럼 전부 순수 snake_case(id/jam_id/user_id/assigned_by/created_at), 큰따옴표 camel alias 0 → §33 case-folding 해당 없음, POJO resultType 안전. **PASS**
|
||||
|
||||
## 로그 스캔
|
||||
clean — Exception/stacktrace 0. WARN 5건: ByteBuddy/Mockito Java-agent dynamic-load 안내 4 + Spring `ResourceHandlerUtils: Appended trailing slash` 정적리소스 기동 경고 1. 전부 프레임워크 기존 노이즈, W2-2 변경 무관.
|
||||
|
||||
## 종합 판정
|
||||
overall: **PASS** (L1 BUILD SUCCESS + 119/119 GREEN + 집합전수 AC-T1~T7 전 PASS + L2 contract a/b/c 실측 PASS)
|
||||
rollback_signal: none (실패 없음)
|
||||
|
||||
### L3 런타임 인가 (자동 불가 → needs_user_verification)
|
||||
L1(단위 인가)+L2(DB 계약) GREEN 으로 컴파일·DB제약·단위 인가는 커버. 실제 WAR 기동 E2E 는 자동 미수행(앱 미기동) — 사용자 수동 단계:
|
||||
1. SUBADMIN(GAME_JAM_MANAGE 무권한) 세션으로 POST /admin/jams/{id}/judges → **403** 기대.
|
||||
2. 미인증으로 GET /admin/jams/{id}/judges → 401 또는 /login redirect 기대.
|
||||
3. ADMIN 세션 지정 → jam_judges 행 적재, 동일 (jam,user) 재지정 → 409 기대.
|
||||
4. USER role 대상 지정 200(전역 role 검사 없음), 미존재 userId → 404 기대.
|
||||
|
||||
## Acceptance 매칭
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| L1 full test 통과 | verify-all (mvnw -o test) | **PASS (119/119)** |
|
||||
| contextLoads / 신규2테스트 / 회귀0 | surefire | **PASS** (JamRoleGateTest 8, JamJudgeAdminControllerTest 14) |
|
||||
| AC-T1~T7 집합 전수 | grep cmd (원문) | **전 PASS** |
|
||||
| VP-1~6 시나리오 | L1 테스트(런타임 도달) + L2 보강(VP-3,6) | **커버 PASS** |
|
||||
| L2 DB contract (a)(b)(c) | 격리 throwaway DB 실측 | **PASS** |
|
||||
| L3 런타임 인가 E2E | WAR 기동 | needs_user_verification |
|
||||
|
||||
---
|
||||
|
||||
# W2-3 검증 결과 (잼 평가 동결 스키마 — ⚠️클러스터 권위)
|
||||
|
||||
검증 시각: 2026-06-24T11:35:00+09:00 · scope: docs/jam-eval-ddl.sql(신규) + db/schema.sql(동기 사본 append) · Java 코드 0 · 신규 매퍼/컨트롤러/테스트 0(하류 W2-4/5/6 소관, 비목표)
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
|
||||
- AC-T1: jam-eval-ddl `grep -c 'CREATE TABLE IF NOT EXISTS'`==4 AND `CREATE.*VIEW`/jam_score_stats VIEW 1건 AND schema.sql 동기.
|
||||
- AC-T2: 트랙 CHECK 4종(jam_awards track 또는 동등 CHECK IN 항목) 존재.
|
||||
- AC-T3: UNIQUE 4건(1심사위원1기준1점·1인1표 등) + 무결성 CHECK(score 1~5 등) 존재.
|
||||
- AC-T4: 매퍼 `${` — W2-3 신규 매퍼 0이므로 N/A(하류 검사).
|
||||
- AC-T5: game_reviews/game_review_stats 쓰기·변경 0(기존 리뷰 인프라 무변경). ALTER/DROP 부재 확인.
|
||||
- AC-T6: FK 적용 순서 — alphabetical glob 에서 jam-ddl < jam-eval-ddl < jam-judge-ddl 순서로 FK 생성 가능(L2 실생성 확인).
|
||||
- L1: full `./mvnw -o test` BUILD SUCCESS, 119 GREEN 유지(신규 0, 회귀 0).
|
||||
- L2(핵심): 격리 throwaway DB DDL 적용 / 제약 거부 a~d / VIEW 집계 정합(fan-out 가드) / alias snake 확인.
|
||||
|
||||
## 실행된 전략
|
||||
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| verify-all(L1) | `JAVA_HOME=... ./mvnw -o test` | 0 | blocker | **pass** (119 GREEN) |
|
||||
| set-exhaustive AC-T1~6 | `grep -c ...` (원문) | 0 | blocker | **전 PASS** |
|
||||
| §33 contract-postgres (L2) | 격리 throwaway DB 알파벳순 DDL 적용+실측+DROP | 0 | blocker | **pass** |
|
||||
|
||||
(분해 결과)
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 회귀(typecheck+unit) | **pass** — BUILD SUCCESS, Tests run 119 / F0 / E0 / S0 (W2-2 119 그대로, Java src 무변경) |
|
||||
| L2 DDL 적용(4테이블+VIEW+9FK, ON_ERROR_STOP=1) | **pass** — 에러 0, 알파벳순 FK 해소 |
|
||||
| L2 제약 거부 (a)score범위 (b)scores UNIQUE (c)votes 1인1표 (d)awards track CHECK | **pass** — 4종 전부 거부 실측 |
|
||||
| L2 VIEW 집계 정합(§33 fan-out 가드) | **pass** — 손계산 일치·미채점 제외·NULLIF·fan-out 불변 |
|
||||
| L2 alias snake 확인 | **pass** — 6컬럼 소문자 비인용 |
|
||||
| 로그 스캔 | **clean** — ON_ERROR_STOP DDL ERROR 0, VIEW 쿼리 WARNING 0 (의도된 제약거부 ERROR는 검증목적) |
|
||||
|
||||
## L1 회귀 (테스트 카운트)
|
||||
|
||||
BUILD SUCCESS · **Tests run 119 / Failures 0 / Errors 0 / Skipped 0** — W2-2 검증서 119 GREEN 그대로 유지. Java src 무변경이라 카운트 동일(예상 일치). 클래스별: PermissionGateTest 7 · JamRoleGateTest 8 · AdminConsoleControllerTest 13 · JamControllerTest 14 · GameCommentControllerTest 18 · GameReviewControllerTest 21 · UserControllerCsrfTest 5 · JamJudgeAdminControllerTest 14 · JamAdminControllerTest 10 · BibimbapApplicationTests 1 · JamLifecycleTest 8. 실패 클래스 없음.
|
||||
|
||||
## 집합 전수 AC-T1~6 실측 vs 기대
|
||||
|
||||
| AC | 기대 | 실측 | 판정 |
|
||||
|---|---|---|---|
|
||||
| T1 | CREATE TABLE IF NOT EXISTS=4, VIEW 1, schema.sql 동기 | ddl: 4테이블(jam_criteria/jam_scores/jam_votes/jam_awards)+VIEW jam_score_stats / schema.sql line608·642·685·721 4테이블 + line765 VIEW 동기 | **PASS** |
|
||||
| T2 | 트랙 CHECK 4종 | jam_awards_track_check = `award_track IN ('JUDGE','USER_RATING','POPULAR','GRAND')` — 트랙 enum 4값. DB 실생성 확인 | **PASS** |
|
||||
| T3 | UNIQUE 4 + 무결성 CHECK | UNIQUE INDEX 4: ux_jam_criteria_jam_key·ux_jam_scores_jam_game_judge_criterion(1심사위원1기준1점)·ux_jam_votes_jam_voter(1인1표)·ux_jam_awards_jam_track_game / CHECK 4: weight>=0·score BETWEEN 1 AND 5·track IN·rank>=1 | **PASS** |
|
||||
| T4 | 매퍼 `${` (신규 매퍼 0 → N/A) | 신규 매퍼 0 — 하류 W2-4 위임. N/A 명시 | **N/A** |
|
||||
| T5 | review 인프라 변경 0, ALTER/DROP 부재 | game_reviews/game_review_stats refs 3건 전부 주석(무변경 단언) / ALTER 17건 전부 신규 jam_* 제약추가·시퀀스 OWNED / **DROP 0** | **PASS** |
|
||||
| T6 | 알파벳순 FK 생성 가능 | jam-ddl(jams 제공)→jam-eval-ddl(9FK: jams5·games3·users3 참조)→jam-judge-ddl 순 적용, FK 9건 실생성·에러 0 | **PASS** |
|
||||
|
||||
## L2 DB contract 실측 (★W2-3 실질 게이트)
|
||||
|
||||
환경: bibimbap-db(postgres:16, Up healthy) 격리 throwaway DB `verify_w23_throwaway` — users/games 최소부모 + jam-ddl + jam-eval-ddl(SUT) + jam-judge-ddl 알파벳순 적용, 실측 후 DROP. 사용자 dev DB(bibimbap) 무접촉(검증 후 jam_eval 테이블 0건 재확인). 잔존 throwaway 0.
|
||||
|
||||
### 1. DDL 적용 (pass)
|
||||
4테이블 + VIEW 생성, FK 9건 실생성(pg_constraint 확인: jam_criteria 1 · jam_scores 3 · jam_votes 3 · jam_awards 2), CHECK 4종 실생성, UNIQUE INDEX 4 + PK 4. ON_ERROR_STOP=1 에러 0.
|
||||
|
||||
### 2. 제약 거부 실측 (pass — 4종 전부 거부)
|
||||
- (a) jam_scores score=0 / score=6 → `violates check constraint "jam_scores_score_check"` 거부 (score=5 정상 INSERT)
|
||||
- (b) jam_scores 중복 (jam=50,game=100,judge=1,criterion='fun') → `duplicate key ... "ux_jam_scores_jam_game_judge_criterion"` 거부
|
||||
- (c) jam_votes 동일 voter(3) 동일 jam 재투표 → `duplicate key ... "ux_jam_votes_jam_voter"` 거부 (1인1표)
|
||||
- (d) jam_awards track='BOGUS' → `violates check constraint "jam_awards_track_check"` 거부 (track='GRAND' 정상) + rank=0 → `jam_awards_rank_check` 거부
|
||||
|
||||
### 3. ★jam_score_stats VIEW 집계 정합 — fan-out 가드 (pass)
|
||||
샘플: criteria fun(weight 3.0)·art(weight 1.0). GameA(100) raw 3행 → 손계산 대조:
|
||||
- per_criterion: fun avg=(4+2)/2=3.0, art avg=5/1=5.0
|
||||
- weighted_total=(3.0×3+5.0×1)/4=**3.5** = VIEW 3.500 ✓ / simple_total=AVG(3,5)=**4.0**=4.000 ✓ / scored_criteria=**2** ✓ / judge_count=MAX(2,1)=**2** ✓
|
||||
- (i) 가중합/단순합 정확 ✓
|
||||
- (ii) 미채점 criterion 제외: GameB(200) fun만 채점·art 미채점 → weighted=(4×3)/3=4.0·scored_criteria=1(art 분모서 제외) ✓
|
||||
- (iii) NULLIF 0-division: GameB 점수 전삭제 → VIEW 행 미생성·에러 0·div0 0 / weight합=0 강제경로 → weighted_total NULL 반환(에러 아님) ✓
|
||||
- **fan-out 불변성**: GameA fun 심사위원 2→4명(raw 3→5행)에도 scored_criteria=2 불변·judge_count=MAX(4,1)=4 정확·weighted_total=3.5 불변 → 자식 행수만큼 COUNT/AVG 부풀지 않음. **game_review_stats 선례 fan-out 버그 재발 0**.
|
||||
|
||||
### 4. alias 정합 (pass)
|
||||
VIEW 컬럼 6개 전부 소문자 snake 비인용: `jam_id`·`game_id`·`weighted_total`·`simple_total`·`scored_criteria`·`judge_count`. 하류 매퍼가 `AS "camelCase"` 인용으로 매핑할 계약(W2-4 검증 위임). 본 단계는 VIEW 자체 컬럼명 확인만.
|
||||
|
||||
## 실패 상세
|
||||
해당 없음 (전 항목 PASS).
|
||||
|
||||
## 종합 판정
|
||||
**overall: PASS**
|
||||
|
||||
verdict: **PASS** — L1 회귀 무변경(119 GREEN 유지) + 집합 전수 AC-T1~6 전 PASS(T4 N/A) + L2 dev DB contract 전 항목 PASS(DDL/제약거부/VIEW집계 fan-out 가드/alias). W2-3 동결 스키마는 PASS이므로 **W2-4/5/6 착수 가능**. VIEW 집계 정합을 손계산·fan-out·NULLIF·미채점 제외 4축으로 엄격 검증했고 game_review_stats 선례 버그 재발 0 확인.
|
||||
|
||||
rollback_signal: none (전 PASS — 파괴적 조작 영향 없음. throwaway DB 격리 적용·DROP 완료, 사용자 dev DB 무접촉.)
|
||||
|
||||
## Acceptance 매칭
|
||||
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| AC-T1 4테이블+VIEW+schema 동기 | grep-c + schema.sql line 확인 + L2 DDL 적용 | **PASS** |
|
||||
| AC-T2 track CHECK 4값 | grep + L2 pg_constraint 실생성 | **PASS** |
|
||||
| AC-T3 UNIQUE 4 + CHECK | grep + L2 인덱스/제약 실생성 + 거부 실측 | **PASS** |
|
||||
| AC-T4 매퍼 ${ | 신규 매퍼 0 → N/A(하류 위임) | **N/A** |
|
||||
| AC-T5 review 인프라 무변경 | grep ALTER/DROP/game_review* | **PASS** |
|
||||
| AC-T6 알파벳순 FK 생성 | L2 순차 적용·FK 9건 실생성 | **PASS** |
|
||||
| L1 회귀 무변경 119 | `./mvnw -o test` | **PASS** |
|
||||
| L2 VIEW 집계 fan-out 가드 | throwaway DB 손계산 대조 | **PASS** |
|
||||
|
||||
|
||||
---
|
||||
|
||||
# 검증 결과 — W2-4 (심사위원 평가) · append 2026-06-24
|
||||
|
||||
> verification-advisor v1 · 제한 컨텍스트(설계·diff·구현경로 무접촉, AC + 실행결과만). bibimbap-db 컨테이너 가용(Up healthy). L2 는 throwaway DB(`bibimbap_l2_w24`)에 `db/schema.sql`(W2-3/W2-4 동결 스키마 통합 권위본) 적용 후 contract → 검증 → DROP. 사용자 dev DB(`bibimbap`) 무접촉 확인.
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
- **VP 3중게이트**: CSRF누락 403 / 미인증 401 / 비-isJudge 403 / 잼없음 404 / 평가기간 외 422 / 출품작없음 404 / 자기출품 422 / 정상 200. 순서대로 도달.
|
||||
- **VP UPSERT 멱등**: 동일 (심사위원,기준,평가단위) 재채점 → update(중복행 0).
|
||||
- **VP criterion 화이트리스트**: 잼 소속 아닌 criterion / score 범위 밖 → 422.
|
||||
- **VP CSRF**: 쓰기 CSRF 누락 → 403 + mapper 미호출.
|
||||
- **AC-T1~T6** (impl echo, grep 그대로 1 어긋나면 FAIL): 신규 매퍼 3파일 `${` 0 / JamScoreStatsMapper camelCase alias 전부 `AS "..."` 큰따옴표 / submitScores 진입부 4가드(CSRF+3게이트) / 401·403·422 정책응답 전수 / BibimbapApplicationTests 신규 3매퍼 @MockBean.
|
||||
|
||||
## 실행된 전략
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| L1-full-test | `JAVA_HOME=…/openjdk@21… ./mvnw -o test` | 0 | blocker | **pass** |
|
||||
| AC-T2 grep | `grep -rc '${' <매퍼 3파일>` | 0 | blocker | **pass** (3파일 모두 0) |
|
||||
| AC-T3 grep | `grep -oE 'AS "…"' JamScoreStatsMapper` + 일반매퍼 비인용 | 0 | blocker | **pass** (VIEW매퍼 5건 큰따옴표 / 일반매퍼 0) |
|
||||
| AC-T4 grep | `grep -rEc 'CREATE TABLE\|ALTER TABLE\|CREATE…VIEW' <매퍼 3파일>` + git status ddl | 0 | blocker | **pass** (DDL 토큰 0, ddl/schema diff 0) |
|
||||
| AC-T5 열거 | `grep isEqualTo(HttpStatus.*)` 분포 | 0 | blocker | **pass** (401×2/403×4/404×3/422×5/200×5) |
|
||||
| AC-T6 열거 | `grep @MockBean … (3매퍼)` | 0 | blocker | **pass** (라인 88-95 인접 등록) |
|
||||
| L2-1 UPSERT | throwaway DB INSERT…ON CONFLICT 2회 동일4키 | 0 | blocker | **pass** (행수 1 유지, score 3→5 갱신) |
|
||||
| L2-2 VIEW alias 케이스폴딩(§33) | 큰따옴표 alias SELECT → 반환 컬럼명 실측 | 0 | blocker | **pass** (camelCase 보존, 폴딩 0) |
|
||||
| L2-3 가중집계 정합 | VIEW 손계산 대조 | 0 | blocker | **pass** (전 값 일치, fan-out 0) |
|
||||
|
||||
### 분해 결과
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck (compile) | pass (BUILD SUCCESS) |
|
||||
| L1 unit+regression | pass — **148/148** (Failures 0, Errors 0, Skipped 0) |
|
||||
| L1 신규 JamScoringControllerTest | pass — 19/19 |
|
||||
| L1 신규 JamEvalWindowTest | pass — 10/10 |
|
||||
| L1 contextLoads (BibimbapApplicationTests, 3매퍼 @MockBean) | pass — 1/1 (NoSuchBeanDefinitionException 0) |
|
||||
| L2 UPSERT ON CONFLICT (jam_scores 4키) | pass |
|
||||
| L2 contract VIEW alias 케이스폴딩 | pass |
|
||||
| L2 가중집계 정합 | pass |
|
||||
| 로그 스캔 | clean (WARN 5건 전부 환경성 noise: JVM Sharing/ByteBuddy agent/Spring static trailing-slash — blocker 아님) |
|
||||
|
||||
## L1 카운트
|
||||
- BUILD SUCCESS. **148 tests run, 0 failures, 0 errors, 0 skipped.**
|
||||
- 회귀 정합: 직전 119 + 신규 29 = 148 ✓ (예상 일치).
|
||||
- 신규: JamScoringControllerTest 19/19, JamEvalWindowTest 10/10.
|
||||
- 실패 클래스: **없음**.
|
||||
|
||||
## 시나리오 VP 커버 (L1 — JamScoringControllerTest 19 메서드 열거로 확인)
|
||||
| VP | 커버 테스트 (메서드명 실측) | 판정 |
|
||||
|---|---|---|
|
||||
| 3중게이트 CSRF누락 403 | `submitRejectsMissingCsrf` | pass |
|
||||
| 미인증 401 | `submitReturns401WhenUnauthenticated` (+myScores/summary 미인증) | pass |
|
||||
| 비-isJudge 403 | `submitReturns403WhenNotJudge` (+myScores 403) | pass |
|
||||
| 잼없음 404 | `submitReturns404WhenJamMissing` (+summary 404) | pass |
|
||||
| 평가기간 외 422 | `submitReturns422WhenEvalClosed` | pass |
|
||||
| 출품작없음 404 | `submitReturns404WhenEntryMissing` | pass |
|
||||
| 자기출품 422 | `submitReturns422WhenOwnEntry` | pass |
|
||||
| 정상 200 | `submitUpsertsWhenAllGatesPass` / `submitReturns200OnIdempotentResubmit` | pass |
|
||||
| UPSERT 멱등 | `submitReturns200OnIdempotentResubmit` (savedCount=1) + L2-1 DB 실측 | pass |
|
||||
| criterion 화이트리스트 422 | `submitReturns422WhenUnknownCriterion` | pass |
|
||||
| score 범위 밖 422 | `submitReturns422WhenScoreOutOfRange` | pass |
|
||||
| 빈 점수 422 | `submitReturns422WhenScoresEmpty` | pass |
|
||||
- 게이트 순서(CSRF→인증→자격→기간→출품작→충돌→criterion) 첫 실패지점 응답은 위 메서드들이 각 분기를 단독 트리거하는 형태로 커버됨(L1 통과로 간접 확인).
|
||||
|
||||
## 집합 전수 AC-T1~T6 실측 vs 기대
|
||||
| AC | 기대 (impl echo) | 실측 | 판정 |
|
||||
|---|---|---|---|
|
||||
| AC-T1 submitScores 진입부 4가드(CSRF+3게이트) | 전수 존재 (impl: 라인 78/87/96/104) | **구현코드 직접확인은 advisor 권한 밖** → L1 VP 게이트 테스트 12종 전부 PASS 로 동작 보증. 정적 라인검증은 needs_user_verification | **pass(간접)** |
|
||||
| AC-T2 신규 매퍼 3파일 `${` 0건 | 0 / 0 / 0 | 0 / 0 / 0 | **pass** |
|
||||
| AC-T3 VIEW매퍼 camelCase alias 큰따옴표 전수 | 5건 `AS "…"` / 일반매퍼 0 | gameId·judgeCount·scoredCriteria·simpleTotal·weightedTotal 5건 `AS "…"`; 비인용 camelCase 0; 일반매퍼 JamCriteriaMapper/JamScoresMapper 각 0 | **pass** |
|
||||
| AC-T4 신규 테이블/VIEW 0건 | 매퍼 DDL토큰 0 + ddl/schema diff 0 | 매퍼 3파일 DDL토큰 0; git status ddl.sql/schema.sql 변경 0 | **pass** |
|
||||
| AC-T5 401/403/422 정책응답 전수 | 401×2/403×4/404×3/422×5/200×5 | `isEqualTo(HttpStatus.*)` 분포 = 401×2 / 403×4 / 404×3 / 422×5 / 200×5 | **pass** |
|
||||
| AC-T6 신규 3매퍼 @MockBean | 3건 + contextLoads PASS | 라인 88/91/94 `@MockBean` → 89/92/95 jamCriteriaMapper/jamScoresMapper/jamScoreStatsMapper 인접; contextLoads 1/1 PASS | **pass** |
|
||||
|
||||
## L2 상세 (dev DB contract — throwaway `bibimbap_l2_w24`, schema.sql 적용 후 DROP)
|
||||
**L2-1 UPSERT ON CONFLICT 멱등**: `ux_jam_scores_jam_game_judge_criterion (jam_id,game_id,judge_user_id,criterion_key)` UNIQUE 타깃 정합 확인. 동일 4키 2회 INSERT…ON CONFLICT → 1차 후 rows=1 score=3, 2차(score=5) 후 **rows=1 유지(중복 0) score=5 갱신**. → PASS.
|
||||
|
||||
**L2-2 VIEW alias 케이스폴딩(§33 핵심)**: 매퍼가 발행하는 큰따옴표 alias SELECT 를 Postgres 직접 실행 → 반환 컬럼명 `jamId|gameId|weightedTotal|simpleTotal|scoredCriteria|judgeCount` (camelCase 보존, 폴딩 0). 대조군(비인용 `AS weightedTotal`) → `weightedtotal|judgecount` 소문자 폴딩 재현. **GameReviewStatsMapper BUG-2(비인용→소문자→null) 메커니즘 확인 + 본 매퍼 큰따옴표로 회피 입증**. → PASS.
|
||||
|
||||
**L2-3 가중집계 정합**: VIEW 정의 = per_criterion(criterion별 avg + count distinct judge) 선집계 후 LEFT JOIN(fan-out 회피). game500(criterion weight 상이 fun2.0/art1.0/tech0.5, judge 2명) weighted_total=3.786 [손계산 (4.0×2+4.5×1+1.5×0.5)/3.5=3.7857] / simple_total=3.333 / scored_criteria=3 / judge_count=2 — **전 값 일치**. game501(부분입력 fun만) weighted=4.000/simple=4.000/scored=1/judge=1 — 미채점 criterion 분모 제외·judge_count 부분참여 반영 일치. 정렬 weighted DESC NULLS LAST → 501>500 결정론 정합. → PASS.
|
||||
|
||||
## L3 런타임 인가 (needs_user_verification — 수동 단계)
|
||||
- 인증/인가/롤 플로우 = L1+L2+L3 의무(전략표 line 23). L1/L2 GREEN 으로 단위·계약은 보증되나, **실제 런타임 스택 E2E 는 advisor 무접촉 범위**:
|
||||
1. WAR 기동 후 심사위원(isJudge) 로그인 → EVAL 상태·구간내 잼의 타인 출품작 채점 POST/PUT → 200 + jam_scores UPSERT 반영.
|
||||
2. 미심사위원/미인증/CSRF누락 실호출 시 각 403/401/403 + mapper 미호출.
|
||||
3. AC-T1 정적 라인검증(submitScores 진입부 4가드 라인 78/87/96/104) — 구현코드 직접 grep 은 사용자/orchestrator 가 확인.
|
||||
|
||||
## 종합 판정
|
||||
**verdict: PASS** (L1 148/148 GREEN + 집합 전수 AC-T1~T6 전건 일치 + L2 contract 3종 PASS + 로그 clean)
|
||||
overall: **pass**
|
||||
rollback_signal: none (전 PASS — 파괴적 조작 없음. L2 는 throwaway DB 격리 적용·DROP 완료, 사용자 dev DB `bibimbap` 무접촉 확인됨)
|
||||
|
||||
## FAIL 항목 실제 출력 인용
|
||||
- 해당 없음 (FAIL 0건).
|
||||
|
||||
## Acceptance 매칭 (요약)
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| VP 3중게이트 순서 | JamScoringControllerTest 12 게이트 메서드 (L1) | **PASS** |
|
||||
| VP UPSERT 멱등 | `submitReturns200OnIdempotentResubmit` (L1) + L2-1 DB 실측 | **PASS** |
|
||||
| VP criterion 화이트리스트 | `submitReturns422WhenUnknownCriterion`/`…ScoreOutOfRange` (L1) | **PASS** |
|
||||
| VP CSRF 누락 403 | `submitRejectsMissingCsrf` (L1) | **PASS** |
|
||||
| AC-T1 submitScores 4가드 | L1 VP 12종 PASS (정적 라인 = needs_user_verification) | **PASS(간접)** |
|
||||
| AC-T2 매퍼 `${` 0 | grep | **PASS** |
|
||||
| AC-T3 VIEW alias 큰따옴표 | grep + L2-2 케이스폴딩 실측 | **PASS** |
|
||||
| AC-T4 신규 DDL 0 | grep + git status | **PASS** |
|
||||
| AC-T5 정책 응답 전수 | grep HttpStatus 분포 | **PASS** |
|
||||
| AC-T6 3매퍼 @MockBean | grep + contextLoads PASS | **PASS** |
|
||||
| L2 VIEW 가중집계 정합 | throwaway DB 손계산 대조 | **PASS** |
|
||||
|
||||
---
|
||||
|
||||
# 검증 결과 — W2-5 인기투표 (append)
|
||||
|
||||
> generated_at: 2026-06-24T12:20:00+09:00 · agent: verification-advisor v1 · phase: verification
|
||||
> concerns_checked: true
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
|
||||
### 시나리오 AC (VP)
|
||||
- 1인1표 토글: 첫 투표 cast / 재투표 update(중복행 0, no-op or 변경) / DB UNIQUE(jam_id,voter_user_id).
|
||||
- 평가기간 게이트: vote/cancelVote 평가기간 외 → 422. CSRF 누락 → 403. 미로그인 → 401(results 는 공개).
|
||||
- 종료후 공개: results + jam-detail JSP 2지점 — CLOSED 또는 eval_end 경과 시만 count 노출, 진행 중 은닉(밴드왜건 회피).
|
||||
|
||||
### 집합 전수 AC (AC-T1~T4 — grep 그대로, 1 어긋나면 FAIL)
|
||||
- AC-T1: 상태변경 핸들러 2(vote/cancelVote) + CsrfTokens.isValid 2 + 평가기간 게이트(JamEvalWindow) 2.
|
||||
- AC-T2: JamVotesMapper `${` 0건.
|
||||
- AC-T3: 노출 게이트 2지점(JamVoteController.results + jam-detail.jsp) CLOSED/eval_end 조건.
|
||||
- AC-T4: jam_votes DDL 변경문 0(docs/*-ddl.sql, schema.sql diff 0).
|
||||
- listCountsByJam camelCase alias 전부 `AS "..."` 인용(gameId/voteCount), 비인용 0.
|
||||
|
||||
## 실행된 전략
|
||||
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| verify-L1-fulltest | `JAVA_HOME=…openjdk@21 ./mvnw -o test` | 0 | blocker | pass |
|
||||
| AC-T1 grep (handlers/CSRF/gate) | `grep -nE '@PostMapping\|@DeleteMapping' …` + `grep -c CsrfTokens.isValid` + `grep -c JamEvalWindow.isOpen` | 0 | blocker | pass |
|
||||
| AC-T2 grep (`${` 0) | `grep -c '\${' JamVotesMapper.java` | 0 | blocker | pass |
|
||||
| AC-T3 grep (노출게이트 2지점) | controller results + jam-detail.jsp resultsPublic | 0 | blocker | pass |
|
||||
| AC-T4 (jam_votes DDL diff 0) | `git status --short` + DDL grep | 0 | blocker | pass |
|
||||
| alias 인용 (gameId/voteCount) | `grep -nE 'AS "..."'` + 비인용 대조 | 0 | blocker | pass |
|
||||
| L2-1 UNIQUE toggle (throwaway DB) | psql 5433 / throwaway / DROP | 0 | blocker | pass |
|
||||
| L2-2 alias 케이스폴딩 §33 (throwaway DB) | psql quoted vs non-quoted | 0 | blocker | pass |
|
||||
| 로그 스캔 | grep surefire-reports FAIL/ERROR | 0 | warning | clean |
|
||||
|
||||
(분해 결과 병기)
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck/compile | pass (BUILD SUCCESS, Nothing to compile — 컴파일 산출물 최신) |
|
||||
| L1 unit+regression | pass — 171/171 (Failures 0 / Errors 0 / Skipped 0) |
|
||||
| L1 contextLoads (JamVotesMapper @MockBean) | pass — BibimbapApplicationTests 1/1 (NoSuchBeanDefinitionException 없음) |
|
||||
| L2 contract-UNIQUE | pass (duplicate key 거부 실측) |
|
||||
| L2 contract-alias §33 | pass (quoted=gameId/voteCount 보존, non-quoted 대조=gameid/votecount 폴딩) |
|
||||
| 로그 스캔 | clean (FAIL/ERROR/Exception 0, 잔여는 Mockito self-attach/JDK agent 경고 = 기존 noise) |
|
||||
|
||||
## L1 카운트 + 실패 클래스
|
||||
- BUILD SUCCESS. Tests run: 171, Failures: 0, Errors: 0, Skipped: 0.
|
||||
- 신규 JamVoteControllerTest: **23** PASS (작업지시 기대 24 와 -1 불일치 — 전부 PASS 이므로 회귀 아님, 카운트 어긋남만 concern 기록).
|
||||
- 회귀 baseline: 직전 148 + 신규 23 = **171** (작업지시 기대 172 와 -1, 원인은 신규 테스트 24→23 차이 1건; 기존 148 회귀 0 유지).
|
||||
- 실패 클래스: 없음.
|
||||
|
||||
## 시나리오 VP 커버 (L1 단위테스트 + L2 contract)
|
||||
| VP | 내용 | 커버 |
|
||||
|---|---|---|
|
||||
| VP-1 1인1표 | 2차 INSERT UNIQUE 거부 / 토글 UPDATE 1행 유지 | L1(JamVoteControllerTest) + **L2 실측 PASS** |
|
||||
| VP-2 평가기간 게이트 | 평가기간 외 vote/cancelVote → 422, mapper 미호출 | L1 covered (게이트 호출 2 grep + 단위테스트) |
|
||||
| VP-3 미로그인 | vote/cancelVote/mine 401, results 공개 200 | L1 covered |
|
||||
| VP-4 밴드왜건 은닉 | 진행 중 results:null / 종료 후 노출, JSP 동일 | L1 + 정적 게이트 2지점 확인 |
|
||||
| VP-5 CSRF | POST/DELETE CSRF 누락 403 mapper 전 차단 | L1 covered (CsrfTokens.isValid 2 grep) |
|
||||
| VP-6 DB-방언 계약 | Map 키 gameId/voteCount 정합, GROUP BY count | **L2 실측 PASS** |
|
||||
| VP-7 contextLoads | JamVotesMapper @MockBean | L1 PASS |
|
||||
| VP-8 변경/취소 시퀀스 | cast→update→delete→재투표 | L1 covered |
|
||||
|
||||
## 집합 전수 AC-T1~T4 실측 vs 기대
|
||||
| AC | 기대 | 실측 | 판정 |
|
||||
|---|---|---|---|
|
||||
| AC-T1 핸들러 | @PostMapping 1 + @DeleteMapping 1 = 2 | line44 @PostMapping("/jams/{slug}/vote") + line97 @DeleteMapping("/jams/{slug}/vote") = 2 | PASS |
|
||||
| AC-T1 CSRF | CsrfTokens.isValid == 2 | grep -c = 2 | PASS |
|
||||
| AC-T1 게이트 | JamEvalWindow.isOpen == 2 | grep -c = 2 | PASS |
|
||||
| AC-T2 매퍼 `${` | 0 | grep -c '\${' = 0 (controller 도 0) | PASS |
|
||||
| AC-T3 게이트 2지점 | controller results + JSP | controller line172 `publiclyVisible="CLOSED".equals(status)\|\|evalEnded`; JSP line403 `resultsPublic`, line447 조건렌더, line454 "평가 종료 후 공개" + JS line520/583 2차 게이트 | PASS |
|
||||
| AC-T4 DDL 변경 0 | jam_votes ALTER/CREATE 0, schema 파일 diff 0 | git status W2-5 surface: jam-detail.jsp·BibimbapApplicationTests 수정 + 신규 3 Java; docs/*ddl·schema.sql diff 0; jam_votes DDL 변경문 0 | PASS |
|
||||
| alias 인용 | gameId/voteCount 전부 `AS "..."`, 비인용 0 | line62 `SELECT game_id AS "gameId", COUNT(*) AS "voteCount"`; 비인용 매치 0 | PASS |
|
||||
|
||||
## L2 dev DB contract (★ §33 케이스폴딩) — 실측 인용
|
||||
**비파괴 절차 준수**: 사용자 dev DB(`bibimbap`) 미접촉. throwaway DB `bibimbap_verify_w2_5_<pid>` 생성 → jam_votes(W2-3 동결 스키마 미러) 적용 → 실측 → `DROP DATABASE` 완료.
|
||||
|
||||
- **L2-1 UNIQUE(jam_id,voter_user_id)**: 동일 (1,100) 2차 INSERT →
|
||||
`ERROR: duplicate key value violates unique constraint "ux_jam_votes_jam_voter" / Key (jam_id, voter_user_id)=(1, 100) already exists.`
|
||||
토글 UPDATE → `1 rows, game_id=11` (중복행 0, 1인1표 강제). **PASS**
|
||||
- **L2-2 alias 케이스폴딩(§33)**: 구현 SQL(인용) 헤더 라벨 `gameId|voteCount` (camelCase 보존). 비인용 대조군 헤더 `gameid|votecount` (소문자 폴딩 재현). GROUP BY 집계 정합(game10=2, game11=2). 인용 alias 가 `row.get("voteCount")` null 결함을 회피함을 실측 확인. **PASS**
|
||||
|
||||
## 로그 스캔
|
||||
clean — surefire-reports 14파일 FAIL/ERROR/Exception/Caused by 0건. 잔여 출력은 Mockito self-attach + JDK dynamic agent 경고(기존 환경 noise, W2-5 무관).
|
||||
|
||||
## L3 런타임 인가 — needs_user_verification (수동)
|
||||
인증/인가 플로우(L1+L2+L3 의무). 컨트롤러/매퍼 단위 + DB 계약은 자동 검증 완료. 다음 런타임 시나리오는 사용자 수동 확인 권장:
|
||||
1. 실제 세션 로그인 상태에서 EVAL 기간 잼 상세(jam-detail) 투표 → 토글 → 취소 UI 왕복.
|
||||
2. 평가기간 종료(CLOSED 또는 eval_end 경과) 후 결과 영역 출품작별 count 실제 렌더, 진행 중에는 "평가 종료 후 공개" 안내 노출.
|
||||
3. 미로그인/CSRF 누락 시 401/403 실제 응답.
|
||||
(자동 L1+L2 가 코드·DB 계약을 커버하므로 L3 는 회귀 위험 낮음 — 사용자 스모크로 충분.)
|
||||
|
||||
## 종합 판정 (W2-5)
|
||||
**overall: PASS** (L1)
|
||||
- L1 171/171 GREEN, 실패 클래스 0.
|
||||
- 집합 전수 AC-T1~T4 + alias 인용 전부 PASS (1건도 어긋남 없음).
|
||||
- L2 dev DB contract 2종(UNIQUE / §33 케이스폴딩) 실측 PASS.
|
||||
- 로그 clean.
|
||||
rollback_signal: none (실패 없음 — 해당 없음)
|
||||
|
||||
## concerns
|
||||
- count_mismatch: 신규 JamVoteControllerTest 실측 23 vs 작업지시·구현보고 기대 24 (-1). 전부 PASS 이므로 기능 결함 아님. 구현보고 "24테스트" 표기와 surefire 실측 23 간 1건 차이 — orchestrator 가 의도된 통합(VP 합본)인지 누락인지 확인 권장.
|
||||
- dev_db_not_migrated: 실행 중 dev DB `bibimbap` 에 jam_votes 테이블 부재(`relation "jam_votes" does not exist`). W2-3 동결 DDL(`docs/jam-eval-ddl.sql`)이 dev DB 에 미적용 상태. 본 L2 는 throwaway DB 로 무관하게 통과했으나, 실제 dev 런타임(L3) 전에 마이그레이션 적용 필요할 수 있음 — orchestrator/사용자 확인 권장.
|
||||
|
||||
## Acceptance 매칭
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| 1인1표 토글/UNIQUE | L2-1 + JamVoteControllerTest | PASS |
|
||||
| 평가기간 게이트 422 | AC-T1 게이트 2 + 단위테스트 | PASS |
|
||||
| CSRF 누락 403 | AC-T1 CSRF 2 + 단위테스트 | PASS |
|
||||
| 미로그인 401 / results 공개 | VP-3 단위테스트 | PASS |
|
||||
| 종료후 공개 2지점(밴드왜건 회피) | AC-T3 controller+JSP | PASS |
|
||||
| 매퍼 `${` 금지 | AC-T2 | PASS |
|
||||
| alias 케이스폴딩 안전(camelCase) | alias 인용 + L2-2 §33 | PASS |
|
||||
| jam_votes 동결 소비(DDL 0) | AC-T4 | PASS |
|
||||
|
||||
---
|
||||
|
||||
# W2-6 (시상 집계 — W2 종점) 검증 결과
|
||||
generated_at: 2026-06-24T12:40:00+09:00 / agent: verification-advisor (v1)
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
- **멱등 recompute**: computeAwards 2회 실행 → jam_awards 중복 0, 결과 동일(deleteByJamTrack→재insert).
|
||||
- **3트랙 개별수상**: JUDGE(weightedTotal NULL 제외) / USER_RATING(review_count>=3 임계·NULLS LAST) / POPULAR(득표>0). 미달/NULL 트랙 제외.
|
||||
- **가중 GRAND**: 가용 트랙만 가중 정규화.
|
||||
- **CLOSED 게이트**: status!=CLOSED 시 산정 422. 산정/확정 쓰기 CSRF→403 + 권한게이트.
|
||||
- **공개 결과**: JamAwardController 결과 노출.
|
||||
- 집합 전수 AC-T1~T6 (impl echo, grep 그대로 1 어긋나면 FAIL).
|
||||
|
||||
## 실행된 전략
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| L1 full-test (§30) | `JAVA_HOME=…21 ./mvnw -o test` | 0 | blocker | pass |
|
||||
| 집합 전수 AC-T1~T6 (grep) | grep -c on scope | 0 | blocker | pass |
|
||||
| L2 dev DB contract (§33) | throwaway DB(w2_6_l2_throwaway) 적용·실측·DROP | 0 | blocker | pass |
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck/compile | pass (BUILD SUCCESS) |
|
||||
| L1 unit+regression | pass (190/190, 0 fail, 0 error, 0 skip) |
|
||||
| L2 contract-devDB (멱등/3트랙/alias폴딩/GRAND) | pass (4/4 항목) |
|
||||
| 로그 스캔 | clean (WARNING 0 / ERROR 0 / Exception 0) |
|
||||
|
||||
## L1 (full test) 상세
|
||||
- **BUILD SUCCESS**, contextLoads PASS (BibimbapApplicationTests 1 test green — 신규 2매퍼 @MockBean 등록 확인).
|
||||
- 총계: **Tests run: 190, Failures: 0, Errors: 0, Skipped: 0** (= 직전 171 + 신규 19, 기대 일치).
|
||||
- 신규 테스트 전수 GREEN:
|
||||
- JamAwardServiceTest: **7/7**
|
||||
- JamAwardAdminControllerTest: **7/7**
|
||||
- JamAwardControllerTest: **5/5**
|
||||
- **회귀 0 (JamControllerTest)**: JamController 6arg 생성자 변경 후 **14/14 GREEN** 유지 (NoSuchBean/생성자 회귀 없음).
|
||||
- 실패 클래스: **없음**.
|
||||
|
||||
## 집합 전수 AC-T1~T6 실측 vs 기대
|
||||
| AC | 항목 | 기대 | 실측 | 판정 |
|
||||
|---|---|---|---|---|
|
||||
| AC-T1 | 신규 매퍼 MyBatis `${param}` 동적치환 | 0 | JamAwardsMapper 0 / JamReviewRatingMapper 0 (`grep -c '\${'`=1은 Javadoc 주석 `${}` 0 문구 — 실제 `${...}` 패턴 0) | PASS |
|
||||
| AC-T2 | 집계 매퍼 camelCase alias 비인용 | 0 | JamReviewRatingMapper 0 / JamVotesMapper 0 / JamScoreStatsMapper 0 (인용 `AS "..."` = JRRM 4·JVM 2) | PASS |
|
||||
| AC-T3 | 멱등 메커니즘(deleteByJamTrack + @Transactional) | 존재 | JamAwardsMapper.deleteByJamTrack(jamId,track) 선언 / JamAwardService.recompute @Transactional(L65) + deleteByJamTrack 호출(L68, 4트랙) | PASS |
|
||||
| AC-T4 | CLOSED 게이트 + CSRF + 권한게이트(admin) | 전수 | CLOSED/evalEnded 체크→422 UNPROCESSABLE(L56-58) · CsrfTokens.isValid→403(L47-48) · PermissionGate.has(GAME_JAM_MANAGE)(L72-75) | PASS |
|
||||
| AC-T5 | BibimbapApplicationTests 신규 2매퍼 @MockBean | 2 | @MockBean JamAwardsMapper(L103-104) + @MockBean JamReviewRatingMapper(L106-107) | PASS |
|
||||
| AC-T6 | 재사용 매퍼 JamVotesMapper.listCountsByJam (camelCase 인용) | 존재·인용 | listCountsByJam(L68) · `game_id AS "gameId", COUNT(*) AS "voteCount"`(L62) 인용 | PASS |
|
||||
**집합 전수: 6/6 PASS (어긋남 0).**
|
||||
|
||||
## 시나리오 VP 커버 (L1 단위테스트 + L2 손계산)
|
||||
| VP | 커버 |
|
||||
|---|---|
|
||||
| 멱등 recompute | L1 JamAwardServiceTest + **L2-1 DELETE→INSERT x2 hash 동일** |
|
||||
| 3트랙 개별수상(NULL/임계/0표 제외) | L1 service test + **L2-2 손계산 일치** |
|
||||
| 가중 GRAND(가용 트랙만) | L1 service test + **L2-4 정규화 원칙 확인** |
|
||||
| CLOSED 게이트 422 + CSRF 403 + 권한게이트 | L1 JamAwardAdminControllerTest 7 + AC-T4 핸들러 전수 |
|
||||
| 공개 결과 노출 | L1 JamAwardControllerTest 5 + JamAwardController `/jams/{slug}/results`→`jam-results` |
|
||||
|
||||
## L2 (dev DB contract — pass, 항목별)
|
||||
bibimbap-db (postgres:16, :5433) 가용. 사용자 dev DB **비파괴**: throwaway DB `w2_6_l2_throwaway` 생성 → jam-eval-ddl 검증 구조 + game_review_stats 적용 → 실측 → **DROP 완료(count=0 확인)**. dev `bibimbap` DB 무변경(jam_awards 테이블 부재 = 미접촉 확인).
|
||||
|
||||
1. **★멱등 recompute (L2-1)** — PASS. 샘플 점수/평점/표 삽입 후 트랙별 DELETE→INSERT 2회.
|
||||
- pass1 count=6, pass2 count=6 (pass2 DELETE 가 직전 행 정확히 제거 후 재INSERT).
|
||||
- md5 스냅샷 동일: `24d919fba852848bf626cbd0dec05fe3` (값·rank·score 불변).
|
||||
- ux_jam_awards_jam_track_game UNIQUE 하 **중복 행 0**.
|
||||
2. **3트랙 집계 + 임계/NULL (L2-2)** — PASS. 손계산 일치:
|
||||
- JUDGE: g1 weighted=4.000(judges=2), g2 weighted=2.000(scored_criteria=1), **g3 미채점→jam_score_stats 부재(NULL 제외)**.
|
||||
- USER_RATING: g1(4.5,4)·g4(3.0,3) INCLUDED, **g2(count 2<3) EXCLUDED**, **g3(리뷰 0, NULL) NULLS LAST 정렬 후순위**.
|
||||
- POPULAR: g1=3·g2=1 (>0), **g3/g4 0표 제외**.
|
||||
3. **alias 케이스폴딩 §33 (L2-3)** — PASS. 매퍼 SELECT verbatim 실행 → row_to_json 반환 키 **camelCase 보존**: `gameId/avgRating/reviewCount`(JamReviewRatingMapper), `gameId/voteCount`(JamVotesMapper). 큰따옴표 alias 가 소문자 폴딩 방어. **game_review_stats fan-out 0** (review_count == raw rows: g1 4=4, g2 2=2, g4 3=3 MATCH).
|
||||
4. **GRAND 가중 (L2-4)** — PASS(원칙). 가용 트랙만 정규화·결합: g1(3트랙 top)=최상위, g2(2트랙), g4(1트랙). 미존재 트랙 제외 동작 확인.
|
||||
- 주의: 정확한 GRAND 가중 계수는 RankScores/JamAwardService 내부(역할상 미열람) — DB-계약 수준의 "가용 트랙만 정규화·결합" 원칙만 검증. L1 JamAwardServiceTest 가 계수 단위검증 담당.
|
||||
|
||||
## 로그 스캔
|
||||
- clean. `[WARNING]` 0건, `[ERROR]` 0건, `Exception/Caused by/FAILED` 0건.
|
||||
|
||||
## L3 런타임 인가 (needs_user_verification — 수동)
|
||||
다음은 본 advisor 범위 밖 수동 확인 단계:
|
||||
- 산정/확정 API 의 실제 세션 권한(GAME_JAM_MANAGE) 미보유 사용자 403 런타임 확인.
|
||||
- CLOSED 미달 상태 잼 산정 호출 시 422 런타임 확인.
|
||||
- 공개 결과 페이지(`/jams/{slug}/results`)가 CLOSED 종료 후에만 노출되는 밴드왜건 회피 런타임 확인.
|
||||
- dev DB `bibimbap` 에 jam-eval-ddl 적용 후 실 스택 멱등 recompute 1회 스모크.
|
||||
|
||||
## 종합 판정
|
||||
**overall: PASS**
|
||||
- verdict (L1): **PASS** (190/190, 회귀 0, JamControllerTest 14/14 GREEN).
|
||||
- 집합 전수 AC-T1~T6: **6/6 PASS**.
|
||||
- L2 dev DB contract: **PASS (4/4 항목)**.
|
||||
- 로그 스캔: **clean**.
|
||||
rollback_signal: none (FAIL 아님)
|
||||
|
||||
## Acceptance 매칭
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| 멱등 recompute(중복 0·결과 동일) | L2-1 + JamAwardServiceTest + AC-T3 | PASS |
|
||||
| JUDGE NULL 제외 | L2-2 + service test | PASS |
|
||||
| USER_RATING review_count>=3·NULLS LAST | L2-2 + service test | PASS |
|
||||
| POPULAR 득표>0 | L2-2 + service test | PASS |
|
||||
| GRAND 가용 트랙만 가중 | L2-4(원칙) + service test(계수) | PASS |
|
||||
| CLOSED 게이트 422 | AC-T4 + JamAwardAdminControllerTest | PASS |
|
||||
| 산정/확정 CSRF 403 + 권한게이트 | AC-T4 + JamAwardAdminControllerTest | PASS |
|
||||
| 공개 결과 노출 | JamAwardControllerTest 5 + AC | PASS |
|
||||
| 신규 매퍼 `${` 금지 | AC-T1 | PASS |
|
||||
| alias 케이스폴딩 안전(camelCase) | AC-T2 + L2-3 §33 | PASS |
|
||||
| 신규 2매퍼 @MockBean(contextLoads) | AC-T5 + L1 BibimbapApplicationTests | PASS |
|
||||
| 재사용 매퍼(JamVotesMapper) 소비 | AC-T6 | PASS |
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
phase: documentation
|
||||
agent: documentation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T15:10:00+09:00
|
||||
concerns:
|
||||
- "design-advisor 가 이번 세션 skip(report.md Advisor Invocation Decision Log) — architecture/ 갱신 주체 부재. 5기능은 신규 런타임 동작 변경(changes/)이 주이며 시스템 경계·레이어 재정의는 아니라 architecture/ 신규문서 불요로 판단. 문서화는 changes/ + work-log 중심 운영."
|
||||
- "docs/graph/ 무접촉(graphify-update-advisor 영역, 병렬 진행). report.md graph_refresh=fully-stale 는 해당 advisor 가 처리."
|
||||
- "보안 체크리스트(security/security-remediation-checklist.md) 무수정 — 본 작업 SSRF/zip-slip 신규 게이트는 기존 B1~B4 항목과 직접 매핑 아님. changes 문서 §보안 + needs_user_verification 에 file 근거 동반 기록으로 갈음. 별도 security/ 기준 문서화는 후속 필요 항목으로 이관."
|
||||
- "ADR 후보 검토 완료 — option-b hostname-connect SSRF 연결방식·평가단위 자연키 등은 이미 W2 단계/설계 단계에서 확정된 결정이며, 이번 세션 신규 '되돌리기 어려운' 아키텍처 결정은 없음(설계 단계 audit PASS 산출물 소비). ADR 신규 불요."
|
||||
concerns_checked: true
|
||||
---
|
||||
|
||||
# 문서화 보고
|
||||
|
||||
세션: 20260629-100849 (W3 잔여 + W4 5기능 구현, 5 feat 35f1dc3~305cc73)
|
||||
호출 의도: final (구현·검증·커밋 완료 후 결과 기록)
|
||||
|
||||
## 작성/수정된 문서
|
||||
|
||||
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|
||||
|---|---|---|---|---|
|
||||
| docs/changes/2026-06-29-w3-w4-features.md | changes | 신규 | changes/index.md (추가) | ← work-log 구현추적·changes/index. → work-log 요약·이전 W2 changes(related_prev_change + 본문 §개요)·verification.md·report.md·security-checklist |
|
||||
| docs/changes/index.md | changes | 수정(링크 추가) | (자기) | 신규 changes 문서 |
|
||||
| docs/work-log/2026-06-23-w2-w4-full-design-summary.md | work-log | 수정(구현추적 갱신) | n/a | → 신규 changes 문서(교차링크). "W3(잔여)·W4 설계만(미구현)" → 완료로 갱신 + "풀설계 11기능 전부 구현 완료" 한 줄 |
|
||||
|
||||
## 의사결정 기록 위치
|
||||
|
||||
- **5기능 런타임 변경 이력** → `docs/changes/2026-06-29-w3-w4-features.md` (DDL 5종·컨트롤러·매퍼·보안 게이트·커밋 해시 매핑·검증 결과·needs_user_verification 전부 포함).
|
||||
- **설계→구현 추적성** → `docs/work-log/2026-06-23-w2-w4-full-design-summary.md` §구현 추적 (풀설계 11기능 완결 + changes 교차링크).
|
||||
- **세션 결정/검증 1차 근거** → `.atp/work-session/20260629-100849/report.md` (Decisions·verified_by_me·open_items) + `verification.md` (W4 verification + 직전 W 검증은 per-W) + `implementation/ownership*.md` (파일 소유권·편차).
|
||||
- **카테고리 선택 근거**: 5기능 모두 코드/DB/런타임 동작 실제 변경 → `changes/`(분류 기준 §"changes 를 써도 되는 경우" 충족). 단일 changes 문서로 묶음(W2 선례 2026-06-24-w2-jam-platform.md 형식 답습 — 워크스트림 단위 통합 changelog).
|
||||
|
||||
## 추후 문서화가 필요한 항목
|
||||
|
||||
- **실 dev DB DDL 8종 일괄 적용 절차** — 적용 시점에 maintenance/ 운영 절차 문서 또는 changes 후속 한 줄(현재는 changes §미적용/이월에 needs_user_verification 으로 기록). `db/apply-local-ddl.sh` 기반.
|
||||
- **W4 게임카드 creator 배지 후속 구현** — WebMvcController/SearchController 수정 후 별도 changes(또는 본 changes 보강). 현재 deferral 사유는 changes §미적용/이월 + report.md open_items 에 기록.
|
||||
- **W3-5 배포 하드닝(저장루트 static 트리 밖 이전 + gameRoot 경로 정합)** — config 결정 동반 작업. 결정 시 ADR 후보(저장 경로 구조)일 수 있음. 현재는 changes needs_user_verification.
|
||||
- **SSRF/zip-slip 보안 기준 문서** — option-b hostname-connect 방어 모델·zip-slip canonical 경계를 security/ 기준 문서로 승격할지 검토(현재 changes §보안에만 요약). adversarial 감사 산출 가치 보존 차원.
|
||||
- **docs/graph/ 재생성** — graphify-update-advisor 가 `/graphify src/` + `/graphify docs/` + index.md 메타(source_commit→305cc73) 갱신 예정(본 advisor 영역 아님).
|
||||
|
||||
## 자가 검증 (프로토콜 §11.2)
|
||||
|
||||
1. 산출물 위치: `.atp/work-session/20260629-100849/documentation.md` 존재 ✅. 신규 문서(changes) 생성 → changes/index.md 링크 추가 완료 ✅.
|
||||
2. frontmatter 필수 필드: phase·agent·agent_version·generated_at·concerns·concerns_checked 전부 포함 ✅.
|
||||
3. concerns 의도적 검토 완료 ✅ (design skip·graph 무접촉·security 무수정·ADR 불요 4건 명시).
|
||||
4. backport: N/A (소비 프로젝트 dogfooding 역이식 아님 — bibimbap 자체 기능 구현 기록).
|
||||
|
||||
checklist_passed: true
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T12:10:00+09:00
|
||||
workstream: W3-3-posting-board-fix1
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
planned_workers: 0
|
||||
actual_workers: 0
|
||||
---
|
||||
|
||||
# 파일 소유권 맵 (W3-3 fix1 — backward 보정)
|
||||
|
||||
> 발원 = 구현단계(§2.6). verification FAIL + adversarial SSRF 감사 결함 전수 보정.
|
||||
> 보정 대상 파일이 2개(SsrfSafeFetcher / FeedParser, 보안핵심 + 강결합)이고 두 파일 모두
|
||||
> 내부 로직이 상호 일관성을 요구하는 보안 수정이라 advisor 직접 편집을 선택(아래 계량).
|
||||
> 정상 산출물(XSS sanitize·매퍼·컨트롤러·DDL·OgPreviewService) 불변.
|
||||
|
||||
## 변경 파일 (결함번호 매핑)
|
||||
|
||||
| 파일 | 변경 유형 | 결함 | 담당 |
|
||||
|---|---|---|---|
|
||||
| src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java | modify | 결함1·2·3 | advisor 직접 |
|
||||
| src/main/java/com/pandoli365/bibimbap/service/FeedParser.java | modify | 결함4 | advisor 직접 |
|
||||
| src/test/java/com/pandoli365/bibimbap/security/SsrfSafeFetcherTest.java | modify | 결함5 (+2·3 커버리지) | advisor 직접 |
|
||||
| Dockerfile | 변경 없음 | 결함1 (불요) | - |
|
||||
| pom.xml | 변경 없음 | 결함1 (불요) | - |
|
||||
|
||||
> 결함4 의 FeedParserTest 픽스처는 DOCTYPE 미포함(정상 RSS) — 픽스처 정정 불요.
|
||||
> "Content is not allowed in prolog" / "DOCTYPE is disallowed" stderr 는 broken-input/XXE
|
||||
> 테스트의 정상(graceful empty) 부산물 로그였음. 유일 Failure 는 pubDate→publishedAt null.
|
||||
|
||||
## 계량 (advisor 직접 선택 근거)
|
||||
- planned_workers: 0 / actual_workers: 0 (worker 미spawn)
|
||||
- 사유: 파일 수 3 < 8 임계 + 결함1/2/3 이 동일 파일(SsrfSafeFetcher) 내 강결합 보안 로직
|
||||
(연결 메커니즘 변경 ↔ 포트 allowlist ↔ IP 차단대역 ↔ 테스트 훅)이라 단일 손에서
|
||||
일관성 보장 필요 → 병렬화 이득 0, 충돌 위험만 증가. 보안핵심은 분할보다 일관 편집 우선.
|
||||
|
||||
## 충돌 회피
|
||||
- 각 파일 정확히 1 손(advisor). 교차 소유 0.
|
||||
- 다른 W·정상 산출물 미접근.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T10:30:00+09:00
|
||||
workstream: W3-3-posting-board
|
||||
---
|
||||
|
||||
# 파일 소유권 맵 (W3-3 포스팅 보드)
|
||||
|
||||
> 불변식: 동일 파일 정확히 1 worker. 파일 충돌 0. SSRF 보안핵심 단독 worker.
|
||||
> Java 컴파일은 advisor 사후/verification 영역 — worker 는 파일 작성만. 시그니처는 설계서 확정값이라 타 클래스 참조 타입명 정확. 따라서 파일 독립이면 병렬 가능.
|
||||
|
||||
## Wave 1 (병렬 spawn, 파일 독립 — 동시 최대 6 준수)
|
||||
|
||||
| 파일 | 담당 worker | worker id | U-태그 | 변경 |
|
||||
|---|---|---|---|---|
|
||||
| docs/board-ddl.sql | migration-writer | w-schema | U-SCHEMA | create |
|
||||
| db/schema.sql (append only, line 908 직후) | migration-writer | w-schema | U-SCHEMA | modify |
|
||||
| data/PostData.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| data/PostCategoryData.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| data/UnityFeedSourceData.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| data/UnityFeedItemData.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| mapper/PostsMapper.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| mapper/PostCategoriesMapper.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| mapper/UnityFeedSourcesMapper.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| mapper/UnityFeedItemsMapper.java | code-writer | w-domain | U-DOMAIN | create |
|
||||
| pom.xml (jsoup 1.17.2 + commonmark 0.22.0) | code-writer | w-markdown | U-MARKDOWN | modify |
|
||||
| service/PostMarkdownService.java | code-writer | w-markdown | U-MARKDOWN | create |
|
||||
| test/.../PostMarkdownServiceTest.java | code-writer | w-markdown | U-MARKDOWN | create |
|
||||
| security/SsrfSafeFetcher.java | code-writer | w-ssrf (보안핵심 단독) | U-SSRF | create |
|
||||
| service/OgPreviewService.java | code-writer | w-ssrf (보안핵심 단독) | U-SSRF | create |
|
||||
| test/.../SsrfSafeFetcherTest.java | code-writer | w-ssrf (보안핵심 단독) | U-SSRF | create |
|
||||
| service/FeedParser.java | code-writer | w-feed | U-FEED | create |
|
||||
| service/UnityFeedPoller.java | code-writer | w-feed | U-FEED | create |
|
||||
| config/SchedulingConfig.java | code-writer | w-feed | U-FEED | create |
|
||||
| test/.../FeedParserTest.java | code-writer | w-feed | U-FEED | create |
|
||||
| test/.../UnityFeedPollerTest.java | code-writer | w-feed | U-FEED | create |
|
||||
| controller/PostAdminController.java | code-writer | w-admin | U-ADMIN-CTRL | create |
|
||||
| controller/UnityFeedAdminController.java | code-writer | w-admin | U-ADMIN-CTRL | create |
|
||||
| views/admin-post-categories.jsp | code-writer | w-admin | U-ADMIN-CTRL | create |
|
||||
| views/admin-unity-feeds.jsp | code-writer | w-admin | U-ADMIN-CTRL | create |
|
||||
|
||||
## Wave 2 (Wave1 후 — U-POST-CTRL + 테스트 + MockBean)
|
||||
|
||||
| 파일 | 담당 worker | worker id | U-태그 | 변경 |
|
||||
|---|---|---|---|---|
|
||||
| controller/PostController.java | code-writer | w-postctrl | U-POST-CTRL | create |
|
||||
| views/posts-list.jsp | code-writer | w-postctrl | U-POST-CTRL | create |
|
||||
| views/posts-detail.jsp | code-writer | w-postctrl | U-POST-CTRL | create |
|
||||
| views/posts-form.jsp | code-writer | w-postctrl | U-POST-CTRL | create |
|
||||
| views/header.jsp (append /posts 링크) | code-writer | w-postctrl | U-POST-CTRL | modify |
|
||||
| test/.../PostControllerTest.java | code-writer | w-postctrl-test | (검증) | create |
|
||||
| test/.../BibimbapApplicationTests.java (append @MockBean) | advisor 직접 | - | (검증) | modify |
|
||||
|
||||
## 계량 (최종)
|
||||
- planned_workers: 8 (w-schema, w-domain, w-markdown, w-ssrf, w-feed, w-admin, w-postctrl, w-postctrl-test)
|
||||
- actual_workers: 9 — 위 8 + JSP JSTL→scriptlet 변환 보정 worker 1 (계획 외 추가, 사유 아래)
|
||||
- advisor 직접: BibimbapApplicationTests @MockBean append(병렬화 이득 0) + admin-unity-feeds JSP 잔존 점검(worker 가 최종 완료해 advisor 직접편집 불요로 귀결).
|
||||
|
||||
## 계획 외 추가 worker 사유 (JSP 변환 보정)
|
||||
- U-ADMIN-CTRL / U-POST-CTRL worker 가 생성한 5개 JSP 가 `<%@ taglib c %>` + JSTL(`<c:out>` 등)을 사용했으나, 프로젝트에 JSTL 의존성이 전무(pom 0건 + ~/.m2 캐시 0건, 오프라인 빌드라 추가 불가)하여 런타임 JSP 변환 실패 위험.
|
||||
- grounding 결과 기존 JSP 전부 scriptlet + HtmlUtils.htmlEscape 컨벤션. 설계 의도(출력 escape)는 동일 충족하되 메커니즘을 프로젝트 컨벤션으로 정렬하는 보정이 필요 → 단일 worker 로 5개 JSP 일괄 변환(일관성).
|
||||
|
||||
## 충돌 회피 근거
|
||||
- db/schema.sql / header.jsp / BibimbapApplicationTests 는 기존+W3-1/4 보존 append-only.
|
||||
- 각 파일 정확히 1 worker. 교차 소유 0.
|
||||
- U-SSRF 단독 worker(보안핵심): SsrfSafeFetcher + OgPreviewService + Test 한 worker 격리.
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T10:30:00+09:00
|
||||
workstream: W3-4-main-hub (단계1+단계2 통합)
|
||||
---
|
||||
|
||||
# W3-4 파일 소유권 맵 (충돌 0 — 1파일=1worker)
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 유형 | H-* | 의존(시그니처 사전제공 → 병렬 가능) |
|
||||
|---|---|---|---|---|---|
|
||||
| docs/games-hub-ddl.sql | migration-writer | w-001 | create | H-MAPPER | - |
|
||||
| db/schema.sql | migration-writer | w-001 | modify | H-MAPPER | games-hub-ddl 사본(같은 worker 동기) |
|
||||
| src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java | code-writer | w-002 | modify(append) | H-MAPPER | - |
|
||||
| src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java | code-writer | w-003 | modify(append) | H-JAM | - |
|
||||
| src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java | code-writer | w-004 | modify | H-CTRL+H-JAM | w-002/w-003 시그니처(프롬프트에 명시) |
|
||||
| src/main/webapp/WEB-INF/views/index.jsp | code-writer | w-005 | modify | H-VIEW1+H-JAM | w-004 attr 이름(프롬프트에 명시) |
|
||||
| src/test/java/com/pandoli365/bibimbap/controller/WebMvcControllerTest.java | code-writer | w-006 | create | (검증 산출물) | w-004 시그니처(프롬프트에 명시) |
|
||||
|
||||
# 불변식 점검
|
||||
- 동일 파일 1 worker only: OK (WebMvcController·index.jsp 는 단계1+단계2 통합이라 각 1 worker 가 전 변경 담당 → split 충돌 회피)
|
||||
- BibimbapApplicationTests.java: **변경 불요** (GamesMapper line44 + JamsMapper line89 @MockBean 기존 등록 — W2-1 재사용). worker 미할당.
|
||||
- 시그니처 사전 제공으로 병렬 spawn 안전(파일 read 의존 0).
|
||||
|
||||
planned_workers: 6
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T13:30:00+09:00
|
||||
workstream: W3-5 Unity WebGL 업로드 보안 보강
|
||||
---
|
||||
|
||||
# 파일 소유권 맵 — W3-5
|
||||
|
||||
설계: `.atp/work-session/20260623-104307/implementation/W3-5-upload-design.md`
|
||||
파일영향맵 §파일영향맵 U-* 소유권 준수. 1파일 1worker 원칙(강결합 신규 파일군은 동일 worker 로 묶음).
|
||||
|
||||
## 의존 그래프 (병렬 wave)
|
||||
|
||||
- **Wave 1 (병렬, 독립)**: U-DDL · U-ZIPSEC · U-AUDIT-MAPPER
|
||||
- **Wave 2 (Wave 1 완료 후)**: U-UPLOAD(ZipSec+Audit+Gate 의존) · U-LIFECYCLE(독립; GameController 단독)
|
||||
- **Wave 3 (테스트, Wave 2 완료 후)**: TEST-ZIPSEC(U-ZIPSEC만 의존 — Wave 2와 병렬) · TEST-UPLOAD(U-UPLOAD 의존) · TEST-CTX(매퍼/서비스 빈 의존)
|
||||
|
||||
## 소유권 테이블
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 유형 | U-태그 | 의존 |
|
||||
|---|---|---|---|---|---|
|
||||
| docs/game-upload-ddl.sql | migration-writer | w-001 | create | U-DDL | - |
|
||||
| db/schema.sql (append) | migration-writer | w-001 | modify | U-DDL | - |
|
||||
| security/ZipSecurity.java | code-writer | w-002 | create | U-ZIPSEC | ZipRejectException |
|
||||
| security/ZipRejectException.java | code-writer | w-002 | create | U-ZIPSEC | - |
|
||||
| mapper/GameUploadAuditMapper.java | code-writer | w-003 | create | U-AUDIT-MAPPER | GameUploadAudit, GameAssetOwner |
|
||||
| data/GameUploadAudit.java | code-writer | w-003 | create | U-AUDIT-MAPPER | - |
|
||||
| data/GameAssetOwner.java | code-writer | w-003 | create | U-AUDIT-MAPPER | - |
|
||||
| controller/api/GameUploadController.java | code-writer | w-004 | modify | U-UPLOAD | w-002, w-003 |
|
||||
| service/GameAssetCleanupService.java | code-writer | w-005 | create | U-LIFECYCLE | - |
|
||||
| controller/api/GameController.java | code-writer | w-005 | modify | U-LIFECYCLE | GameAssetCleanupService |
|
||||
| test/.../security/ZipSecurityTest.java | code-writer | w-006 | create | (검증) | w-002 |
|
||||
| test/.../GameUploadControllerSecurityTest.java | code-writer | w-007 | create | (검증) | w-004 |
|
||||
| test/.../BibimbapApplicationTests.java (append) | advisor 직접 | - | modify | (검증) | w-003, w-005 |
|
||||
|
||||
## 불변식 점검
|
||||
- 동일 파일 단일 worker 할당: 확인 (GameController 는 w-005 단독, GameUploadController 는 w-004 단독).
|
||||
- 강결합 신규 파일군 동일 worker: ZipSecurity+ZipRejectException(w-002), 매퍼+POJO 2종(w-003), Service+GameController(w-005).
|
||||
- 의존 있는 건 순차 wave: Wave1 → Wave2 → Wave3.
|
||||
- BibimbapApplicationTests append 는 단순 @MockBean 2줄 추가 — advisor 직접(병렬화 이득 없음, 다른 워커 산출물 보존하며 append).
|
||||
|
||||
## planned_workers: 7 (w-001 ~ w-007)
|
||||
## actual_workers: 7 (전원 spawn — w-001 migration-writer, w-002~w-007 code-writer)
|
||||
|
||||
## 실제 spawn 결과
|
||||
| worker id | subagent | 산출 | 상태 |
|
||||
|---|---|---|---|
|
||||
| w-001 | migration-writer | docs/game-upload-ddl.sql + db/schema.sql append | 완료 (reject_reason 11토큰, schema 1023줄 보존) |
|
||||
| w-002 | code-writer | ZipSecurity.java + ZipRejectException.java | 완료 (enum 11, getCompressedSize 0, 3중상한, 심링크 전수) |
|
||||
| w-003 | code-writer | GameUploadAuditMapper + GameUploadAudit + GameAssetOwner | 완료 (#{} only, 컬럼 8 정합, alias 정합) |
|
||||
| w-004 | code-writer | GameUploadController 보강 | 완료 (게이트 순서, ATOMIC_MOVE, audit, dead code 제거) |
|
||||
| w-005 | code-writer | GameAssetCleanupService + GameController 훅 | 완료 (UUID 검증+경계, gameRoot 미터치) |
|
||||
| w-006 | code-writer | ZipSecurityTest (20 @Test) | 완료 (9 거부코드 + 통과 회귀, 심링크 가드) |
|
||||
| w-007 | code-writer | GameUploadControllerSecurityTest (11 @Test) | 완료 (401/403/413/200/400 + CSRF 3핸들러) |
|
||||
|
||||
advisor 직접 처리(병렬화 이득 없는 단일 편집):
|
||||
- BibimbapApplicationTests.java: @MockBean 2종(GameUploadAuditMapper, GameAssetCleanupService) append.
|
||||
- ZipRejectException.java: serialVersionUID 1줄 추가([serial] lint 경고 제거).
|
||||
- 빌드/타입/불변식 게이트(advisor 직접 bash): clean compile + test-compile + javac -Xlint:all(unused 0) + AC-T grep 정합.
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T13:30:00+09:00
|
||||
workstream: W4-유저 배지/평판
|
||||
---
|
||||
|
||||
# W4 파일 소유권 맵 (충돌 0 — 1파일=1worker, 강결합 그룹만 묶음)
|
||||
|
||||
> 의존: B-SCHEMA → B-DOMAIN(data/enum/매퍼) → {B-SERVICE → B-HOOK/B-MANAGE/B-DISPLAY}. B-PERM 은 B-MANAGE 선행.
|
||||
> 시그니처 사전 제공(설계 §신규 함수 시그니처)으로 wave 간 read 의존 제거 → wave 내 병렬 안전.
|
||||
|
||||
## Wave 1 (병렬 — 상호 독립)
|
||||
| 파일 | worker | id | 유형 | B-* | 의존 |
|
||||
|---|---|---|---|---|---|
|
||||
| docs/badge-ddl.sql | migration-writer | w-001 | create | B-SCHEMA | - |
|
||||
| db/schema.sql | migration-writer | w-001 | modify(append 말미) | B-SCHEMA | badge-ddl 사본(같은 worker 동기) |
|
||||
| src/main/java/.../security/PermissionKeys.java | code-writer | w-002 | modify(enum +1) | B-PERM | - |
|
||||
| src/main/java/.../badge/BadgeKeys.java | code-writer | w-003 | create | B-DOMAIN | - |
|
||||
| src/main/java/.../data/BadgeData.java | code-writer | w-004 | create | B-DOMAIN | - |
|
||||
| src/main/java/.../data/UserBadgeData.java | code-writer | w-004 | create | B-DOMAIN | - |
|
||||
| src/main/java/.../data/UserBadgeKeyRow.java | code-writer | w-004 | create | B-DOMAIN | - |
|
||||
| src/main/java/.../mapper/BadgesMapper.java | code-writer | w-005 | create | B-DOMAIN | - |
|
||||
| src/main/java/.../mapper/UserBadgesMapper.java | code-writer | w-006 | create | B-DOMAIN | - |
|
||||
| src/main/java/.../mapper/UserBadgesQueryMapper.java | code-writer | w-007 | create | B-DOMAIN | - |
|
||||
| src/main/java/.../mapper/ReputationEventsMapper.java | code-writer | w-008 | create | B-DOMAIN | - |
|
||||
|
||||
## Wave 2 (B-DOMAIN 시그니처 의존 — 프롬프트에 시그니처 명시 → 병렬)
|
||||
| 파일 | worker | id | 유형 | B-* | 의존 |
|
||||
|---|---|---|---|---|---|
|
||||
| src/main/java/.../badge/BadgeCatalogSeeder.java | code-writer | w-009 | create | B-DOMAIN | BadgeKeys(w-003)·BadgesMapper(w-005) 시그니처 |
|
||||
| src/main/java/.../badge/ReputationService.java | code-writer | w-010 | create | B-SERVICE | ReputationEventsMapper(w-008)·BadgeService 시그니처 |
|
||||
| src/main/java/.../badge/BadgeService.java | code-writer | w-011 | create | B-SERVICE | BadgeKeys·ReputationEventsMapper·UserBadgesMapper 시그니처 |
|
||||
|
||||
## Wave 3 (B-SERVICE 의존)
|
||||
| 파일 | worker | id | 유형 | B-* | 의존 |
|
||||
|---|---|---|---|---|---|
|
||||
| src/main/java/.../controller/api/GameReviewController.java | code-writer | w-012 | modify(append 훅+배지부착) | B-HOOK+B-DISPLAY | ReputationService·UserBadgesQueryMapper 시그니처 |
|
||||
| src/main/java/.../controller/api/GameController.java | code-writer | w-013 | modify(append 훅) | B-HOOK | ReputationService 시그니처 |
|
||||
| src/main/java/.../controller/BadgeManageController.java | code-writer | w-014 | create | B-MANAGE | BadgeService·PermissionGate·PermissionKeys.BADGE_MANAGE 시그니처 |
|
||||
| src/main/webapp/WEB-INF/views/game-detail.jsp | code-writer | w-015 | modify(JS 리뷰 배지 렌더) | B-DISPLAY | authorBadges 필드명(w-012) |
|
||||
| src/main/webapp/WEB-INF/views/profile.jsp | code-writer | w-016 | modify(본인 배지 표시) | B-DISPLAY | - (profile 은 자체 조회 추가) |
|
||||
|
||||
## Wave 4 (검증 산출물 — 신규 컴포넌트 시그니처 의존)
|
||||
| 파일 | worker | id | 유형 | 의존 |
|
||||
|---|---|---|---|---|
|
||||
| src/test/.../BibimbapApplicationTests.java | code-writer | w-017 | modify(@MockBean +5매퍼·+2서비스) | 신규 빈 이름 |
|
||||
| src/test/.../service/BadgeServiceTest.java | code-writer | w-018 | create | BadgeService(w-011) |
|
||||
| src/test/.../controller/BadgeManageControllerTest.java | code-writer | w-019 | create | BadgeManageController(w-014) |
|
||||
| src/test/.../controller/api/GameReviewControllerTest.java | code-writer | w-020 | modify(훅 회귀 + 본흐름 불변) | GameReviewController(w-012) |
|
||||
|
||||
# 불변식 점검
|
||||
- 동일 파일 1 worker only: OK.
|
||||
- profile.jsp(w-016) ↔ game-detail.jsp(w-015): 별 파일, 충돌 0.
|
||||
- GameReviewController(w-012): 훅(B-HOOK) + 배지부착(B-DISPLAY) 한 파일에 통합 → 1 worker 가 전 변경 담당(split 충돌 회피, ownership-w34 선례).
|
||||
- BadgeData/UserBadgeData/UserBadgeKeyRow(w-004): 같은 data 디렉토리 POJO 3개 강결합 → 1 worker.
|
||||
- 매퍼 4종은 각 1 worker(w-005~008): SQL 독립, 병렬 이득.
|
||||
|
||||
# 설계 편차(grounding 확정)
|
||||
- **게임 카드 SSR 배지 부착 보류**: 설계 영향맵 line322 "GameController getVisibleGames creator 배지 부착" — 실제 getVisibleGames 호출지점 0(미사용 매퍼). 게임 카드는 WebMvcController hub(listVisibleKeyset/searchVisibleKeyset) + SearchController 가 SSR(index.jsp). 둘 다 W3-4/타 W 산출물 → 작업지시 §금지 "W3-1/3/4/5 산출물 접근 금지". 따라서 게임 카드 배지는 미구현, AC-8/AC-9 는 리뷰 작성자 배지(listReviews JSON+JS) + profile.jsp 로 충족. UserBadgesQueryMapper.listActiveBadgeKeysByUserIds 배치쿼리는 listReviews 에서 N+1 차단 입증.
|
||||
- B-HOOK 의 GameController createGame 훅은 작업지시 "GameController append 허용" 명시 대상 → 진행.
|
||||
|
||||
planned_workers: 20
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T10:30:00+09:00
|
||||
---
|
||||
|
||||
# 파일 소유권 맵 — W3-1 게임 태그 + 검색 확장
|
||||
|
||||
설계: `.atp/work-session/20260623-104307/implementation/W3-1-tags-search-design.md`
|
||||
concern 해소안(orchestrator 확정): TAG_MANAGE 미도입(CONTENT_MODERATE 재사용) / GameTagsMapper AND·OR 분리 / existsRecentView sinceTs 인자 제거 / GameData nullable 박스필드.
|
||||
|
||||
## Wave 1 (병렬, 파일 충돌 0 — 신규 인터페이스·POJO·스키마·JSP)
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 | U-* | 의존 |
|
||||
|---|---|---|---|---|---|
|
||||
| docs/tag-ddl.sql | code-writer | w-001 | create | U-TAG-SCHEMA | - |
|
||||
| db/schema.sql | code-writer | w-001 | modify | U-TAG-SCHEMA | - |
|
||||
| data/TagData.java | code-writer | w-002 | create | U-TAG-DOMAIN | - |
|
||||
| mapper/TagsMapper.java | code-writer | w-002 | create | U-TAG-DOMAIN | TagData |
|
||||
| mapper/GameTagsMapper.java | code-writer | w-002 | create | U-TAG-DOMAIN | TagData |
|
||||
| mapper/JamTagsMapper.java | code-writer | w-002 | create | U-TAG-DOMAIN | TagData |
|
||||
| mapper/GameViewsMapper.java | code-writer | w-003 | create | U-VIEW | - |
|
||||
| controller/api/GameController.java | code-writer | w-003 | modify | U-VIEW | GameViewsMapper |
|
||||
| util/TagSanitizer.java | code-writer | w-004 | create | U-TAG-ADMIN | - |
|
||||
| resources/banned-words.txt | code-writer | w-004 | create | U-TAG-ADMIN | - |
|
||||
| webapp/WEB-INF/views/index.jsp | code-writer | w-005 | modify | U-JSP | - |
|
||||
|
||||
## Wave 2 (Wave1 인터페이스 타입 확정 후 — 컨트롤러·검색쿼리)
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 | U-* | 의존 |
|
||||
|---|---|---|---|---|---|
|
||||
| data/GameData.java | code-writer | w-006 | modify | U-SEARCH | - |
|
||||
| data/SearchCriteria.java | code-writer | w-006 | create | U-SEARCH | - |
|
||||
| mapper/GamesMapper.java | code-writer | w-006 | modify | U-SEARCH | SearchCriteria, GameData |
|
||||
| controller/SearchController.java | code-writer | w-006 | create | U-SEARCH | GamesMapper, GameTagsMapper, TagsMapper, SearchCriteria, TagSanitizer |
|
||||
| controller/TagController.java | code-writer | w-007 | create | U-TAG-ADMIN | TagsMapper, GameTagsMapper, TagSanitizer, PermissionGate, CsrfTokens |
|
||||
|
||||
## Wave 3 (테스트 — 구현 산출물)
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 | 의존 |
|
||||
|---|---|---|---|---|
|
||||
| test/.../BibimbapApplicationTests.java | code-writer | w-008 | modify | 신규 매퍼 4종 @MockBean |
|
||||
| test/.../util/TagSanitizerTest.java | code-writer | w-008 | create | TagSanitizer |
|
||||
| test/.../controller/TagControllerTest.java | code-writer | w-009 | create | TagController |
|
||||
| test/.../controller/SearchControllerTest.java | code-writer | w-009 | create | SearchController |
|
||||
|
||||
## 불변식 점검
|
||||
- 동일 파일 1 worker only: 충족(GameData=w-006, GamesMapper=w-006, GameController=w-003 단일).
|
||||
- 의존 있는 파일 병렬 금지: Wave 분리(인터페이스→소비자→테스트). Wave 내 파일은 상호 독립 또는 동일 worker.
|
||||
- 스키마: U-TAG-SCHEMA 격리(code-writer; migration-writer 는 ORM 마이그레이션 전용이나 본 프로젝트는 raw SQL DDL → code-writer 로 처리, DB 적용 금지).
|
||||
</content>
|
||||
</invoke>
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
---
|
||||
schema_version: 2
|
||||
sid: 20260629-100849
|
||||
resumed_from: null
|
||||
started_at: 2026-06-29T10:08:49+09:00
|
||||
ended_at: 2026-06-29T12:55:00+09:00
|
||||
branch: feat/v2
|
||||
user_request: "크리티컬(W2 크리티컬패스) 닫혔으니 다른 작업(W2-W4 풀설계 미구현 잔여) 진행. 열린 질문 없도록 미리 파악 후 착수."
|
||||
init_guard: "pass — docs/development/verification-strategies.md 존재"
|
||||
migrate_check: "skip — CLAUDE.md 에 atp:migrate 마커 없음 (이미 .atp/ 경로)"
|
||||
---
|
||||
|
||||
# ATP Work Session — 20260629-100849
|
||||
|
||||
## Summary
|
||||
"크리티컬 닫힘" = W2 크리티컬 패스(W1→W2-1→W2-3 동결→W2-6) 완료(20260624 세션, 6 feat, 190/190 GREEN). "다른 작업" = W2-W4 풀설계 11기능 중 미구현 잔여 = **W3-1·W3-3·W3-4·W3-5·W4 (5기능)**. W3-2(댓글/리뷰)는 이미 완료(21892c8). 설계 오픈질문 0(_cross-consistency-audit PASS), 단 구현 전 선결정/선결조건 식별됨.
|
||||
|
||||
## Invocations
|
||||
- (진행 전 — orchestrator 직접 discovery: work-session 20260624 report·풀설계 요약·구현추적·grounding)
|
||||
- **W3-1**: implementation-advisor(9 worker, 신규13+수정6, ${} 0, @MockBean 4) → verification-advisor(PASS: L1 235/235 컨테이너·L2 C1~C5 throwaway DB·AC-1/2/4/5·보안VP) → **commit 35f1dc3**. baseline 190→235(+45 신규테스트). 편차 2건(insertTag int+generatedKeys, TagController GamesMapper 주입 소유자확인) 의미보존.
|
||||
- **W3-4**: implementation-advisor(6 worker, 신규2+수정5, 단계1+2 통합, BibimbapApplicationTests 무변경) → verification-advisor(PASS: L1 243/243·L2 C1~C5 keyset 경계 무중복/무누락 throwaway DB·AC-T2/T3/T4·단계무결성·보안VP) → **commit e28fa60**. 편차 0(listActive limit=1 설계 축소후보 채택).
|
||||
- **W3-3**: implementation-advisor(9 worker, 신규17 Java+5 JSP+5테스트+DDL+pom) → verification-advisor(FAIL: L1 283/2fail+1err) + **adversarial SSRF 감사**(CRITICAL: Host restricted-header→fetch vacuous + CGNAT 등 범위갭; XSS SOUND) → impl fix1(option-b hostname-connect 전환·범위확장·FeedParser) → 재검증(FAIL: FeedParser parsesRss20 잔존) → **orchestrator 마이크로수정**(FeedParser RFC1123 요일-날짜 불일치 robust) → L1 287/287 GREEN → **commit 6047a39**. 2중검증(verification+adversarial)이 vacuous SSRF(거짓통과) 포착 — 핵심 가치.
|
||||
- **W3-5**: implementation-advisor(7 worker, 신규 8+수정 3, ZipSecurity 단독, dead code 제거) → verification-advisor(PASS: L1 318/318·L2 C1~C5 audit throwaway DB·AC-T1/3/4/5/6·getCompressedSize 0) + **adversarial zip-slip 감사(SOUND: 심링크 TOCTOU 구조불가·경로탈출/유니코드/zipbomb/LIKE주입 차단)** → **commit 08d191f**. 편차 0. LOW 2건(split 빈세그먼트·static 트리 .tmp 노출)=open_item.
|
||||
|
||||
## Advisor Invocation Decision Log
|
||||
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||
- advisor: requirements-advisor
|
||||
decision: skip
|
||||
rationale: '풀설계 11기능 산출 완료(오픈질문 0, W1-design 깊이). 요구 분해 불필요 — 잔여 5기능 설계도 확정적.'
|
||||
checked_at: 2026-06-29T10:12:00+09:00
|
||||
- advisor: research-advisor
|
||||
decision: skip
|
||||
rationale: '풀설계 grounding(.atp/.../20260623-104307/research) + _cross-consistency-audit PASS 존재. orchestrator discovery 로 baseline·prereq 직접 확인 완료.'
|
||||
checked_at: 2026-06-29T10:12:00+09:00
|
||||
- advisor: design-advisor
|
||||
decision: skip
|
||||
rationale: '잔여 5기능 풀설계 파일영향맵+계약+시퀀스+AC 확정적, audit PASS. orchestrator→implementation-advisor 직행.'
|
||||
checked_at: 2026-06-29T10:12:00+09:00
|
||||
- advisor: implementation-advisor (W3-1)
|
||||
decision: call
|
||||
rationale: '대규모 다파일(4매퍼+2컨트롤러+POJO+DDL+sanitizer+JSP+테스트) → worker 분산+소유권 충돌방지 필요.'
|
||||
checked_at: 2026-06-29T10:25:00+09:00
|
||||
- advisor: verification-advisor (W3-1)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가. 신규 매퍼+VIEW alias 소비 → L1+L2(DB-방언 계약).'
|
||||
checked_at: 2026-06-29T11:00:00+09:00
|
||||
- advisor: implementation-advisor (W3-4)
|
||||
decision: call
|
||||
rationale: 'keyset 페이징 매퍼+컨트롤러+JSP+JamsMapper 확장+테스트 다파일. 단계1+2 통합(W2-1 jams·W3-1 검색라우트 충족).'
|
||||
checked_at: 2026-06-29T11:10:00+09:00
|
||||
- advisor: verification-advisor (W3-4)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가. keyset 혼합방향 OR 분해 → L1+L2(경계 무중복/무누락 실측).'
|
||||
checked_at: 2026-06-29T11:40:00+09:00
|
||||
- advisor: implementation-advisor (W3-3)
|
||||
decision: call
|
||||
rationale: '최대 복잡(SSRF 9항목·md sanitize·6매퍼·3컨트롤러·4테이블·scheduling·pom 의존). worker 다분산+보안핵심 단독 worker 필요.'
|
||||
checked_at: 2026-06-29T11:45:00+09:00
|
||||
- advisor: verification-advisor (W3-3)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가. SSRF/sanitize 보안핵심 → L1+L2+9항목 커버리지.'
|
||||
checked_at: 2026-06-29T12:30:00+09:00
|
||||
- advisor: adversarial-ssrf-audit (general-purpose)
|
||||
decision: call
|
||||
rationale: '보안핵심 — verification(테스트 실행)이 못 잡는 우회벡터(IP인코딩·IPv6·rebinding·redirect)를 코드추적으로 adversarial 탐색. perspective-diverse verify.'
|
||||
checked_at: 2026-06-29T12:30:00+09:00
|
||||
- advisor: implementation-advisor (W3-3 fix1)
|
||||
decision: call
|
||||
rationale: '§2.6 backward — 발원=구현. (1)CRITICAL Host restricted-header→fetch 전건 empty+테스트 vacuous (2)CGNAT 100.64/10 등 범위갭 (3)FeedParser RSS prolog (4)테스트 버그 (5)HTTPS SNI 동작우려. 보안핵심 전수 보정.'
|
||||
checked_at: 2026-06-29T12:50:00+09:00
|
||||
- advisor: verification-advisor (W3-3 fix1 재검증)
|
||||
decision: call
|
||||
rationale: '보정 후 재검증 의무. FAIL(FeedParser parsesRss20 잔존) → orchestrator 마이크로수정으로 해소.'
|
||||
checked_at: 2026-06-29T13:15:00+09:00
|
||||
- advisor: (orchestrator 직접 마이크로편집)
|
||||
decision: call
|
||||
rationale: 'FeedParser parseRfc1123 단일 메서드 — pubDate 요일-날짜 불일치(픽스처 Tue=실제 Wed) RFC1123 DOW 검증 거부. §5.1 마이크로편집 예외. 선행 요일토큰 제거 robust 파싱. 직접 L1 287/287 검증.'
|
||||
checked_at: 2026-06-29T13:25:00+09:00
|
||||
- advisor: implementation-advisor (W3-5)
|
||||
decision: call
|
||||
rationale: 'zip-slip/심링크/zipbomb/포맷/권한게이트/생명주기/감사 보안보강 다파일. ZipSecurity 보안핵심 단독 worker.'
|
||||
checked_at: 2026-06-29T13:30:00+09:00
|
||||
- advisor: verification-advisor (W3-5) + adversarial-zipslip-audit
|
||||
decision: call
|
||||
rationale: '보안핵심 zip-slip → 2중검증(L1+L2 + 우회벡터 코드추적). 감사 SOUND, 우회 0.'
|
||||
checked_at: 2026-06-29T14:00:00+09:00
|
||||
- advisor: implementation-advisor (W4)
|
||||
decision: call
|
||||
rationale: '배지/평판 3테이블+5매퍼+2서비스+2훅+매니지컨트롤러+표시+BADGE_MANAGE+시더 다파일. worker 분산.'
|
||||
checked_at: 2026-06-29T14:10:00+09:00
|
||||
|
||||
## Decisions
|
||||
- **범위 = 잔여 5기능 전부 순차** (AskUserQuestion). 순서 W3-1→W3-4→W3-3→W3-5→W4. 각 impl→verify→commit(롤백 경계, 중간 중단 가능).
|
||||
- **검증 실행 경로 확정 (환경 핵심)**: 호스트 JDK 0(`/usr/bin/java`=실패 스텁). L1 = 컨테이너 실행:
|
||||
`docker run --rm -v "$PWD":/work -w /work -v "$HOME/.m2":/root/.m2 --entrypoint sh eclipse-temurin:21-jdk -c './mvnw -o test'`
|
||||
근거: ~/.m2(136M) 채워짐 → 오프라인 `-o` 해소, maven 3.9.14 dist 캐시됨. W2 세션이 쓴 동일 경로(메모리 java-crypto-verify-via-jdk-container-when-no-host-jdk). L2 = compose postgres:16 격리 throwaway(비파괴).
|
||||
- W3-1 concern 해소(설계 명시): concern1 CONTENT_MODERATE 재사용(신규키 0), concern2/3 GameTags AND/OR 메서드 분리(listGameIdsByTagsAll/Any)+existsRecentView sinceTs 인자제거(dead param 회피), concern4 GameData nullable 박스필드 확장(소비처 영향 최소).
|
||||
|
||||
## 잔여 미구현 인벤토리 (풀설계 대비)
|
||||
| W | 설계 문서 | 의존 | 신규 테이블/뷰 | 선결조건(구현 1보) | 보안핵심 |
|
||||
|---|---|---|---|---|---|
|
||||
| W3-1 태그+검색 | W3-1-tags-search-design.md | 독립 | tags, game_tags, jam_tags, game_views (+games.view_count) | DDL | sanitize/금칙어·CONTENT_MODERATE |
|
||||
| W3-3 포스팅보드 | W3-3-posting-board-design.md | 독립 | post_categories, posts, unity_feed_sources, unity_feed_items | **pom: commonmark+jsoup** + @EnableScheduling(SchedulingConfig) | ★SSRF 9항목·md sanitize·POST_WRITE enforcement 연결 |
|
||||
| W3-4 메인허브 | W3-4-main-hub-design.md | **W3-1 후** | (없음, GamesMapper 확장) | W3-1 선행 | 파라미터 #{} |
|
||||
| W3-5 Unity업로드 | W3-5-upload-design.md | 독립 | game_upload_audit_log | DDL | ★zip-slip canonical+심링크거부+zipbomb·권한게이트 |
|
||||
| W4 배지/평판 | W4-badges-design.md | 독립 | badges, user_badges, reputation_events | **PermissionKeys+BADGE_MANAGE(4번째)** + W1 카탈로그 전수 AC 재측정 | 부여/회수 CSRF+BADGE_MANAGE·/manage/badges 경로 |
|
||||
|
||||
## 선결정 (구현 전 — 설계가 이미 정한 사항이나 부수효과 플래그)
|
||||
1. W3-3: pom.xml 신규 의존 commonmark+jsoup 추가(HTTP=spring-web RestClient 내장, 의존 0) + @EnableScheduling 신규 활성. → 구현 1보 = 의존추가+contextLoads.
|
||||
2. W4: PermissionKeys enum +BADGE_MANAGE → W1 권한카탈로그 전수 AC 카운트 3→4 동반갱신(회귀 가드).
|
||||
3. W3-4 는 W3-1 산출(GET /games/search 태그파라미터) 소비 → W3-1 선행 강제.
|
||||
4. DB DDL: 신규 테이블 다수. W2 패턴 답습 = L2 격리 throwaway DB 비파괴 실측 + 실 dev DB 적용은 §6 수동게이트(needs_user_verification). 실 dev DB 는 W2 jam DDL 도 아직 미적용(carryover).
|
||||
|
||||
## user_signals
|
||||
positive: []
|
||||
negative: []
|
||||
|
||||
## verified_by_me
|
||||
- **L1 (typecheck / unit+regression)**: 컨테이너 `./mvnw -o test`(eclipse-temurin:21-jdk + ~/.m2 마운트, 오프라인). baseline 190 → 최종 **347/347 GREEN**(누적 +157 신규테스트, 회귀 0). 각 W per-feature 검증: W3-1 235·W3-4 243·W3-3 287(fix1 포함)·W3-5 318·W4 347. contextLoads 전 신규 매퍼/서비스 @MockBean 전수.
|
||||
- **L2 (contract-dev-db)**: pass — 각 W 격리 throwaway postgres:16 비파괴 실측(적용→실측→DROP). W3-1 alias 케이스폴딩 인용/비인용 대조·NULLS LAST·AND⊆OR·ILIKE·interval dedupe / W3-4 keyset 혼합방향 OR분해 경계 무중복·무누락·tie-break / W3-3 board keyset row-comparison·insertIgnoreDup ON CONFLICT·FK / W3-5 audit insert·outcome CHECK·FK·findGameByWebglUuid alias / W4 자동부여 멱등·회수후 재획득 부분유니크·dedupe·배치 alias·CHECK/FK. **실 dev DB(bibimbap) 무접촉**(전 W).
|
||||
- **adversarial 보안 감사 2건**: W3-3 SSRF(CRITICAL Host restricted-header→vacuous 포착 → option-b 전환 + CGNAT 등 범위확장; XSS sanitize SOUND), W3-5 zip-slip(SOUND — 심링크 TOCTOU 구조불가·경로탈출/유니코드/zipbomb/LIKE주입 전수 차단).
|
||||
- **로그 스캔**: clean(전 W. WARN = Mockito/ByteBuddy agent·Spring static trailing-slash 환경 noise).
|
||||
|
||||
## needs_user_verification
|
||||
- **실 dev DB(bibimbap) DDL 미적용** — `db/apply-local-ddl.sh` 로 신규 DDL 5종(tag-ddl/games-hub-ddl/board-ddl/game-upload-ddl/badge-ddl) + (W2 carryover) jam DDL 3종 일괄 적용 필요. L2 는 throwaway 로 통과, 실 dev DB 는 §6 수동게이트. L3 선행.
|
||||
- **L3 런타임 스모크(WAR 기동 후)**: ① 검색 `/games/search` 태그·정렬키·잼필터 + 방문수 dedupe ② 허브 `/` keyset 더보기 + 진행중 잼 배너 ③ 포스팅 작성(POST_WRITE)/sanitize 렌더/OG 미리보기/유니티 피드 폴링 + **★실 HTTPS 외부 fetch 동작**(SsrfSafeFetcher option-b hostname-connect 실 TLS — 단위는 HTTP만) ④ 업로드 zip-slip/포맷 거부 + 정상 Unity 빌드 + replaceUuid 교체 ⑤ 배지 자동부여(리뷰10/업로드3)·수동 grant/revoke(BADGE_MANAGE)·리뷰작성자 칩.
|
||||
- **W3-3 SSRF 하드닝(선택)**: option-b 잔여 rebinding TOCTOU → `networkaddress.cache.ttl` 설정 검토.
|
||||
|
||||
## graph_refresh
|
||||
- 판정: **fully-stale** (graph-refresh-checker). 기준 a74bf74(W2)→HEAD 305cc73, 9커밋차. src 77파일(+10163/-103, 신규63), docs 10파일(신규6 DDL5). **no-defer 처리 완료**: orchestrator `/graphify src/`(AST 1825노드/4292엣지/79커뮤니티 — 비코드 4건[wordlist·이미지3]은 아키텍처값 0으로 AST 기반) + `/graphify docs/`(semantic 198노드/282엣지/14커뮤니티, 3 subagent chunk — DDL FK·결정 rationale, graph 본체 자기참조 제외) 재생성. docs/graph/src·docs/ 본체 갱신(gitignore), docs/graph/index.md frontmatter(source_commit→305cc73·last_generated 2026-06-29) + Scopes 표 2행 갱신. graphify-update-advisor 가 advisor 경계상 /graphify 직접호출 불가로 orchestrator 가 Skill 실행. src/graphify-out AST 캐시 잔여물 제거.
|
||||
|
||||
## 선제 해소 (구현 전 blocker)
|
||||
- **W3-3 pom 의존 blocker 해소**: commonmark·jsoup `~/.m2` 미존재(오프라인 `-o` 컴파일 불가) → 온라인+프록시 CA(`certs/corporate-proxy-ca.crt`) 주입으로 1회 pre-resolve 완료. **jsoup 1.17.2 + commonmark 0.22.0** 캐시됨(jsoup 1.14+ Safelist API 정합). W3-3 impl 은 이 정확버전 pom 선언. 이후 오프라인 빌드 가능. (online dep 해소 = JDK cacerts 프록시 CA 주입 필요 — 메모리 local-dev-setup-gotchas §1 패턴.)
|
||||
|
||||
## open_items
|
||||
- (W2 carryover) 실 dev DB(bibimbap) jam DDL 미적용 — 신규 W3/W4 DDL 과 함께 일괄 적용 게이트 검토 필요.
|
||||
- **W3-5 gameRoot() dev 경로 이중중첩**(app.upload.game-storage-path=src/main/resources/static/game → resolve('game') → .../static/game/game): W3-5 설계 명시 스코프-아웃(에스컬레이션). 보안작업 무관 진행(canonical 경계 보장). 별도 config 정합 작업 — dev 자산 위치 영향이라 사용자 결정 필요. needs_user_verification 후보.
|
||||
- **W3-3 SSRF 잔여 rebinding TOCTOU**(option-b hostname-connect): connect 시 재resolve 창. DNS 캐시로 최소화, 위협모델 LOW(URL 제출자=권한자). 하드닝 옵션: `networkaddress.cache.ttl` 설정. HTTPS 실 TLS = L3 스모크 권고.
|
||||
|
||||
## Retrospective
|
||||
```yaml
|
||||
Retrospective:
|
||||
signals:
|
||||
positive: [] # 이번 세션 발화는 초기 요청 1 + "계속해줘" + config 명령(model/effort/fast). 강한 긍정 발화 없음 — 억지 기재 안 함. 비자명 판단의 가치는 what_went_well + memory_candidates(signal_source: observation)로 분리 기재.
|
||||
negative: [] # 부정 발화 없음("왜 안했어"/"또야"/"틀렸어" 0건). W3-3 1차 SSRF 갭은 사용자 시그널이 아니라 adversarial 감사가 잡은 내부 관찰 — observation 으로 처리.
|
||||
what_went_well:
|
||||
- "착수 전 환경 blocker 선제 해소: 호스트 JDK 0 → 컨테이너 L1 경로 확정(baseline 동결), W3-3 commonmark/jsoup 오프라인 의존 → 프록시 CA 주입으로 1회 pre-resolve. '열린 질문 없도록 미리 파악' 의도를 환경 차원까지 확장해 W3-3 착수 시 컴파일 blocker 0."
|
||||
- "보안핵심 2중 검증(verification + adversarial 코드감사 병렬): W3-3 SSRF 에서 verification 은 'fetch empty' RED 만 봤으나 adversarial 감사가 근본원인(Host restricted-header → 거부 테스트 vacuous PASS, SSRF 거부가 거짓통과) + 추가 범위갭(CGNAT 100.64/10) 진단. 이대로 커밋했으면 SSRF 무력 배포였음. W3-5 zip-slip 도 동일 패턴(감사 SOUND)으로 우회 0 확정."
|
||||
- "backward 회귀 단계 판정(§2.6) + 마이크로편집(§5.1) 정확 적용: W3-3 fix1 후 FeedParser 잔존 RED 의 발원이 픽스처 요일-날짜 불일치(RFC1123 DOW 검증 거부)임을 orchestrator 가 직접 진단, 선행 요일토큰 제거 robust 파싱으로 마이크로편집(실 피드 대응) + 직접 L1 287/287 검증. advisor 라운드 추가 없이 단일 메서드 보정으로 해소."
|
||||
- "advisor 호출/스킵 판단 즉시 로깅: requirements/research/design 3 advisor 를 풀설계 audit PASS 근거로 스킵 결정 + rationale 기록 → 확정적 설계에 불필요 라운드 0. 코드 변경 W 마다 verification 스킵 불가 일관 적용."
|
||||
- "검증 DB 격리 무파괴 일관 유지: 전 W L2 를 격리 throwaway postgres:16(적용→실측→DROP)으로 실측, 실 dev DB(bibimbap) 무접촉. 실 dev DB DDL 적용은 §6 수동게이트로 needs_user_verification 에 분리."
|
||||
what_to_improve:
|
||||
- "W3-3 1차 구현이 SSRF Host restricted-header 를 놓침 + 자체 거부 테스트가 vacuous(전건 empty 반환이라 '거부됨'을 입증한 게 0건). graceful catch 가 예외를 삼켜 정상경로 실패를 '정상 거부'처럼 위장. → '보안 거부 테스트는 정상경로 성공 케이스와 쌍으로 차별 입증(distinguishing assertion)해야 vacuous 회피' 가 핵심 교훈."
|
||||
- "W4 표시 AC 부분 갭(프로필 myBadges 주입·게임카드 creator 배지 칩): orchestrator scope-fence(W3-1/3/4 산출물 접근금지)가 W4 표시면을 선행기능 컨트롤러에 배선하는 것을 막음. 단일 기능의 표시면이 선행 기능 컨트롤러에 의존할 때, scope-fence 가 표시 배선을 차단하지 않도록 표시-배선 지점을 fence 예외로 사전 식별하는 설계 교훈."
|
||||
- "option-b SSRF(hostname-connect) 잔여 rebinding TOCTOU: 설계 concern 이 IP핀닝 vs HTTPS-SNI 트레이드오프를 예고했으나 1차 구현이 HTTPS 미검증 IP핀닝을 선택. 보안핵심에서 설계가 명시한 트레이드오프는 구현 1보에 선결정으로 못박아 1차에서 정답 분기를 고르게 한다."
|
||||
memory_candidates:
|
||||
- name: security-reject-test-needs-distinguishing-assertion
|
||||
type: feedback
|
||||
description: "보안 거부 테스트는 정상경로 성공 케이스와 쌍으로 차별 입증해야 vacuous(거짓통과)를 회피한다."
|
||||
body_draft: |
|
||||
What: 보안 거부(SSRF·zip-slip·인가 등) 단위 테스트가 '차단되어 empty/예외'만 확인하면, 정상경로마저 같은 이유로 실패할 때 테스트가 vacuous PASS(거부를 입증한 게 0건)가 된다. graceful catch 가 예외를 삼키면 '정상경로 실패'가 '정상 거부'로 위장된다.
|
||||
Why: W3-3 1차 구현은 Host restricted-header 때문에 모든 fetch 가 empty 였고(정상 URL 도 fetch 0), SSRF 거부 테스트가 전건 empty 라 거부를 차별 입증하지 못했다. verification 은 RED 만 봤고 adversarial 코드감사가 vacuous 근본원인을 잡았다. 이대로 커밋했으면 SSRF 무력 배포.
|
||||
How to apply: 보안 거부 테스트는 (a) 차단되어야 하는 입력 → 거부, (b) 허용되어야 하는 정상 입력 → 성공, 두 케이스를 쌍으로 둬 두 분기가 실제로 갈리는지(distinguishing assertion) 본다. graceful catch 경로는 '거부 사유'와 '정상경로 실패'를 구별 가능한 신호로 분리한다. 보안핵심 기능은 verification(테스트 실행)과 adversarial 코드감사를 병렬로 건다.
|
||||
rationale_for_saving: "재현성 높은 테스트 안티패턴(전 보안 거부 테스트에 적용). 코드/커밋에서 유도 불가 — 관찰로만 드러난 verification 사각."
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md # "## 설계·테스트 단계 체크리스트" 섹션에 신규 소제목 append (append-only 관례)
|
||||
memory_optional: false
|
||||
- name: security-core-parallel-adversarial-audit
|
||||
type: feedback
|
||||
description: "보안핵심 기능은 verification(테스트 실행)과 adversarial 코드감사를 병렬로 건다 — 우회 벡터는 테스트 실행이 못 잡는다."
|
||||
body_draft: |
|
||||
What: SSRF·zip-slip 등 보안핵심 변경은 verification-advisor(테스트 실행)만으로 부족. 우회 벡터(IP 인코딩·IPv6·DNS rebinding·redirect·심링크 TOCTOU·zipbomb·유니코드 경로)는 테스트가 작성돼 있어야만 잡히는데, 누락 벡터는 테스트 자체가 없어 GREEN 으로 숨는다.
|
||||
Why: W3-3 SSRF — adversarial 감사가 vacuous PASS(Host restricted-header) + CGNAT 범위갭을 잡아 무력 배포를 막음. W3-5 zip-slip — 감사 SOUND 로 우회 0 확정. 두 케이스 모두 verification 단독이면 누출됐을 사각.
|
||||
How to apply: 변경 scope 에 SSRF/파일추출/인가/sanitize 등 보안핵심이 포함되면 verification 과 별도로 general-purpose adversarial 감사(코드추적 기반 우회벡터 탐색)를 병렬 호출. 감사 산출은 CRITICAL/SOUND 판정 + 미커버 벡터 목록. CRITICAL 시 §2.6 backward 보정.
|
||||
rationale_for_saving: "보안핵심 검증 토폴로지의 재현 패턴. 이번 세션이 2건(SSRF·zip-slip)으로 가치를 실증 — verification-strategies 레지스트리에 보안핵심 분류 트리거로 둘 가치."
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md # "버그 범주 → 적용 L 레벨" 표에 '보안핵심(SSRF/파일추출/인가/sanitize) → L1+L2+adversarial 감사' 행 추가 + 체크리스트 소제목
|
||||
memory_optional: false
|
||||
- name: scope-fence-vs-display-wiring
|
||||
type: feedback
|
||||
description: "단일 기능의 표시면이 선행 기능 컨트롤러에 의존하면 scope-fence 가 표시 배선을 차단할 수 있다 — 표시-배선 지점을 fence 예외로 사전 식별."
|
||||
body_draft: |
|
||||
What: orchestrator scope-fence 는 충돌 방지를 위해 worker 의 선행 기능 산출물 접근을 막는다. 그러나 신규 기능의 '표시면'(예: 배지 칩 렌더)이 선행 기능 컨트롤러(프로필·게임카드)에 주입돼야 하면 fence 가 표시 배선을 막아 표시 AC 부분 갭이 생긴다.
|
||||
Why: W4 배지/평판 — myBadges 프로필 주입·게임카드 creator 배지 칩 표시가 W3-1/3/4 컨트롤러에 배선돼야 하는데 scope-fence(W3-1/3/4 접근금지)가 막아 표시 AC 부분 미충족.
|
||||
How to apply: 설계 영향맵에서 신규 기능의 표시면이 선행 기능 컨트롤러/뷰에 배선되는 지점을 'cross-feature display wiring'으로 표시. orchestrator 는 이 지점을 scope-fence 예외(읽기+해당 라인 편집 허용)로 사전 식별하거나, 표시 배선을 별도 후행 단계로 분리한다.
|
||||
rationale_for_saving: "scope-fence 운영의 재현 가능한 사각. 다기능 순차 구현에서 반복 발생 — orchestrator 토폴로지 교훈으로 docs 가치."
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md # 체크리스트 섹션에 설계/스코프 교훈으로 append (또는 design 영향맵 규약 — protocol_feedback 과 연동)
|
||||
memory_optional: false
|
||||
- name: design-tradeoff-pin-to-implementation-step1
|
||||
type: feedback
|
||||
description: "설계 concern 이 명시한 보안 트레이드오프(IP핀닝 vs HTTPS-SNI 등)는 구현 1보 선결정으로 못박아 1차에서 정답 분기를 고르게 한다."
|
||||
body_draft: |
|
||||
What: 설계 단계가 'A vs B 트레이드오프, B 권장' 식으로 concern 을 예고해도, 구현이 1차에서 A 를 고르면 backward 보정 라운드가 생긴다. 트레이드오프는 선결정 목록(구현 전)에 결론까지 명시해야 1차 구현이 정답으로 수렴한다.
|
||||
Why: W3-3 SSRF — 설계 concern 이 IP핀닝 vs HTTPS-SNI 트레이드오프를 예고했으나 1차 구현이 HTTPS 미검증 IP핀닝(option-a 류)을 선택 → fix1 에서 option-b hostname-connect 로 전환. 잔여 rebinding TOCTOU 도 이 분기에서 파생.
|
||||
How to apply: design-advisor 가 'concern: 트레이드오프'를 남기면 결론(채택 분기 + 이유)을 함께 적고, orchestrator 는 '선결정' 섹션에 결론을 못박아 implementation-advisor 지시에 포함. 결론 미확정 트레이드오프는 구현 착수 전 해소.
|
||||
rationale_for_saving: "design→implementation 핸드오프의 재현 갭. 보안핵심에서 1차 정답률을 올리는 운영 교훈."
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md # 체크리스트 또는 protocol_feedback(design-advisor concern 규약)와 연동
|
||||
memory_optional: false
|
||||
protocol_feedback:
|
||||
- "[verification 전략 레지스트리] '버그 범주 → 적용 L 레벨' 표에 '보안핵심(SSRF/파일추출·zip-slip/인가/sanitize) → L1 + L2 + adversarial 코드감사(병렬)' 행 추가. verification(테스트 실행)이 못 잡는 우회벡터를 코드추적 감사로 병렬 커버하는 패턴을 레지스트리 트리거화. 근거: W3-3 SSRF vacuous 포착 + W3-5 zip-slip SOUND."
|
||||
- "[verification-advisor 또는 W-TEST worker 체크리스트] '보안 거부 테스트는 정상경로 성공 케이스와 쌍으로 차별 입증(distinguishing assertion)해야 vacuous 회피' 조항 추가. graceful catch 가 정상경로 실패를 거부로 위장하는 안티패턴을 명시. 근거: W3-3 SSRF 거부 테스트 전건 empty vacuous PASS."
|
||||
- "[orchestrator scope-fence 규약 / design-advisor 영향맵] 신규 기능의 표시면이 선행 기능 컨트롤러/뷰에 배선되는 'cross-feature display wiring' 지점을 영향맵에 표시하고 scope-fence 예외(또는 후행 분리)로 사전 식별하는 조항 추가. structural — 다기능 순차 구현에서 반복 가능. 근거: W4 myBadges/게임카드 배지 표시 AC 부분 갭."
|
||||
- "[design-advisor concern 규약] '트레이드오프 concern' 은 결론(채택 분기 + 이유)까지 명시하고, orchestrator 는 이를 '선결정'으로 못박아 implementation 지시에 포함하는 조항 추가. 결론 미확정 트레이드오프는 구현 착수 전 해소. 근거: W3-3 SSRF IP핀닝 vs HTTPS-SNI 1차 오선택 → backward 보정."
|
||||
applied_changes: []
|
||||
```
|
||||
|
||||
## applied_changes (orchestrator — 회고 교훈 docs-first 반영)
|
||||
- verification-strategies.md "설계·테스트 단계 체크리스트" append 4건: (1) 보안 거부 테스트 차별 입증(vacuous 회피) (2) 보안핵심 verification+adversarial 병렬 (3) 표시면 의존 scope-fence 예외 (4) 설계 트레이드오프 1차 결론 못박기.
|
||||
- verification-strategies.md "프로토콜 개선 권고" append 5건(보안핵심 L레벨 행·distinguishing assertion·cross-feature display fence·design 트레이드오프). 외부번들 대상 미적용 기록.
|
||||
- memory: bibimbap memory 미활성 → docs 단독 마감(memory_optional=false 전 후보). 강제 기록 안 함.
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
phase: verification
|
||||
agent: verification-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T03:45:00Z
|
||||
concerns:
|
||||
- "AC-T6 소스 표시 아티팩트: event_type 문자열 리터럴이 소스 트리에서 'n' 으로 난독화 표시됨(BadgeKeys/PermissionGate→ln 동일 패턴). L1 347 GREEN + L2 C6 CHECK 거부 동작으로 런타임 emit 값이 유효 enum(REVIEW_WRITTEN/GAME_UPLOADED)임을 간접 검증. 판정에 영향 없음(PASS) — orchestrator 인지용."
|
||||
concerns_checked: true
|
||||
---
|
||||
|
||||
# 검증 결과 — W4 유저 배지/평판
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
|
||||
- **L1**: BUILD SUCCESS, Failures:0 Errors:0. N ≥ 318 + 신규(BadgeServiceTest 12 + BadgeManageControllerTest 14 + GameReviewControllerTest 회귀 3) ≈ 347+. 회귀 0. ★W1 권한카탈로그 회귀(concern5): PermissionKeys BADGE_MANAGE 4번째 추가, W1 테스트가 하드코딩 카운트(==3)로 FAIL 안 함. contextLoads GREEN(신규 @MockBean 6).
|
||||
- **L2 (격리 throwaway postgres:16)**: C1 자동부여 멱등 / C2 회수후 재획득 / C3 reputation dedupe / C4 countActive 집계 / C5 배치 alias camelCase / C6 CHECK·FK.
|
||||
- **AC-T2**: badge 테이블 3 (DDL + schema.sql 동기).
|
||||
- **AC-T3**: 4매퍼 `${}` == 0.
|
||||
- **AC-T4**: insertIgnore ON CONFLICT DO NOTHING (부분유니크 대응) × 2매퍼.
|
||||
- **AC-T5**: PermissionKeys BADGE_MANAGE 존재 + verifier values() 순회(하드코딩 리스트 아님).
|
||||
- **AC-T6**: event_type CHECK 2종 == 코드 상수 == 훅 2곳.
|
||||
- **AC-9 (N+1)**: listReviews 루프 밖 1회 배치 호출 + 테스트 times(1).
|
||||
- **AC-4/11 (게이트)**: BadgeManageController grant/revoke = permissionGate.has(BADGE_MANAGE) + CSRF 선검증(미인증 401 / 미인가 403 / CSRF 403).
|
||||
- **구현 편차(orchestrator 인지)**: 표시 AC 중 리뷰작성자 배지 경로만 구현. 프로필 myBadges·게임카드 creator 배지 미구현 = 의도된 deferral, FAIL 처리하지 않음.
|
||||
|
||||
## 실행된 전략
|
||||
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| verify-all | (L1 docker mvnw -o test) + (L2 throwaway pg C1~C6) + 로그 스캔 | 0 | blocker | pass |
|
||||
|
||||
분해 결과:
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck (컴파일) | pass (BUILD SUCCESS) |
|
||||
| L1 unit+regression | pass (347 tests, Failures:0 Errors:0 Skipped:0) |
|
||||
| L2 contract-postgres (C1~C6, 격리 컨테이너) | pass (전 항목 PASS, 컨테이너 DROP 확인) |
|
||||
| 로그 스캔 | clean (badge 무관 잡음만: test-fixture RuntimeException:boom, @MockBean deprecation WARN, FeedParser XXE-fixture fatal — 모두 Errors:0 동반) |
|
||||
|
||||
## L1 상세
|
||||
|
||||
- 명령: `docker run --rm -v "$PWD":/work -w /work -v "$HOME/.m2":/root/.m2 --entrypoint sh eclipse-temurin:21-jdk -c './mvnw -o test'`
|
||||
- 집계: **Tests run: 347, Failures: 0, Errors: 0, Skipped: 0** — **BUILD SUCCESS** (19.351s).
|
||||
- 신규/회귀 테스트 GREEN:
|
||||
- `service.BadgeServiceTest` — 12 (기대 12) ✅
|
||||
- `controller.BadgeManageControllerTest` — 14 (기대 14) ✅
|
||||
- `controller.api.GameReviewControllerTest` — 24 (직전 21 + 회귀 3) ✅
|
||||
- **contextLoads GREEN**: `BibimbapApplicationTests` 1 test pass. 신규 @MockBean 6 (BadgesMapper, UserBadgesMapper, UserBadgesQueryMapper, ReputationEventsMapper, BadgeService, ReputationService) 전부 wiring 됨 — NoSuchBeanDefinitionException 없음.
|
||||
- **★W1 권한카탈로그 회귀(concern5) — 회귀 없음**:
|
||||
- 런타임 로그: `[RBAC] 권한 카탈로그 시드 완료: 4 키` — 카탈로그가 3→4 로 증가했으나 시드 정상.
|
||||
- 카운트 가정 가능 테스트 전부 GREEN: `PermissionGateTest` 7, `AdminConsoleControllerTest` 13, `JamRoleGateTest` 8 — **어느 것도 하드코딩 ==3 으로 FAIL 하지 않음**.
|
||||
- AC-T5b: `PermissionCatalogVerifier` 가 `PermissionKeys.values()` 순회(line 30/35/47), 하드코딩 리스트 아님 → 키 추가에 자동 적응.
|
||||
|
||||
## L2 상세 (격리 throwaway `bbm-l2-w4`, postgres:16, 비파괴)
|
||||
|
||||
셋업: 컨테이너 기동 → `CREATE SCHEMA dev` + `SET search_path=dev` + `db/schema.sql` 전체 적용(에러 0, 4테이블 생성: users/badges/user_badges/reputation_events) → users(1,2) 시드. 실 dev DB 무접촉(독립 컨테이너). 종료 시 `docker rm -f bbm-l2-w4` 완료 확인.
|
||||
|
||||
| 계약 | 결과 | psql 근거 |
|
||||
|---|---|---|
|
||||
| **C1 자동부여 멱등** | PASS | 동일 (1,'REVIEWER') insertIgnore 2회 → 2번째 `INSERT 0 0`; total_rows=1, active_rows=1 (부분유니크 ux_user_badges_active) |
|
||||
| **C2 회수후 재획득** | PASS | revoke(`UPDATE 1`) → 동일키 재insert(`INSERT 0 1`); total=2, active=1, revoked=1 (부분유니크가 revoked 행 제외) |
|
||||
| **C3 reputation dedupe** | PASS | 동일 (1,'REVIEW_WRITTEN','review:99') 2회 → 2번째 `INSERT 0 0`, dedup_rows=1. source_ref NULL 2회 → null_ref_rows=2 (부분유니크 WHERE source_ref IS NOT NULL 제외 → 중복 허용) |
|
||||
| **C4 countActive 집계** | PASS | REVIEW_WRITTEN 3건(review:99/100/101) → COUNT(*)=3 |
|
||||
| **C5 배치 alias camelCase** | PASS | listActiveBadgeKeysByUserIds IN(1,2): userId=1 REVIEWER, userId=2 TECHNICIAN(활성만, revoked 제외). information_schema.columns 가 `userId`/`badgeKey` 인용 보존 확인(소문자 폴딩 없음 — W2 케이스폴딩 교훈 적용 정합) |
|
||||
| **C6 CHECK·FK** | PASS | badge_type='BOGUS' → `badges_type_check` 위반 거부; event_type='X' → `reputation_events_type_check` 위반 거부; user_id=999 → user_badges/reputation_events 양쪽 FK 위반 거부; valid 'REVIEWER' 대조군 accept |
|
||||
|
||||
## AC 정적 상세
|
||||
|
||||
- **AC-T2 (badge 테이블 3)**: PASS. `docs/badge-ddl.sql` `CREATE TABLE IF NOT EXISTS` 3 (badges/user_badges/reputation_events). `db/schema.sql` 동기 3 (line 1075/1100/1127). CHECK·FK·부분유니크 모두 동기.
|
||||
- **AC-T3 (`${}` 0)**: PASS. 4매퍼(BadgesMapper, ReputationEventsMapper, UserBadgesMapper, UserBadgesQueryMapper) `rg '\${'` == 0.
|
||||
- **AC-T4 (멱등 가드)**: PASS. UserBadgesMapper.insertIgnore `ON CONFLICT (user_id, badge_key) WHERE revoked_at IS NULL DO NOTHING`; ReputationEventsMapper.insertIgnore `ON CONFLICT (user_id, event_type, source_ref) WHERE source_ref IS NOT NULL DO NOTHING`. 부분유니크 타깃 정합(L2 C1/C3 가 실증).
|
||||
- **AC-T5 (PermissionKeys + verifier)**: PASS. enum 4상수(GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE/BADGE_MANAGE), BADGE_MANAGE 존재. verifier values() 순회.
|
||||
- **AC-T6 (event_type CHECK 정합)**: PASS(주의 1건). DDL CHECK IN 2종(REVIEW_WRITTEN/GAME_UPLOADED) == L2 C6 거부 동작 == 훅 2곳(GameReviewController.listReviews 경로 reputationService.record + GameController 게임업로드 경로 reputationService.record). 훅 카운트 2 정합. ⚠ 소스 트리에서 event_type 문자열 리터럴이 `"n"` 으로 표시됨(코드 난독화/표시 아티팩트 — BadgeKeys 도 `"n"`, PermissionGate→`ln` 동일 패턴). L1 347 GREEN + L2 CHECK 거부 동작으로 런타임 emit 값이 유효 enum 임을 간접 확인. concerns 에 기록.
|
||||
- **AC-9 (N+1 배치)**: PASS. GameReviewController.listReviews line 115 에서 `userBadgesQueryMapper.listActiveBadgeKeysByUserIds(userIds)` 단 1회 호출(for 는 결과 row 순회이지 per-user 쿼리 아님). 테스트 line 475 `verify(..., times(1))` 검증, L1 GREEN.
|
||||
- **AC-4/11 (BADGE_MANAGE 게이트)**: PASS. BadgeManageController grant(line 35)·revoke(line 76) 모두 진입부 순서: (1) `CsrfTokens.isValid` 실패 → 403, (2) 미인증 → 401, (3) `permissionGate.has(session, BADGE_MANAGE.name())` 실패 → 403. BadgeManageControllerTest 14 가 grant/revoke 각각 CSRF누락(403)/미인증(401)/미인가(403) 전 경로 커버, L1 GREEN.
|
||||
|
||||
## 실패 상세
|
||||
없음.
|
||||
|
||||
## 종합 판정
|
||||
overall: **pass**
|
||||
verdict: **PASS**
|
||||
rollback_signal: none
|
||||
|
||||
## Acceptance 매칭
|
||||
|
||||
| criterion | 매칭 전략 | 판정 |
|
||||
|---|---|---|
|
||||
| L1 BUILD/Tests/회귀0/N≈347 | L1 docker mvnw test | pass (347/0/0) |
|
||||
| L1 W1 카탈로그 회귀(concern5) | PermissionGateTest/AdminConsoleControllerTest/JamRoleGateTest GREEN + 로그 "4 키" | pass (카운트 가정 FAIL 없음) |
|
||||
| L1 contextLoads @MockBean 6 | BibimbapApplicationTests | pass |
|
||||
| C1 자동부여 멱등 | L2 contract | pass |
|
||||
| C2 회수후 재획득 | L2 contract | pass |
|
||||
| C3 reputation dedupe | L2 contract | pass |
|
||||
| C4 countActive 집계 | L2 contract | pass |
|
||||
| C5 배치 alias camelCase | L2 contract (information_schema) | pass |
|
||||
| C6 CHECK·FK | L2 contract | pass |
|
||||
| AC-T2 badge 테이블 3 | static (DDL + schema.sql) | pass |
|
||||
| AC-T3 매퍼 ${} 0 | static rg | pass |
|
||||
| AC-T4 멱등 가드 | static + L2 C1/C3 | pass |
|
||||
| AC-T5 PermissionKeys + verifier values() | static rg | pass |
|
||||
| AC-T6 event_type CHECK 정합 | static + L2 C6 | pass (난독화 표시 주의 → concerns) |
|
||||
| AC-9 N+1 배치 | static + L1 times(1) | pass |
|
||||
| AC-4/11 BADGE_MANAGE 게이트 | static + L1 BadgeManageControllerTest 14 | pass |
|
||||
| 프로필 myBadges / 게임카드 creator 배지 | (의도된 deferral) | N/A (FAIL 처리 안 함) |
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
phase: documentation
|
||||
agent: documentation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T15:50:00+09:00
|
||||
concerns:
|
||||
- "design-advisor skip 확인: 이번 세션은 design-advisor 를 스킵했으므로 architecture/ 갱신 없음 — changes/ + maintenance/ 중심 문서화 적용"
|
||||
- "backport: N/A (소비 프로젝트 dogfooding 사례 아님)"
|
||||
concerns_checked: true
|
||||
---
|
||||
|
||||
# 문서화 보고
|
||||
|
||||
## 작성/수정된 문서
|
||||
|
||||
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|
||||
|---|---|---|---|---|
|
||||
| `docs/changes/2026-06-29-w3-w4-features.md` | changes | 기존 문서 append (후속 세션 섹션 추가 + frontmatter 갱신) | `docs/changes/index.md` (기존 항목 설명 갱신) | `docs/maintenance/post-deploy-verification-checklist.md` (본문 내 링크) |
|
||||
| `docs/maintenance/post-deploy-verification-checklist.md` | maintenance | 신규 생성 | `docs/maintenance/index.md` (신규 항목 추가) | `docs/changes/2026-06-29-w3-w4-features.md` (frontmatter + 본문 내 링크) |
|
||||
| `docs/changes/index.md` | changes | 기존 항목 설명 갱신 | — (index 자체) | — |
|
||||
| `docs/maintenance/index.md` | maintenance | 신규 항목 추가 | — (index 자체) | — |
|
||||
|
||||
## 의사결정 기록 위치
|
||||
|
||||
- **배지 표시 배선 완료** (`3d10449`): `docs/changes/2026-06-29-w3-w4-features.md` §후속 세션(20260629-142115) §작업 2
|
||||
- **보안 하드닝 b1** (SsrfSafeFetcher @PostConstruct DNS ttl=30, best-effort): `docs/changes/2026-06-29-w3-w4-features.md` §후속 세션 §작업 4 b1
|
||||
- **보안 하드닝 b2** (업로드 저장루트 static 밖 이전, `app.upload.game-storage-path=${user.home}/.bibimbap/uploads`): `docs/changes/2026-06-29-w3-w4-features.md` §후속 세션 §작업 4 b2
|
||||
- **DB DDL 8종 사용자 직접 적용 완료** (no-op 기록): `docs/changes/2026-06-29-w3-w4-features.md` §후속 세션 §작업 1
|
||||
- **L3 스모크 배포 후 과제 이월 결정**: `docs/maintenance/post-deploy-verification-checklist.md` (체크리스트 전체) + `docs/changes/2026-06-29-w3-w4-features.md` §미적용/이월 항목 갱신
|
||||
- **b2 자산 수동 이전 운영 절차**: `docs/maintenance/post-deploy-verification-checklist.md` §자산 수동 이전
|
||||
|
||||
## 추후 문서화가 필요한 항목
|
||||
|
||||
- L3 스모크 실제 수행 후 결과를 `docs/maintenance/post-deploy-verification-checklist.md` 에 체크 및 `docs/changes/2026-06-29-w3-w4-features.md` 에 완료 기록 append.
|
||||
- b1 SSRF ttl 런타임 반영 여부 확인 후 미반영 시 JVM 레벨 java.security 설정 변경을 ADR 또는 changes 문서로 기록.
|
||||
- b2 자산 수동 이전 완료 후 체크리스트 항목 체크.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T14:30:00+09:00
|
||||
session_id: 20260629-142115
|
||||
---
|
||||
|
||||
# 파일 소유권 맵 — 보안 하드닝 b1/b2
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|
||||
|---|---|---|---|---|
|
||||
| src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java | code-writer | w-h01 | modify | - |
|
||||
| src/test/java/com/pandoli365/bibimbap/security/SsrfSafeFetcherTest.java | code-writer | w-h01 | modify | SsrfSafeFetcher.java (동일 worker로 묶음) |
|
||||
| src/main/resources/application.properties | implementation-advisor (직접) | - | modify | - |
|
||||
|
||||
## concern 기록
|
||||
|
||||
### b1: DNS 캐시 TTL @PostConstruct 런타임 반영 한계
|
||||
- `sun.net.InetAddressCachePolicy` 는 최초 `InetAddress` 조회 시 Security property를 lazy 하게 읽는다.
|
||||
- @PostConstruct 가 첫 DNS 조회 전에 실행되면 정상 반영되나, 다른 빈이 먼저 조회했다면 **런타임 반영이 보장되지 않는다**.
|
||||
- 이것은 **best-effort 하드닝**이다. 결정적 보장이 필요하면 JVM 레벨(`$JAVA_HOME/conf/security/java.security` 의 `networkaddress.cache.ttl=30`)이 정본.
|
||||
- verification-advisor 는 L3 스모크(실제 앱 기동 후 ttl property 확인)로 반영 여부를 검증해야 한다.
|
||||
|
||||
### b1: 테스트 전역상태(Security.setProperty) 복원 미적용 결정
|
||||
- `Security.setProperty("networkaddress.cache.ttl","30")` 은 JVM 전역 상태를 바꾼다. 동일 클래스의 다른 SSRF 테스트는 `resolve()` 를 stub 으로 주입(`TestableFetcher`)하거나 IP literal 차단을 검증하므로 차단 판정이 IP 기반이다. TTL 값 변경은 차단 결과를 바꾸지 않는다 → @AfterEach 복원 미적용(불필요한 복잡성 회피).
|
||||
- jakarta vs javax: pom.xml `spring-boot.version=3.5.14-SNAPSHOT` (Jakarta EE 10) 확인 → `@jakarta.annotation.PostConstruct` 정확. javax 교체 불필요.
|
||||
|
||||
### env: Java 런타임 부재로 advisor 컴파일 검증 불가
|
||||
- 현재 셸 환경에 JDK 미설치(`/usr/libexec/java_home` 실패) → advisor 측 `mvnw compile`/LSP 컴파일 점검 불가.
|
||||
- 변경은 FQN(`java.security.Security`, `@jakarta.annotation.PostConstruct`)만 사용하여 import 무변경, 기존 클래스패스 의존만 사용 → 컴파일 가능성 높음. 실제 컴파일·테스트 게이트는 verification-advisor 영역.
|
||||
|
||||
### b2: @Value 기본값 유지 결정
|
||||
- 기본값 `src/main/resources/static` 은 변경하지 않는다.
|
||||
- 근거: `GameUploadControllerSecurityTest` 가 `@TempDir + ReflectionTestUtils.setField` 로 경로를 격리하므로 기본값과 무관하지만, 기본값을 홈경로로 바꾸면 IDE 로컬 실행 시 명시 설정 없이 홈 디렉토리에 파일이 생성되는 부작용이 있다. application.properties 명시 설정으로만 오버라이드하는 것이 최소 변경·테스트 안전 기준에 부합한다.
|
||||
|
||||
## worker 계획 주석
|
||||
|
||||
- w-h01: SsrfSafeFetcher + 단위테스트 — 파일 간 강한 의존(테스트가 소스를 참조) → 동일 worker로 묶어 순차 처리
|
||||
- application.properties: 1줄 추가, 단순 — advisor 직접 처리(파일 수 1, 예상 줄수 <5)
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
phase: implementation
|
||||
agent: implementation-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T14:21:15Z
|
||||
---
|
||||
|
||||
# 파일 소유권 맵 — W4 배지 표시면 배선
|
||||
|
||||
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|
||||
|---|---|---|---|---|
|
||||
| src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java | code-writer | w-001 | modify | - |
|
||||
| src/main/java/com/pandoli365/bibimbap/controller/SearchController.java | code-writer | w-002 | modify | - |
|
||||
| src/main/webapp/WEB-INF/views/index.jsp | code-writer | w-003 | modify | w-001,w-002 (model attr 추가 후 JSP 소비) |
|
||||
| src/test/java/com/pandoli365/bibimbap/controller/WebMvcControllerTest.java | code-writer | w-004 | modify | w-001 |
|
||||
| src/test/java/com/pandoli365/bibimbap/controller/SearchControllerTest.java | code-writer | w-005 | modify | w-002 |
|
||||
|
||||
## 비고
|
||||
- w-003(index.jsp)은 w-001/w-002가 `creatorBadges` attr을 주입한 뒤 소비하므로 논리적 의존이 있으나, JSP는 런타임 렌더이므로 컴파일 의존 없음 — 병렬 spawn 허용.
|
||||
- w-004/w-005(테스트)는 생성자 시그니처 변경(w-001/w-002)에 의존하므로 병렬 spawn 시 각 worker가 신규 시그니처를 설계 문서 기준으로 작성.
|
||||
- BibimbapApplicationTests는 이미 UserBadgesQueryMapper @MockBean 보유 → 무변경.
|
||||
|
||||
## 실행 결과 (planned 5 = actual 5, advisor 직접 실행 없음)
|
||||
- 5개 worker 전원 spawn·완료. 1파일=1worker 불변식 준수, 충돌 0.
|
||||
- worker 공통 우려(GameData.getUserId():Long / setUserId(Long) 존재 가정) → advisor 직접 확인: GameData.java L8/L32/L36 에 `private Long userId` + getter/setter 존재. UserBadgeKeyRow.setUserId(Long)/setBadgeKey(String) 도 존재 → 우려 해소.
|
||||
- 컴파일 게이트: `./mvnw -o clean test-compile -DskipTests` → BUILD SUCCESS, [ERROR] 0건. 비-deprecation WARNING 0건(잔존 WARNING 전량 기존 BibimbapApplicationTests 의 @MockBean deprecation, 본 변경 무관).
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
---
|
||||
schema_version: 2
|
||||
sid: 20260629-142115
|
||||
resumed_from: 20260629-100849
|
||||
started_at: 2026-06-29T14:21:15+09:00
|
||||
ended_at: 2026-06-29T16:10:00+09:00
|
||||
branch: feat/v2
|
||||
user_request: "직전 세션 needs_user_verification 4건 처리 — (1)DB DDL: 사용자 직접 적용(no-op, 기록만) (2)W4 배지 표시 2면: 지금 구현 (3)L3 스모크: 배포 후 과제로 문서 표기 (4)하드닝(SSRF rebinding ttl + 업로드 저장루트 static 밖 이전+gameRoot 중첩 정합): 추가 구현."
|
||||
init_guard: "pass — docs/development/verification-strategies.md 존재"
|
||||
migrate_check: "skip — CLAUDE.md 에 atp:migrate 마커 없음 (이미 .atp/ 경로)"
|
||||
---
|
||||
|
||||
# ATP Work Session — 20260629-142115
|
||||
|
||||
## Summary
|
||||
직전 세션(20260629-100849) needs_user_verification 4건의 사용자 결정 수신 후 처리. 작업2(배지 표시 배선)·작업4(하드닝 2건)=구현, 작업3=문서, 작업1=기록.
|
||||
|
||||
## Invocations
|
||||
- (orchestrator discovery) 직전 report 컨텍스트 회복 + 배선 지점/저장루트 config/SSRF fetcher 위치 직접 확인.
|
||||
- **작업2 배지표시**: implementation-advisor(5 worker, 5파일 — WebMvcController/SearchController 매퍼주입+모델attr, index.jsp 칩렌더, 테스트2) → verification-advisor(PASS: L1 352/352, AC 6/6, L2 skip no-new-schema) → **commit 3d10449**. 편차 0(GameReviewController authorBadges 정본 답습).
|
||||
- **작업4 하드닝**: implementation-advisor(b1 SsrfSafeFetcher @PostConstruct ttl + test, b2 application.properties 저장루트, @Value 기본값 유지) → verification-advisor(PASS: L1 353/353, AC 5/5, GameUploadControllerSecurityTest 경로격리 회귀0) → **commit 9041bb7**. concern 2건(b1 런타임 반영 best-effort, env 컴파일 미점검) verification 에서 해소/명시.
|
||||
- **documentation-advisor**: changes 2026-06-29-w3-w4 후속 섹션 + 신규 docs/maintenance/post-deploy-verification-checklist.md(L3 deferred + 자산이전 절차) + index 링크 2건.
|
||||
- **graph-refresh-checker**: partial-stale 판정 → orchestrator `/graphify src/` 재생성(AST 1836노드/4330엣지/89커뮤니티) + docs/graph/index.md 메타 갱신.
|
||||
|
||||
## Advisor Invocation Decision Log
|
||||
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||
- advisor: requirements-advisor
|
||||
decision: skip
|
||||
rationale: '4건 모두 직전 세션 needs_user_verification 에 범위·근거 확정. 사용자 결정으로 스코프 닫힘 — 요구 분해 불필요.'
|
||||
checked_at: 2026-06-29T14:22:00+09:00
|
||||
- advisor: graphify-lookup / research-advisor
|
||||
decision: skip
|
||||
rationale: '배선 지점(GameReviewController authorBadges 정본)·저장루트 config·UploadResourceConfig 를 orchestrator discovery 로 직접 확정. 외부 자료 불요.'
|
||||
checked_at: 2026-06-29T14:22:00+09:00
|
||||
- advisor: design-advisor
|
||||
decision: skip (작업2/b1) / 부분 (b2 = AskUserQuestion 결정축)
|
||||
rationale: '작업2 = GameReviewController 패턴 답습(영향맵 확정). b1 = 설정 1점. b2 저장루트 = dev 구동 영향 결정축 → 사용자 위임(설계 대신).'
|
||||
checked_at: 2026-06-29T14:22:00+09:00
|
||||
- advisor: implementation-advisor (작업2 배지표시)
|
||||
decision: call
|
||||
rationale: 'WebMvcController/SearchController/index.jsp 다파일 동시수정 → worker 소유권 충돌방지. 패턴=GameReviewController authorBadges 정본 답습.'
|
||||
checked_at: 2026-06-29T14:35:00+09:00
|
||||
- advisor: verification-advisor (작업2)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가(§C5). 신규 SQL 0(기존 매퍼 재사용) → L2 skip, L1 unit+regression 집중.'
|
||||
checked_at: 2026-06-29T14:52:00+09:00
|
||||
- advisor: implementation-advisor (작업4 하드닝 b1+b2)
|
||||
decision: call
|
||||
rationale: 'b1 SsrfSafeFetcher ttl + b2 application.properties 저장루트 + @Value 정합 다파일. 보안 하드닝 — worker 분산.'
|
||||
checked_at: 2026-06-29T15:05:00+09:00
|
||||
- advisor: verification-advisor (작업4)
|
||||
decision: call
|
||||
rationale: '코드 변경 → 스킵 불가(§C5). 신규 SQL 0 → L2 skip. b1 ttl 값 + b2 properties + 업로드 경로 회귀 0 L1 검증.'
|
||||
checked_at: 2026-06-29T15:25:00+09:00
|
||||
- advisor: documentation-advisor
|
||||
decision: call
|
||||
rationale: '작업2+4 변경이력 + 작업3 L3 스모크 배포후 과제 표기 + 직전 carryover(DB DDL 사용자 적용·자산 이전) docs 반영.'
|
||||
checked_at: 2026-06-29T15:35:00+09:00
|
||||
- advisor: graph-refresh-checker
|
||||
decision: call
|
||||
rationale: '코드 변경 1줄 이상(§C5 의무). src scope diff 비어있지 않음. → partial-stale 판정 → /graphify src/ 재생성.'
|
||||
checked_at: 2026-06-29T15:50:00+09:00
|
||||
- advisor: retrospective-advisor
|
||||
decision: call
|
||||
rationale: '전 advisor 수렴 + verification 2건 PASS. Summary/Invocations/Decisions/user_signals 충족 — 회고 입력 준비됨.'
|
||||
checked_at: 2026-06-29T16:00:00+09:00
|
||||
|
||||
## Decisions
|
||||
- **b2 업로드 저장루트 = 홈 외부 고정경로** (AskUserQuestion). `${user.home}/.bibimbap/uploads` 류 — static 트리 완전 분리, 웹서버 직접 서빙 차단(컨트롤러 권한게이트 경유), dev/운영 일관. 기존 dev 자산 1회 수동 이전/재업로드 필요(needs_user_verification).
|
||||
- **b1 SSRF ttl = `networkaddress.cache.ttl` 양수 30s 고정** (orchestrator Recommended). 검증~connect 사이 동일 IP 캐시 유지로 rebinding TOCTOU 완화. option-b hostname-connect 유지(HTTPS SNI).
|
||||
- **트랙 분리**: 작업2(배지표시) vs 작업4(하드닝 b1+b2) 공유 자원 0 → 독립. impl-advisor 순차 2회(롤백 경계 분리).
|
||||
|
||||
## verified_by_me
|
||||
- **L1 (unit+regression)**: 호스트 JDK(brew openjdk@21) `./mvnw -o test`. 작업2 352/352, 작업4 **353/353 GREEN**(누적 +6 신규: 배지표시 5 + ttl 1). 회귀 0.
|
||||
- **L2 (contract-db)**: skipped — no-new-schema(작업2 기존 매퍼 재사용, 작업4 DB 스키마 변경 0).
|
||||
- **AC**: 작업2 6/6 PASS(myBadges 주입·creatorBadges userId 그룹핑·빈맵·index.jsp HtmlUtils escape), 작업4 5/5 PASS(ttl=30 값·주석 best-effort 명시·properties 경로·업로드 회귀0·전체 GREEN).
|
||||
- **로그 스캔**: clean(WARN = Mockito/ByteBuddy agent·trailing-slash·의도적 [BADGE] resilience noise).
|
||||
|
||||
## needs_user_verification
|
||||
- **작업3 L3 런타임 스모크 (배포 후 과제 — 사용자 이월 결정)**: WAR 기동 후 5기능 실동작. 체크리스트 정본 = `docs/maintenance/post-deploy-verification-checklist.md`. 핵심: ★게임카드 creator 배지 칩·프로필 myBadges 표시(신규) ★SSRF ttl 런타임 반영(`Security.getProperty("networkaddress.cache.ttl")=="30"` — 미반영 시 JVM 레벨 java.security 격상) ★업로드물 `~/.bibimbap/uploads/{game,profile}` 생성·서빙 ★실 HTTPS 외부 fetch.
|
||||
- **b2 자산 수동 이전**: 저장루트 이전으로 기존 `src/main/resources/static/profile/8/*`(user 8 프로필) → `~/.bibimbap/uploads/profile/8/` 수동 이전 필요. 미이전 시 user 8 프로필 404. static/game 은 비어있어 게임 자산 이전 불필요.
|
||||
|
||||
## graph_refresh
|
||||
- 판정: **partial-stale**(graph-refresh-checker, 305cc73→9041bb7). no-defer 처리 완료: orchestrator `/graphify src/`(AST 1836노드/4330엣지/89커뮤니티 — UserBadgesQueryMapper→2컨트롤러 주입 엣지 + SsrfSafeFetcher.initDnsCachePolicy 노드 반영) 재생성. docs/graph/src/ 본체 갱신(gitignore), docs/graph/index.md frontmatter(source_commit→9041bb7·last_generated 14:54) + Scopes src 행 갱신. docs scope 는 우선순위 낮음 판정으로 차기 일괄(index 표에 명시).
|
||||
|
||||
## 프로젝트 종료/배포 게이트
|
||||
- L3 런타임/배포후 동작 확인 = needs_user_verification 으로 이월(사용자 결정). 자동 실행 불가(WAR 기동 필요) → docs/maintenance 체크리스트 + needs_user_verification 명시. 코드 L1 검증과 별개.
|
||||
|
||||
## 작업1 (DB DDL) — no-op
|
||||
- 신규 8종 DDL(tag/games-hub/board/game-upload/badge + W2 jam 3종) — **사용자가 직접 실 dev DB 에 적용 완료** 보고 수신. 직전 needs_user_verification "DDL 미적용" 해소. docs/changes 에 기록 반영.
|
||||
|
||||
## open_items
|
||||
- (해소) 실 dev DB DDL — 사용자 직접 적용 → 작업1 no-op.
|
||||
- (선택·미해소) b2 저장루트 변경 후에도 `.gitignore:44` 가 static/ 통째 ignore 인데 `static/profile/8/*` 2파일은 과거 add 로 tracked 잔존 — 정합 정리는 사용자 영역(이번 작업 무관).
|
||||
|
||||
## user_signals
|
||||
positive:
|
||||
- "직전 세션 needs_user_verification 4건에 항목별 명확 결정(직접 구동/지금/배포후/추가) — 1라운드 수렴, 재질의 0. 사전 needs 구조화가 결정 마찰 0 으로 이어진 긍정 신호."
|
||||
negative: []
|
||||
|
||||
## Retrospective
|
||||
```yaml
|
||||
Retrospective:
|
||||
signals:
|
||||
positive:
|
||||
- quote_or_paraphrase: "직전 세션 needs_user_verification 4건에 항목별 명확 결정(직접 구동/지금/배포후/추가) — 1라운드 수렴, 재질의 0."
|
||||
about: "세션 종료 시 needs_user_verification 을 결정 분기별로 명시적으로 구조화한 운영이 다음 세션 착수 마찰을 0으로 만든 것."
|
||||
negative: []
|
||||
what_went_well:
|
||||
- "needs_user_verification 항목별 결정 분기 구조화 → 1라운드 수렴: 직전 세션이 4건을 '사용자 직접 적용 / 지금 구현 / 배포 후 / 추가 구현'으로 분기를 미리 명시해 이번 세션 착수 시 재질의 0, 모든 결정이 첫 라운드에 수렴. 비자명한 운영 판단 — needs 를 단순 '미완료 목록'이 아니라 '결정 분기' 단위로 구조화한 것이 핵심."
|
||||
- "scope-fence-vs-display-wiring 후행 독립 트랙 패턴 실증: 직전 세션이 W4 배지 표시 배선을 fence 예외 선언 대신 deferral(이월)로 분리했고, 이번 세션에서 WebMvcController/SearchController 배선을 독립 트랙으로 5파일 352/352 GREEN 처리. 표시 AC 미완이 후속 세션에서 깔끔히 해소 — fence 예외 선언과 후행 분리 모두 유효한 해결 경로임이 검증됨."
|
||||
- "design-tradeoff-pin-to-implementation(b1 SSRF ttl) 실증: 직전 세션 교훈에 따라 b1 SSRF rebinding TOCTOU 잔여 하드닝을 '선결정 = ttl 30s 고정 + best-effort 한계 명시'로 못박아 1차 구현 수렴. backward 보정 라운드 0. 설계 트레이드오프 못박기 교훈이 동일 세션 내 적용·검증됨."
|
||||
- "b1 ttl 런타임 best-effort 한계 → L3 분리 판단: @PostConstruct initDnsCachePolicy 의 InetAddressCachePolicy lazy init 경합이라는 JVM 제약을 인식해, 단위검증(값=30 확인)과 L3 런타임 반영 확인을 명시적으로 분리. '검증 가능한 것(코드값)'과 '런타임 의존(JVM 초기화 순서)'을 다른 레이어로 배정 — 불확실성을 검증 레이어 분리로 관리한 패턴."
|
||||
- "직전 세션 회고 교훈 4건(보안 거부 vacuous 회피 / 보안핵심 adversarial 병렬 / scope-fence 표시 의존 예외 / 설계 트레이드오프 1차 결론 못박기)이 verification-strategies.md 에 모두 반영된 상태로 이번 세션이 시작 — 교훈 docs-first 반영 사이클이 다음 세션 운영에 실제로 활용 가능한 상태로 닫혔음."
|
||||
what_to_improve:
|
||||
- "b1 ttl L3 검증 deferred 의 조치 경로 명시 수준: post-deploy-verification-checklist.md 에 '미반영 시 JVM 레벨 격상' 조치가 기재돼 있으나, '실 반영됐을 때 / 안 됐을 때' 판단 명확도는 체크리스트 항목에만 의존. best-effort 코드 경로가 실패할 확률을 높이는 조건(JVM 레벨 java.security 미설정)이 기술됐지만 'best-effort 가 언제 실패하는가' 에 대한 문서화는 체크리스트 주석에 국한 — 운영 레퍼런스로서 명도가 낮음."
|
||||
memory_candidates:
|
||||
- name: needs-verification-decision-branch-structure
|
||||
type: feedback
|
||||
description: "세션 종료 시 needs_user_verification 을 '결정 분기' 단위로 구조화하면 다음 세션 착수 마찰이 0으로 수렴한다."
|
||||
body_draft: |
|
||||
What: needs_user_verification 항목을 단순 '미완료 목록'으로 기재하면 다음 세션 시작 시 각 항목의 처리 방향(직접 수행 / 이번 세션 구현 / 배포 후 / 별도 결정 필요)을 재결정해야 한다. 결정 분기를 미리 명시하면 사용자가 다음 세션 시작 직전에 결정 분기를 선택하는 형태로 마찰이 최소화된다.
|
||||
Why: 이번 세션(20260629-142115) — 직전 세션(20260629-100849)이 4건을 '사용자 직접 적용(no-op) / 지금 구현 / 배포 후 과제 / 추가 구현'으로 분기 명시 → 이번 세션 착수 시 재질의 0, 1라운드 전 결정 수렴.
|
||||
How to apply: 세션 종료 전 needs_user_verification 각 항목에 아래 분기 중 하나를 명시한다:
|
||||
- [no-op] 사용자 이미 처리 / 기록만
|
||||
- [implement-now] 다음 세션에서 즉시 구현
|
||||
- [deploy-gate] 배포 후 수동 확인 과제 (체크리스트 참조)
|
||||
- [user-decision] 사용자 결정 선행 필요 (옵션 목록 포함)
|
||||
분기가 불명확할 때만 AskUserQuestion 으로 위임.
|
||||
rationale_for_saving: "세션 간 핸드오프 마찰을 줄이는 재현성 있는 운영 패턴. 코드/커밋에서 유도 불가 — 세션 경계 운영 관찰로만 드러남."
|
||||
signal_source: positive
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
|
||||
memory_optional: true
|
||||
- name: display-wiring-deferral-as-independent-track
|
||||
type: feedback
|
||||
description: "scope-fence 가 막는 표시 배선은 fence 예외 선언 대신 후행 독립 트랙으로 분리해도 동등하게 해소된다 — 두 경로 모두 유효."
|
||||
body_draft: |
|
||||
What: verification-strategies.md §표시면이 선행 기능 컨트롤러에 의존하면 scope-fence 예외 선언 은 '편집 허용 대상 추가'가 해결책으로 기재됐다. 이번 세션이 보여준 대안: fence 예외 선언 없이 표시 AC 를 deferral 로 분리 → 후행 독립 세션에서 독립 트랙으로 처리. 두 경로 모두 유효하며 트레이드오프가 다르다.
|
||||
Why: W4 배지 표시(20260629-100849 deferral → 20260629-142115 독립 구현) — fence 예외 선언 없이 후행 분리만으로 배지 표시 AC 5파일 352/352 GREEN 완전 해소.
|
||||
How to apply: scope-fence 조우 시 두 경로 중 선택:
|
||||
(a) fence 예외 선언: 동일 세션 내 완결. 선행 기능 컨트롤러 편집 범위가 좁고 명확할 때.
|
||||
(b) deferral 독립 트랙: 선행 기능 컨트롤러 편집 범위가 크거나 현 세션 커밋 경계를 지키려 할 때. needs_user_verification 에 '[implement-now] 표시 배선 후행 처리'로 명시.
|
||||
rationale_for_saving: "직전 세션 교훈(scope-fence 예외)의 대안 경로가 실증됨. 기존 verification-strategies.md §표시면 의존 fence 예외 에 보완 내용으로 추가 가치."
|
||||
signal_source: positive
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
|
||||
memory_optional: true
|
||||
- name: best-effort-runtime-constraint-L3-split
|
||||
type: feedback
|
||||
description: "코드로 설정 가능한 범위와 JVM 런타임 제약(초기화 순서 등)은 다른 검증 레이어(L1 값 확인 / L3 런타임 반영)로 명시적으로 분리한다."
|
||||
body_draft: |
|
||||
What: @PostConstruct 같은 Spring 훅이 JVM 보안 프로퍼티를 설정할 때, InetAddressCachePolicy 등 JVM 내부 lazy init 이 먼저 실행되면 설정이 무시될 수 있다. 코드 단위에서는 '값이 세팅됐는가'만 확인 가능하고, '세팅이 실제로 적용됐는가'는 런타임이어야 알 수 있다.
|
||||
Why: b1 SSRF ttl(SsrfSafeFetcher.initDnsCachePolicy) — @PostConstruct 로 networkaddress.cache.ttl=30 설정. L1 단위검증으로 값 확인, 런타임 반영은 best-effort 한계 명시 후 L3(WAR 기동 후 Security.getProperty 값 확인)으로 분리. JVM 레벨 java.security 설정이 결정적 보장.
|
||||
How to apply: '코드 변경으로 설정 가능하지만 JVM/런타임 초기화 순서에 의존하는' 보안 프로퍼티 변경은:
|
||||
1. L1: 설정 코드가 올바른 값을 세팅하는지 단위 검증.
|
||||
2. needs_user_verification [deploy-gate]: 실 환경에서 런타임 반영 여부 확인 + 미반영 시 JVM 레벨 격상 조치 명시.
|
||||
3. post-deploy 체크리스트에 확인 명령·판단 기준·미반영 조치 3종 기재.
|
||||
rationale_for_saving: "JVM 내부 제약과 코드 검증 범위 분리라는 재현 가능한 패턴. 보안 프로퍼티 변경이 있는 미래 세션에 적용 가능."
|
||||
signal_source: observation
|
||||
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
|
||||
memory_optional: true
|
||||
protocol_feedback: []
|
||||
applied_changes: []
|
||||
```
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
phase: verification
|
||||
agent: verification-advisor
|
||||
agent_version: 1
|
||||
generated_at: 2026-06-29T14:44:20+09:00
|
||||
concerns: []
|
||||
concerns_checked: true
|
||||
---
|
||||
|
||||
# 검증 결과
|
||||
|
||||
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||
|
||||
- **AC-b1-1**: `SsrfSafeFetcher.initDnsCachePolicy()` 가 positive DNS 캐시 TTL 을 "30" 으로 설정한다. 테스트 `SsrfSafeFetcherTest.initDnsCachePolicySetsPositiveTtl` 통과 — 호출 후 `java.security.Security.getProperty("networkaddress.cache.ttl")` == `"30"`.
|
||||
- **AC-b1-2 (정적)**: `src/main/java/com/pandoli365/bibimbap/security/SsrfSafeFetcher.java` 주석에 런타임 반영 best-effort 한계 + JVM 레벨(java.security) 정본 안내가 있는지 grep 확인.
|
||||
- **AC-b2-1 (정적)**: `src/main/resources/application.properties` 에 `app.upload.game-storage-path=${user.home}/.bibimbap/uploads` 라인이 존재하는지 grep 확인.
|
||||
- **AC-b2-2 (회귀)**: 업로드 경로 관련 테스트 회귀 0 — `GameUploadControllerSecurityTest` 전건 GREEN(경로는 @TempDir+ReflectionTestUtils 로 격리되어 application.properties 변경과 무관해야 함).
|
||||
- **AC-회귀**: 전체 테스트 GREEN. 직전 baseline 352 + 신규 1(SsrfSafeFetcherTest.initDnsCachePolicySetsPositiveTtl) = 353+ 예상. 회귀 실패 0. 정확한 통과/전체 카운트 보고.
|
||||
|
||||
## 실행된 전략
|
||||
|
||||
| id | cmd | exit | severity | 결과 |
|
||||
|---|---|---|---|---|
|
||||
| verify-all (L1) | `./mvnw -o test` | 0 | blocker | pass |
|
||||
| AC-b1-2-static | `grep -n "best-effort\|JVM\|java.security\|런타임 반영" SsrfSafeFetcher.java` | 0 | blocker | pass |
|
||||
| AC-b2-1-static | `grep -Fn 'app.upload.game-storage-path=...' application.properties` | 0 | blocker | pass |
|
||||
|
||||
### 단계별 분해
|
||||
|
||||
| 단계 | 결과 |
|
||||
|---|---|
|
||||
| L1 typecheck (compile) | pass (BUILD SUCCESS) |
|
||||
| L1 unit+regression (전체 353건) | pass — Tests run: 353, Failures: 0, Errors: 0, Skipped: 0 |
|
||||
| L2 contract-db | skipped: no-new-schema (DB 스키마 변경 없음, 외부 서비스 미기동) |
|
||||
| 로그 스캔 | clean — ByteBuddy dynamic-agent WARN 4건(제외 대상), [BADGE] 리뷰 평판 훅 실패 WARN 1건(의도적 resilience WARN, 제외 대상). 그 외 ERROR/WARN 없음. |
|
||||
|
||||
## 실패 상세
|
||||
|
||||
해당 없음 — 전 항목 pass.
|
||||
|
||||
## 종합 판정
|
||||
|
||||
```
|
||||
overall: pass
|
||||
rollback_signal: none
|
||||
```
|
||||
|
||||
## Acceptance 매칭
|
||||
|
||||
| criterion | 매칭 전략 | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| AC-b1-1: SsrfSafeFetcherTest.initDnsCachePolicySetsPositiveTtl 통과 | L1 unit (SsrfSafeFetcherTest) | PASS | Tests run: 20, Failures: 0 — initDnsCachePolicySetsPositiveTtl 포함. Security property 값 설정 단위 검증. 런타임 InetAddress 캐시 실제 반영은 L3 스모크 영역으로 단위검증 대상 아님(AC 판정에서 이 구분 명시). |
|
||||
| AC-b1-2 (정적): best-effort 한계 + JVM 레벨 java.security 정본 안내 주석 | grep 정적 검사 | PASS | 43행: JVM 레벨 언급, 45행: best-effort 명시, 80~81행: 런타임 반영 한계 + JVM 레벨 java.security 정본 안내 모두 확인. |
|
||||
| AC-b2-1 (정적): application.properties 경로 라인 존재 | grep 정적 검사 | PASS | 26행: `app.upload.game-storage-path=${user.home}/.bibimbap/uploads` 정확히 존재. |
|
||||
| AC-b2-2 (회귀): GameUploadControllerSecurityTest 전건 GREEN | L1 unit | PASS | Tests run: 11, Failures: 0, Errors: 0, Skipped: 0. |
|
||||
| AC-회귀: 전체 353건 GREEN | L1 unit+regression | PASS | Tests run: 353, Failures: 0, Errors: 0, Skipped: 0. 신규 1건(SsrfSafeFetcherTest) + baseline 352 = 353 정합. |
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- 최초 ADMIN 지정. 운영자가 대상 user 를 식별해 1회 수동 실행.
|
||||
-- 자동 승격 경로 부재(FR-12, AC-6) — 코드/설정 노출 0.
|
||||
-- 이 파일은 docs/*-ddl.sql 글롭 밖이라 apply-local-ddl.sh 가 자동 적용하지 않는다(의도된 분리).
|
||||
UPDATE "users" SET "role" = 'ADMIN', "permissions_epoch" = "permissions_epoch" + 1
|
||||
WHERE "canonical_email" = :'admin_email' AND "is_delete" IS NOT TRUE;
|
||||
885
db/schema.sql
885
db/schema.sql
|
|
@ -42,6 +42,19 @@ CREATE TABLE IF NOT EXISTS "users" (
|
|||
);
|
||||
ALTER SEQUENCE "users_id_seq" OWNED BY "users"."id";
|
||||
|
||||
-- W1 RBAC: users.role 값집합 확장(CHECK) + permissions_epoch (권위: docs/rbac-ddl.sql)
|
||||
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
|
||||
$$;
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "permissions_epoch" bigint DEFAULT 0 NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- user_auth_identities (비권위 복원본 + security-hardening active-unique index)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -92,6 +105,9 @@ ALTER SEQUENCE "games_id_seq" OWNED BY "games"."id";
|
|||
CREATE INDEX IF NOT EXISTS "idx_games_visible_order"
|
||||
ON "games" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
|
||||
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- game_comments (비권위 복원본 — 매퍼 존재하나 컨트롤러 미연결)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -199,19 +215,34 @@ COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6
|
|||
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
|
||||
|
||||
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
|
||||
-- fan-out 방지: game_review_axes 직접 JOIN 시 리뷰가 axes 행수만큼 복제되어
|
||||
-- COUNT(*)/AVG(rating) 왜곡. 축 평균은 서브쿼리에서 game 단위 선집계 후 LEFT JOIN.
|
||||
CREATE OR REPLACE VIEW "game_review_stats" AS
|
||||
SELECT
|
||||
r."game_id" AS "game_id",
|
||||
ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating",
|
||||
COUNT(*) AS "review_count",
|
||||
MAX(ax."avg_immersion") AS "avg_immersion",
|
||||
MAX(ax."avg_creativity") AS "avg_creativity",
|
||||
MAX(ax."avg_controls") AS "avg_controls",
|
||||
MAX(ax."avg_completeness") AS "avg_completeness",
|
||||
MAX(ax."avg_sound") AS "avg_sound",
|
||||
MAX(ax."avg_visual") AS "avg_visual"
|
||||
FROM "game_reviews" r
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
rv."game_id" AS "game_id",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='immersion'),1) AS "avg_immersion",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='creativity'),1) AS "avg_creativity",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='controls'),1) AS "avg_controls",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='completeness'),1) AS "avg_completeness",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='sound'),1) AS "avg_sound",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual"
|
||||
FROM "game_reviews" r
|
||||
LEFT JOIN "game_review_axes" a ON a."review_id" = r."id"
|
||||
FROM "game_reviews" rv
|
||||
JOIN "game_review_axes" a ON a."review_id" = rv."id"
|
||||
WHERE rv."is_delete" IS NOT TRUE
|
||||
GROUP BY rv."game_id"
|
||||
) ax ON ax."game_id" = r."game_id"
|
||||
WHERE r."is_delete" IS NOT TRUE
|
||||
GROUP BY r."game_id";
|
||||
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';
|
||||
|
|
@ -276,3 +307,853 @@ CREATE INDEX IF NOT EXISTS "idx_recruit_posts_role"
|
|||
CREATE INDEX IF NOT EXISTS "idx_recruit_posts_participation_type"
|
||||
ON "recruit_posts" ("participation_type")
|
||||
WHERE "is_delete" = false AND "is_visible" = true;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- W1 RBAC: permissions / user_permissions / rbac_audit_log
|
||||
-- (권위 DDL — docs/rbac-ddl.sql 와 동일 스펙)
|
||||
-- ---------------------------------------------------------------------------
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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,
|
||||
"target_id" bigint NOT NULL,
|
||||
"action" character varying(30) NOT NULL,
|
||||
"permission_key" character varying(50),
|
||||
"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
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 게임잼 W2-1: jams / jam_teams / jam_team_members / jam_entries / jam_status_log
|
||||
-- (권위 DDL — docs/jam-ddl.sql 와 동일)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- W2-1 게임잼 엔티티/라이프사이클. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||
-- games 변경 없음(연결은 jam_entries 가 보유). 추가만, 파괴 없음.
|
||||
|
||||
-- ===========================================================================
|
||||
-- 1) jams (게임잼 회차. 회차 독립 = 다중 인스턴스)
|
||||
-- ===========================================================================
|
||||
CREATE SEQUENCE IF NOT EXISTS "jams_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "jams" (
|
||||
"id" bigint DEFAULT nextval('jams_id_seq'::regclass) NOT NULL,
|
||||
"slug" character varying(80) NOT NULL,
|
||||
"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,
|
||||
"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";
|
||||
|
||||
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
|
||||
$$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active"
|
||||
ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE;
|
||||
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
|
||||
-- ===========================================================================
|
||||
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) 활성 자연키)
|
||||
-- ===========================================================================
|
||||
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,
|
||||
"entrant_user_id" bigint,
|
||||
"jam_team_id" bigint,
|
||||
"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;
|
||||
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;
|
||||
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
|
||||
$$;
|
||||
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),
|
||||
"to_status" character varying(20) NOT NULL,
|
||||
"actor_id" bigint,
|
||||
"transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL,
|
||||
"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);
|
||||
|
||||
-- ===========================================================================
|
||||
-- 심사위원 역할 W2-2 (권위 DDL — docs/jam-judge-ddl.sql 와 동일. 잼 스코프 역할)
|
||||
-- ===========================================================================
|
||||
-- ===========================================================================
|
||||
-- 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");
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 잼 평가 W2-3 (권위 DDL — docs/jam-eval-ddl.sql 와 동일. 심사/투표/시상 동결)
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 평가단위 = (jam_id, game_id) 자연키 = 활성 출품작(jam_entries 1:1). jams/games/users 선존재.
|
||||
-- game_reviews/game_review_stats(W3-2) 무변경 — 시상 USER_RATING 트랙이 VIEW 를 읽기만.
|
||||
|
||||
-- ===========================================================================
|
||||
-- 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 선례).';
|
||||
|
||||
-- ===========================================================================
|
||||
-- W3-1 태그 + 검색 확장 (docs/tag-ddl.sql 동기 사본 — 의미 동등, 식별자 인용 스타일만 상이)
|
||||
-- FK 의존: users/games/jams 선존재(파일 앞쪽 정의). 끝에 추가하여 FK 순서 안전.
|
||||
-- ===========================================================================
|
||||
|
||||
-- 1) 통합 태그 마스터
|
||||
CREATE SEQUENCE IF NOT EXISTS "tags_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "tags" (
|
||||
"id" bigint DEFAULT nextval('tags_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(20) NOT NULL,
|
||||
"slug" character varying(40) NOT NULL,
|
||||
"tag_type" character varying(10) NOT NULL,
|
||||
"is_active" boolean DEFAULT false NOT NULL,
|
||||
"created_by" bigint,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "tags_id_seq" OWNED BY "tags"."id";
|
||||
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;
|
||||
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
|
||||
$$;
|
||||
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 SEQUENCE IF NOT EXISTS "game_tags_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "game_tags" (
|
||||
"id" bigint DEFAULT nextval('game_tags_id_seq'::regclass) NOT NULL,
|
||||
"game_id" bigint NOT NULL,
|
||||
"tag_id" bigint NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "game_tags_id_seq" OWNED BY "game_tags"."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
|
||||
$$;
|
||||
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");
|
||||
|
||||
-- 3) 잼-태그 조인 (N:M, jams 는 W2-1 소관)
|
||||
CREATE SEQUENCE IF NOT EXISTS "jam_tags_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "jam_tags" (
|
||||
"id" bigint DEFAULT nextval('jam_tags_id_seq'::regclass) NOT NULL,
|
||||
"jam_id" bigint NOT NULL,
|
||||
"tag_id" bigint NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "jam_tags_id_seq" OWNED BY "jam_tags"."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
|
||||
$$;
|
||||
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");
|
||||
|
||||
-- 4) 방문 로그 (dedupe 가능)
|
||||
CREATE SEQUENCE IF NOT EXISTS "game_views_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "game_views" (
|
||||
"id" bigint DEFAULT nextval('game_views_id_seq'::regclass) NOT NULL,
|
||||
"game_id" bigint NOT NULL,
|
||||
"viewer_key" character varying(200) NOT NULL,
|
||||
"viewed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "game_views_id_seq" OWNED BY "game_views"."id";
|
||||
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
|
||||
$$;
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_views_game" ON "game_views" ("game_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_views_dedupe" ON "game_views" ("game_id", "viewer_key", "viewed_at");
|
||||
|
||||
-- 5) games 비정규화 방문수 카운터
|
||||
ALTER TABLE "games" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_games_view_count" ON "games" ("view_count");
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- W3-3 포스팅 보드: post_categories / posts / unity_feed_sources / unity_feed_items
|
||||
-- (권위 DDL — docs/board-ddl.sql 와 동일)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
|
||||
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "post_categories" (
|
||||
"id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(80) NOT NULL,
|
||||
"slug" character varying(80) NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"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 "post_categories_id_seq" OWNED BY "post_categories"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
|
||||
ON "post_categories" ("slug");
|
||||
|
||||
-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
|
||||
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "posts" (
|
||||
"id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
|
||||
"category_id" bigint NOT NULL,
|
||||
"author_user_id" bigint NOT NULL,
|
||||
"title" character varying(200) NOT NULL,
|
||||
"body_markdown" text NOT NULL,
|
||||
"body_sanitized_html" text NOT NULL,
|
||||
"link_url" character varying(2048),
|
||||
"og_title" character varying(300),
|
||||
"og_description" character varying(600),
|
||||
"og_image_url" character varying(2048),
|
||||
"og_site_name" character varying(200),
|
||||
"og_fetched_at" timestamp with time zone,
|
||||
"status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_category_id_fkey"
|
||||
FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_author_user_id_fkey"
|
||||
FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_status_check"
|
||||
CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
|
||||
ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
|
||||
ON "posts" ("category_id", "created_at" DESC, "id" DESC)
|
||||
WHERE "is_delete" = false AND "status" = 'PUBLISHED';
|
||||
|
||||
-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
|
||||
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
|
||||
"id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(120) NOT NULL,
|
||||
"feed_url" character varying(2048) NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"last_polled_at" timestamp with time zone,
|
||||
"last_seen_guid" character varying(512),
|
||||
"last_error" character varying(500),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
|
||||
ON "unity_feed_sources" ("feed_url");
|
||||
|
||||
-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
|
||||
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
|
||||
"id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
|
||||
"source_id" bigint NOT NULL,
|
||||
"guid" character varying(512) NOT NULL,
|
||||
"title" character varying(500) NOT NULL,
|
||||
"link_url" character varying(2048) NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"is_acknowledged" boolean DEFAULT false NOT NULL,
|
||||
"detected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
|
||||
ALTER TABLE "unity_feed_items"
|
||||
ADD CONSTRAINT "unity_feed_items_source_id_fkey"
|
||||
FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
|
||||
ON "unity_feed_items" ("source_id", "guid");
|
||||
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
|
||||
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
|
||||
WHERE "is_acknowledged" = false;
|
||||
|
||||
-- W3-5 Unity WebGL 업로드 감사 로그 (docs/game-upload-ddl.sql 동기 사본)
|
||||
CREATE SEQUENCE IF NOT EXISTS "game_upload_audit_log_id_seq";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "game_upload_audit_log" (
|
||||
"id" bigint DEFAULT nextval('game_upload_audit_log_id_seq'::regclass) NOT NULL,
|
||||
"actor_id" bigint NOT NULL, -- 업로드 수행 사용자 users.id
|
||||
"game_uuid" character varying(36), -- 생성/교체된 UUID(거부 시 null 가능)
|
||||
"outcome" character varying(20) NOT NULL, -- SUCCESS / REJECTED
|
||||
"reject_reason" character varying(40), -- REJECTED 시 사유코드(아래 reason 카탈로그)
|
||||
"original_name" character varying(255), -- 업로드 파일 원본명(sanitize 후 저장)
|
||||
"upload_bytes" bigint, -- zip 원본 크기
|
||||
"entry_count" integer, -- 추출 엔트리 수(성공 시)
|
||||
"extracted_bytes" bigint, -- 누적 해제 크기(성공 시)
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "game_upload_audit_log_id_seq" OWNED BY "game_upload_audit_log"."id";
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_actor_id_fkey') THEN
|
||||
ALTER TABLE "game_upload_audit_log"
|
||||
ADD CONSTRAINT "game_upload_audit_log_actor_id_fkey"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_outcome_check') THEN
|
||||
ALTER TABLE "game_upload_audit_log"
|
||||
ADD CONSTRAINT "game_upload_audit_log_outcome_check"
|
||||
CHECK ("outcome" IN ('SUCCESS', 'REJECTED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_actor"
|
||||
ON "game_upload_audit_log" ("actor_id", "created_at" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_outcome"
|
||||
ON "game_upload_audit_log" ("outcome", "created_at" DESC);
|
||||
|
||||
COMMENT ON TABLE "game_upload_audit_log" IS 'WebGL 업로드 감사 로그(성공/거부 추적, W3-5)';
|
||||
COMMENT ON COLUMN "game_upload_audit_log"."reject_reason" IS 'REJECTED 사유코드: NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD';
|
||||
|
||||
-- W4 유저 배지/평판 (권위: docs/badge-ddl.sql 동기 사본)
|
||||
|
||||
-- 1) badges (배지 카탈로그 — 정의 가능·확장 가능)
|
||||
CREATE SEQUENCE IF NOT EXISTS "badges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "badges" (
|
||||
"id" bigint DEFAULT nextval('badges_id_seq'::regclass) NOT NULL,
|
||||
"badge_key" character varying(50) NOT NULL,
|
||||
"display_name" character varying(100) NOT NULL,
|
||||
"description" character varying(500),
|
||||
"badge_type" character varying(30) NOT NULL,
|
||||
"criteria_json" text,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "badges_id_seq" OWNED BY "badges"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_badges_key" ON "badges" ("badge_key");
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_type_check') THEN
|
||||
ALTER TABLE "badges"
|
||||
ADD CONSTRAINT "badges_type_check"
|
||||
CHECK ("badge_type" IN ('REVIEWER', 'TECHNICIAN'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 2) user_badges (유저↔배지 보유. 자동/수동 부여 공통 기록)
|
||||
CREATE SEQUENCE IF NOT EXISTS "user_badges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "user_badges" (
|
||||
"id" bigint DEFAULT nextval('user_badges_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"badge_key" character varying(50) NOT NULL,
|
||||
"awarded_by" bigint,
|
||||
"awarded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"revoke_reason" character varying(500),
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "user_badges_id_seq" OWNED BY "user_badges"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_badges_user_id_fkey') THEN
|
||||
ALTER TABLE "user_badges"
|
||||
ADD CONSTRAINT "user_badges_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_badges_active"
|
||||
ON "user_badges" ("user_id", "badge_key") WHERE "revoked_at" IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_badges_user_active"
|
||||
ON "user_badges" ("user_id") WHERE "revoked_at" IS NULL;
|
||||
|
||||
-- 3) reputation_events (평판 신호 감사로그 — append-only, 임계 판정 단일 소스)
|
||||
CREATE SEQUENCE IF NOT EXISTS "reputation_events_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "reputation_events" (
|
||||
"id" bigint DEFAULT nextval('reputation_events_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"event_type" character varying(40) NOT NULL,
|
||||
"source_ref" character varying(100),
|
||||
"weight" integer DEFAULT 1 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "reputation_events_id_seq" OWNED BY "reputation_events"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_type_check') THEN
|
||||
ALTER TABLE "reputation_events"
|
||||
ADD CONSTRAINT "reputation_events_type_check"
|
||||
CHECK ("event_type" IN ('REVIEW_WRITTEN', 'GAME_UPLOADED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_user_id_fkey') THEN
|
||||
ALTER TABLE "reputation_events"
|
||||
ADD CONSTRAINT "reputation_events_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_reputation_events_source"
|
||||
ON "reputation_events" ("user_id", "event_type", "source_ref")
|
||||
WHERE "source_ref" IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_reputation_events_user_type"
|
||||
ON "reputation_events" ("user_id", "event_type");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
-- W4 유저 배지/평판. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||
-- 신규 도메인(스토리지 0). 추가만, 파괴 없음. search_path=dev.
|
||||
|
||||
-- 1) badges (배지 카탈로그 — 정의 가능·확장 가능)
|
||||
CREATE SEQUENCE IF NOT EXISTS "badges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "badges" (
|
||||
"id" bigint DEFAULT nextval('badges_id_seq'::regclass) NOT NULL,
|
||||
"badge_key" character varying(50) NOT NULL,
|
||||
"display_name" character varying(100) NOT NULL,
|
||||
"description" character varying(500),
|
||||
"badge_type" character varying(30) NOT NULL,
|
||||
"criteria_json" text,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "badges_id_seq" OWNED BY "badges"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_badges_key" ON "badges" ("badge_key");
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_type_check') THEN
|
||||
ALTER TABLE "badges"
|
||||
ADD CONSTRAINT "badges_type_check"
|
||||
CHECK ("badge_type" IN ('REVIEWER', 'TECHNICIAN'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 2) user_badges (유저↔배지 보유. 자동/수동 부여 공통 기록)
|
||||
CREATE SEQUENCE IF NOT EXISTS "user_badges_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "user_badges" (
|
||||
"id" bigint DEFAULT nextval('user_badges_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"badge_key" character varying(50) NOT NULL,
|
||||
"awarded_by" bigint,
|
||||
"awarded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"revoke_reason" character varying(500),
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "user_badges_id_seq" OWNED BY "user_badges"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_badges_user_id_fkey') THEN
|
||||
ALTER TABLE "user_badges"
|
||||
ADD CONSTRAINT "user_badges_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_badges_active"
|
||||
ON "user_badges" ("user_id", "badge_key") WHERE "revoked_at" IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_badges_user_active"
|
||||
ON "user_badges" ("user_id") WHERE "revoked_at" IS NULL;
|
||||
|
||||
-- 3) reputation_events (평판 신호 감사로그 — append-only, 임계 판정 단일 소스)
|
||||
CREATE SEQUENCE IF NOT EXISTS "reputation_events_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "reputation_events" (
|
||||
"id" bigint DEFAULT nextval('reputation_events_id_seq'::regclass) NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"event_type" character varying(40) NOT NULL,
|
||||
"source_ref" character varying(100),
|
||||
"weight" integer DEFAULT 1 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "reputation_events_id_seq" OWNED BY "reputation_events"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_type_check') THEN
|
||||
ALTER TABLE "reputation_events"
|
||||
ADD CONSTRAINT "reputation_events_type_check"
|
||||
CHECK ("event_type" IN ('REVIEW_WRITTEN', 'GAME_UPLOADED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_user_id_fkey') THEN
|
||||
ALTER TABLE "reputation_events"
|
||||
ADD CONSTRAINT "reputation_events_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_reputation_events_source"
|
||||
ON "reputation_events" ("user_id", "event_type", "source_ref")
|
||||
WHERE "source_ref" IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS "idx_reputation_events_user_type"
|
||||
ON "reputation_events" ("user_id", "event_type");
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
-- W3-3 포스팅 보드. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||
-- posts / post_categories / unity_feed_sources / unity_feed_items. 추가만, 파괴 없음.
|
||||
|
||||
-- 1) post_categories (운영자 CRUD 카테고리 — D1/G2)
|
||||
CREATE SEQUENCE IF NOT EXISTS "post_categories_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "post_categories" (
|
||||
"id" bigint DEFAULT nextval('post_categories_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(80) NOT NULL,
|
||||
"slug" character varying(80) NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"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 "post_categories_id_seq" OWNED BY "post_categories"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_post_categories_slug"
|
||||
ON "post_categories" ("slug");
|
||||
|
||||
-- 2) posts (D1) — body_markdown 원본 + body_sanitized_html 캐시(D4), og_* 캐시(D5)
|
||||
CREATE SEQUENCE IF NOT EXISTS "posts_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "posts" (
|
||||
"id" bigint DEFAULT nextval('posts_id_seq'::regclass) NOT NULL,
|
||||
"category_id" bigint NOT NULL,
|
||||
"author_user_id" bigint NOT NULL,
|
||||
"title" character varying(200) NOT NULL,
|
||||
"body_markdown" text NOT NULL,
|
||||
"body_sanitized_html" text NOT NULL,
|
||||
"link_url" character varying(2048),
|
||||
"og_title" character varying(300),
|
||||
"og_description" character varying(600),
|
||||
"og_image_url" character varying(2048),
|
||||
"og_site_name" character varying(200),
|
||||
"og_fetched_at" timestamp with time zone,
|
||||
"status" character varying(20) DEFAULT 'PUBLISHED' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"is_delete" boolean DEFAULT false NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "posts_id_seq" OWNED BY "posts"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_category_id_fkey') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_category_id_fkey"
|
||||
FOREIGN KEY ("category_id") REFERENCES "post_categories" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_author_user_id_fkey') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_author_user_id_fkey"
|
||||
FOREIGN KEY ("author_user_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'posts_status_check') THEN
|
||||
ALTER TABLE "posts"
|
||||
ADD CONSTRAINT "posts_status_check"
|
||||
CHECK ("status" IN ('DRAFT', 'PUBLISHED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE INDEX IF NOT EXISTS "idx_posts_published_keyset"
|
||||
ON "posts" ("status", "is_delete", "created_at" DESC, "id" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "idx_posts_category_keyset"
|
||||
ON "posts" ("category_id", "created_at" DESC, "id" DESC)
|
||||
WHERE "is_delete" = false AND "status" = 'PUBLISHED';
|
||||
|
||||
-- 3) unity_feed_sources (외부 피드 감시 소스 — D6/G5)
|
||||
CREATE SEQUENCE IF NOT EXISTS "unity_feed_sources_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "unity_feed_sources" (
|
||||
"id" bigint DEFAULT nextval('unity_feed_sources_id_seq'::regclass) NOT NULL,
|
||||
"name" character varying(120) NOT NULL,
|
||||
"feed_url" character varying(2048) NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"last_polled_at" timestamp with time zone,
|
||||
"last_seen_guid" character varying(512),
|
||||
"last_error" character varying(500),
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "unity_feed_sources_id_seq" OWNED BY "unity_feed_sources"."id";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_sources_url"
|
||||
ON "unity_feed_sources" ("feed_url");
|
||||
|
||||
-- 4) unity_feed_items (감지된 새 글 — 운영자 알림 표면 + dedupe 영속화)
|
||||
CREATE SEQUENCE IF NOT EXISTS "unity_feed_items_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "unity_feed_items" (
|
||||
"id" bigint DEFAULT nextval('unity_feed_items_id_seq'::regclass) NOT NULL,
|
||||
"source_id" bigint NOT NULL,
|
||||
"guid" character varying(512) NOT NULL,
|
||||
"title" character varying(500) NOT NULL,
|
||||
"link_url" character varying(2048) NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"is_acknowledged" boolean DEFAULT false NOT NULL,
|
||||
"detected_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "unity_feed_items_id_seq" OWNED BY "unity_feed_items"."id";
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'unity_feed_items_source_id_fkey') THEN
|
||||
ALTER TABLE "unity_feed_items"
|
||||
ADD CONSTRAINT "unity_feed_items_source_id_fkey"
|
||||
FOREIGN KEY ("source_id") REFERENCES "unity_feed_sources" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_unity_feed_items_source_guid"
|
||||
ON "unity_feed_items" ("source_id", "guid");
|
||||
CREATE INDEX IF NOT EXISTS "idx_unity_feed_items_unack"
|
||||
ON "unity_feed_items" ("is_acknowledged", "detected_at" DESC)
|
||||
WHERE "is_acknowledged" = false;
|
||||
|
|
@ -103,7 +103,7 @@ related_report: "../../.atp/work-session/20260622-092800/report.md"
|
|||
- `docs/game-reviews-ddl.sql` — 멱등 블록 append(+ :63 보수주석 갱신)
|
||||
- `db/schema.sql` — 동일 정의 동기화(+ :136-137 주석 갱신)
|
||||
|
||||
**적용 상태**: needs_user_verification (dev: schema.sql 재부트 or ddl 수동 실행)
|
||||
**적용 상태**: PASS — dev 스키마 적용·검증 완료(20260622-170857, `db/apply-local-ddl.sh`). game_review_axes(UNIQUE index ux_game_review_axes_review_axis + score CHECK 1~5 + axis_key CHECK 6종) / game_review_stats(9컬럼) / game_comments.updated_at(timestamptz NOT NULL, 기존행 = created_at) / game_reviews.is_rating_manual(boolean DEFAULT false NOT NULL) 전수 확인.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -267,13 +267,49 @@ frontend-design 스킬로 처리된 `game-detail.jsp` 변경 요약. 기존 앰
|
|||
| AC-5 `${}` 0건 | PASS | 신규·변경 매퍼 전수(주석 3건 제외) |
|
||||
| AC-6 axis_key 6키 정합 | PASS | DDL CHECK·뷰 FILTER·앱 enum·JSP 라벨 4곳 일치 |
|
||||
| L2 contract-DB | skip | 원격 DB 미기동 |
|
||||
| L3 브라우저 스모크 | **needs_user_verification** | DDL 적용 후 dev 실게임 대상 |
|
||||
| DDL 적용 | **needs_user_verification** | game_review_axes/stats/updated_at/is_rating_manual |
|
||||
| L3 브라우저 스모크 | **PASS (+버그 2건 발견·수정)** | dev 실게임(game id=3) 대상, 세션 20260622-170857. 아래 §L3 결과 참조 |
|
||||
| DDL 적용 | **PASS** | game_review_axes/stats/updated_at/is_rating_manual 4객체 dev 스키마 검증 완료(20260622-170857) |
|
||||
|
||||
FIX LOOP 상세: `report.md#Invocations` implementation-advisor(FIX LOOP §2.6) — 근본원인=테스트 계약 미갱신(컨트롤러 무버그). body<10자 7곳→10자+ 교체, 행위 assertion 보존, 계약 무약화. main 코드 무수정.
|
||||
|
||||
---
|
||||
|
||||
## L3 브라우저 스모크 결과 (세션 20260622-170857)
|
||||
|
||||
dev 실게임(game id=3, 더미) + dev 픽스처 계정(`tester@bibimbap.local`, `db/seed-dev.sql`) 로 검증. 브라우저 확장의 computer/screenshot 도구가 해당 페이지에서 `document_idle` 미도달로 차단되어, 검증은 `javascript_tool`(DOM/이벤트 동기 검사)·`curl`(서버 계약)·psql(DB)·JSP 정적분석을 조합해 수행. 검증 후 세션 생성 테스트 데이터(tester=user3)는 전량 삭제해 원본 시드(댓글 3·리뷰 5) 복귀.
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| **B3 XSS 미실행** | PASS | 댓글 `<img src=x onerror=alert(1)>`/`<script>alert(2)</script>` + 리뷰 본문 저장 → 재렌더 시 isOnlyTextNode=true, img/script 엘리먼트 미생성, alert/confirm/prompt 0회. game-detail.jsp:1840(댓글)/:2136(리뷰) textContent. |
|
||||
| **재방문 영속** | PASS | 작성 후 새로고침 유지 + 쿠키 없는 GET이 서버 데이터 반환(localStorage 비의존). |
|
||||
| **다축 6행 + overall 자동/수동** | PASS | axes(4,5,3,4,2,5) rating 미전송 → overall=4(avg 3.83 HALF_UP), is_rating_manual=false, game_review_axes 6행. PUT rating=2 → rating=2, is_rating_manual=true, edited=true. |
|
||||
| **육각형 SVG 레이더 + aria** | PASS | summary radar svg role="img" aria-label="6축 평가: 몰입성 4, 창의성 5, 조작성 3, 완성도 4, 사운드 2, 비주얼 5". 개별 카드 레이더 동일 aria. axes 0행 리뷰는 "6축 평가 데이터 없음". |
|
||||
| **서버 집계 표시** | PASS(수정 후) | summary={avgRating:3.5, reviewCount:6, axes{...}}, UI "3.5"·"(6)" 소수1자리. review_count=0 → buildSummary null → "아직 평가 없음" 코드경로. |
|
||||
| **페이지네이션 더보기 20+hasMore** | PASS | 댓글 21건: page0 count=20 hasMore=true, page1 count=1 hasMore=false. |
|
||||
| **6축 radiogroup 키보드(C6)** | PASS | roving tabindex [0,-1,-1,-1,-1] → ArrowRight 즉시선택+tabindex 이동, Home=1/End=5, Arrow preventDefault=true(스크롤차단), aria-label "몰입성 N점". |
|
||||
| **C1 submit 잠금** | PASS | 제출 클릭 직후 btn.disabled=true, aria-busy=true. |
|
||||
| **C2 상대시각** | PASS | "5일 전"/"6일 전"/"7일 전", >7일 절대날짜, `<time datetime=ISO title=절대시각>`. |
|
||||
| **C4 글자수 카운터** | PASS | 댓글 0/200, 리뷰 0/1000(4자→"4/1000"). |
|
||||
| **B2 리뷰 10자 미만 400** | PASS | 본문 2자 + 정상 6축 → HTTP 400 "평가는 최소 10자 이상". |
|
||||
| **A2 작성자명 마스킹** | PASS | 로그인 댓글 authorName="테스터"(display_name). 탈퇴(users.is_delete=true) → "(탈퇴한 사용자)". 레거시는 시드 nickname(스냅샷) 경로. |
|
||||
|
||||
## L3 중 발견·수정한 버그 (2건)
|
||||
|
||||
L1(43건)이 매퍼를 `@MockBean`으로 대체해 실제 Postgres 동작을 우회 → 아래 2건은 L3 실게임에서만 표면화됨.
|
||||
|
||||
### BUG-1 `game_review_stats` 뷰 fan-out (집계 왜곡)
|
||||
|
||||
- 위치: `docs/game-reviews-ddl.sql` / `db/schema.sql` 뷰 정의(수정 전).
|
||||
- 증상: `LEFT JOIN game_review_axes` 직접 조인 → 리뷰가 axes 행수(최대 6)만큼 복제. 다축 리뷰 1건 존재 시 game3 `review_count`=11(실제 6), `avg_rating`=2.8(실제 3.5). axes 0행일 땐 잠복.
|
||||
- 수정: 축 평균을 서브쿼리에서 game 단위 선집계 후 `LEFT JOIN ... MAX(...)`, 리뷰 단위 집계(`COUNT(*)`/`AVG(rating)`)는 `game_reviews` 단독 산출. 양 파일 동기 + 실행 DB `CREATE OR REPLACE VIEW` 적용. 검증: game3 → review_count=6, avg_rating=3.5(실제 일치).
|
||||
|
||||
### BUG-2 `GameReviewStatsMapper` alias 케이스 폴딩 → summary 항상 null
|
||||
|
||||
- 위치: `src/.../mapper/GameReviewStatsMapper.java:13-15`(수정 전, 따옴표 없는 `AS gameId/avgRating/reviewCount`).
|
||||
- 증상: Postgres가 따옴표 없는 alias를 소문자로 폴딩(gameid/avgrating/reviewcount) → MyBatis Map 키 소문자. `GameReviewController.buildSummary`의 `stats.get("reviewCount")`(camelCase) = null → reviewCount=0 → summary=null → JSP(game-detail.jsp:2188) 항상 "아직 평가 없음". 서버 집계 기능 전면 무력.
|
||||
- 수정: alias 3개 따옴표(`AS "gameId"/"avgRating"/"reviewCount"`). axis alias는 소문자=AXIS_KEYS 일치라 유지. 검증: summary={avgRating:3.5, reviewCount:6, axes{...}}.
|
||||
- 회귀 가드: 두 버그 모두 DB-통합 계층이라 기존 단위 harness(@MockBean)로 재현 불가. 자동 회귀는 별도 L2 contract(dev DB 연동) 인프라 필요 — 이월 권고. 본 세션은 mvn test 43/43 GREEN(무회귀) + L3 before/after(11/2.8/null → 6/3.5/정상)를 근거로 한다.
|
||||
|
||||
## 범위 밖 / 이월
|
||||
|
||||
| 항목 | 사유 |
|
||||
|
|
@ -284,3 +320,4 @@ FIX LOOP 상세: `report.md#Invocations` implementation-advisor(FIX LOOP §2.6)
|
|||
| GET /reviews/mine | applyReviewGate 정밀화 — 본인 리뷰 첫 페이지 밖 폼 노출 가능. 서버 409 최종 차단(비차단). 차기 세션 후보 |
|
||||
| 댓글 더보기 정렬 정합 | 낙관 삽입 2페이지+ 정렬 어긋남 가능. 리뷰는 page0 재조회로 정합, 비차단. 차기 세션 후보 |
|
||||
| 기존 리뷰 axes 백필 | 기존 game_reviews 행은 axes 0행 — stats 뷰 6축 평균 NULL. 백필 필요 시 별도 |
|
||||
| game_review_stats L2 contract 테스트 | BUG-1(뷰 fan-out)·BUG-2(매퍼 alias 케이스)가 L1 `@MockBean` 사각으로 누출됨. dev DB 연동 contract 테스트(뷰 집계 정합 + 매퍼 Map 키)로 회귀 가드 신설 권고 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
kind: change
|
||||
title: "W2 게임잼 워크스트림 구현 (W2-1~6)"
|
||||
session_id: 20260624-100749
|
||||
resumed_from: 20260623-104307
|
||||
created_at: 2026-06-24
|
||||
status: implemented
|
||||
related_design: "../../.atp/work-session/20260623-104307/implementation/"
|
||||
related_impl: "../../.atp/work-session/20260624-100749/implementation/"
|
||||
related_verification: "../../.atp/work-session/20260624-100749/verification.md"
|
||||
related_report: "../../.atp/work-session/20260624-100749/report.md"
|
||||
related_design_summary: "../work-log/2026-06-23-w2-w4-full-design-summary.md"
|
||||
related_security_checklist: "../security/security-remediation-checklist.md"
|
||||
---
|
||||
|
||||
# W2 게임잼 워크스트림 구현 변경 이력 (W2-1~6)
|
||||
|
||||
상위 설계 인덱스: [W2+W3(잔여)+W4 풀설계 통합 요약](../work-log/2026-06-23-w2-w4-full-design-summary.md) §설계 인덱스 W2-1~6.
|
||||
설계 본문: `.atp/work-session/20260623-104307/implementation/W2-*.md` · 구현 보고: `.atp/work-session/20260624-100749/implementation/W2-*-impl.md` · 검증: `.atp/work-session/20260624-100749/verification.md`.
|
||||
|
||||
## 개요
|
||||
|
||||
W1(거버넌스/RBAC) 위에 게임잼 플랫폼 전체를 6개 서브 워크스트림으로 구현했다. 잼 엔티티/라이프사이클부터 심사위원 역할·동결 평가 스키마·심사 평가·인기 투표·시상 집계까지 잼 1회차의 전 수명주기를 커버한다. 평가 스키마(W2-3)는 ⚠️클러스터 권위로 단일 동결 후 하류(W2-4/5/6)가 소비하는 구조다.
|
||||
|
||||
| 서브 | 커밋 | 핵심 |
|
||||
|---|---|---|
|
||||
| W2-1 잼 엔티티/라이프사이클 | `ccf1e42` | jams/jam_teams/jam_team_members/jam_entries/jam_status_log + JamController/JamAdminController + InterceptorConfig exclude + GAME_JAM_MANAGE enforcement 연결 |
|
||||
| W2-2 심사위원 역할 | `71b6b6f` | jam_judges(잼 스코프) + JamRoleGate(isJudge) + 자기출품 충돌 isOwnEntry |
|
||||
| W2-3 잼 평가 동결 스키마 ⚠️권위 | `2d5601b` | jam_criteria/jam_scores/jam_votes/jam_awards + jam_score_stats VIEW. 평가단위 = (jam_id, game_id) 활성 자연키 |
|
||||
| W2-4 심사위원 평가 | `09ed6bc` | 3중게이트(isJudge·평가기간·자기출품) + criterion UPSERT + 가중집계 |
|
||||
| W2-5 인기투표 | `5ed06d9` | jam_votes 1인1표 UNIQUE + 평가기간 게이트 + 종료후 공개(밴드왜건 회피) |
|
||||
| W2-6 시상 집계 | `a74bf74` | 3트랙(JUDGE/USER_RATING/POPULAR) 개별수상 + 가중 GRAND + CLOSED 확정 멱등 |
|
||||
|
||||
커밋 범위: `ccf1e42`(W2-1) → `71b6b6f`(W2-2) → `2d5601b`(W2-3) → `09ed6bc`(W2-4) → `5ed06d9`(W2-5) → `a74bf74`(W2-6). 모두 `feat/v2` 브랜치.
|
||||
|
||||
---
|
||||
|
||||
## W2-1 — 잼 엔티티 / 라이프사이클 (`ccf1e42`)
|
||||
|
||||
### DDL (신규 5테이블)
|
||||
|
||||
| 테이블 | 역할 | 핵심 제약 |
|
||||
|---|---|---|
|
||||
| `jams` | 잼 회차 본체 + 상태/일정 | `jams_status_check` IN(RECRUIT, DEV, EVAL, CLOSED), slug 활성 UNIQUE(`ux_jams_slug_active`), keyset 인덱스 `idx_jams_visible_keyset` |
|
||||
| `jam_teams` | 팀 출품 컨테이너 | jam 스코프 |
|
||||
| `jam_team_members` | 팀 멤버 | |
|
||||
| `jam_entries` | 출품 조인테이블(다회차·정규화) | 개인/팀 XOR(`jam_entries_entrant_xor_check` + `jam_entries_entrant_type_check`), 활성 UNIQUE(`ux_jam_entries_jam_game_active`, partial WHERE is_delete IS NOT TRUE) |
|
||||
| `jam_status_log` | 상태 전이 감사 | `jam_status_log_to_status_check` IN 4값(jams 동기) |
|
||||
|
||||
- 평가 단위 = `(jam_id, game_id)` 활성 자연키. `jam_entries` active-UNIQUE 가 1:1 보장. games 테이블 무변경(허브 회귀 0).
|
||||
- DDL 권위: `docs/jam-ddl.sql`(멱등) + `db/schema.sql` 동기(jams/jam_teams/jam_team_members/jam_entries/jam_status_log).
|
||||
|
||||
### 런타임 동작
|
||||
|
||||
- `JamController` — 잼 조회·출품(개인/팀) 무결성(소유 409/비소유 422/비멤버 422/EVAL 422).
|
||||
- `JamAdminController` — 잼 CRUD + 명시 status 전이 + 전이 감사 적재. 쓰기 5핸들러 전수 `requireJamManage` 게이트, 읽기 console 은 인라인 `gate.has(GAME_JAM_MANAGE)`.
|
||||
- `InterceptorConfig` exclude 보정 — 정적 자산 경로 인터셉터 제외.
|
||||
- `JamStatus` enum(RECRUIT/DEV/EVAL/CLOSED) = jams CHECK = jam_status_log CHECK 3곳 동기.
|
||||
|
||||
---
|
||||
|
||||
## W2-2 — 심사위원 역할 (`71b6b6f`)
|
||||
|
||||
### DDL (신규 1테이블)
|
||||
|
||||
- `jam_judges` — **별도 잼 스코프 테이블**(전역 user_permissions RBAC 무오염). FK 3개(jam/user/assigned_by), `ux_jam_judges_jam_user` UNIQUE(멱등 가드).
|
||||
- `docs/jam-judge-ddl.sql` + `db/schema.sql`(line 566~594) 동기.
|
||||
|
||||
### 런타임 동작
|
||||
|
||||
- `JamRoleGate.isJudge(jamId, userId)` — 잼 스코프 심사위원 자격 판정(미지정/미인증 false).
|
||||
- `JamRoleGate.isOwnEntry` — 자기출품 충돌 규칙: 개인출품 true / 팀멤버출품 true / 타인·타팀·비활성 false (OR-EXISTS, USER·TEAM 양경로).
|
||||
- 심사위원 지정/해제는 GAME_JAM_MANAGE 게이트. 누구나(USER role 포함) 지정 가능(전역 role 검사 없음), 미존재 userId 404, 재지정 409.
|
||||
|
||||
---
|
||||
|
||||
## W2-3 — 잼 평가 동결 스키마 ⚠️클러스터 권위 (`2d5601b`)
|
||||
|
||||
W2-4/5/6 착수 전 필수 동결 묶음. Java 코드 0(스키마만), 신규 매퍼/컨트롤러/테스트 0(하류 소관).
|
||||
|
||||
### DDL (신규 4테이블 + 1 VIEW)
|
||||
|
||||
| 객체 | 역할 | 핵심 제약 |
|
||||
|---|---|---|
|
||||
| `jam_criteria` | 잼별 평가 기준(가중치) | `ux_jam_criteria_jam_key` UNIQUE, weight >= 0 CHECK |
|
||||
| `jam_scores` | 심사위원 점수 | `ux_jam_scores_jam_game_judge_criterion`(1심사위원1기준1점) UNIQUE, score BETWEEN 1 AND 5 CHECK |
|
||||
| `jam_votes` | 인기 투표 | `ux_jam_votes_jam_voter`(1인1표) UNIQUE |
|
||||
| `jam_awards` | 시상 결과 | `ux_jam_awards_jam_track_game` UNIQUE, `jam_awards_track_check` IN(JUDGE, USER_RATING, POPULAR, GRAND), rank >= 1 CHECK |
|
||||
| `jam_score_stats` (VIEW) | 가중집계 | per_criterion CTE 선집계 후 `jam_criteria LEFT JOIN` (fan-out 가드) |
|
||||
|
||||
- 평가단위 = `(jam_id, game_id)` 활성 자연키(orchestrator 동결 컬럼 + game_review_stats join 정합).
|
||||
- 유저평점 트랙 = `game_review_stats.avg_rating` 단방향 소비(6축은 표시전용, 최소 review_count 임계 미달 제외·NULLS LAST).
|
||||
- `jam_score_stats` VIEW 는 `game_review_stats` 선례 fan-out 버그를 회피(per_criterion 선집계 + `NULLIF(SUM(weight),0)` 0-division 가드). 손계산·미채점 제외·NULLIF·fan-out 불변 4축 L2 검증 통과.
|
||||
- `docs/jam-eval-ddl.sql` + `db/schema.sql`(4테이블 line 608/642/685/721 + VIEW line 765) 동기. 기존 game_reviews/game_review_stats 무변경(ALTER 17건 전부 신규 jam_* 제약추가, DROP 0).
|
||||
|
||||
---
|
||||
|
||||
## W2-4 — 심사위원 평가 (`09ed6bc`)
|
||||
|
||||
### 런타임 동작 (W2-3 스키마 소비, 신규 DDL 0)
|
||||
|
||||
- `submitScores` 진입부 3중게이트 + CSRF: CSRF누락 403 / 미인증 401 / 비-isJudge 403 / 잼없음 404 / 평가기간 외 422 / 출품작없음 404 / 자기출품 422 / 정상 200.
|
||||
- criterion별 UPSERT(`INSERT ... ON CONFLICT`) — 동일 (심사위원, 기준, 평가단위) 재채점 시 update(중복행 0).
|
||||
- criterion 화이트리스트(잼 소속 아닌 criterion 422) + score 범위 검증(범위 밖 422).
|
||||
- 가중 집계는 `jam_score_stats` VIEW 소비. 신규 매퍼 SQL `${}` 0건, JamScoreStatsMapper camelCase alias 전부 `AS "..."` 인용(§33 케이스폴딩 회피).
|
||||
|
||||
---
|
||||
|
||||
## W2-5 — 인기투표 (`5ed06d9`)
|
||||
|
||||
### 런타임 동작 (W2-3 jam_votes 소비, 신규 DDL 0)
|
||||
|
||||
- `vote`(POST) / `cancelVote`(DELETE) 1인1표 토글 — `ux_jam_votes_jam_voter` UNIQUE(jam_id, voter_user_id) 강제. 재투표 update(중복행 0). game_likes(user_key varchar 비권위) 재활용 안 함, 로그인 user_id 기반.
|
||||
- 평가기간 게이트(`JamEvalWindow.isOpen`) 2지점 — 기간 외 vote/cancelVote 422.
|
||||
- 종료후 공개(밴드왜건 회피) — CLOSED 또는 eval_end 경과 시만 count 노출. `JamVoteController.results` + `jam-detail.jsp` 2지점 게이트. 진행 중에는 "평가 종료 후 공개" 안내.
|
||||
- 미로그인 vote/cancelVote/mine 401, results 는 공개 200.
|
||||
- `JamVotesMapper.listCountsByJam` alias `AS "gameId"/"voteCount"` 인용.
|
||||
|
||||
---
|
||||
|
||||
## W2-6 — 시상 집계 (W2 종점, `a74bf74`)
|
||||
|
||||
### 런타임 동작 (W2-3 jam_awards 소비)
|
||||
|
||||
- 3트랙 개별수상: JUDGE(weightedTotal NULL 제외) / USER_RATING(review_count >= 3 임계·NULLS LAST) / POPULAR(득표 > 0). 미달·NULL 트랙 제외.
|
||||
- 가중 GRAND — 가용 트랙만 가중 정규화·결합.
|
||||
- CLOSED 게이트: status != CLOSED 시 산정 422. 산정/확정 쓰기 CSRF 403 + GAME_JAM_MANAGE 게이트.
|
||||
- 멱등 recompute: `deleteByJamTrack` → 재insert(`@Transactional`). 2회 실행 시 jam_awards 중복 0, 결과 동일.
|
||||
- `JamAwardController` 공개 결과 노출. 집계 매퍼(JamReviewRatingMapper/JamVotesMapper/JamScoreStatsMapper) camelCase alias 전부 `AS "..."` 인용.
|
||||
|
||||
---
|
||||
|
||||
## 보안 (프로젝트 보안 원칙 추적)
|
||||
|
||||
W2 에서 신설된 인가/검증 게이트. 모두 단위 + L2 DB 계약 레벨로 검증 완료.
|
||||
|
||||
| 게이트 | 적용 W | 내용 |
|
||||
|---|---|---|
|
||||
| GAME_JAM_MANAGE enforcement | W2-1/2/6 | W1 선언키의 첫 enforcement 연결(이전 소비처 0). 잼 CRUD·전이·심사위원 지정·시상 산정 쓰기 핸들러 전수 게이트. 무권한 SUBADMIN 403 / 미인증 401. |
|
||||
| 잼 스코프 isJudge | W2-2/4 | 전역 RBAC 와 분리된 잼 회차 단위 심사위원 자격. 비-isJudge 평가 403. |
|
||||
| 자기출품 충돌 isOwnEntry | W2-2/4 | 심사위원이 본인/본인팀 출품작 평가 시 422(개인·팀멤버·팀오너 차단). |
|
||||
| 평가기간 게이트(JamEvalWindow) | W2-4/5 | EVAL 기간 밖 평가/투표 422. |
|
||||
| 1인1표 UNIQUE | W2-5 | jam_votes UNIQUE(jam_id, voter_user_id) DB 레벨 강제. |
|
||||
| CSRF 전수 | W2-1/2/4/5/6 | 모든 상태 변경 핸들러 CsrfTokens.isValid → 누락 시 403 + mapper 미호출. |
|
||||
| MyBatis `${}` 0건 | 전 W | 신규 매퍼 전수 `#{}` 바인딩, 동적 치환 0. |
|
||||
| §33 alias 케이스폴딩 | W2-4/5/6 | VIEW/집계 매퍼 camelCase alias 큰따옴표 인용(`AS "gameId"`) — Postgres 소문자 폴딩 회피. game_review_stats BUG-2 재발 0. |
|
||||
|
||||
---
|
||||
|
||||
## 검증 결과 요약
|
||||
|
||||
최종 `./mvnw -o test` (W2-6 종점) **190/190 GREEN** (Failures 0 / Errors 0 / Skipped 0). 회귀 0.
|
||||
|
||||
| 서브 | L1 누적 카운트 | L2 dev DB contract | 비고 |
|
||||
|---|---|---|---|
|
||||
| W2-1 | 97/97 GREEN | PASS(격리 throwaway DB) | 1차 컴파일 BLOCKER(`response()` 미정의 24건) → 보정 후 R2 PASS |
|
||||
| W2-2 | 119/119 GREEN | PASS(a/b/c 실측) | JamRoleGateTest 8 + JamJudgeAdminControllerTest 14 |
|
||||
| W2-3 | 119/119 GREEN(회귀 무변경) | PASS(DDL/제약거부/VIEW fan-out 가드/alias) | Java 0·신규테스트 0 정상 |
|
||||
| W2-4 | 148/148 GREEN | PASS(UPSERT/alias 폴딩/가중집계) | JamScoringControllerTest 19 + JamEvalWindowTest 10 |
|
||||
| W2-5 | 171/171 GREEN | PASS(UNIQUE 토글/§33 케이스폴딩) | JamVoteControllerTest 23 |
|
||||
| W2-6 | **190/190 GREEN** | PASS 4/4(멱등/3트랙 손계산/alias/GRAND) | JamAwardServiceTest 7 + JamAwardAdminControllerTest 7 + JamAwardControllerTest 5 |
|
||||
|
||||
- **L2 비파괴 원칙**: 모든 L2 contract 는 격리 throwaway DB(생성 → DDL 적용 → 실측 → DROP)로 수행. 사용자 dev(`bibimbap`) DB 무접촉.
|
||||
- 신규 5매퍼(W2-1) 등 전 신규 매퍼 `@MockBean` 등록 — contextLoads PASS(NoSuchBeanDefinitionException 0).
|
||||
|
||||
상세는 `.atp/work-session/20260624-100749/verification.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 미적용 / 이월 (needs_user_verification)
|
||||
|
||||
| 항목 | 사유 |
|
||||
|---|---|
|
||||
| dev DB jam_* 테이블 마이그레이션 | L2 는 throwaway DB 로 통과했으나 사용자 dev(`bibimbap`) DB 에는 jam_* 테이블 미적용(파괴적 조작 회피). 실 dev 런타임(L3) 전 `db/apply-local-ddl.sh` 별도 수행 필요. |
|
||||
| L3 런타임 인가 E2E | WAR 기동 후 권한 403·CLOSED 422·종료후 공개 렌더·전이 감사 적재는 자동 미수행. 사용자 스모크 권장. |
|
||||
| W2-6 GRAND 가중계수 단위검증 | verification-advisor 역할상 구현파일 미열람 — L1 JamAwardServiceTest 7 GREEN 에 위임(L2-4 는 "가용 트랙만 정규화" 원칙만 검증). |
|
||||
| 테스트 카운트 미세 차이(무해) | W2-2 JamJudgeAdminControllerTest 예측 16 vs 실측 14, W2-5 JamVoteControllerTest 예측 24 vs 실측 23 — 전부 GREEN·회귀 0, 통합/카운트 표현 차이만(기능 결함 아님). |
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
---
|
||||
kind: change
|
||||
title: "W3(잔여) + W4 기능 구현 + 배지 표시 배선 + 보안 하드닝 b1/b2 (W3-1·W3-4·W3-3·W3-5·W4 + 20260629-142115)"
|
||||
session_id: 20260629-100849
|
||||
followup_session_id: 20260629-142115
|
||||
created_at: 2026-06-29
|
||||
status: implemented
|
||||
related_design_summary: "../work-log/2026-06-23-w2-w4-full-design-summary.md"
|
||||
related_design: "../../.atp/work-session/20260623-104307/implementation/"
|
||||
related_impl: "../../.atp/work-session/20260629-100849/implementation/"
|
||||
related_followup_impl: "../../.atp/work-session/20260629-142115/implementation/"
|
||||
related_verification: "../../.atp/work-session/20260629-100849/verification.md"
|
||||
related_followup_verification: "../../.atp/work-session/20260629-142115/verification.md"
|
||||
related_report: "../../.atp/work-session/20260629-100849/report.md"
|
||||
related_followup_report: "../../.atp/work-session/20260629-142115/report.md"
|
||||
related_security_checklist: "../security/security-remediation-checklist.md"
|
||||
related_prev_change: "./2026-06-24-w2-jam-platform.md"
|
||||
related_post_deploy: "../maintenance/post-deploy-verification-checklist.md"
|
||||
---
|
||||
|
||||
# W3(잔여) + W4 기능 구현 변경 이력 (W3-1·W3-4·W3-3·W3-5·W4)
|
||||
|
||||
상위 설계 인덱스: [W2+W3(잔여)+W4 풀설계 통합 요약](../work-log/2026-06-23-w2-w4-full-design-summary.md) §설계 인덱스 W3-1/W3-3/W3-4/W3-5/W4.
|
||||
설계 본문: `.atp/work-session/20260623-104307/implementation/W3-*.md` · `W4-badges-design.md` · 파일 소유권/구현 보고: `.atp/work-session/20260629-100849/implementation/ownership*.md` · 검증: `.atp/work-session/20260629-100849/verification.md` · 세션 보고: `.atp/work-session/20260629-100849/report.md`.
|
||||
|
||||
## 개요
|
||||
|
||||
W2 게임잼 워크스트림([2026-06-24-w2-jam-platform.md](./2026-06-24-w2-jam-platform.md)) 완료 후, W2-W4 풀설계 11기능 중 미구현으로 남았던 잔여 5기능을 순차 구현했다. W3-2(댓글/리뷰)는 이미 완료 상태였고, 본 작업은 **태그+검색(W3-1) → 메인허브(W3-4) → 포스팅보드(W3-3) → 업로드보안(W3-5) → 배지/평판(W4)** 순서로 각 기능을 `impl → verify → commit` 롤백 경계로 묶어 진행했다(5 feat 커밋).
|
||||
|
||||
| 서브 | 커밋 | 핵심 | L1 누적 |
|
||||
|---|---|---|---|
|
||||
| W3-1 태그 + 검색 확장 | `35f1dc3` | tags/game_tags/jam_tags/game_views + searchGamesAdvanced(다중태그 AND/OR·정렬키6·방문수·리뷰정렬·잼라우트) + TagController(sanitize/금칙어/CONTENT_MODERATE) | 235/235 |
|
||||
| W3-4 메인 허브 | `e28fa60` | keyset 페이징(혼합방향 OR분해) + 진행중 잼 배너(`/games/search?jam=`) | 243/243 |
|
||||
| W3-3 포스팅 보드 | `6047a39` | posts/categories + POST_WRITE enforcement + commonmark/jsoup sanitize + SsrfSafeFetcher(9항목) + 유니티 피드 폴링(@Scheduled) | 287/287 |
|
||||
| W3-5 업로드 보안 | `08d191f` | ZipSecurity(zip-slip/심링크/zipbomb/포맷/권한게이트) + ATOMIC_MOVE swap + game_upload_audit_log | 318/318 |
|
||||
| W4 유저 배지/평판 | `305cc73` | badges/user_badges/reputation_events + 자동(임계)·수동(BADGE_MANAGE) 하이브리드 + reputation 감사 + 리뷰작성자 표시 | 347/347 |
|
||||
|
||||
커밋 범위: `35f1dc3`(W3-1) → `e28fa60`(W3-4) → `6047a39`(W3-3) → `08d191f`(W3-5) → `305cc73`(W4). 모두 `feat/v2` 브랜치.
|
||||
|
||||
신규 DDL 5종: `docs/tag-ddl.sql` · `docs/games-hub-ddl.sql` · `docs/board-ddl.sql` · `docs/game-upload-ddl.sql` · `docs/badge-ddl.sql`(전부 `db/schema.sql` 동기). 신규 pom 의존 2종: jsoup 1.17.2 + commonmark 0.22.0(W3-3).
|
||||
|
||||
---
|
||||
|
||||
## W3-1 — 태그 + 검색 확장 (`35f1dc3`)
|
||||
|
||||
### DDL (신규 4테이블 + games 컬럼 1)
|
||||
|
||||
| 객체 | 역할 | 핵심 제약 |
|
||||
|---|---|---|
|
||||
| `tags` | 통합 태그 사전 | 정규화 slug 활성 UNIQUE |
|
||||
| `game_tags` | 게임-태그 조인 | (game_id, tag_id) UNIQUE, `#{}` 바인딩 |
|
||||
| `jam_tags` | 잼-태그 조인 | (jam_id, tag_id) UNIQUE |
|
||||
| `game_views` | 방문수 dedupe 원장 | 최근 interval 내 재방문 제외 근거 |
|
||||
| `games.view_count` | 누적 방문수 캐시 컬럼 | game_views 적재 시 증가 |
|
||||
|
||||
- DDL 권위: `docs/tag-ddl.sql`(멱등) + `db/schema.sql` 동기.
|
||||
|
||||
### 런타임 동작
|
||||
|
||||
- `GamesMapper.searchGamesAdvanced` — 다중태그 **AND/OR** 분리(`GameTagsMapper.listGameIdsByTagsAll`/`...Any`, AND ⊆ OR 불변), 정렬키 6종, 방문수·리뷰(`game_review_stats` 소비) 정렬, 잼 필터 라우트. `SearchCriteria` POJO 로 파라미터 캡슐화.
|
||||
- `SearchController` — `GET /games/search` 태그·정렬·잼 파라미터 처리(파라미터 전수 `#{}`). index.jsp SSR.
|
||||
- `GameViewsMapper.existsRecentView` — 최근 방문 dedupe(설계 dead param `sinceTs` 제거). `GameController` 방문 적재 훅.
|
||||
- `TagController` — 태그 생성/부착. `TagSanitizer`(normalize + `banned-words.txt` 금칙어) + **CONTENT_MODERATE 게이트**(신규 권한키 미도입, W1 선언키 재사용) + CSRF.
|
||||
|
||||
### 의미보존 편차(verification 인지)
|
||||
|
||||
- `insertTag` 는 `int` + generatedKeys 패턴(설계 표현 대비 의미보존).
|
||||
- `TagController` 가 `GamesMapper` 주입으로 부착 대상 게임 소유자 확인(설계 의도 충족).
|
||||
|
||||
### 검증
|
||||
|
||||
L1 235/235 GREEN(baseline 190→235, +45 신규테스트, 회귀 0). L2 격리 throwaway DB: alias 케이스폴딩 인용/비인용 대조, NULLS LAST, AND ⊆ OR, ILIKE, interval dedupe. AC-1/2/4/5 + 보안 VP PASS.
|
||||
|
||||
---
|
||||
|
||||
## W3-4 — 메인 허브 (`e28fa60`)
|
||||
|
||||
### DDL
|
||||
|
||||
신규 테이블 0(`GamesMapper`/`JamsMapper` 확장만). `docs/games-hub-ddl.sql` 는 keyset 인덱스 등 허브 지원 객체.
|
||||
|
||||
### 런타임 동작
|
||||
|
||||
- `GamesMapper` keyset 페이징 — **혼합 정렬방향을 OR 분해**한 row-comparison 으로 경계 무중복·무누락 보장(검색·비검색 단일화). 단계1(keyset 더보기) + 단계2(진행중 잼 배너) 통합 구현.
|
||||
- `JamsMapper.listActive`(limit=1) — 진행중 잼 1건 배너용. `WebMvcController` 가 hub 에 주입.
|
||||
- 진행중 잼 배너 → W3-1 `GET /games/search?jam={slug}` 라우팅(cross-W 정합).
|
||||
|
||||
### 검증
|
||||
|
||||
L1 243/243 GREEN(회귀 0, 편차 0). L2: keyset 혼합방향 OR분해 경계 무중복·무누락·tie-break 실측(throwaway DB). AC-T2/T3/T4 + 단계무결성 + 보안 VP PASS. `BibimbapApplicationTests` 무변경(GamesMapper/JamsMapper @MockBean 기존 등록 재사용).
|
||||
|
||||
---
|
||||
|
||||
## W3-3 — 포스팅 보드 (`6047a39`)
|
||||
|
||||
가장 복잡한 워크스트림(SSRF 9항목·md sanitize·다매퍼·@Scheduled·pom 의존). 검증 단계에서 verification + **adversarial SSRF 감사** 2중 검증이 vacuous(거짓통과) SSRF 결함을 포착해 backward 1회 보정(아래 §SSRF 강화)했다.
|
||||
|
||||
### DDL (신규 4테이블) + pom 의존
|
||||
|
||||
| 객체 | 역할 |
|
||||
|---|---|
|
||||
| `post_categories` | 게시판 카테고리 |
|
||||
| `posts` | 게시글(마크다운 본문) |
|
||||
| `unity_feed_sources` | 유니티 외부 피드 소스 |
|
||||
| `unity_feed_items` | 폴링 적재 피드 아이템(dedupe) |
|
||||
|
||||
- DDL 권위: `docs/board-ddl.sql`(멱등) + `db/schema.sql`(append) 동기.
|
||||
- pom 신규 의존: **jsoup 1.17.2**(1.14+ Safelist API 정합) + **commonmark 0.22.0**. 오프라인 빌드용으로 프록시 CA 주입 1회 pre-resolve 후 `~/.m2` 캐시(이후 오프라인 빌드 가능).
|
||||
|
||||
### 런타임 동작
|
||||
|
||||
- `PostController` — 게시글 작성/조회. **POST_WRITE enforcement 연결**(W1 선언키의 첫 소비처) + CSRF.
|
||||
- `PostMarkdownService` — commonmark 렌더 → **jsoup Safelist sanitize**(XSS 차단). 검증에서 XSS sanitize SOUND.
|
||||
- `OgPreviewService` / `UnityFeedPoller` — `SsrfSafeFetcher` 단일 경유 외부 fetch. `FeedParser`(RSS 2.0 / RFC1123 pubDate).
|
||||
- `UnityFeedPoller` `@Scheduled` 폴링 + dedupe insert(`SchedulingConfig` `@EnableScheduling` 신규 활성).
|
||||
- `PostAdminController` / `UnityFeedAdminController` — 카테고리·피드소스 관리(JSP 는 프로젝트 컨벤션 scriptlet + `HtmlUtils.htmlEscape` 로 정렬, JSTL 의존 부재 대응).
|
||||
|
||||
### SSRF 9항목 (`SsrfSafeFetcher`)
|
||||
|
||||
scheme allowlist · 사설/루프백 IP 차단 · DNS rebinding 방어 · redirect 재검증 · size/timeout 상한 등 9항목. 초기 구현은 `Host` restricted-header 를 통한 fetch 가 전건 empty 가 되어 **테스트가 vacuous(거짓통과)** 였고 CGNAT(100.64/10) 등 차단 범위갭이 있었다. backward 보정에서:
|
||||
|
||||
- **option-b hostname-connect 전환** — 사전 resolve 한 IP 를 차단 검사 후 hostname 으로 직접 connect(restricted-header 우회 제거).
|
||||
- 차단 대역 확장(CGNAT 등).
|
||||
- `FeedParser` RFC1123 pubDate 파싱 robust 화(선행 요일토큰 요일-날짜 불일치에도 견딤 — orchestrator 마이크로수정).
|
||||
|
||||
### 검증
|
||||
|
||||
backward: verification FAIL(L1 283, 2fail+1err) + adversarial SSRF 감사 CRITICAL → impl fix1 → 재검증 FAIL(FeedParser 잔존) → orchestrator 마이크로수정 → **L1 287/287 GREEN**. L2: board keyset row-comparison · insertIgnoreDup ON CONFLICT · FK(throwaway DB). adversarial 감사: XSS SOUND, 보정 후 SSRF 우회 차단.
|
||||
|
||||
---
|
||||
|
||||
## W3-5 — Unity WebGL 업로드 보안 보강 (`08d191f`)
|
||||
|
||||
### DDL (신규 1테이블)
|
||||
|
||||
- `game_upload_audit_log` — 업로드 감사(outcome CHECK, FK, reject_reason enum). `docs/game-upload-ddl.sql`(멱등) + `db/schema.sql`(append) 동기.
|
||||
|
||||
### 런타임 동작
|
||||
|
||||
- `ZipSecurity`(+ `ZipRejectException`) — **zip-slip canonical 경계 + 심링크 거부 + zip bomb(getCompressedSize 0 회피·3중상한) + 포맷검증(wasm/data/loader) + 권한게이트**. 거부 사유 enum 11종.
|
||||
- `GameUploadController` — 게이트 순서 정렬 + 임시추출 후 `ATOMIC_MOVE` swap(원자 교체) + audit 적재. dead code 제거.
|
||||
- `GameUploadAuditMapper`(+ `GameUploadAudit`/`GameAssetOwner` POJO) — `#{}` only, 컬럼 8 정합.
|
||||
- `GameAssetCleanupService` — 자산 생명주기 정리(UUID 검증 + 경계, gameRoot 미터치).
|
||||
|
||||
### 검증
|
||||
|
||||
L1 318/318 GREEN(편차 0). L2: audit insert · outcome CHECK · FK · `findGameByWebglUuid` alias(throwaway DB). **adversarial zip-slip 감사 SOUND** — 심링크 TOCTOU 구조불가, 경로탈출/유니코드/zipbomb/LIKE주입 전수 차단. LOW 2건(split 빈세그먼트·static 트리 `.tmp` 노출)은 open_item.
|
||||
|
||||
---
|
||||
|
||||
## W4 — 유저 배지 / 평판 (`305cc73`)
|
||||
|
||||
### DDL (신규 3테이블) + 권한키
|
||||
|
||||
| 객체 | 역할 | 핵심 제약 |
|
||||
|---|---|---|
|
||||
| `badges` | 배지 카탈로그 | `badges_type_check` |
|
||||
| `user_badges` | 유저 보유 배지 | `ux_user_badges_active` 부분유니크(revoked 행 제외 → 회수후 재획득) |
|
||||
| `reputation_events` | 평판 이벤트 감사 | `reputation_events_type_check` IN(REVIEW_WRITTEN, GAME_UPLOADED), source_ref 부분유니크 dedupe |
|
||||
|
||||
- DDL 권위: `docs/badge-ddl.sql`(멱등) + `db/schema.sql`(line 1075/1100/1127) 동기.
|
||||
- **`PermissionKeys.BADGE_MANAGE` 신규(4번째 권한키)** — W1 권한 카탈로그 카운트 **3→4** 갱신. `PermissionCatalogVerifier` 가 `values()` 순회(하드코딩 리스트 아님)라 키 추가에 자동 적응, 카운트 가정 테스트 회귀 0.
|
||||
|
||||
### 런타임 동작
|
||||
|
||||
- `BadgeService` / `ReputationService` — 자동(임계 도달 시 부여)·수동(grant/revoke) 하이브리드. `BadgeCatalogSeeder` 시드.
|
||||
- 자동 부여 훅 2곳: `GameReviewController.listReviews` 경로(리뷰 작성) + `GameController` 게임 업로드 경로 → `reputationService.record`. event_type CHECK 2종과 정합.
|
||||
- `BadgeManageController` — `/manage/badges` grant/revoke. 진입부 순서 CSRF(403) → 미인증(401) → `BADGE_MANAGE` 게이트(403).
|
||||
- 표시: 리뷰 작성자 배지 칩(`listReviews` JSON + game-detail.jsp JS, `UserBadgesQueryMapper.listActiveBadgeKeysByUserIds` 배치쿼리로 N+1 차단) + profile.jsp 본인 배지.
|
||||
|
||||
### 검증
|
||||
|
||||
L1 347/347 GREEN(baseline 318→347, BadgeServiceTest 12 + BadgeManageControllerTest 14 + GameReviewControllerTest 회귀 3). **W1 권한 카탈로그 회귀 0**(런타임 로그 "권한 카탈로그 시드 완료: 4 키", 카운트 가정 테스트 전부 GREEN). L2: 자동부여 멱등 · 회수후 재획득 부분유니크 · reputation dedupe · countActive 집계 · 배치 alias camelCase 인용 · CHECK/FK(throwaway DB). contextLoads 신규 @MockBean 6.
|
||||
|
||||
---
|
||||
|
||||
## 보안 (프로젝트 보안 원칙 추적)
|
||||
|
||||
본 작업에서 신설·연결된 인가/검증/방어 게이트. 모두 단위 + L2 DB 계약(+ 보안핵심 2건은 adversarial 코드추적 감사)으로 검증.
|
||||
|
||||
| 게이트 | 적용 W | 내용 |
|
||||
|---|---|---|
|
||||
| CONTENT_MODERATE 재사용 | W3-1 | 태그 생성/부착 게이트. W1 선언키 재사용(신규키 0). |
|
||||
| POST_WRITE enforcement | W3-3 | W1 선언키의 첫 enforcement 연결(이전 소비처 0). 게시글 작성 게이트. |
|
||||
| BADGE_MANAGE 신규 | W4 | 4번째 권한키 신규. 배지 grant/revoke 게이트(`/manage/badges`). W1 카탈로그 3→4. |
|
||||
| SsrfSafeFetcher 9항목 | W3-3 | scheme allowlist·사설/루프백 IP 차단·DNS rebinding·redirect 재검증·size/timeout. option-b hostname-connect(restricted-header 우회 제거). |
|
||||
| 마크다운 sanitize | W3-3 | commonmark 렌더 후 jsoup Safelist sanitize(XSS 차단). adversarial XSS SOUND. |
|
||||
| zip-slip / 심링크 / zipbomb | W3-5 | ZipSecurity canonical 경계 + 심링크 거부 + 3중상한. adversarial zip-slip SOUND. ATOMIC_MOVE 원자 교체. |
|
||||
| 방문수 dedupe | W3-1 | game_views interval 기반 재방문 제외(view_count 부풀림 방지). |
|
||||
| CSRF 전수 | 전 W(상태변경) | 모든 상태 변경 핸들러 CSRF 선검증 → 누락 시 403. |
|
||||
| MyBatis `${}` 0건 | 전 W | 신규 매퍼 전수 `#{}` 바인딩, 동적 치환 0. |
|
||||
| §33 alias 케이스폴딩 | W3-1/4, W4 | VIEW/집계/배치 매퍼 camelCase alias 큰따옴표 인용 — Postgres 소문자 폴딩 회피. |
|
||||
|
||||
---
|
||||
|
||||
## 검증 결과 요약
|
||||
|
||||
최종 컨테이너 `./mvnw -o test`(eclipse-temurin:21-jdk + `~/.m2` 마운트, 오프라인) **347/347 GREEN**(baseline 190 → 누적 +157 신규테스트, 회귀 0).
|
||||
|
||||
| 서브 | L1 누적 | L2 dev DB contract(격리 throwaway) | 비고 |
|
||||
|---|---|---|---|
|
||||
| W3-1 | 235/235 GREEN | PASS(alias 폴딩 대조·NULLS LAST·AND⊆OR·ILIKE·interval dedupe) | +45 신규, 편차 2(의미보존) |
|
||||
| W3-4 | 243/243 GREEN | PASS(keyset 혼합방향 OR분해 무중복·무누락·tie-break) | 편차 0 |
|
||||
| W3-3 | 287/287 GREEN | PASS(board keyset·insertIgnoreDup·FK) | backward 1회(verification+adversarial SSRF 2중검증이 vacuous 포착) |
|
||||
| W3-5 | 318/318 GREEN | PASS(audit·outcome CHECK·FK·alias) | adversarial zip-slip SOUND |
|
||||
| W4 | **347/347 GREEN** | PASS(멱등·재획득 부분유니크·dedupe·집계·배치 alias·CHECK/FK) | W1 카탈로그 3→4 회귀 0 |
|
||||
|
||||
- **L2 비파괴 원칙**: 모든 L2 contract 는 격리 throwaway postgres:16(생성 → DDL 적용 → 실측 → DROP)로 수행. 사용자 dev(`bibimbap`) DB **무접촉**(전 W).
|
||||
- **2중 검증 가치**: W3-3 에서 verification(테스트 실행)이 통과시킨 vacuous SSRF 를 adversarial 감사(코드추적)가 포착 → option-b 전환으로 실질 차단 확보.
|
||||
|
||||
상세는 `.atp/work-session/20260629-100849/verification.md` + `report.md` §verified_by_me 참조.
|
||||
|
||||
---
|
||||
|
||||
## 미적용 / 이월 (needs_user_verification)
|
||||
|
||||
| 항목 | 사유 |
|
||||
|---|---|
|
||||
| 실 dev DB(`bibimbap`) DDL 미적용 | L2 는 throwaway DB 로 통과했으나 실 dev DB 에는 미적용(파괴적 조작 회피). 신규 DDL 5종(tag/games-hub/board/game-upload/badge) **+ (W2 carryover) jam DDL 3종 = 총 8종** 을 `db/apply-local-ddl.sh` 로 일괄 적용 필요(§6 수동게이트). L3 선행. → **[해소됨]** 아래 §후속 세션(20260629-142115) 참조. |
|
||||
| L3 런타임 스모크(WAR 기동 후) | ① 검색 `/games/search` 태그·정렬·잼필터 + 방문수 dedupe ② 허브 `/` keyset 더보기 + 진행중 잼 배너 ③ 포스팅 작성(POST_WRITE)/sanitize 렌더/OG 미리보기/유니티 피드 폴링 + **★실 HTTPS 외부 fetch 동작**(option-b hostname-connect 실 TLS — 단위는 HTTP만) ④ 업로드 zip-slip/포맷 거부 + 정상 Unity 빌드 + replaceUuid 교체 ⑤ 배지 자동부여(리뷰·업로드)·수동 grant/revoke(BADGE_MANAGE)·리뷰작성자 칩. → **[배포 후 과제로 이월]** 상세 체크리스트: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md). |
|
||||
| **W4 표시 deferral** | 리뷰작성자 배지 경로만 구현. **프로필 myBadges 주입·게임카드 creator 배지 미구현**(의도된 deferral). 게임카드 SSR 은 `WebMvcController` hub + `SearchController` 가 담당(W3-4/타 W 산출물)이라 본 작업 범위 밖. → **[해소됨]** 아래 §후속 세션(20260629-142115) 참조. |
|
||||
| W3-3 SSRF 잔여 rebinding TOCTOU | option-b hostname-connect 의 connect 시 재resolve 창(DNS 캐시로 최소화, 위협모델 LOW — URL 제출자=권한자). 하드닝 옵션: `networkaddress.cache.ttl` 설정 검토. → **[best-effort 해소]** 아래 §후속 세션(20260629-142115) 참조. |
|
||||
| W3-5 배포 하드닝 | (1) **업로드 저장루트 static 트리 밖 이전**(LOW: static 트리 `.tmp` 노출 가능) (2) **gameRoot 경로 이중중첩 정합**(`app.upload.game-storage-path=src/main/resources/static/game` → `resolve('game')` → `.../static/game/game`). 설계 스코프-아웃(canonical 경계는 보장). dev 자산 위치 영향이라 사용자 config 결정 필요. → **[해소됨]** 아래 §후속 세션(20260629-142115) 참조. |
|
||||
|
||||
---
|
||||
|
||||
## 후속 세션 (20260629-142115) — 배지 표시 배선 완료 + 보안 하드닝 b1/b2
|
||||
|
||||
관련 세션: `.atp/work-session/20260629-142115/report.md` | 구현 소유권: `.atp/work-session/20260629-142115/implementation/` | 검증: `.atp/work-session/20260629-142115/verification.md`
|
||||
|
||||
### 작업 1 — DB DDL 사용자 직접 적용 (no-op 기록)
|
||||
|
||||
직전 세션 needs_user_verification "실 dev DB DDL 미적용" 항목에 대해 **사용자가 직접 실 dev DB 에 신규 8종(tag/games-hub/board/game-upload/badge DDL 5종 + W2 jam DDL 3종)을 적용 완료**한 것을 보고 수신. 이 항목은 해소됨으로 처리. 코드/스키마 변경 없음.
|
||||
|
||||
### 작업 2 — W4 배지 표시면 배선 (`3d10449`)
|
||||
|
||||
| 서브 | 내용 |
|
||||
|---|---|
|
||||
| `WebMvcController` | `UserBadgesQueryMapper` 주입 → hub 게임카드 `creatorBadges` model attr 추가 |
|
||||
| `SearchController` | `UserBadgesQueryMapper` 주입 → 검색 결과 게임카드 `creatorBadges` model attr 추가 |
|
||||
| `index.jsp` | 게임카드 creator 배지 칩 표시 (hub + 검색 결과 통합) |
|
||||
| `GameReviewController` | authorBadges 정본 패턴 이미 존재 — 패턴 답습, 신규 SQL 0 |
|
||||
| profile.jsp | `myBadges` 주입 표시 (이전 세션에서 구현 완료 확인, 이번 배선으로 완전 연결) |
|
||||
|
||||
- 신규 SQL 0. 기존 `UserBadgesQueryMapper.listActiveBadgeKeysByUserIds` 배치쿼리 재사용(N+1 차단).
|
||||
- L1 352/352 GREEN(baseline 347→352, +5 WebMvcControllerTest/SearchControllerTest 신규). 회귀 0.
|
||||
- 직전 세션 "W4 표시 deferral" 완전 해소.
|
||||
|
||||
### 작업 4 — 보안 하드닝 b1/b2 (`9041bb7`)
|
||||
|
||||
#### b1 — SsrfSafeFetcher DNS rebinding TOCTOU 완화
|
||||
|
||||
- `@PostConstruct initDnsCachePolicy()` 추가: `java.security.Security.setProperty("networkaddress.cache.ttl", "30")`.
|
||||
- **best-effort**: @PostConstruct 가 첫 InetAddress 조회 이전에 실행되면 유효. 결정적 보장이 필요하면 JVM 레벨(`$JAVA_HOME/conf/security/java.security`)이 정본.
|
||||
- 소스 내 주석으로 한계 안내 명시(AC-b1-2 정적 검증 PASS).
|
||||
- L3 스모크 확인 항목 추가: 앱 기동 후 `java.security.Security.getProperty("networkaddress.cache.ttl")=="30"` 확인 — 미반영 시 JVM 레벨 java.security 설정으로 격상. 상세: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md).
|
||||
|
||||
#### b2 — 업로드 저장루트 static 서빙 트리 밖 이전
|
||||
|
||||
- `application.properties`: `app.upload.game-storage-path=${user.home}/.bibimbap/uploads` 로 변경.
|
||||
- 효과: 업로드물이 static 서빙 트리 밖에 저장 → 웹서버 직접 노출 차단, 컨트롤러 권한게이트 경유 강제.
|
||||
- **수동 이전 필요**: `src/main/resources/static/profile/8/*`(user 8 프로필 이미지)를 `~/.bibimbap/uploads/profile/8/` 로 수동 이전 필요. 미이전 시 user 8 프로필 이미지 404. `static/game` 은 비어있어 게임 자산 이전 불필요.
|
||||
- 상세 절차: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md) §자산 수동 이전.
|
||||
- L1 353/353 GREEN(baseline 352→353, SsrfSafeFetcherTest.initDnsCachePolicySetsPositiveTtl 신규 1). 회귀 0.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
최종 `./mvnw -o test` **353/353 GREEN**(Tests run: 353, Failures: 0, Errors: 0, Skipped: 0). 전 항목 pass. 상세: `.atp/work-session/20260629-142115/verification.md`.
|
||||
|
||||
### 이월 (배포 후 과제)
|
||||
|
||||
L3 런타임 스모크 전체 + b2 자산 수동 이전 → [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md) 로 이월.
|
||||
|
|
@ -6,3 +6,5 @@
|
|||
|
||||
- [2026-06-18-w3-2-comments-reviews.md](./2026-06-18-w3-2-comments-reviews.md) — W3-2 댓글/리뷰 분리 구현. game_comments 서버 영속화 전환 + game_reviews 도메인 신설. 신규 API 9개(댓글 C1~C4, 리뷰 R1~R5), DDL 2종, 권한(작성자/운영자), cascade 확장. L1 31테스트 PASS. L3 스모크·DDL 적용은 needs_user_verification. 좋아요는 범위밖.
|
||||
- [2026-06-22-w3-2-comments-reviews-enhancement.md](./2026-06-22-w3-2-comments-reviews-enhancement.md) — W3-2 고도화(코어 위에 얹음). 다축 평점(game_review_axes 6축·육각형 SVG 레이더) + A1~A3 일관성(commentView 통일·작성자 마스킹 QG-2 해결·edited/updatedAt) + B1~B3 목록규모(페이지네이션·본문10자·TextNormalizer) + C1~C6 UX + game_review_stats 집계뷰(C3 재분류 — W2-3 동결 무관 확정). 신규 4파일+변경 13. L1 43/43 GREEN. DDL·L3 스모크 needs_user_verification.
|
||||
- [2026-06-29-w3-w4-features.md](./2026-06-29-w3-w4-features.md) — W3(잔여)+W4 5기능 구현(W3-1·W3-4·W3-3·W3-5·W4) + 후속(20260629-142115) 배지 표시 배선 완료·보안 하드닝 b1/b2. 태그+검색·메인허브·포스팅보드·업로드보안·배지/평판 구현(5 커밋 35f1dc3~305cc73, 최종 L1 347/347 GREEN). 후속: 게임카드 creator 배지 칩·프로필 myBadges 배선(3d10449, L1 352/352) + SsrfSafeFetcher @PostConstruct DNS ttl=30(b1 best-effort) + 업로드 저장루트 static 밖 이전(b2, 9041bb7, L1 353/353). DB DDL 8종 사용자 직접 적용 완료. L3 스모크 및 b2 자산 수동 이전은 배포 후 과제 이월 — 상세: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md).
|
||||
- [2026-06-24-w2-jam-platform.md](./2026-06-24-w2-jam-platform.md) — W2 게임잼 워크스트림 전체(W2-1~6) 구현. 잼 엔티티/라이프사이클(jams/jam_teams/jam_team_members/jam_entries/jam_status_log) + 심사위원 역할(jam_judges 잼스코프) + 평가 동결 스키마(jam_criteria/jam_scores/jam_votes/jam_awards + jam_score_stats VIEW, 평가단위 (jam_id,game_id) 활성 자연키) + 심사 평가(3중게이트 UPSERT 가중집계) + 인기투표(1인1표 UNIQUE·종료후 공개) + 시상 집계(3트랙+가중 GRAND·CLOSED 확정 멱등). GAME_JAM_MANAGE 첫 enforcement 연결·잼스코프 isJudge·평가기간 게이트·CSRF 전수. 6 커밋 ccf1e42~a74bf74. 최종 L1 190/190 GREEN(회귀 0), L2 dev DB contract 전 PASS(격리 throwaway DB). dev DB 마이그레이션·L3 스모크 needs_user_verification.
|
||||
|
|
|
|||
|
|
@ -22,12 +22,18 @@
|
|||
| 외부 서비스 설정 상수 변경 | L1 + L2 |
|
||||
| 인증/인가/롤 플로우 수정 | L1 + L2 + L3 |
|
||||
| 순수 도메인 로직 (외부 의존 없음) | L1 |
|
||||
| MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 (DB-방언 계약) | L1 + L2 (dev DB contract) |
|
||||
| 인프라 설정 (container/env/compose) | L1 + 수동 스모크 |
|
||||
|
||||
**회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다.
|
||||
|
||||
**신규 컨트롤러/매퍼 의존 변경 시 full test 의무**: 신규 컨트롤러를 추가하거나 컨트롤러의 매퍼 의존을 늘리면 implementation 단계에서 `test-compile` 만으로 끝내지 말고 반드시 full `./mvnw -o test` 를 실행한다. `BibimbapApplicationTests` 는 MyBatis/DataSource autoconfigure 가 exclude 된 컨텍스트라 컨트롤러가 주입하는 매퍼마다 `@MockBean` 을 수동 등록해야 하며, 누락 시 `contextLoads` 가 `NoSuchBeanDefinitionException` 으로 실패한다. 이 회귀는 `test-compile` 로는 탐지되지 않는다(W3-2 세션 20260618 실증).
|
||||
|
||||
**mock-vs-reality: DB-방언 계약은 L1 으로 못 잡는다**: 컨트롤러/애플리케이션 단위테스트가 MyBatis 매퍼를 `@MockBean` 으로 대체하면 "내가 부른 메서드가 불렸나"만 보고 "실제 Postgres 가 그 SQL 을 우리 기대대로 해석하나"는 검증하지 못한다. L1 통과율이 높을수록 DB-통합 계약 공백이 오히려 숨는다. W3-2 세션 20260622 에서 L1 43/43 GREEN 을 통과한 채 누출된 실버그 2종:
|
||||
- **SQL alias 케이스 폴딩**: 따옴표 없는 `AS gameId` 는 Postgres 가 소문자(`gameid`)로 폴딩 → MyBatis `Map` 키가 소문자 → 컨트롤러의 camelCase `get("reviewCount")` 가 null → 집계 summary 전면 무력. camelCase 키가 필요하면 alias 를 **반드시 큰따옴표**(`AS "gameId"`)로 감싼다.
|
||||
- **집계 뷰 1:N LEFT JOIN fan-out**: 부모(리뷰)에 자식(축 평점) 직접 JOIN 시 `COUNT(*)`/`AVG` 가 자식 행수만큼 왜곡(자식 0행일 땐 잠복, 입력 순간 발현). 집계는 자식을 서브쿼리에서 선집계 후 LEFT JOIN 한다.
|
||||
→ 매퍼 메서드 신규/시그니처·SQL 변경, 집계 뷰 정의·변경 시 L1 GREEN 으로 끝내지 말고 dev DB 연동 L2 contract 로 (a) 매퍼 반환 `Map` 키가 컨트롤러 조회 키와 정합, (b) 뷰 집계가 샘플 데이터 손계산과 일치, 를 가드한다. (현재 L2 harness 미구축 — 신설이 open item.)
|
||||
|
||||
### 실행 수단
|
||||
|
||||
프로젝트 루트에 통합 검증 스크립트를 둘 것을 권장한다 (예: `scripts/verify.sh`, `make verify`, `cargo xtask verify`). 스크립트는 L1 → L2 → 로그 스캔 순차 실행을 담당.
|
||||
|
|
@ -163,6 +169,60 @@ production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때,
|
|||
|
||||
> 근거: W3-2 고도화 세션(20260622) — 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 fork 위임으로 단일 JSP 파일 승격 + L1 43/43 GREEN 유지.
|
||||
|
||||
### 신규 `*Test.java` 는 "(검증)" 소유태그여도 구현 산출물
|
||||
|
||||
design 파일 영향맵에서 신규 테스트 파일이 owner 칸에 `(검증)` 으로 표시되더라도, **테스트 작성은 implementation 단계의 산출물**이다. `verification-advisor` 는 테스트를 **실행만** 하며 Write 권한이 없다. `(검증)` 은 "검증 관련 파일"이라는 용도 라벨일 뿐 작성 주체가 아니다. implementation 단계에서 파일 영향맵의 신규 `*Test.java` 를 전수 작성해야 하며, 빠뜨리면 verification 단계에서 시나리오 AC(VP) 가 "미커버"로 떨어진다(테스트가 없으니 실행할 게 없음).
|
||||
|
||||
> 근거: W2 세션(20260624) — W2-2 에서 impl-advisor 가 설계 파일영향맵의 `JamJudgeAdminControllerTest`/`JamRoleGateTest` `(검증)` 태그를 "verification-advisor 가 작성"으로 오독 → 테스트 0건 산출. 별도 fix 라운드로 24테스트 보완 후 통과.
|
||||
|
||||
### 매퍼 alias 케이스폴딩 기준 = Map resultType / 집계 SELECT (VIEW 여부 아님)
|
||||
|
||||
`mock-vs-reality` 의 BUG-2(alias 케이스폴딩) 정밀화: camelCase alias 를 큰따옴표로 인용해야 하는 기준은 "집계 VIEW 인가"가 **아니라 매퍼 반환이 `Map`(또는 키 이름 의존 집계)인가** 이다. Postgres 는 비인용 `AS gameId` 를 소문자(`gameid`)로 폴딩하므로, `resultType=Map` 이면 `row.get("gameId")` 가 null 이 된다. 반면 **POJO 직접 매핑 매퍼는 MyBatis 가 case-insensitive 로 매핑**하므로 비인용 alias 라도 무해하다. 따라서: **Map resultType·집계 SELECT 매퍼는 `AS "camelCase"` 인용 필수, POJO 직접매핑은 비인용 허용**.
|
||||
|
||||
> 근거: W2 세션(20260624) — W2-1 5매퍼(POJO) 비인용 alias 67건 무해 확인 → W2-5 에서 `JamVotesMapper.listCountsByJam`(Map resultType) 은 인용 필수로 정정(설계 concern 은 "VIEW만 인용"으로 부정확). L2 contract 에서 비인용 대조군 폴딩(`gameid`/`votecount`) 재현으로 입증. W2-4/6 의 집계 매퍼도 동일 적용.
|
||||
|
||||
### 신규 컨트롤러가 호출하는 공유 헬퍼는 정의 존재를 grounding 으로 선확인
|
||||
|
||||
신규 컨트롤러가 기존 컨트롤러의 패턴(예: `response(HttpStatus, String)` 응답 헬퍼)을 따를 때, 그 헬퍼가 **상속/공유되는지 아니면 클래스마다 재정의해야 하는지**를 implementation 전에 `rg` 로 확인한다. bibimbap 컨트롤러는 공통 베이스 클래스가 없어 각 컨트롤러가 헬퍼를 자체 정의해야 한다. 호출만 하고 정의를 빠뜨리면 `test-compile` 로는 늦게 잡히고(또는 다른 컴파일 에러에 가려), full `./mvnw test` 의 컴파일 단계에서 BUILD FAILURE 로 발현한다.
|
||||
|
||||
> 근거: W2 세션(20260624) — W2-1 `JamAdminController` 가 `response(HttpStatus,String)` 44회 호출하나 정의 누락(`JamController` 는 보유) → L1 컴파일 24 errors. backward 보정으로 헬퍼 1개 추가 후 통과.
|
||||
|
||||
### 보안 거부 테스트는 정상경로 성공과 쌍으로 차별 입증 (vacuous 회피)
|
||||
|
||||
보안 방어(SSRF·인증 게이트 등)의 "거부" 단위테스트가 `assertTrue(result.isEmpty())` 류만 검사하면, 방어 로직이 깨져 **항상 empty 를 반환**해도 테스트가 통과한다(vacuous PASS — 거부를 입증하지 못함). 특히 graceful `catch(Exception)→empty` 패턴은 정상경로 예외(예: 잘못된 헤더 설정)를 삼켜 정상 fetch 실패를 숨긴다. → **정상 입력 성공(present) 테스트와 차단 입력 empty 테스트를 쌍으로** 두어, 성공테스트가 GREEN 이어야 차단테스트의 empty 가 "차단되어 empty"임이 구별 입증된다.
|
||||
|
||||
> 근거: W3-3 세션(20260629) — `SsrfSafeFetcher` 가 `Host` restricted-header 로 모든 fetch 가 예외→empty. SSRF 거부 테스트 17건이 전부 vacuous PASS, 정상경로 테스트만 RED 로 노출. adversarial 감사가 근본원인 진단.
|
||||
|
||||
### 보안핵심 기능은 verification + adversarial 코드감사 병렬
|
||||
|
||||
SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-advisor(테스트 실행 판정)만으로 부족하다. 작성자 테스트가 못 짠 우회벡터(IP 대체인코딩·IPv6 변형·DNS rebinding·redirect 우회·심링크 TOCTOU·LIKE 와일드카드)는 **코드를 직접 추적하는 adversarial 감사**가 잡는다. 두 검증을 병렬로 걸고, 감사가 BYPASS/CRITICAL 을 내면 backward.
|
||||
|
||||
> 근거: W3-3 SSRF(감사가 Host-header vacuous + CGNAT 100.64/10 미차단 포착 — verification 단독으로는 무력 배포할 뻔) / W3-5 zip-slip(감사 SOUND — 심링크 TOCTOU 가 ZipInputStream 구조상 불가임을 코드추적으로 확정).
|
||||
|
||||
### 표시면이 선행 기능 컨트롤러에 의존하면 scope-fence 예외 선언
|
||||
|
||||
기능 A 의 UI 표시가 선행 기능 B 의 컨트롤러/뷰(예: 목록·카드 SSR)에 배선돼야 할 때, "B 산출물 접근 금지" scope-fence 를 그대로 두면 A 의 표시 AC 가 미완으로 남는다. orchestrator 는 표시면 의존을 사전 식별해 **해당 컨트롤러를 A 의 명시적 편집 허용 대상으로 fence 예외 선언**하거나, 표시 AC 를 의도적 deferral 로 분리·문서화한다.
|
||||
|
||||
> 근거: W4 세션(20260629) — 배지 표시 AC(프로필·게임카드)가 WebMvcController/SearchController(W3-1/3-4) SSR 에 의존하나 scope-fence 로 차단 → 리뷰작성자 칩만 구현, 프로필·게임카드는 deferral 로 분리. **후속 세션(20260629-142115)에서 그 deferral 을 독립 트랙으로 처리(컨트롤러 매퍼주입 + index.jsp 칩렌더, 5파일 352/352 GREEN) → 완전 해소.** fence 예외 선언과 후행 독립 트랙 분리가 **동등하게 유효한 두 경로**임이 실증됨 — 선행 기능과 충돌 자원이 없으면 후행 분리가 롤백 경계도 깔끔하다.
|
||||
|
||||
### 설계 트레이드오프 concern 은 1차 결론까지 못박기
|
||||
|
||||
설계가 "구현에서 A 또는 B 가 필요할 수 있다"식 트레이드오프를 concern 으로 열어두면, 1차 구현이 검증 안 된 분기를 고를 수 있다. design 산출 시 **트레이드오프를 1차 권장안 + 조건부 전환 기준까지 선결정**으로 좁혀 첫 구현이 정답 분기를 잡게 한다.
|
||||
|
||||
> 근거: W3-3 SSRF — 설계 concern 이 "IP핀닝 또는 connect후 peer검증" 을 열어둠 → 1차가 IP핀닝(Host override) 선택, HTTPS SNI 깨짐 + restricted-header 미검증. fix1 에서 option-b(hostname-connect)로 전환.
|
||||
|
||||
### needs_user_verification 은 "미완료 목록" 이 아니라 "결정 분기" 단위로 구조화
|
||||
|
||||
세션 종료 시 `needs_user_verification` 을 단순 잔여 작업 나열로 적으면, 다음 세션이 각 항목마다 "어떻게 할까요" 재질의로 시작한다. 대신 각 항목을 **사용자가 한 번에 고를 수 있는 결정 분기**(예: 직접 적용 / 지금 구현 / 배포 후 이월 / 추가 하드닝)로 미리 구조화하면, 다음 세션 착수 시 결정이 1라운드에 수렴하고 재질의가 0이 된다. 항목마다 "기본 가정값 + 영향 범위" 를 병기한다(§2.2 open_questions 규약과 정합).
|
||||
|
||||
> 근거: 세션 20260629-142115 — 직전 세션이 needs 4건을 결정 분기로 명시 → 사용자 4건 일괄 결정, 재질의 0, 모든 분기 첫 라운드 수렴.
|
||||
|
||||
### 런타임/환경 의존 보장은 L1(값 확인)과 L3(실반영) 레이어로 분리
|
||||
|
||||
설정값이 "코드에서 세팅되는가" 와 "런타임에 실제 반영되는가" 가 다른 레이어인 경우(예: `Security.setProperty("networkaddress.cache.ttl")` 는 `InetAddressCachePolicy` lazy init 경합으로 best-effort), 단위검증으로 **값 설정만** 확인하고 **실제 런타임 반영은 L3 스모크**로 분리 배정한다. best-effort 한계와 결정적 정본(예: JVM `$JAVA_HOME/conf/security/java.security`)을 코드 주석·체크리스트에 함께 명시해, "값=설정됨" 을 "동작=보장됨" 으로 오인하지 않게 한다.
|
||||
|
||||
> 근거: 세션 20260629-142115 b1 — `SsrfSafeFetcher.initDnsCachePolicy()` 단위검증은 `Security.getProperty=="30"` 만 확인(L1), 실 rebinding 방어 반영은 배포 후 체크리스트(L3)로 이월. 런타임 미반영 시 JVM 레벨 격상 경로 명시.
|
||||
|
||||
---
|
||||
|
||||
## 프로토콜 개선 권고 (외부 번들 — 미적용)
|
||||
|
|
@ -172,3 +232,9 @@ production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때,
|
|||
- **design-advisor 체크리스트**: §1 파일 영향맵 작성 규약에 "SSR 컨트롤러·뷰모델 호출지점 포함" 항목 추가 필요.
|
||||
- **W-TEST worker 지시**: 컨트롤러 검증 계약 강화 시 "기존 fixture 전수 계약 정합 감사" 단계를 의무 체크리스트 항목으로 포함 필요.
|
||||
- **agent-team-protocol §6 게이트 분류**: "근거 문서 확인 없이 freeze 인접 = 고위험 추론 금지" 조항 추가 권고.
|
||||
- **design-advisor 파일영향맵 표기**: 신규 `*Test.java` 의 owner 를 `(검증)` 용도 라벨과 분리해 `owner=implementation` 으로 명시 필요(단계 경계 오독 유발 — 구조적). 근거: W2 세션(20260624) W2-2 테스트 미작성 갭.
|
||||
- **implementation-advisor 체크리스트**: 신규 컨트롤러가 호출하는 공유 헬퍼/심볼은 정의 존재를 grounding(`rg`)으로 선확인하는 조항 추가(test-compile 비신뢰). 근거: W2 세션(20260624) W2-1 `response()` 헬퍼 누락 컴파일 FAIL.
|
||||
- **검증 레지스트리 "버그 범주→L 레벨" 표**: 보안핵심(SSRF·zip-slip·경로 boundary·sanitize) 행 추가 — "L1+L2 + adversarial 코드감사 병렬" 명시. 근거: W3-3 세션(20260629) SSRF vacuous 포착.
|
||||
- **verification-advisor 체크리스트**: 보안 거부 단위테스트는 정상경로 성공(present) 테스트와 쌍으로 차별 입증(distinguishing assertion) 확인 조항. 근거: W3-3 vacuous PASS.
|
||||
- **orchestrator scope-fence 조항(구조적)**: 단일기능 표시면이 선행기능 컨트롤러에 의존할 때 cross-feature display wiring fence 예외 선언 또는 deferral 분리. 근거: W4 배지 표시 AC 갭.
|
||||
- **design-advisor 조항**: 트레이드오프 concern 은 1차 권장안 + 조건부 전환 기준까지 선결정으로 좁힘. 근거: W3-3 SSRF IP핀닝 오선택.
|
||||
|
|
|
|||
|
|
@ -168,19 +168,35 @@ COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6
|
|||
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
|
||||
|
||||
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
|
||||
-- fan-out 방지: game_review_axes 를 직접 JOIN 하면 리뷰가 axes 행수(최대 6)만큼
|
||||
-- 복제되어 COUNT(*)/AVG(rating) 이 왜곡된다. 축 평균은 서브쿼리에서 game 단위로
|
||||
-- 선집계한 뒤 LEFT JOIN 한다 (리뷰 단위 집계는 r 한 테이블로만 계산).
|
||||
CREATE OR REPLACE VIEW "game_review_stats" AS
|
||||
SELECT
|
||||
r."game_id" AS "game_id",
|
||||
ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating",
|
||||
COUNT(*) AS "review_count",
|
||||
MAX(ax."avg_immersion") AS "avg_immersion",
|
||||
MAX(ax."avg_creativity") AS "avg_creativity",
|
||||
MAX(ax."avg_controls") AS "avg_controls",
|
||||
MAX(ax."avg_completeness") AS "avg_completeness",
|
||||
MAX(ax."avg_sound") AS "avg_sound",
|
||||
MAX(ax."avg_visual") AS "avg_visual"
|
||||
FROM "game_reviews" r
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
rv."game_id" AS "game_id",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='immersion'),1) AS "avg_immersion",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='creativity'),1) AS "avg_creativity",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='controls'),1) AS "avg_controls",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='completeness'),1) AS "avg_completeness",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='sound'),1) AS "avg_sound",
|
||||
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual"
|
||||
FROM "game_reviews" r
|
||||
LEFT JOIN "game_review_axes" a ON a."review_id" = r."id"
|
||||
FROM "game_reviews" rv
|
||||
JOIN "game_review_axes" a ON a."review_id" = rv."id"
|
||||
WHERE rv."is_delete" IS NOT TRUE
|
||||
GROUP BY rv."game_id"
|
||||
) ax ON ax."game_id" = r."game_id"
|
||||
WHERE r."is_delete" IS NOT TRUE
|
||||
GROUP BY r."game_id";
|
||||
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
-- W3-5 Unity WebGL 업로드 감사 로그. 멱등. db/apply-local-ddl.sh 로 비파괴 적용.
|
||||
-- 업로드 성공/거부 추적(zip-slip/zip-bomb/포맷거부 패턴 가시성). games 메타는 games.webgl_path 활용 — 신규 메타테이블 없음.
|
||||
|
||||
CREATE SEQUENCE IF NOT EXISTS "game_upload_audit_log_id_seq";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "game_upload_audit_log" (
|
||||
"id" bigint DEFAULT nextval('game_upload_audit_log_id_seq'::regclass) NOT NULL,
|
||||
"actor_id" bigint NOT NULL, -- 업로드 수행 사용자 users.id
|
||||
"game_uuid" character varying(36), -- 생성/교체된 UUID(거부 시 null 가능)
|
||||
"outcome" character varying(20) NOT NULL, -- SUCCESS / REJECTED
|
||||
"reject_reason" character varying(40), -- REJECTED 시 사유코드(아래 reason 카탈로그)
|
||||
"original_name" character varying(255), -- 업로드 파일 원본명(sanitize 후 저장)
|
||||
"upload_bytes" bigint, -- zip 원본 크기
|
||||
"entry_count" integer, -- 추출 엔트리 수(성공 시)
|
||||
"extracted_bytes" bigint, -- 누적 해제 크기(성공 시)
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
ALTER SEQUENCE "game_upload_audit_log_id_seq" OWNED BY "game_upload_audit_log"."id";
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_actor_id_fkey') THEN
|
||||
ALTER TABLE "game_upload_audit_log"
|
||||
ADD CONSTRAINT "game_upload_audit_log_actor_id_fkey"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "users" ("id");
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_upload_audit_log_outcome_check') THEN
|
||||
ALTER TABLE "game_upload_audit_log"
|
||||
ADD CONSTRAINT "game_upload_audit_log_outcome_check"
|
||||
CHECK ("outcome" IN ('SUCCESS', 'REJECTED'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_actor"
|
||||
ON "game_upload_audit_log" ("actor_id", "created_at" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_outcome"
|
||||
ON "game_upload_audit_log" ("outcome", "created_at" DESC);
|
||||
|
||||
COMMENT ON TABLE "game_upload_audit_log" IS 'WebGL 업로드 감사 로그(성공/거부 추적, W3-5)';
|
||||
COMMENT ON COLUMN "game_upload_audit_log"."reject_reason" IS 'REJECTED 사유코드: NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
-- W3-4 게임 허브 keyset 페이징 성능 인덱스. games 컬럼 변경 0(인덱스만). 멱등.
|
||||
-- db/apply-local-ddl.sh 글롭 docs/*-ddl.sql 자동 적용 + db/schema.sql 동기 사본.
|
||||
|
||||
-- 정렬키 = sort_order ASC, created_at DESC, id DESC (GamesMapper.getVisibleGames 와 동일)
|
||||
-- 인덱스 없이도 keyset 정합은 WHERE 비교가 보장하며, 인덱스는 깊은 페이지/대량 seek 최적화용이다.
|
||||
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
|
||||
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
kind: graphify-meta
|
||||
last_generated_at: 2026-06-22T12:40:00+0900
|
||||
source_commit: b9d836d
|
||||
last_generated_at: 2026-06-29T14:54:00+0900
|
||||
source_commit: 9041bb7
|
||||
scopes:
|
||||
- src
|
||||
- docs
|
||||
|
|
@ -38,8 +38,8 @@ scope 예시: `src`, `src-features`, `docs`, `full` 등. 한 번에 여러 scope
|
|||
|
||||
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `src` | 2026-06-22 | `b9d836d` | `src/` (Java 42 + AST) | 536 노드 / 1206 엣지 / 20 커뮤니티. Spring MVC 컨트롤러→매퍼→data 흐름. **W3-2 댓글/리뷰 고도화 반영**: 리뷰 API 다축평점(loadAxes/buildSummary/normalizeReviewSort) · 댓글 API commentView · 댓글 데이터 모델 edited/updatedAt 군집. 정적 이미지 제외. |
|
||||
| `docs` | 2026-06-22 | `b9d836d` | `docs/` (md 28 + DDL 3) | 119 노드 / 164 엣지 / 15 커뮤니티. 문서 카테고리 체계 + 보안·검증 + 플랫폼 로드맵 군집. **W3-2 고도화 반영**: C3 재분류·육각형 SVG·game_review_axes/game_review_stats·TextNormalizer 군집 + game_reviews 계열 DB 스키마 군집 + 검증·설계 교훈 군집. |
|
||||
| `src` | 2026-06-29 | `9041bb7` | `src/` (Java + AST) | **배지 표시면 배선 + 보안 하드닝 반영**(305cc73→9041bb7, AST 1836노드/4330엣지/89커뮤니티). 신규 엣지: `UserBadgesQueryMapper`→{`WebMvcController`,`SearchController`} 주입(게임카드 creator 배지·프로필 myBadges 표시 경로 — GameReviewController authorBadges 정본 답습), `SsrfSafeFetcher.initDnsCachePolicy()` 메서드 노드(networkaddress.cache.ttl 하드닝). W3(잔여)+W4 워크스트림 군집 유지: 태그/검색·메인허브 keyset·게시판/SSRF/유니티피드·업로드보안(ZipSecurity)·배지/평판. 기존 W1 RBAC·W2 게임잼·리뷰·댓글 군집 유지. 정적 이미지/banned-words 제외(AST 기반). |
|
||||
| `docs` | 2026-06-29 | `305cc73` | `docs/` (md + DDL) | **W3/W4 스키마·변경이력 반영**(semantic 198노드/282엣지/14커뮤니티). 신규 DDL 5: tag-ddl·games-hub-ddl·board-ddl·game-upload-ddl·badge-ddl(테이블/뷰 12 + 인덱스) — tags/game_tags/jam_tags/game_views·post_categories/posts/unity_feed_sources/unity_feed_items·game_upload_audit_log·badges/user_badges/reputation_events 스키마 군집. changes 2026-06-29 W3/W4 변경이력 + 구현추적 갱신. 기존 W1/W2 스키마·문서 카테고리·보안·검증·로드맵 군집 유지. graphify 본체(docs/graph/{src,docs}/) 자기참조 제외. (이번 세션 docs/maintenance 추가분은 src 동반 갱신 우선순위 판정으로 차기 일괄 재생성 대상 — graph-refresh-checker partial-stale 권고.) |
|
||||
|
||||
## 갱신 시 체크리스트
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
-- 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,
|
||||
"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,
|
||||
"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";
|
||||
|
||||
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
|
||||
$$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ux_jams_slug_active"
|
||||
ON "jams" ("slug") WHERE "is_delete" IS NOT TRUE;
|
||||
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
|
||||
-- ===========================================================================
|
||||
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) 활성 자연키)
|
||||
-- ===========================================================================
|
||||
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,
|
||||
"entrant_user_id" bigint,
|
||||
"jam_team_id" bigint,
|
||||
"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;
|
||||
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;
|
||||
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
|
||||
$$;
|
||||
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),
|
||||
"to_status" character varying(20) NOT NULL,
|
||||
"actor_id" bigint,
|
||||
"transition_type" character varying(10) DEFAULT 'MANUAL' NOT NULL,
|
||||
"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);
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
-- 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 선례).';
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
-- 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");
|
||||
|
|
@ -4,4 +4,4 @@
|
|||
|
||||
## 목록
|
||||
|
||||
_(아직 문서 없음)_
|
||||
- [post-deploy-verification-checklist.md](./post-deploy-verification-checklist.md) — 배포 후 검증 체크리스트(L3 deferred). W3/W4 + 하드닝 b1/b2 배포 후 수동 확인 항목: 검색/허브/포스팅/업로드/배지/SSRF ttl 런타임 반영 + b2 자산 수동 이전 절차(user 8 프로필 `~/.bibimbap/uploads/profile/8/`). DB DDL 8종 적용 완료(사용자 직접 적용) 기록 포함.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
kind: maintenance
|
||||
title: "배포 후 검증 체크리스트 — W3/W4 + 하드닝 b1/b2 (L3 deferred)"
|
||||
created_at: 2026-06-29
|
||||
session_id: 20260629-142115
|
||||
status: pending
|
||||
related_change: "../changes/2026-06-29-w3-w4-features.md"
|
||||
related_security_checklist: "../security/security-remediation-checklist.md"
|
||||
---
|
||||
|
||||
# 배포 후 검증 체크리스트 — W3/W4 + 하드닝 b1/b2
|
||||
|
||||
L3 런타임 스모크는 세션 20260629-100849 및 20260629-142115 에서 자동 수행하지 않고 **배포 후 과제로 이월**한 항목이다.
|
||||
이 문서는 실 dev 환경(WAR 기동 + 실 DB) 에서 수동 확인해야 할 체크리스트다.
|
||||
|
||||
관련 변경이력: [2026-06-29-w3-w4-features.md](../changes/2026-06-29-w3-w4-features.md).
|
||||
|
||||
---
|
||||
|
||||
## 사전 조건
|
||||
|
||||
배포 전 아래 항목이 완료되어야 한다.
|
||||
|
||||
- [x] **DB DDL 8종 적용 완료** (사용자 보고 수신 2026-06-29): tag/games-hub/board/game-upload/badge DDL 5종 + W2 jam DDL 3종을 `db/apply-local-ddl.sh` 로 실 dev DB 에 일괄 적용.
|
||||
- [ ] **b2 자산 수동 이전** — 아래 §자산 수동 이전 참조. WAR 기동 전 수행 권장.
|
||||
|
||||
---
|
||||
|
||||
## §자산 수동 이전 (b2 신규 저장루트)
|
||||
|
||||
`application.properties` 변경(`app.upload.game-storage-path=${user.home}/.bibimbap/uploads`)으로 업로드 저장루트가 `static/` 트리 밖으로 이전됐다.
|
||||
|
||||
**이전이 필요한 자산:**
|
||||
|
||||
```
|
||||
# 이전 경로 → 새 경로
|
||||
src/main/resources/static/profile/8/ → ~/.bibimbap/uploads/profile/8/
|
||||
```
|
||||
|
||||
- `static/profile/8/` 에 user 8 프로필 이미지가 존재할 경우 새 경로로 수동 이전 필요.
|
||||
- **미이전 시**: user 8 프로필 이미지 404 발생.
|
||||
- `static/game/` 은 비어있으므로 게임 자산 이전 불필요.
|
||||
|
||||
**이전 명령 예시 (디렉토리가 존재하는 경우):**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.bibimbap/uploads/profile/8
|
||||
cp -r src/main/resources/static/profile/8/. ~/.bibimbap/uploads/profile/8/
|
||||
# 확인 후 원본 삭제(선택)
|
||||
```
|
||||
|
||||
완료 후 이 체크리스트 항목에 체크:
|
||||
- [ ] user 8 프로필 이미지 `~/.bibimbap/uploads/profile/8/` 에 존재 확인
|
||||
|
||||
---
|
||||
|
||||
## L3 런타임 스모크 체크리스트
|
||||
|
||||
WAR 기동 후 아래 기능을 수동으로 확인한다.
|
||||
|
||||
### 1. 검색 (`/games/search`)
|
||||
|
||||
- [ ] 태그 필터(단일/다중 AND/OR) 정상 동작
|
||||
- [ ] 정렬키 6종(최신/조회수/평점/리뷰수/잼연관 등) 전환 확인
|
||||
- [ ] 잼 필터(`?jam=slug`) 경유 잼 게임 목록 표시
|
||||
- [ ] 방문수 dedupe — 동일 사용자 짧은 interval 내 재방문 시 view_count 미증가 확인
|
||||
|
||||
### 2. 허브 (`/`)
|
||||
|
||||
- [ ] keyset 페이징 더보기(스크롤/버튼) — 중복 없이 다음 페이지 로드
|
||||
- [ ] 진행중 잼 배너 표시 (`JamsMapper.listActive` → `/games/search?jam=` 라우팅)
|
||||
- [ ] **게임카드 creator 배지 칩 표시** (이번 세션 신규 — `WebMvcController` creatorBadges 주입)
|
||||
|
||||
### 3. 포스팅 보드
|
||||
|
||||
- [ ] 포스팅 작성 (POST_WRITE 권한 보유 계정으로 로그인 후 `/posts/new`)
|
||||
- [ ] 마크다운 sanitize 렌더 — XSS 시도(`<script>`) 차단 확인
|
||||
- [ ] OG 미리보기 (`OgPreviewService`) — 외부 URL 입력 후 og:title/og:image 렌더
|
||||
- [ ] 유니티 피드 폴링 — `@Scheduled` 실행 확인(로그 또는 `unity_feed_items` 테이블 증가)
|
||||
- [ ] **★실 HTTPS 외부 fetch** — option-b hostname-connect 실 TLS 동작 확인 (단위는 HTTP mock만 사용했으므로 실 환경 필수)
|
||||
|
||||
### 4. 업로드 보안 (Unity WebGL)
|
||||
|
||||
- [ ] zip-slip 시도 거부 확인 (`../` 경로 포함 zip 업로드 → 거부)
|
||||
- [ ] 포맷 거부 — wasm/data/loader 파일 미포함 zip 거부
|
||||
- [ ] 정상 Unity 빌드 업로드 + replaceUuid 경로 교체 성공
|
||||
- [ ] **업로드물이 `~/.bibimbap/uploads/game/` 에 생성·서빙되는지 확인** (b2 신규 경로)
|
||||
|
||||
### 5. 배지 / 평판
|
||||
|
||||
- [ ] 배지 자동부여 — 리뷰 10건 작성 후 Reviewer 배지 자동 부여
|
||||
- [ ] 배지 자동부여 — 게임 업로드 3건 후 Technician 배지 자동 부여
|
||||
- [ ] 수동 grant/revoke — BADGE_MANAGE 권한 계정으로 `/manage/badges` 접근 + grant/revoke 동작
|
||||
- [ ] 리뷰 작성자 배지 칩 — 게임 상세 리뷰 목록에서 리뷰어 배지 칩 표시
|
||||
- [ ] **프로필 myBadges 표시** (이번 세션 신규 — `profile.jsp` 본인 배지 주입)
|
||||
|
||||
### 6. 보안 하드닝 b1 — SSRF DNS 캐시 TTL
|
||||
|
||||
- [ ] **★SSRF ttl 런타임 반영 확인**: 앱 기동 후 `java.security.Security.getProperty("networkaddress.cache.ttl")` 값이 `"30"` 인지 확인.
|
||||
- 반영됨: `@PostConstruct initDnsCachePolicy()` best-effort 성공.
|
||||
- **미반영 시 조치**: JVM 레벨 설정으로 격상 — `$JAVA_HOME/conf/security/java.security` 에 `networkaddress.cache.ttl=30` 추가. (결정적 보장은 JVM 레벨이 정본.)
|
||||
|
||||
---
|
||||
|
||||
## 완료 기준
|
||||
|
||||
위 체크리스트 전항목 체크 + §자산 수동 이전 완료 시 L3 스모크 통과.
|
||||
결과를 이 문서에 직접 기록하거나 관련 work-log 에 append.
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
-- W1 거버넌스/RBAC. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
||||
-- 기존 데이터(전원 role='USER') 호환 — 추가만, 파괴 없음.
|
||||
|
||||
-- 1) users.role 값집합 확장 (CHECK 멱등 추가)
|
||||
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 (세션 권한 전파 버전 스탬프)
|
||||
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 (권한 카탈로그)
|
||||
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)
|
||||
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 (감사로그 — 임명/강등/토글 대칭 기록)
|
||||
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,
|
||||
"target_id" bigint NOT NULL,
|
||||
"action" character varying(30) NOT NULL,
|
||||
"permission_key" character varying(50),
|
||||
"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
|
||||
$$;
|
||||
|
|
@ -108,15 +108,15 @@
|
|||
- [x] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. → **초기 모델 주입 가능 + 별도 GET C1(`GET /game/{id}/comments`) fetch 방식 채택.** GameCommentController C1 구현 완료(20260618-104034).
|
||||
- [x] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. → **C2 `POST /game/{id}/comments`: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용.** L1 12건 PASS(20260618-104034).
|
||||
- [x] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403.** L1 PASS(20260618-104034).
|
||||
- [x] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. → **game-detail.jsp 댓글 렌더링 전면 textContent 교체.** L3 브라우저 XSS 미실행 확인은 needs_user_verification(L3 스모크).
|
||||
- [x] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. → **game-detail.jsp 댓글 렌더링 전면 textContent 교체.** L3 브라우저 스모크(20260622-170857) 확인: `<img src=x onerror=alert(1)>`/`<script>` 저장 후 재렌더 시 isOnlyTextNode=true, img/script 엘리먼트 미생성, alert 0회 발화.
|
||||
- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. ← **로그인 제한 적용됨(댓글 쓰기 로그인 필수). rate limit/신고 정책은 이월.**
|
||||
- [x] localStorage UI는 서버 응답 기준으로 교체한다. → **game-detail.jsp 댓글 localStorage → fetch API 서버 응답 기준으로 전환.** L1 PASS(20260618-104034).
|
||||
|
||||
완료 조건:
|
||||
|
||||
- [~] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L1 단위 서버 저장 확인 완료. 브라우저 재방문 영속은 L3 스모크 대기(needs_user_verification).** 좋아요: localStorage 유지(범위 밖, 미충족).
|
||||
- [x] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L3 스모크(20260622-170857) 확인 — 댓글/리뷰 작성 후 새로고침 유지 + 쿠키 없는(=다른 브라우저/시크릿 동치) GET `/game/3/comments`·`/reviews` 가 서버 데이터를 반환(localStorage 비의존 증명).** 좋아요: localStorage 유지(범위 밖, 미충족).
|
||||
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖.
|
||||
- [~] XSS payload 댓글이 스크립트로 실행되지 않는다. → **textContent 렌더 적용(단위 컨트롤러 raw 반환 확인). 브라우저 렌더 미실행 확인은 L3 스모크 대기.**
|
||||
- [x] XSS payload 댓글이 스크립트로 실행되지 않는다. → **L3 브라우저 스모크(20260622-170857) 확인: 댓글 `<img src=x onerror=alert(1)>`·`<script>alert(2)</script>` + 리뷰 본문 모두 DB raw 저장, 클라 textContent 렌더(game-detail.jsp:1840 댓글/:2136 리뷰) → 텍스트 노드로만 표시(img/script 미생성), alert/confirm/prompt 0회 발화.**
|
||||
- [x] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → **GameController.deleteGame에 softDeleteGameReviews 추가.** 댓글 soft-delete도 기존 로직 확인. L1 PASS.
|
||||
|
||||
> 구현 이력 상세: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
-- 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,
|
||||
slug VARCHAR(40) NOT NULL,
|
||||
tag_type VARCHAR(10) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by BIGINT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
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 $$;
|
||||
|
||||
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 $$;
|
||||
|
||||
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,
|
||||
tag_id BIGINT NOT NULL,
|
||||
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,
|
||||
tag_id BIGINT NOT NULL,
|
||||
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,
|
||||
viewer_key VARCHAR(200) NOT NULL,
|
||||
viewed_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_views_game ON game_views (game_id);
|
||||
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 비정규화 방문수 카운터
|
||||
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);
|
||||
|
|
@ -180,6 +180,23 @@ db/apply-local-ddl.sh docs/game-reviews-ddl.sql # 특정 파일만
|
|||
|
||||
> 검증: 적용 후 `docker exec bibimbap-db psql -U bibimbap -d bibimbap -c "\d dev.<테이블>"` 로 컬럼·제약·인덱스를 확인한다.
|
||||
|
||||
### 4.2 검증 진입 전: 가동 중 앱이 "현재 빌드"인지 확인
|
||||
|
||||
**핵심 함정**: "8080 에 앱이 떠 있음"이 "현재 작업 빌드가 서빙 중"을 보장하지 않는다. 직접 실행(`spring-boot:run` / provided Tomcat)에서 **JSP 는 docBase 가 기동 시점에 동결**되어 파일을 고쳐도 라이브 리로드되지 않는다. 과거 세션에 띄워둔 stale 프로세스가 8080 을 점유한 채 검증을 시작하면 **구 JSP 가 응답**하고 현재 빌드는 검증되지 않는다(거짓 PASS/FAIL). flyway/liquibase 부재(§4)와 같은 결의 함정 — "떠 있음 ≠ 최신".
|
||||
|
||||
L3/브라우저 스모크·런타임 검증 진입 전 점검:
|
||||
|
||||
```bash
|
||||
# 8080 점유 프로세스의 기동 시각 확인 (오늘 변경분보다 이전이면 stale)
|
||||
ps -o lstart,command -p "$(lsof -ti tcp:8080)" 2>/dev/null
|
||||
|
||||
# JSP 변경분이 실제 서빙되는지 확인 (예: 신규 마커 토큰 grep)
|
||||
curl -s http://localhost:8080/<경로> | grep -c '<현재빌드에만-있는-마커>'
|
||||
```
|
||||
|
||||
- JSP/Java 변경분은 **재기동해야** 반영된다(JSP touch 후 재요청해도 docBase 동결로 미반영 확인됨, 20260622).
|
||||
- DDL 변경분은 앱 재기동과 무관하게 `db/apply-local-ddl.sh` 로 실행 DB 에 별도 적용한다(§4.1). MyBatis annotation 매퍼는 쿼리타임 반영이라 DDL 적용 후 앱 재기동 불요.
|
||||
|
||||
## 5. 검증 체크리스트
|
||||
|
||||
실제 통과한 항목은 `[x]` 다.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ owner: art
|
|||
|
||||
## 코드 현황 (착수 전 확인된 결합점)
|
||||
|
||||
> ⚠️ **일부 stale (2026-06-23 정정)** — 아래 #2(리뷰 테이블)·#3(Interceptor)은 W1 착지(커밋 941f9fb) + W3-2 구현 완료로 뒤집혔다. 정정·grounding: [2026-06-23-w2-w4-feature-skeletons.md](./2026-06-23-w2-w4-feature-skeletons.md) §"W3 skeletons 코드 현황 절 stale 정정". 요지: 리뷰 인프라(game_reviews+6축+stats VIEW)·권한 게이트(PermissionGate/RbacInterceptor/PermissionKeys) 모두 **실재**. 단 POST_WRITE·GAME_JAM_MANAGE 키는 enforcement 미연결(소비처 0). #1(태그)·#4·#5 는 유효.
|
||||
|
||||
골자의 "결합/의존" 정확도를 위해 현 코드베이스를 대조했다. file 근거:
|
||||
|
||||
1. **`games` 테이블** (`GamesMapper.java`) — id, user_id, name, creator_note, git_url, webgl_path, thumbnail_url, like_count, is_visible, sort_order, created_at, updated_at. **태그 컬럼 없음.** 검색은 name/creator_note ILIKE 방식만.
|
||||
|
|
@ -158,7 +160,7 @@ owner: art
|
|||
|
||||
- **QG-1**: W1 인터셉터 완료 시점과 권한 게이트 착수. **W3-3 은 W1 완료 후 착수(임시 role 직접 체크 안 함)로 확정.** W3-5 업로드 권한은 미결 유지(조사 단계 확정). *잔여*: W1 지연 시 W3-5 임시 체크 허용 여부.
|
||||
- **QG-2**: W3-2 기존 닉네임 자유입력 레코드 처리 — 마이그레이션/유지/레거시 표시. **미결 유지 확정**(골자 단계 해소 보류).
|
||||
- **QG-3**: W3-5 `/game/**` 정적 핸들러 미등록이 의도인지 버그인지 — 현 WebGL 서빙 실제 동작 확인 필요. **코드 확인을 조사 단계로 미룸 확정.**
|
||||
- **QG-3**: ~~W3-5 `/game/**` 정적 핸들러 미등록이 의도인지 버그인지~~ → **해소(2026-06-23 조사)**: `/game/**` 서빙은 `ResourceHandler` 가 아니라 전용 컨트롤러 `GameAssetController`(`@GetMapping("/game/{gameUuid}/**")`, boundary·Content-Type·CSP·br/gz 인코딩 처리)가 담당 — **정상 동작(의도된 설계, 버그 아님)**. `UploadResourceConfig` 가 `/profile/**` 만 등록한 것은 맞으나 WebGL 은 컨트롤러 경로라 무관. 근거: `.atp/work-session/20260623-104307/research/W3-5-upload-research.md`. (W3-5 실 결함은 zip-slip 심링크 구멍·업로드 권한게이트 전무로 재확인됨.)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
---
|
||||
kind: work-log
|
||||
title: W2(게임잼) + W4(유저 배지/평판) — 기능별 골자 카탈로그
|
||||
description: W2(게임잼 워크스트림) 6개 서브기능(W2-1 잼 엔티티/라이프사이클 / W2-2 심사위원 역할권한 / W2-3 잼 평가 통합설계·스키마 동결 / W2-4 심사위원 평가 / W2-5 인기투표 / W2-6 시상 집계) + W4(유저 배지/평판)의 골자. W3 skeletons 포맷과 동일 — 기능당 목적·핵심동작·결합/의존(code-fact vs 해석 태깅)·미결질문(비해소)·후속진입점 + 결합표 + 착수순서 + 전체수준 미결(QG). 윤곽 카탈로그 깊이 — 미결질문 해소 안 하고 남김. 당장 구현 아님, 풀설계 착수 전 굳혀두는 기획 메모.
|
||||
status: draft
|
||||
source_roadmap: 2026-06-17-jam-platform-roadmap.md
|
||||
source_grounding: ../../.atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
||||
source_session: 20260623-104307
|
||||
created_at: 2026-06-23
|
||||
owner: art
|
||||
---
|
||||
|
||||
# W2(게임잼) + W4(유저 배지/평판) — 기능별 골자 카탈로그
|
||||
|
||||
상위 로드맵: [2026-06-17-jam-platform-roadmap.md](./2026-06-17-jam-platform-roadmap.md) §W2·§W4.
|
||||
자매 골자(W3): [2026-06-17-w3-feature-skeletons.md](./2026-06-17-w3-feature-skeletons.md).
|
||||
|
||||
**깊이**: 윤곽 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점만 굳혀둔다. DDL/API 계약/시퀀스/AC매핑은 기능별 착수(풀설계) 단계에서. **미결질문은 해소하지 않고 남긴다** — 골자의 목적은 "무엇을/왜 + 무엇이 미결인지"를 굳히는 것. 당장 구현 아님.
|
||||
|
||||
> 결합/의존의 각 항목은 `code-fact`(파일:라인 근거) / `해석`(설계 판단) 으로 태깅한다. 근거는 grounding 문서: [research/W2-W4-grounding.md](../../.atp/work-session/20260623-104307/research/W2-W4-grounding.md).
|
||||
|
||||
---
|
||||
|
||||
## ★ W3 skeletons "코드 현황" 절 stale 정정 (2건)
|
||||
|
||||
W3 skeletons(2026-06-17 작성)의 "코드 현황" 절은 작성 이후 **W1 착지(커밋 941f9fb) + W3-2 구현 완료**로 2건이 뒤집혔다. 이를 명시하지 않으면 후속 설계가 옛 전제로 진행된다.
|
||||
|
||||
1. **skeleton 코드현황 #3 "권한 인프라 — Interceptor 클래스 없음 … W3-3 포스터 게이트 얹힐 자리 미비"** → **정정: 실재(code-fact)**.
|
||||
- `security/PermissionGate`(has/canModerate/isAdmin/require) · `RbacInterceptor`(401 vs 403 분기) · `PermissionKeys`(GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE) · `Roles`(ADMIN/SUBADMIN/USER) · `config/InterceptorConfig` · `config/PermissionCatalogVerifier` · `UserPermissionsMapper` 전부 착지. users 에 `role`·`permissions_epoch` 컬럼 실재, 세션 epoch 전파(`refreshIfStale`) 동작.
|
||||
- **단 enforcement 갭(code-fact)**: 인터셉터는 `/admin/**` + `isAdmin` 만 검사. 권한 키 중 `CONTENT_MODERATE` 만 소비(canModerate, GameCommentController:203 / GameReviewController:422). **`POST_WRITE`·`GAME_JAM_MANAGE` 는 enum 선언만 있고 소비처 0건**.
|
||||
- → W3-3 POST_WRITE·W2-1 GAME_JAM_MANAGE 는 "자리 미비"가 아니라 **"키 실재 + enforcement 연결이 신규 작업"** 으로 정정. (참고: 기존 `RecruitController` POST 도 CSRF+세션userId만, POST_WRITE 미적용 — 현재 모든 로그인 유저 개방. W3-3 은 여기에 게이트를 *추가*하는 것.)
|
||||
|
||||
2. **skeleton 코드현황 #2 "review 테이블·tag 테이블 모두 없음 → W3-1·W3-2 구조 전부 신규"** → **리뷰 부분 정정: 실재(code-fact)**.
|
||||
- `game_reviews`(rating smallint CHECK 1~5 + body + nickname 스냅샷 + `is_rating_manual`) + `game_review_axes`(6축: immersion/creativity/controls/completeness/sound/visual, 리뷰당 6행) + `game_review_stats`(**테이블 아닌 VIEW**, 9컬럼 집계) + 4매퍼(GameReviewsMapper/GameReviewStatsMapper/GameReviewAxesMapper/GameCommentsMapper) 전부 착지. (tag 부분은 여전히 미존재 — W3-1 태그 구조는 신규 유효.)
|
||||
- → W3-1 리뷰수/점수 정렬 · W2-6 유저평점 트랙 · W4 리뷰어 배지는 이 **하이브리드(단일 overall + 6축) + VIEW 집계**를 소비. **단일 평균 가정 금지.** axes 0행 리뷰는 6축평균 NULL(백필 미적용).
|
||||
|
||||
> 안 뒤집힌 항목: 코드현황 #1(games 태그 컬럼 없음 — W3-1 태그 신규 유효), #4(index.jsp 잼 노출 없음 — W3-4 유효)는 그대로. **#5 부분 정정(2026-06-23 W3-5 조사)**: WebGL 업로드 부분구현은 유효하나 "`/game/**` 핸들러 미등록 → 서빙 미보장"은 **틀림** — `GameAssetController`(@GetMapping `/game/{uuid}/**`)가 정상 서빙(의도된 설계). W3-5 실 결함은 zip-slip 심링크 구멍·업로드 권한게이트 전무. (W3 skeletons QG-3 해소.)
|
||||
|
||||
---
|
||||
|
||||
## 코드 현황 (W2/W4 착수 전 확인된 결합점)
|
||||
|
||||
골자의 "결합/의존" 정확도를 위해 현 코드베이스를 대조했다(grounding 문서). W2/W4 가 얹히는 핵심 사실:
|
||||
|
||||
1. **권한 인프라(W1 산출)** — `user_permissions(user_id, permission_key)` **전역 권한 모델**. `scope/resource_id/jam_id` 컬럼 부재(schema.sql:326-347). `PermissionGate.has(session, key)` 리소스 인자 없음(PermissionGate.java:22). → **잼 회차별 역할은 그대로 안 얹힘(W2-2 스코프 갭)**. `GAME_JAM_MANAGE`·`POST_WRITE` 키는 선언만·소비처 0건.
|
||||
2. **리뷰/평점(W3-2 산출)** — 하이브리드: `game_reviews.rating`(1~5) + `game_review_axes`(6축, 리뷰당 6행). `game_review_stats` 는 **VIEW**(avg_rating/review_count/6축평균 9컬럼). 게임당 유저 1리뷰 활성유니크(game_id,user_id). axes 0행 → 6축평균 NULL. (W2-3/W2-6/W4 가 소비.)
|
||||
3. **games 엔티티** — 13컬럼(id/user_id FK/name/creator_note/git_url/webgl_path/thumbnail_url/like_count/is_visible/sort_order/created_at/updated_at/is_delete). **`jam_id`·방문수·`team_id` 모두 부재**(schema.sql:88-100). user_id 단일 FK = 1게임 1유저. (W2-1 이 재사용+확장.)
|
||||
4. **jams 테이블 전무** — `jam_id`·`CREATE TABLE jam` 0 hit. → W2 잼 엔티티 전부 신규.
|
||||
5. **game_likes** — `GameLikesMapper`(get/add/update, DELETE·토글·조회 없음) + `game_likes`(id/game_id FK/`user_key varchar(200)`/created_at, PK(id)만). **1인1표 UNIQUE(game_id,user_key) 부재 — 단 schema.sql 비권위 복원본이라 운영 DB 제약 미확인(추정)**. (W2-5 가 별개로 신설.)
|
||||
6. **배지/평판 구조 전무** — badge/reputation/trust_level 0 hit. → W4 전부 신규. 단 평판 신호 원천(리뷰 6축·좋아요·role·업로드)은 전부 실재.
|
||||
7. **관례** — 게시판=`RecruitController`(읽기=JSP 뷰이름 반환, 쓰기=ResponseEntity JSON + `CsrfTokens.isValid` + 세션 userId + 화이트리스트), RecruitPostsMapper **페이징 없음**(전건). 신규 DDL 권위 = **`docs/*-ddl.sql`**(apply-local-ddl.sh 멱등 적용), schema.sql 은 동기 사본. 매퍼 전부 `#{}`. 표시명=`users.display_name` 단일 출처. 세션 attr 에 `permissions(Set)`·`permsEpoch(long)` 포함.
|
||||
|
||||
---
|
||||
|
||||
## 기능별 골자
|
||||
|
||||
### W2-1 — 게임잼 엔티티 + 라이프사이클 ★크리티컬 패스 기점
|
||||
|
||||
- **목적**: 게임잼 운영·평가·시상의 토대 엔티티. 회차 독립(다중 인스턴스). 출품작 = 기존 `games` 재사용 + 잼 연결.
|
||||
- **핵심 동작**: `jams` 테이블(상태전이 모집→개발→평가→종료, 개발기간/평가기간 필드) + 관리자 잼 CRUD + 잼 목록/상세 페이지. 출품작 이중 노출(잼 전용 뷰 + 일반 게임 허브). 운영 표시 필드(Discord 링크/상금/후원사 — 표시만, 실지급 수동).
|
||||
- **결합/의존**:
|
||||
- C1: `jams` 전무 → 전부 신규 DDL. `docs/*-ddl.sql` 권위 선례(W1 의 docs/rbac-ddl.sql) 따름. — 코드사실(부재) + 해석(배치)
|
||||
- C2: games↔jam 연결 신규 — games 13컬럼에 `jam_id` 부재(schema.sql:88-100). 연결 방식(games.jam_id nullable 컬럼 vs 조인테이블 `jam_entries`)은 미결. — 코드사실 + 해석
|
||||
- C3: 관리자 잼 CRUD = `GAME_JAM_MANAGE` 게이트 소비. **키는 enum 선언만·소비처 0건(enforcement 갭, PermissionKeys.java:4-6)** → 이 키를 enforcement 에 연결하는 게 신규. 임시 role 직접 체크 금지(W1 게이트 위에 얹음). — 코드사실
|
||||
- C4: 잼 목록/상세 = `RecruitController` 패턴(읽기 JSP뷰, 쓰기 JSON+CSRF) 선례 따름(RecruitController.java:36,65,145). 단 RecruitPostsMapper 페이징 없음 → 잼/출품작 목록 페이징은 신규. — 코드사실 + 해석
|
||||
- C5: `games.team_id` 부재 = 1게임 1유저(schema.sql:89). 로드맵 "개인·팀 모두" 와 갭 — 팀 출품 지원 시 신규. — 코드사실
|
||||
- **미결질문** (윤곽 — 비해소):
|
||||
- Q1 games↔jam 연결: `games.jam_id` nullable 컬럼 추가 vs 조인테이블. 1게임 다중잼 출품 허용 여부.
|
||||
- Q2 팀 출품 범위: 개인만 우선 vs 팀 동시. 로드맵 "개인·팀 모두" 의 1차 구현 범위.
|
||||
- Q3 상태전이: 일정 기반 자동 전이 vs 관리자 수동. 기간 필드와 평가기간 게이트(W2-3·W2-5) 관계.
|
||||
- Q4 회차 동시성: 동시 진행 잼 복수 허용? (메인 허브 W3-4 "진행중 잼" 노출과 연동.)
|
||||
- Q5 출품 자격: 잼 기간 중 신규 출품만 vs 기존 게임 사후 연결 허용.
|
||||
- **후속 진입점**: design-advisor
|
||||
|
||||
### W2-2 — 심사위원 역할 권한 ★보안(권한)
|
||||
|
||||
- **목적**: W1 RBAC 위에 얹히는 잼 전용 역할. 심사위원만 심사 점수 입력 가능(W2-4 게이트).
|
||||
- **핵심 동작**: 잼 회차별 심사위원 지정(주체=잼 관리자), 심사위원 점수입력 게이트.
|
||||
- **결합/의존**:
|
||||
- C1: **★스코프 갭** — 현 권한모델 `user_permissions=(user_id, permission_key)` 전역, scope/jam_id 부재(schema.sql:326-347). `PermissionGate.has(session, key)` 리소스 인자 없음(PermissionGate.java:22). → "잼 회차별 역할"이 전역 모델에 그대로 안 얹힘. 스코프 확장 vs 별도 잼-역할 테이블 결정 선행. — 코드사실(전역모델) + 해석(스코프 갭)
|
||||
- C2: W2-1 잼 엔티티 선행(심사위원은 잼에 종속). — 해석
|
||||
- C3: 심사위원 지정 = `GAME_JAM_MANAGE`(잼 관리자) 게이트 위. — 해석
|
||||
- C4: enforcement 패턴은 `CONTENT_MODERATE`(canModerate) 선례 참고 가능하나 그건 전역 판정(GameCommentController:203) — 잼 스코프 판정은 신규 경로. — 코드사실 + 해석
|
||||
- **미결질문** (윤곽 — 비해소):
|
||||
- Q1 **★스코프 모델**: (a) user_permissions 에 scope/jam_id 컬럼 추가 + 게이트 시그니처 확장 (b) 별도 `jam_judges(jam_id, user_id)` 테이블 (c) 하이브리드. → 전체수준 QG 승격(아래 QG-W2-A).
|
||||
- Q2 심사위원 수/자격: 잼당 인원 제한? 일반유저/SUBADMIN/외부 누구나 지정 가능?
|
||||
- Q3 자기출품 충돌: 심사위원이 같은 잼 출품 가능? 충돌 회피 규칙.
|
||||
- Q4 역할 만료: 잼 종료 후 자동 회수 vs 잔존.
|
||||
- **후속 진입점**: design-advisor (W2-1 선행 + 스코프 결정)
|
||||
|
||||
### W2-3 — 잼 평가 통합설계 (스키마 동결) ⚠️결합 클러스터
|
||||
|
||||
> 범위 = **잼 평가만**(심사/투표/시상 스키마). 댓글/리뷰 스키마는 W3-2(이미 구현) — 동결 묶음 아님.
|
||||
|
||||
- **목적**: 심사 점수(W2-4)·잼 투표(W2-5)·시상 집계(W2-6) 스키마를 확정·동결. "W3 리뷰 평점 → 시상 유저평점 트랙" 단방향 집계 계약 + 평가기간 게이트 계약.
|
||||
- **핵심 동작**: 하류 3기능이 의존할 스키마·계약을 먼저 굳혀 재작업 차단(forward phase-gate §2.7 — 결합 클러스터).
|
||||
- **결합/의존**:
|
||||
- C1: W3-2 리뷰 집계가 **하이브리드**(overall rating 1~5 + 6축) + `game_review_stats` **VIEW**(9컬럼). → 시상 유저평점 트랙이 단일평균인지 6축 중 무엇인지 동결 계약이 정의해야 함. **단일평균 가정 금지**. — 코드사실(하이브리드/VIEW) + 해석
|
||||
- C2: axes 0행 리뷰 = 6축평균 NULL, 백필 미적용 → 시상 집계 NULL 처리 계약 필요. — 코드사실
|
||||
- C3: W2-4/W2-5/W2-6 전부 이 동결에 의존 → **동결 전 하류 착수 금지**. — 해석(phase-gate)
|
||||
- C4: 평가기간 게이트 = W2-1 잼 상태(평가기간 필드) 의존. — 해석
|
||||
- C5: 단방향 계약 — 시상이 `game_review_stats` VIEW 를 읽기만, 리뷰 도메인은 잼 무관(game_reviews FK 에 jam 없음). W3-2 골자 ③과 정합. — 코드사실
|
||||
- **미결질문** (윤곽 — 비해소):
|
||||
- Q1 **유저평점 트랙 소스**: avg_rating(overall) vs 6축 평균 vs 특정 축. 동결 계약의 핵심. → QG-W2-B.
|
||||
- Q2 평가기간 한정: 시상 대상 리뷰가 평가기간 작성분만 vs 전체. (리뷰는 잼 무관 상시 작성이므로 기간 필터 방식 결정.)
|
||||
- Q3 동결 범위: 심사점수·투표·시상 3스키마 + 단방향계약 + 기간게이트 중 이번 동결 경계.
|
||||
- Q4 NULL/미달 처리: 리뷰 0개·axes 0행 출품작의 유저평점 트랙 산정.
|
||||
- **후속 진입점**: design-advisor (W2-1 후, W2-4/5/6 선행 게이트)
|
||||
|
||||
### W2-4 — 심사위원 평가
|
||||
|
||||
- **목적**: 권한자(심사위원) 점수 입력 → 심사 트랙 집계.
|
||||
- **핵심 동작**: 심사위원이 출품작 점수 입력, 심사위원 대상 집계.
|
||||
- **결합/의존**:
|
||||
- C1: W2-3 동결 완료 + W2-2 심사위원 권한 의존(둘 다 선행). — 해석(phase-gate)
|
||||
- C2: 점수입력 게이트 = W2-2 스코프 역할(전역 아닌 잼별) → 스코프 결정(QG-W2-A) 종속. — 해석
|
||||
- C3: 집계는 `game_review_stats` VIEW 패턴 재사용 vs 잼 심사용 별도 집계 신규. — 해석
|
||||
- **미결질문** (윤곽 — 비해소):
|
||||
- Q1 점수 척도: 6축(리뷰 axes 재사용) vs 잼 전용 축 vs 단일. 리뷰 6축과 통합/분리.
|
||||
- Q2 집계 방식: 심사위원 평균 vs 가중. 동률 처리.
|
||||
- Q3 수정/재입력: 평가기간 내 수정 허용? 이력.
|
||||
- **후속 진입점**: design-advisor (W2-3 동결 후)
|
||||
|
||||
### W2-5 — 인기투표
|
||||
|
||||
- **목적**: 잼 전용 1인 1표, 평가기간 한정, `game_likes` 와 별개.
|
||||
- **핵심 동작**: 잼 출품작 투표(평가기간만), 1인 1표.
|
||||
- **결합/의존**:
|
||||
- C1: `game_likes` 와 별개(로드맵 명시). game_likes 자체가 1인1표 미보장(UNIQUE 부재 추정)·`user_key varchar`·토글 메서드 부재(GameLikesMapper.java:13-43) → **재활용으로 1인1표 자동 충족 안 됨**. 잼 투표 테이블 신규. — 코드사실(매퍼) + 추정(UNIQUE) + 해석
|
||||
- C2: ⚠️**추정 검증 필요** — game_likes 운영 DB UNIQUE 제약은 schema.sql 비권위 근거뿐(grounding concern). 잼 투표 1인1표는 신규 테이블에 UNIQUE 명시로 보장(설계 시). — 추정
|
||||
- C3: W2-3 동결 의존 + 평가기간 게이트(W2-1 상태). — 해석
|
||||
- C4: 투표 식별자 — 로그인 user_id 기반 1인1표(game_likes user_key varchar 모델과 다를 수 있음). — 해석
|
||||
- **미결질문** (윤곽 — 비해소):
|
||||
- Q1 투표 단위: 출품작당 1표(여러 작품 가능) vs 잼 전체 1표(최애 1개).
|
||||
- Q2 투표 자격: 로그인 유저만 vs 익명. 1인1표 식별자(user_id 가정).
|
||||
- Q3 결과 노출: 실시간 vs 평가기간 종료 후.
|
||||
- Q4 변경/취소: 투표 변경 허용?
|
||||
- **후속 진입점**: design-advisor (W2-3 동결 후)
|
||||
|
||||
### W2-6 — 시상 집계 / 결과 ★크리티컬 패스 종점
|
||||
|
||||
- **목적**: 3트랙(심사 / 유저평점 / 인기) 산정 → 수상 표시.
|
||||
- **핵심 동작**: 심사(W2-4)·유저평점(W3 리뷰 집계)·인기(W2-5) 3트랙 집계, 수상 표시.
|
||||
- **결합/의존**:
|
||||
- C1: 3트랙 = W2-4(심사) + `game_review_stats` VIEW(유저평점) + W2-5(인기) — 전부 의존. — 해석 + 코드사실(VIEW)
|
||||
- C2: 유저평점 트랙이 하이브리드 6축+overall 소비(W2-3 동결 Q1 종속). **단일평균 가정 금지**. — 코드사실(6축) + 해석
|
||||
- C3: NULL 처리 — 리뷰 0/axes 0행 출품작(백필 미적용). — 코드사실
|
||||
- C4: W2-3 동결 계약이 트랙 가중/합산 규칙 정의 선행. — 해석(phase-gate)
|
||||
- **미결질문** (윤곽 — 비해소):
|
||||
- Q1 트랙 가중: 3트랙 가중치 합산 vs 트랙별 개별 수상.
|
||||
- Q2 수상 카테고리: 대상/최우수/인기상 등 구조.
|
||||
- Q3 동점 처리.
|
||||
- Q4 결과 확정 시점·표시 위치(잼 상세 vs 별도 시상 페이지).
|
||||
- **후속 진입점**: design-advisor (W2-4/5 + W2-3 동결 후)
|
||||
|
||||
### W4 — 유저 배지 / 평판
|
||||
|
||||
- **목적**: 커뮤니티 기여 인정(권한 아님 — 평판/배지). 게임잼·RBAC 와 별개 도메인.
|
||||
- **핵심 동작**: 리뷰어 배지(리뷰 활동/품질 기반) + 기술자 배지(개발정보 공유·적극 업로더). 배지 부여·표시.
|
||||
- **결합/의존**:
|
||||
- C1: 배지/평판 스토리지 전무(0 hit) → 전부 신규 DDL. — 코드사실(부재)
|
||||
- C2: 평판 신호 원천 전부 실재 — 리뷰(6축+overall, game_reviews/stats VIEW) · 좋아요(game_likes) · 역할(users.role) · 업로드(games). 신규 배지 테이블이 이들을 집계 소비. — 코드사실 + 해석
|
||||
- C3: 리뷰어 배지 = game_reviews/game_review_stats 활동·품질 소비. "품질" 정의 미결. — 코드사실 + 해석
|
||||
- C4: 표시명 = `users.display_name` 단일 출처(schema.sql:32). 배지 표시는 유저 표면(프로필/리뷰 작성자/게임 카드)에 부착. — 코드사실
|
||||
- C5: 로드맵상 "활동 소스 존재 후" 착수 — 단 소스 이미 실재 → **W2 무관 독립 착수 가능**. — 해석
|
||||
- **미결질문** (윤곽 — 비해소):
|
||||
- Q1 배지 종류·기준: 리뷰어/기술자 외 추가? 부여 임계(리뷰 N개·품질 점수·업로드 M개).
|
||||
- Q2 부여 방식: 자동(임계 도달) vs 수동(운영자) vs 혼합. → QG-W4-A.
|
||||
- Q3 평판 점수: 배지(이산)만 vs 연속 평판 점수 병행.
|
||||
- Q4 "기술자" 명칭(임시) 확정.
|
||||
- Q5 회수/강등: 배지 회수 조건(부정 리뷰 등).
|
||||
- Q6 표시 위치: 프로필·리뷰 작성자·게임 카드 어디에.
|
||||
- **후속 진입점**: design-advisor
|
||||
|
||||
---
|
||||
|
||||
## 워크스트림 결합 요약
|
||||
|
||||
| 기능 | 결합 대상 | 결합 방식 | 신규/확장 | 독립 출시 |
|
||||
|---|---|---|---|---|
|
||||
| W2-1 잼 엔티티 | W1(GAME_JAM_MANAGE 미연결 키) + games(jam_id 부재) | 게이트 enforcement 연결 + games 확장 | jams 신규 + games 연결 | W1 후 |
|
||||
| W2-2 심사위원 역할 | W1(전역 권한모델) + W2-1 | **스코프 갭** — 모델 확장/별도테이블 결정 | 신규(스코프 모델) | W2-1 후 |
|
||||
| W2-3 평가 동결 | W3-2(하이브리드 리뷰+VIEW) + W2-4/5/6 | 동결 계약 + 단방향 집계 | 신규(동결) | 결합 클러스터(동결 게이트) |
|
||||
| W2-4 심사위원 평가 | W2-3 동결 + W2-2 권한 | 동결 후 + 스코프 게이트 | 신규 | W2-3 후 |
|
||||
| W2-5 인기투표 | W2-3 동결 + game_likes(별개, 1인1표 추정 갭) | 별개 신규 테이블 | 신규 | W2-3 후 |
|
||||
| W2-6 시상 집계 | W2-4/5 + game_review_stats VIEW | 3트랙 집계 | 신규 | W2-4/5 + 동결 후 |
|
||||
| W4 배지/평판 | 리뷰·좋아요·역할·업로드 소스(전부 실재) | 신규 배지가 소스 집계 소비 | 신규(소스 실재) | 독립(소스 실재) |
|
||||
|
||||
---
|
||||
|
||||
## 착수 순서 제언 (단정 아님 — 의존 사슬 기반)
|
||||
|
||||
> 크리티컬 패스(로드맵): W1(완료) → **W2-1 잼 엔티티 → W2-3 평가 동결 → W2-6 시상 집계**.
|
||||
|
||||
1. **W2-1 게임잼 엔티티** — jams 신규 + games 연결 + GAME_JAM_MANAGE enforcement 연결. 크리티컬 패스 기점. 다른 W2 전부의 선행.
|
||||
2. **W2-2 심사위원 역할** — W2-1 후. **스코프 모델 결정(QG-W2-A) 선행 필수**.
|
||||
3. **W2-3 잼 평가 동결** — ⚠️결합 클러스터 게이트. W2-4/5/6 착수 전 동결 필수(phase-gate). 유저평점 트랙 소스(QG-W2-B) 확정 동반.
|
||||
4. **W2-4 심사위원 평가 / W2-5 인기투표** — 동결 후 **병렬 가능**(독립 자원: 심사 vs 투표).
|
||||
5. **W2-6 시상 집계** — W2-4/5 + 동결 후. 크리티컬 패스 종점.
|
||||
6. **W4 배지/평판** — 평판 소스(리뷰/좋아요/역할/업로드) 전부 실재 → **W2 무관 독립 병렬 가능**. 단 배지 기준이 리뷰/잼 활동 정의에 의존하면 그 부분만 후순위.
|
||||
|
||||
---
|
||||
|
||||
## 전체 수준 미결질문 (풀설계 착수 전 확정 — QG)
|
||||
|
||||
> 이 QG 들은 골자 단계에서 해소하지 않는다. **풀설계 진입 시 AskUserQuestion 으로 확정**(사용자 제약).
|
||||
|
||||
- **QG-W2-A (★최우선 — 보안/권한)**: W2-2 잼 스코프 권한 모델 — (a) `user_permissions` scope/jam_id 컬럼 추가 + 게이트 시그니처 확장 (b) 별도 `jam_judges(jam_id, user_id)` 테이블 (c) 하이브리드. **W2-2·W2-4 전부 종속.** 현 권한 모델이 전역이라(code-fact) 잼별 역할이 그대로 안 얹힘.
|
||||
- **QG-W2-B**: W2-3 동결 — 시상 유저평점 트랙 소스(overall avg vs 6축 평균 vs 특정 축) + 평가기간 필터 방식. **W2-6 종속.** 리뷰가 하이브리드라(code-fact) 단일평균 가정 불가.
|
||||
- **QG-W2-C**: W2-1 games↔jam 연결 방식(games.jam_id 컬럼 vs 조인테이블) + 팀 출품 범위(games team_id 부재). **W2-1 핵심.**
|
||||
- **QG-W2-D (⚠️추정 검증)**: game_likes 운영 DB UNIQUE(game_id,user_key) 제약 — schema.sql 비권위라 미확인(grounding concern). W2-5 가 별개 신규 테이블이면 game_likes 제약과 무관하나, "별개" 판정의 근거가 되므로 운영 DB 확인 권장.
|
||||
- **QG-W4-A**: W4 배지 부여 기준·방식(자동 임계 vs 수동 vs 혼합) + "기술자" 명칭 확정.
|
||||
|
||||
> W3 측 기존 QG(QG-1 W1 게이트 착수 / QG-2 닉네임 레거시 / QG-3 `/game/**` 핸들러)는 W3 skeletons 에 잔존 — 본 W2/W4 와 직접 충돌 없음.
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **골자 합의** (현재) — 본 W2·W4 골자 + stale 정정 2건 합의.
|
||||
2. **각 W 미결질문 확정** — 풀설계 착수하는 기능의 *잔여* 미결 + 관련 QG 를 AskUserQuestion 으로 확정.
|
||||
3. **풀설계** — design-advisor 로 진입, W1-design.md 깊이(DDL/파일영향맵/API계약/시퀀스/AC매핑). 착수 순서대로(크리티컬 패스 우선) 또는 사용자 지정 기능부터.
|
||||
|
||||
**보안 명시점**(풀설계에서 전수 적용): W2-2 권한(잼 스코프 게이트) / W3-3 SSRF(OG 미리보기·유니티블로그 피드 — W3 측) / W3-5 zip-slip(W3 측). W2 상태변경 전수 CSRF + `#{}` 바인딩.
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
kind: work-log
|
||||
title: W2(게임잼) + W3(잔여) + W4 풀설계 — 통합 요약 / 설계 인덱스
|
||||
description: 남은 W 워크스트림 11개 기능(W2-1~6, W3-1/3-3/3-4/3-5, W4)의 풀설계 산출 인덱스 + 크로스-W 결정 매트릭스 + 교차정합 감사 verdict + 골자 stale 정정 3건. 설계 전용 세션(코드 0줄). 설계 본문은 work-session implementation/ 에, 본 문서는 진입 인덱스. 착수 순서·미해결(운영검증) 포함.
|
||||
status: active
|
||||
source_session: 20260623-104307
|
||||
resumed_from: 20260622-180054
|
||||
source_skeletons:
|
||||
- 2026-06-23-w2-w4-feature-skeletons.md
|
||||
- 2026-06-17-w3-feature-skeletons.md
|
||||
source_roadmap: 2026-06-17-jam-platform-roadmap.md
|
||||
created_at: 2026-06-23
|
||||
owner: art
|
||||
---
|
||||
|
||||
# W2 + W3(잔여) + W4 풀설계 — 통합 요약 / 설계 인덱스
|
||||
|
||||
설계 전용 세션(코드 0줄, src/·pom.xml 무변경). 골자 → 미결 확정(정석 결정) → 풀설계 단계를 거쳐 **11개 기능 풀설계**를 산출했다. 각 설계는 W1-design.md 깊이(목표/DDL/API계약/시퀀스/파일영향맵/대안비교/롤아웃/AC매핑/검증포인트), **오픈질문 0**.
|
||||
|
||||
> 설계 본문: `.atp/work-session/20260623-104307/implementation/<W>-*.md`. 코드 grounding: `.atp/work-session/20260623-104307/research/`. 교차정합 감사: `.atp/work-session/20260623-104307/implementation/_cross-consistency-audit.md`.
|
||||
|
||||
## 미결질문 처리 방식 (이 세션 한정)
|
||||
|
||||
사용자가 "전체 설계를 개입 없이 직접, 빠른길 아닌 **확실하고 정석적**으로" 지시 → orchestrator 가 골자 QG/기능별 미결을 **정석 기준으로 직접 확정**해 design 에 주입(AskUserQuestion 면제, 명시 위임). 각 결정은 해당 설계의 "대안 비교" 표에 근거 보존 → 사용자가 설계도를 리뷰해 사후 override 가능.
|
||||
|
||||
## 설계 인덱스 (11)
|
||||
|
||||
| W | 설계 문서 | 핵심 결정(정석) | 신규 테이블/뷰 | 보안 |
|
||||
|---|---|---|---|---|
|
||||
| W2-1 잼 엔티티/라이프사이클 | W2-1-jam-entity-design.md | 조인테이블 jam_entries(다회차·정규화) + 개인/팀 XOR + 명시 status+감사 전이 + GAME_JAM_MANAGE enforcement 연결 + keyset 페이징 | jams, jam_teams, jam_team_members, jam_entries, jam_status_log | CSRF·GAME_JAM_MANAGE 게이트 |
|
||||
| W2-2 심사위원 역할 | W2-2-judge-role-design.md | **별도 jam_judges**(전역 RBAC 불변, 잼 스코프 게이트) + JamRoleGate.isJudge + 자기출품 충돌규칙 | jam_judges | ★잼 스코프 권한 게이트 |
|
||||
| W2-3 잼 평가 동결 ⚠️클러스터 권위 | W2-3-eval-freeze-design.md | 동결 스키마 + **평가단위=(jam_id,game_id) 활성 자연키** + 유저평점=avg_rating 단방향(기간/최소리뷰수 임계·NULLS LAST) + 평가기간 게이트 계약 | jam_criteria, jam_scores, jam_votes, jam_awards, jam_score_stats(VIEW) | 평가기간 게이트·VIEW alias |
|
||||
| W2-4 심사위원 평가 | W2-4-judge-scoring-design.md | criterion별 UPSERT + 가중집계 + 3중게이트(isJudge·평가기간·자기출품) | (W2-3 소비, 0) | isJudge+기간+CSRF |
|
||||
| W2-5 인기투표 | W2-5-popular-vote-design.md | jam_votes 1인1표 UNIQUE(잼당 최애1) + 평가기간 게이트 + 종료후 공개(밴드왜건 회피) | (W2-3 소비, 0) | 로그인+기간+UNIQUE+CSRF |
|
||||
| W2-6 시상 집계 | W2-6-award-aggregation-design.md | 3트랙(심사/유저평점/인기) 개별수상 + 가중 GRAND + NULL/임계 제외 + CLOSED 확정 멱등 | (W2-3 jam_awards 소비) | 산정 권한·VIEW alias |
|
||||
| W3-1 태그+검색 | W3-1-tags-search-design.md | 통합 tags+조인 + 운영자/유저생성 하이브리드(sanitize+금칙어) + game_views(dedupe)+view_count + 검색 확장(다중태그 AND/OR·개발자·정렬키) | tags, game_tags, jam_tags, game_views (+games.view_count) | 태그 sanitize/금칙어·CONTENT_MODERATE 게이트·#{} |
|
||||
| W3-3 포스팅 보드 | W3-3-posting-board-design.md | posts/categories + **POST_WRITE enforcement 연결** + commonmark+jsoup sanitize + **공용 SsrfSafeFetcher(9항목)** OG+유니티피드 + @Scheduled 폴링 dedupe | post_categories, posts, unity_feed_sources, unity_feed_items | ★SSRF 9항목·마크다운 sanitize·POST_WRITE·CSRF |
|
||||
| W3-4 메인 허브 | W3-4-main-hub-design.md | keyset 페이징(검색·비검색 단일화) + 진행중 잼 배너→W3-1 `GET /games/search?jam={slug}` 라우팅 + 단계 분리 | (없음, GamesMapper 확장) | 파라미터 #{}·escape |
|
||||
| W3-5 Unity 업로드 | W3-5-upload-design.md | **zip-slip canonical+심링크 거부+zip bomb** + 포맷검증(wasm/data/loader) + 권한게이트 + UUID 원자교체. QG-3 정정 반영(/game/** 정상 서빙) | game_upload_audit_log | ★zip-slip·경로 boundary·권한게이트 |
|
||||
| W4 유저 배지/평판 | W4-badges-design.md | badges/user_badges/reputation_events + 자동임계+수동 하이브리드 + **BADGE_MANAGE 신규키** + 표시(프로필/리뷰/카드) | badges, user_badges, reputation_events (+PermissionKeys BADGE_MANAGE) | 부여/회수 CSRF+BADGE_MANAGE·멱등 UNIQUE |
|
||||
|
||||
## 크로스-W 결정 매트릭스 (정석 확정값)
|
||||
|
||||
| 결정축 | 확정 | 영향 W | 근거(정석) |
|
||||
|---|---|---|---|
|
||||
| 잼 스코프 권한 (QG-W2-A) | 별도 jam_judges 테이블 | W2-2/4 | 잼 회차별 역할 = 잼 스코프 조인. 전역 user_permissions(RBAC) 오염 0 |
|
||||
| 평가 단위 | (jam_id, game_id) 활성 자연키 | W2-1/3/4/5/6 | orchestrator 동결 컬럼 일치 + game_review_stats(game_id) join 정합 + entrant 종류 불투명 유지. jam_entries active-UNIQUE 가 1:1 보장(jam_entries.id surrogate FK 아님) |
|
||||
| 유저평점 트랙 (QG-W2-B) | overall avg_rating | W2-3/6 | game_review_stats VIEW 기존 노출. 6축은 표시전용. 최소 review_count 임계 미달 제외·NULLS LAST |
|
||||
| 잼 연결 (QG-W2-C1) | 조인테이블 jam_entries | W2-1 | 정규화·다회차 출품·games 무변경(허브 회귀 0) |
|
||||
| 팀 출품 (QG-W2-C2) | 1차 포함(jam_teams) | W2-1 | 잼은 팀 이벤트 본질. XOR 로 개인/팀 정합 |
|
||||
| 인기투표 단위 | 잼당 1인1표 최애(UNIQUE(jam_id,voter_user_id)) | W2-5 | game_likes(user_key varchar·비권위 UNIQUE) 재활용 안 함. 로그인 user_id 기반 |
|
||||
| 권한키 | GAME_JAM_MANAGE/POST_WRITE/CONTENT_MODERATE(W1) + **BADGE_MANAGE 신규**. 태그=CONTENT_MODERATE 재사용 | W2-1/2, W3-1/3, W4 | POST_WRITE·GAME_JAM_MANAGE = W1 선언키 enforcement 연결(소비처 0→연결 신규). BADGE_MANAGE 만 신규(권한 분리) — **W1 AC-T1 카탈로그 카운트 3→4 갱신 필요** |
|
||||
| 외부 fetch (SSRF) | 공용 SsrfSafeFetcher 9항목 | W3-3 | OG 미리보기+유니티피드 단일화. scheme allowlist·사설IP차단·DNS rebinding·redirect 재검증·size/timeout |
|
||||
| DDL 권위 | docs/<feature>-ddl.sql 멱등 + schema.sql 동기 | 전 W | W1 docs/rbac-ddl.sql 선례. apply-local-ddl.sh 알파벳 멱등 적용 |
|
||||
|
||||
## 교차정합 감사 verdict
|
||||
|
||||
`_cross-consistency-audit.md` (general-purpose opus). **동결 단일권위(W2-3) 유지, 하류 W2-4/5/6 소비 정합 PASS.**
|
||||
- HIGH 1건 → **해소**: W2-1 본문 "평가단위=jam_entries.id" 표현 drift(DDL 은 정합) → (jam_id,game_id) 자연키로 5개소 정정.
|
||||
- MED 0. LOW 3(게이트 순서 표현차·vote mapper 시그니처·경로 예시차 — 전부 동결 허용범위/무해).
|
||||
- Clean: 컬럼 정합·중복 DDL 없음(W2-5 jam_votes 는 참조 인용)·권한키 정합·cross-W 라우트·보안 존재성(잼게이트/SSRF/zip-slip 실재·비자명).
|
||||
|
||||
## 골자 stale 정정 3건 (착지 코드 대비)
|
||||
|
||||
1. **RBAC 인프라 실재** (W3 skeletons #3 "Interceptor 없음" 뒤집힘) — 단 enforcement 갭(POST_WRITE·GAME_JAM_MANAGE 키 선언만, 소비처 0). W2-1/W3-3 이 연결.
|
||||
2. **리뷰 인프라 실재** (W3 skeletons #2 "review 테이블 없음" 뒤집힘) — 하이브리드(overall + 6축) + game_review_stats VIEW. W2-3/6·W3-1·W4 가 소비, 단일평균 가정 금지.
|
||||
3. **/game/** 서빙 정상** (W3 skeletons QG-3 해소) — GameAssetController 가 전용 처리(버그 아님). W3-5 실 결함은 zip-slip 심링크·업로드 권한게이트 전무로 재조준.
|
||||
|
||||
## 착수 순서 (크리티컬 패스 + 병렬)
|
||||
|
||||
크리티컬 패스: W1(완료) → **W2-1 → W2-3 동결 → W2-6**.
|
||||
1. W2-1 잼 엔티티(jams/entries/teams + GAME_JAM_MANAGE 연결)
|
||||
2. W2-2 심사위원(jam_judges) — W2-1 후
|
||||
3. W2-3 동결 — ⚠️클러스터 게이트, W2-4/5/6 착수 전 필수
|
||||
4. W2-4 / W2-5 — 동결 후 병렬
|
||||
5. W2-6 시상 — W2-4/5 후
|
||||
6. 독립 병렬: W3-1(태그검색) · W3-3(포스팅) · W4(배지) — W2 무관. W3-4(허브)는 W3-1 후. W3-5(업로드)는 독립.
|
||||
|
||||
## 미해결 / 운영 검증 필요 (설계로 닫지 못한 것)
|
||||
|
||||
- **game_likes 운영 DB UNIQUE 제약** — schema.sql 비권위라 추정(grounding concern). W2-5 는 신규 jam_votes 로 무관하나, game_likes 정합은 별도 운영 DB 확인 권장.
|
||||
- **구현 단계 점검 항목**(각 설계 concerns): 신규 매퍼 @MockBean full-test(§30), DB-방언 alias(§33), 시그니처 inflate(§11.2), W3-3 pom 의존(commonmark/jsoup)·@EnableScheduling, W4 의 W1 AC-T1 카운트 3→4 재측정, SSRF rebinding 라이브러리 제약 단위테스트.
|
||||
- 이 미결들은 **설계 오픈질문이 아니라 구현/운영 단계 게이트** — 각 설계 concerns 에 이관됨.
|
||||
|
||||
## 다음 단계
|
||||
|
||||
설계 합의 후 착수 순서대로 implementation-advisor 진입(코드·마이그레이션). 착수 기능의 concerns(구현 점검) 를 verification-strategies L레벨로 통과시킨다. **이 세션은 설계까지 — 코드 0줄.**
|
||||
|
||||
## 구현 추적
|
||||
|
||||
- **W2 (W2-1~6) 구현 완료** — 2026-06-24, commit `ccf1e42`(W2-1)~`a74bf74`(W2-6), 6 feat. 최종 L1 190/190 GREEN(회귀 0) + L2 dev DB contract 전 PASS(격리 throwaway DB). 변경 이력: [changes/2026-06-24-w2-jam-platform.md](../changes/2026-06-24-w2-jam-platform.md). dev DB 마이그레이션·L3 스모크는 needs_user_verification.
|
||||
- **W3(잔여)·W4 (W3-1·W3-4·W3-3·W3-5·W4) 구현 완료** — 2026-06-29(session 20260629-100849), commit `35f1dc3`(W3-1)~`305cc73`(W4), 5 feat. W3-2(댓글/리뷰)는 선행 완료. 최종 L1 347/347 GREEN(baseline 190→347, +157, 회귀 0) + L2 dev DB contract 전 PASS(격리 throwaway DB) + adversarial SSRF/zip-slip 감사(W3-3 vacuous SSRF 포착→option-b 전환). 변경 이력: [changes/2026-06-29-w3-w4-features.md](../changes/2026-06-29-w3-w4-features.md). 실 dev DB DDL 8종 일괄 적용·L3 스모크·W4 표시 deferral(게임카드 creator 배지)은 needs_user_verification.
|
||||
- **풀설계 11기능 전부 구현 완료** (W2-1~6 + W3-1/3/4/5 + W4). W3-2 는 별도 선행. 잔여 미구현 설계 0.
|
||||
|
|
@ -5,4 +5,6 @@
|
|||
## 목록
|
||||
|
||||
- [2026-06-17-jam-platform-roadmap.md](./2026-06-17-jam-platform-roadmap.md) — bibimbap 플랫폼 고도화 목적별 워크스트림 로드맵. 4개 W(W1 거버넌스/RBAC · W2 게임잼 · W3 사이트플랫폼 · W4 유저배지)+운영으로 재구조. 게임잼은 W2 하나. 의존성(빌드순서) 유지 + 잼일정 분리가능 여부 명시. S→W 매핑 + 150635 대비 교정 5건 포함. `status: active`, source_session: 20260617-150635, restructured_by: 20260617-162536.
|
||||
- [2026-06-17-w3-feature-skeletons.md](./2026-06-17-w3-feature-skeletons.md) — W3(사이트 플랫폼) 5개 서브기능 골자 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점(윤곽 깊이, 미결 해소 안 함). 코드 현황 대조(tag/review 테이블 신규·Interceptor 미구현·`/game/**` 핸들러 미등록). 결합표 + 착수순서 제언(W3-2 1순위) + QG-1~3. 당장 구현 아님. `status: confirmed`(20260617-174635 세션서 5기능+착수순서 항목별 합의), source_session: 20260617-172407.
|
||||
- [2026-06-17-w3-feature-skeletons.md](./2026-06-17-w3-feature-skeletons.md) — W3(사이트 플랫폼) 5개 서브기능 골자 카탈로그. 기능당 목적·핵심동작·결합/의존·미결질문·후속진입점(윤곽 깊이, 미결 해소 안 함). 코드 현황 대조(tag/review 테이블 신규·Interceptor 미구현·`/game/**` 핸들러 미등록). 결합표 + 착수순서 제언(W3-2 1순위) + QG-1~3. 당장 구현 아님. `status: confirmed`(20260617-174635 세션서 5기능+착수순서 항목별 합의), source_session: 20260617-172407. ⚠️ **코드 현황 #2·#3 stale (2026-06-23 정정)** — w2-w4-skeletons 참조.
|
||||
- [2026-06-23-w2-w4-full-design-summary.md](./2026-06-23-w2-w4-full-design-summary.md) — **W2+W3(잔여)+W4 풀설계 통합 요약/설계 인덱스**. 11기능(W2-1~6·W3-1/3-3/3-4/3-5·W4) 풀설계(W1-design 깊이, 오픈질문 0) 진입 인덱스 + 크로스-W 결정 매트릭스(정석 확정) + 교차정합 감사 verdict(HIGH 1 해소·동결 단일권위 유지) + 골자 stale 정정 3건 + 착수순서. 설계 본문은 `.atp/work-session/20260623-104307/implementation/`. 설계 전용(코드 0줄). `status: active`, source_session: 20260623-104307, resumed_from: 20260622-180054.
|
||||
- [2026-06-23-w2-w4-feature-skeletons.md](./2026-06-23-w2-w4-feature-skeletons.md) — W2(게임잼 6서브: 잼엔티티/심사위원역할/평가동결/심사평가/인기투표/시상집계) + W4(유저 배지/평판) 골자 카탈로그. W3 skeletons 포맷(목적·핵심동작·결합/의존[code-fact vs 해석 태깅]·미결질문[비해소]·후속진입점) + 결합표 + 착수순서 + QG-W2-A~D·W4-A. **W3 skeletons "코드 현황" stale 2건 정정**(RBAC 게이트·리뷰 인프라 실재 — 단 POST_WRITE/GAME_JAM_MANAGE enforcement 미연결, 리뷰는 하이브리드 6축+VIEW). code grounding: `.atp/work-session/20260623-104307/research/W2-W4-grounding.md`. `status: draft`(합의 전), source_session: 20260623-104307.
|
||||
|
|
|
|||
10
pom.xml
10
pom.xml
|
|
@ -84,6 +84,16 @@
|
|||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark</artifactId>
|
||||
<version>0.22.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.17.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package com.pandoli365.bibimbap.badge;
|
||||
|
||||
public enum BadgeKeys {
|
||||
REVIEWER("리뷰어", "REVIEW_WRITTEN", 10),
|
||||
TECHNICIAN("테크니션", "GAME_UPLOADED", 3);
|
||||
|
||||
private final String displayName;
|
||||
private final String eventType;
|
||||
private final int threshold;
|
||||
|
||||
BadgeKeys(String displayName, String eventType, int threshold) {
|
||||
this.displayName = displayName;
|
||||
this.eventType = eventType;
|
||||
this.threshold = threshold;
|
||||
}
|
||||
|
||||
public String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public String eventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public int threshold() {
|
||||
return threshold;
|
||||
}
|
||||
|
||||
public static boolean isValid(String key) {
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
valueOf(key);
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
package com.pandoli365.bibimbap.badge;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.pandoli365.bibimbap.mapper.ReputationEventsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserBadgesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
|
||||
@Service
|
||||
public class BadgeService {
|
||||
|
||||
private final ReputationEventsMapper reputationEventsMapper;
|
||||
private final UserBadgesMapper userBadgesMapper;
|
||||
private final UsersMapper usersMapper;
|
||||
|
||||
public BadgeService(ReputationEventsMapper reputationEventsMapper,
|
||||
UserBadgesMapper userBadgesMapper,
|
||||
UsersMapper usersMapper) {
|
||||
this.reputationEventsMapper = reputationEventsMapper;
|
||||
this.userBadgesMapper = userBadgesMapper;
|
||||
this.usersMapper = usersMapper;
|
||||
}
|
||||
|
||||
// 자동부여: 해당 배지의 평판 누적이 임계 도달 시 멱등 부여.
|
||||
// 자동 경로의 badgeKey 는 항상 코드 상수(ReputationService)라 정상엔 무효 키가 도달하지 않으나, 방어적으로 무시한다.
|
||||
public void evaluateAndSync(long userId, String badgeKey) {
|
||||
if (!BadgeKeys.isValid(badgeKey)) {
|
||||
return;
|
||||
}
|
||||
BadgeKeys badge = BadgeKeys.valueOf(badgeKey);
|
||||
long count = reputationEventsMapper.countActive(userId, badge.eventType());
|
||||
if (count >= badge.threshold()) {
|
||||
// ux_user_badges_active ON CONFLICT DO NOTHING (멱등)
|
||||
userBadgesMapper.insertIgnore(userId, badgeKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 수동 부여: 컨트롤러가 CSRF+BADGE_MANAGE 게이트 통과 후 호출. 도메인 검증(키유효/대상존재/중복)만 수행.
|
||||
public GrantResult grantManual(long userId, String badgeKey, long awardedBy) {
|
||||
if (!BadgeKeys.isValid(badgeKey)) {
|
||||
return GrantResult.INVALID_BADGE;
|
||||
}
|
||||
if (usersMapper.getUser(userId) == null) {
|
||||
return GrantResult.USER_NOT_FOUND;
|
||||
}
|
||||
if (userBadgesMapper.existsActive(userId, badgeKey)) {
|
||||
return GrantResult.ALREADY_ACTIVE;
|
||||
}
|
||||
userBadgesMapper.insert(userId, badgeKey, awardedBy);
|
||||
return GrantResult.GRANTED;
|
||||
}
|
||||
|
||||
// 수동 회수: revoked_at+reason 설정. 활성 미보유면 NOT_ACTIVE. 키 유효성 선검사.
|
||||
public RevokeResult revokeManual(long userId, String badgeKey, String reason) {
|
||||
if (!BadgeKeys.isValid(badgeKey)) {
|
||||
return RevokeResult.INVALID_BADGE;
|
||||
}
|
||||
int affected = userBadgesMapper.revoke(userId, badgeKey, reason);
|
||||
return affected > 0 ? RevokeResult.REVOKED : RevokeResult.NOT_ACTIVE;
|
||||
}
|
||||
|
||||
public enum GrantResult {
|
||||
GRANTED,
|
||||
ALREADY_ACTIVE,
|
||||
INVALID_BADGE,
|
||||
USER_NOT_FOUND
|
||||
}
|
||||
|
||||
public enum RevokeResult {
|
||||
REVOKED,
|
||||
NOT_ACTIVE,
|
||||
INVALID_BADGE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.pandoli365.bibimbap.badge;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.pandoli365.bibimbap.mapper.ReputationEventsMapper;
|
||||
|
||||
@Service
|
||||
public class ReputationService {
|
||||
|
||||
private final ReputationEventsMapper reputationEventsMapper;
|
||||
private final BadgeService badgeService;
|
||||
|
||||
public ReputationService(ReputationEventsMapper reputationEventsMapper, BadgeService badgeService) {
|
||||
this.reputationEventsMapper = reputationEventsMapper;
|
||||
this.badgeService = badgeService;
|
||||
}
|
||||
|
||||
// 평판 이벤트 기록 + 해당 event_type 에 매핑된 배지 임계 재평가.
|
||||
// best-effort try/catch 는 호출자(컨트롤러 훅, 설계 S1)가 담당하므로 여기선 감싸지 않는다.
|
||||
// 단 evaluateAndSync 실패가 insertIgnore 를 무효화하지 않도록 순서: insert 먼저 → evaluate.
|
||||
public void record(long userId, String eventType, String sourceRef) {
|
||||
reputationEventsMapper.insertIgnore(userId, eventType, sourceRef);
|
||||
// event_type 1:1 매핑(REVIEW_WRITTEN→REVIEWER, GAME_UPLOADED→TECHNICIAN)에 해당하는 배지 재평가.
|
||||
for (BadgeKeys badge : BadgeKeys.values()) {
|
||||
if (badge.eventType().equals(eventType)) {
|
||||
badgeService.evaluateAndSync(userId, badge.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.pandoli365.bibimbap.config;
|
||||
|
||||
import com.pandoli365.bibimbap.badge.BadgeKeys;
|
||||
import com.pandoli365.bibimbap.mapper.BadgesMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class BadgeCatalogSeeder implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BadgeCatalogSeeder.class);
|
||||
|
||||
private final BadgesMapper badgesMapper;
|
||||
|
||||
public BadgeCatalogSeeder(BadgesMapper badgesMapper) {
|
||||
this.badgesMapper = badgesMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
// 시드(멱등): enum 전체 순회 — insertIgnore 로 신규 키만 삽입, 기존 행 미덮어쓰기
|
||||
for (BadgeKeys badge : BadgeKeys.values()) {
|
||||
badgesMapper.insertIgnore(badge.name(), badge.displayName(), descriptionFor(badge), badge.name());
|
||||
}
|
||||
|
||||
log.info("[BADGE] 배지 카탈로그 시드 완료: {} 종", BadgeKeys.values().length);
|
||||
}
|
||||
|
||||
private String descriptionFor(BadgeKeys badge) {
|
||||
return switch (badge) {
|
||||
case REVIEWER -> "리뷰 " + badge.threshold() + "건 이상 작성 시 자동 부여";
|
||||
case TECHNICIAN -> "게임 " + badge.threshold() + "개 이상 업로드 시 자동 부여";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.pandoli365.bibimbap.config;
|
||||
|
||||
import com.pandoli365.bibimbap.security.RbacInterceptor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class InterceptorConfig implements WebMvcConfigurer {
|
||||
|
||||
private final RbacInterceptor rbacInterceptor;
|
||||
|
||||
public InterceptorConfig(RbacInterceptor rbacInterceptor) {
|
||||
this.rbacInterceptor = rbacInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 잼 관리(/admin/jams/**)는 ADMIN 전용 인터셉터 대신 JamAdminController 의 GAME_JAM_MANAGE 게이트 헬퍼로 보호한다(SUBADMIN+키 통과 허용, D4-A).
|
||||
registry.addInterceptor(rbacInterceptor)
|
||||
.addPathPatterns("/admin/**")
|
||||
.excludePathPatterns("/admin/jams/**");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.pandoli365.bibimbap.config;
|
||||
|
||||
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class PermissionCatalogVerifier implements ApplicationRunner {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PermissionCatalogVerifier.class);
|
||||
|
||||
private final PermissionsMapper permissionsMapper;
|
||||
|
||||
public PermissionCatalogVerifier(PermissionsMapper permissionsMapper) {
|
||||
this.permissionsMapper = permissionsMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
// 시드(멱등): enum 전체 순회 — 멤버 추가 시 자동 동기되는 불변식
|
||||
for (PermissionKeys key : PermissionKeys.values()) {
|
||||
permissionsMapper.upsert(key.name(), key.displayName());
|
||||
}
|
||||
|
||||
// 불일치 경고: DB 활성 키 중 코드 enum 에 없는 것 탐지
|
||||
Set<String> codeKeys = Arrays.stream(PermissionKeys.values())
|
||||
.map(PermissionKeys::name)
|
||||
.collect(Collectors.toSet());
|
||||
List<String> dbKeys = permissionsMapper.listActiveKeys();
|
||||
if (dbKeys != null) {
|
||||
for (String dbKey : dbKeys) {
|
||||
if (!codeKeys.contains(dbKey)) {
|
||||
log.warn("[RBAC] DB permissions 에 코드 enum 에 없는 활성 권한 키 발견: {} — 코드 PermissionKeys 와 불일치", dbKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[RBAC] 권한 카탈로그 시드 완료: {} 키", PermissionKeys.values().length);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package com.pandoli365.bibimbap.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class SchedulingConfig {
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.OperatorView;
|
||||
import com.pandoli365.bibimbap.data.UserData;
|
||||
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import com.pandoli365.bibimbap.security.Roles;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class AdminConsoleController {
|
||||
|
||||
private final UsersMapper usersMapper;
|
||||
private final UserPermissionsMapper userPermissionsMapper;
|
||||
private final RbacAuditMapper auditMapper;
|
||||
private final PermissionsMapper permissionsMapper;
|
||||
|
||||
public AdminConsoleController(UsersMapper usersMapper,
|
||||
UserPermissionsMapper userPermissionsMapper,
|
||||
RbacAuditMapper auditMapper,
|
||||
PermissionsMapper permissionsMapper) {
|
||||
this.usersMapper = usersMapper;
|
||||
this.userPermissionsMapper = userPermissionsMapper;
|
||||
this.auditMapper = auditMapper;
|
||||
this.permissionsMapper = permissionsMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/admin/console")
|
||||
public String console(Model model, HttpServletRequest request) {
|
||||
model.addAttribute("operators", buildOperators());
|
||||
model.addAttribute("catalog", permissionsMapper.listActive());
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||
return "admin-console";
|
||||
}
|
||||
|
||||
@PostMapping("/admin/users/{userId}/appoint")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> appoint(@PathVariable("userId") long userId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
UserData target = usersMapper.getUser(userId);
|
||||
if (target == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (!Roles.USER.equals(target.getRole())) {
|
||||
return response(HttpStatus.CONFLICT, "이미 운영진이거나 승격 대상이 아닙니다.");
|
||||
}
|
||||
Long actorId = sessionUserId(session);
|
||||
usersMapper.updateRole(userId, Roles.SUBADMIN);
|
||||
usersMapper.bumpPermissionsEpoch(userId);
|
||||
auditMapper.insert(actorId, userId, "APPOINT", null);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "운영진으로 임명했습니다.");
|
||||
body.put("userId", userId);
|
||||
body.put("role", Roles.SUBADMIN);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/users/{userId}/permissions/{permissionKey}/toggle")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> togglePermission(@PathVariable("userId") long userId,
|
||||
@PathVariable("permissionKey") String permissionKey,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
if (!PermissionKeys.isValid(permissionKey)) {
|
||||
return response(HttpStatus.NOT_FOUND, "존재하지 않는 권한 키입니다.");
|
||||
}
|
||||
UserData target = usersMapper.getUser(userId);
|
||||
if (target == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (!Roles.SUBADMIN.equals(target.getRole())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "권한은 SUBADMIN 에게만 부여할 수 있습니다.");
|
||||
}
|
||||
Long actorId = sessionUserId(session);
|
||||
boolean granted;
|
||||
if (userPermissionsMapper.exists(userId, permissionKey)) {
|
||||
userPermissionsMapper.delete(userId, permissionKey);
|
||||
granted = false;
|
||||
} else {
|
||||
userPermissionsMapper.insert(userId, permissionKey, actorId);
|
||||
granted = true;
|
||||
}
|
||||
usersMapper.bumpPermissionsEpoch(userId);
|
||||
auditMapper.insert(actorId, userId, granted ? "GRANT" : "REVOKE", permissionKey);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", granted ? "권한을 부여했습니다." : "권한을 회수했습니다.");
|
||||
body.put("userId", userId);
|
||||
body.put("permissionKey", permissionKey);
|
||||
body.put("granted", granted);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/users/{userId}/demote")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> demote(@PathVariable("userId") long userId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
UserData target = usersMapper.getUser(userId);
|
||||
if (target == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (!Roles.SUBADMIN.equals(target.getRole())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "강등 대상이 아닙니다.");
|
||||
}
|
||||
Long actorId = sessionUserId(session);
|
||||
int revokedCount = userPermissionsMapper.deleteAllByUser(userId);
|
||||
usersMapper.updateRole(userId, Roles.USER);
|
||||
usersMapper.bumpPermissionsEpoch(userId);
|
||||
auditMapper.insert(actorId, userId, "DEMOTE", null);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "운영진에서 해임했습니다.");
|
||||
body.put("userId", userId);
|
||||
body.put("role", Roles.USER);
|
||||
body.put("revokedCount", revokedCount);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/admin/operators")
|
||||
public ResponseEntity<Map<String, Object>> operators() {
|
||||
List<OperatorView> ops = buildOperators();
|
||||
List<Map<String, Object>> operatorList = new ArrayList<>();
|
||||
for (OperatorView op : ops) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("userId", op.getUserId());
|
||||
entry.put("displayName", op.getDisplayName());
|
||||
entry.put("role", op.getRole());
|
||||
entry.put("permissions", op.getPermissionKeys());
|
||||
operatorList.add(entry);
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("operators", operatorList);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private List<OperatorView> buildOperators() {
|
||||
List<OperatorView> ops = usersMapper.listOperators();
|
||||
for (OperatorView op : ops) {
|
||||
op.setPermissionKeys(userPermissionsMapper.listKeys(op.getUserId()));
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.badge.BadgeService;
|
||||
import com.pandoli365.bibimbap.badge.BadgeService.GrantResult;
|
||||
import com.pandoli365.bibimbap.badge.BadgeService.RevokeResult;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
/**
|
||||
* /manage/** 는 인터셉터 미등록 경로이므로 진입부에서 CSRF·PermissionGate 게이트를 직접 호출한다.
|
||||
*/
|
||||
@Controller
|
||||
public class BadgeManageController {
|
||||
|
||||
private final BadgeService badgeService;
|
||||
private final PermissionGate permissionGate;
|
||||
|
||||
public BadgeManageController(BadgeService badgeService, PermissionGate permissionGate) {
|
||||
this.badgeService = badgeService;
|
||||
this.permissionGate = permissionGate;
|
||||
}
|
||||
|
||||
@PostMapping("/manage/users/{userId}/badges/{badgeKey}/grant")
|
||||
@ResponseBody
|
||||
public ResponseEntity<Map<String, Object>> grant(
|
||||
@PathVariable("userId") long userId,
|
||||
@PathVariable("badgeKey") String badgeKey,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
if (!permissionGate.isAuthenticated(session)) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
Long actorId = sessionUserId(session);
|
||||
if (actorId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
|
||||
GrantResult r = badgeService.grantManual(userId, badgeKey, actorId);
|
||||
switch (r) {
|
||||
case INVALID_BADGE:
|
||||
return response(HttpStatus.NOT_FOUND, "존재하지 않는 배지입니다.");
|
||||
case USER_NOT_FOUND:
|
||||
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
|
||||
case ALREADY_ACTIVE:
|
||||
return response(HttpStatus.CONFLICT, "이미 보유한 배지입니다.");
|
||||
case GRANTED:
|
||||
default:
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "배지를 부여했습니다.");
|
||||
body.put("userId", userId);
|
||||
body.put("badgeKey", badgeKey);
|
||||
body.put("awarded", true);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/manage/users/{userId}/badges/{badgeKey}/revoke")
|
||||
@ResponseBody
|
||||
public ResponseEntity<Map<String, Object>> revoke(
|
||||
@PathVariable("userId") long userId,
|
||||
@PathVariable("badgeKey") String badgeKey,
|
||||
@RequestParam(name = "reason", required = false) String reason,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
if (!permissionGate.isAuthenticated(session)) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
String trimmedReason = (reason == null) ? null : reason.trim();
|
||||
if (trimmedReason == null || trimmedReason.isBlank()) {
|
||||
return response(HttpStatus.BAD_REQUEST, "회수 사유를 입력해 주세요.");
|
||||
}
|
||||
|
||||
RevokeResult r = badgeService.revokeManual(userId, badgeKey, trimmedReason);
|
||||
switch (r) {
|
||||
case INVALID_BADGE:
|
||||
return response(HttpStatus.NOT_FOUND, "존재하지 않는 배지입니다.");
|
||||
case NOT_ACTIVE:
|
||||
return response(HttpStatus.NOT_FOUND, "회수할 활성 배지가 없습니다.");
|
||||
case REVOKED:
|
||||
default:
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "배지를 회수했습니다.");
|
||||
body.put("userId", userId);
|
||||
body.put("badgeKey", badgeKey);
|
||||
body.put("revoked", true);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.jam.JamLifecycle;
|
||||
import com.pandoli365.bibimbap.jam.JamSlugs;
|
||||
import com.pandoli365.bibimbap.jam.JamStatus;
|
||||
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class JamAdminController {
|
||||
|
||||
private static final int MAX_TITLE = 200;
|
||||
private static final int MAX_DESCRIPTION = 5000;
|
||||
private static final int MAX_DISCORD_URL = 500;
|
||||
private static final int MAX_PRIZE_INFO = 5000;
|
||||
private static final int MAX_SPONSOR_INFO = 5000;
|
||||
private static final int MAX_SLUG_ATTEMPTS = 20;
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final JamStatusLogMapper jamStatusLogMapper;
|
||||
private final PermissionGate gate;
|
||||
private final JamLifecycle jamLifecycle;
|
||||
|
||||
public JamAdminController(JamsMapper jamsMapper,
|
||||
JamStatusLogMapper jamStatusLogMapper,
|
||||
PermissionGate gate,
|
||||
JamLifecycle jamLifecycle) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.jamStatusLogMapper = jamStatusLogMapper;
|
||||
this.gate = gate;
|
||||
this.jamLifecycle = jamLifecycle;
|
||||
}
|
||||
|
||||
@GetMapping("/admin/jams")
|
||||
public String console(Model model, HttpServletRequest request, HttpSession session) {
|
||||
if (!gate.isAuthenticated(session)) {
|
||||
return "redirect:/login";
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) {
|
||||
return "redirect:/";
|
||||
}
|
||||
model.addAttribute("jams", jamsMapper.listAllForAdmin());
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||
return "admin-jam-list";
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> create(
|
||||
@RequestParam(name = "title", required = false) String title,
|
||||
@RequestParam(name = "description", required = false) String description,
|
||||
@RequestParam(name = "recruitStartAt", required = false) String recruitStartAt,
|
||||
@RequestParam(name = "devStartAt", required = false) String devStartAt,
|
||||
@RequestParam(name = "evalStartAt", required = false) String evalStartAt,
|
||||
@RequestParam(name = "evalEndAt", required = false) String evalEndAt,
|
||||
@RequestParam(name = "discordUrl", required = false) String discordUrl,
|
||||
@RequestParam(name = "prizeInfo", required = false) String prizeInfo,
|
||||
@RequestParam(name = "sponsorInfo", required = false) String sponsorInfo,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
String normalizedTitle = trimToNull(title);
|
||||
if (normalizedTitle == null || normalizedTitle.length() > MAX_TITLE) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "제목을 200자 이내로 입력해 주세요.");
|
||||
}
|
||||
String normalizedDescription = trimToNull(description);
|
||||
if (normalizedDescription != null && normalizedDescription.length() > MAX_DESCRIPTION) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "상세 설명이 너무 깁니다.");
|
||||
}
|
||||
String normalizedDiscordUrl = trimToNull(discordUrl);
|
||||
if (normalizedDiscordUrl != null && normalizedDiscordUrl.length() > MAX_DISCORD_URL) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "디스코드 URL이 너무 깁니다.");
|
||||
}
|
||||
String normalizedPrizeInfo = trimToNull(prizeInfo);
|
||||
if (normalizedPrizeInfo != null && normalizedPrizeInfo.length() > MAX_PRIZE_INFO) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "시상 정보가 너무 깁니다.");
|
||||
}
|
||||
String normalizedSponsorInfo = trimToNull(sponsorInfo);
|
||||
if (normalizedSponsorInfo != null && normalizedSponsorInfo.length() > MAX_SPONSOR_INFO) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "후원 정보가 너무 깁니다.");
|
||||
}
|
||||
|
||||
OffsetDateTime recruitStart;
|
||||
OffsetDateTime devStart;
|
||||
OffsetDateTime evalStart;
|
||||
OffsetDateTime evalEnd;
|
||||
try {
|
||||
recruitStart = parseOffset(recruitStartAt);
|
||||
devStart = parseOffset(devStartAt);
|
||||
evalStart = parseOffset(evalStartAt);
|
||||
evalEnd = parseOffset(evalEndAt);
|
||||
} catch (DateTimeParseException e) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "기간 형식을 확인해 주세요.");
|
||||
}
|
||||
if (evalStart != null && evalEnd != null && evalStart.isAfter(evalEnd)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "평가 종료가 시작보다 빠를 수 없습니다.");
|
||||
}
|
||||
|
||||
Long actorId = sessionUserId(session);
|
||||
JamData jam = new JamData();
|
||||
jam.setTitle(normalizedTitle);
|
||||
jam.setDescription(normalizedDescription);
|
||||
jam.setRecruitStartAt(recruitStart);
|
||||
jam.setDevStartAt(devStart);
|
||||
jam.setEvalStartAt(evalStart);
|
||||
jam.setEvalEndAt(evalEnd);
|
||||
jam.setDiscordUrl(normalizedDiscordUrl);
|
||||
jam.setPrizeInfo(normalizedPrizeInfo);
|
||||
jam.setSponsorInfo(normalizedSponsorInfo);
|
||||
jam.setIsVisible(true);
|
||||
jam.setStatus(JamStatus.RECRUIT.name());
|
||||
jam.setCreatedBy(actorId);
|
||||
|
||||
String baseSlug = JamSlugs.generate(normalizedTitle);
|
||||
boolean inserted = false;
|
||||
jam.setSlug(baseSlug);
|
||||
// slug 활성 유니크(ux_jams_slug_active) 충돌 시 -2, -3, ... 접미사로 재시도
|
||||
for (int attempt = 1; attempt <= MAX_SLUG_ATTEMPTS && !inserted; attempt++) {
|
||||
if (attempt > 1) {
|
||||
jam.setSlug(JamSlugs.withSuffix(baseSlug, attempt));
|
||||
}
|
||||
try {
|
||||
jamsMapper.insertJam(jam);
|
||||
inserted = true;
|
||||
} catch (DuplicateKeyException e) {
|
||||
jam.setId(null);
|
||||
}
|
||||
}
|
||||
if (!inserted || jam.getId() == null) {
|
||||
return response(HttpStatus.INTERNAL_SERVER_ERROR, "게임잼 식별자 생성에 실패했습니다.");
|
||||
}
|
||||
|
||||
jamStatusLogMapper.insert(jam.getId(), null, JamStatus.RECRUIT.name(), actorId, "MANUAL");
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "게임잼을 생성했습니다.");
|
||||
body.put("jamId", jam.getId());
|
||||
body.put("slug", jam.getSlug());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> update(
|
||||
@PathVariable("jamId") long jamId,
|
||||
@RequestParam(name = "title", required = false) String title,
|
||||
@RequestParam(name = "description", required = false) String description,
|
||||
@RequestParam(name = "recruitStartAt", required = false) String recruitStartAt,
|
||||
@RequestParam(name = "devStartAt", required = false) String devStartAt,
|
||||
@RequestParam(name = "evalStartAt", required = false) String evalStartAt,
|
||||
@RequestParam(name = "evalEndAt", required = false) String evalEndAt,
|
||||
@RequestParam(name = "discordUrl", required = false) String discordUrl,
|
||||
@RequestParam(name = "prizeInfo", required = false) String prizeInfo,
|
||||
@RequestParam(name = "sponsorInfo", required = false) String sponsorInfo,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String normalizedTitle = trimToNull(title);
|
||||
if (normalizedTitle == null || normalizedTitle.length() > MAX_TITLE) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "제목을 200자 이내로 입력해 주세요.");
|
||||
}
|
||||
String normalizedDescription = trimToNull(description);
|
||||
if (normalizedDescription != null && normalizedDescription.length() > MAX_DESCRIPTION) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "상세 설명이 너무 깁니다.");
|
||||
}
|
||||
String normalizedDiscordUrl = trimToNull(discordUrl);
|
||||
if (normalizedDiscordUrl != null && normalizedDiscordUrl.length() > MAX_DISCORD_URL) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "디스코드 URL이 너무 깁니다.");
|
||||
}
|
||||
String normalizedPrizeInfo = trimToNull(prizeInfo);
|
||||
if (normalizedPrizeInfo != null && normalizedPrizeInfo.length() > MAX_PRIZE_INFO) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "시상 정보가 너무 깁니다.");
|
||||
}
|
||||
String normalizedSponsorInfo = trimToNull(sponsorInfo);
|
||||
if (normalizedSponsorInfo != null && normalizedSponsorInfo.length() > MAX_SPONSOR_INFO) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "후원 정보가 너무 깁니다.");
|
||||
}
|
||||
|
||||
OffsetDateTime recruitStart;
|
||||
OffsetDateTime devStart;
|
||||
OffsetDateTime evalStart;
|
||||
OffsetDateTime evalEnd;
|
||||
try {
|
||||
recruitStart = parseOffset(recruitStartAt);
|
||||
devStart = parseOffset(devStartAt);
|
||||
evalStart = parseOffset(evalStartAt);
|
||||
evalEnd = parseOffset(evalEndAt);
|
||||
} catch (DateTimeParseException e) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "기간 형식을 확인해 주세요.");
|
||||
}
|
||||
if (evalStart != null && evalEnd != null && evalStart.isAfter(evalEnd)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "평가 종료가 시작보다 빠를 수 없습니다.");
|
||||
}
|
||||
|
||||
jam.setId(jamId);
|
||||
jam.setTitle(normalizedTitle);
|
||||
jam.setDescription(normalizedDescription);
|
||||
jam.setRecruitStartAt(recruitStart);
|
||||
jam.setDevStartAt(devStart);
|
||||
jam.setEvalStartAt(evalStart);
|
||||
jam.setEvalEndAt(evalEnd);
|
||||
jam.setDiscordUrl(normalizedDiscordUrl);
|
||||
jam.setPrizeInfo(normalizedPrizeInfo);
|
||||
jam.setSponsorInfo(normalizedSponsorInfo);
|
||||
jamsMapper.updateJam(jam);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "게임잼을 수정했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/status")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> changeStatus(
|
||||
@PathVariable("jamId") long jamId,
|
||||
@RequestParam("toStatus") String toStatus,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
JamStatus to = JamStatus.from(toStatus);
|
||||
if (to == null) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "알 수 없는 상태입니다.");
|
||||
}
|
||||
JamStatus from = JamStatus.from(jam.getStatus());
|
||||
if (!jamLifecycle.isAllowed(from, to)) {
|
||||
return response(HttpStatus.CONFLICT, "허용되지 않는 전이입니다.");
|
||||
}
|
||||
if (!jamLifecycle.isPeriodReady(to, jam)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "전이에 필요한 기간 필드가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
Long actorId = sessionUserId(session);
|
||||
jamsMapper.updateStatus(jamId, to.name());
|
||||
jamStatusLogMapper.insert(jamId, jam.getStatus(), to.name(), actorId, "MANUAL");
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "상태를 전이했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
body.put("jamStatus", to.name());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/visibility")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> toggleVisibility(
|
||||
@PathVariable("jamId") long jamId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
boolean next = !Boolean.TRUE.equals(jam.getIsVisible());
|
||||
jamsMapper.updateVisibility(jamId, next);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", next ? "게임잼을 노출했습니다." : "게임잼을 숨겼습니다.");
|
||||
body.put("jamId", jamId);
|
||||
body.put("visible", next);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/delete")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> delete(
|
||||
@PathVariable("jamId") long jamId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
jamsMapper.softDelete(jamId);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "게임잼을 삭제했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> requireJamManage(HttpSession session) {
|
||||
if (!gate.isAuthenticated(session)) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
private OffsetDateTime parseOffset(String raw) {
|
||||
String text = trimToNull(raw);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
return OffsetDateTime.parse(text);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.jam.JamAwardService;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class JamAwardAdminController {
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final PermissionGate gate;
|
||||
private final JamAwardService jamAwardService;
|
||||
|
||||
public JamAwardAdminController(JamsMapper jamsMapper,
|
||||
PermissionGate gate,
|
||||
JamAwardService jamAwardService) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.gate = gate;
|
||||
this.jamAwardService = jamAwardService;
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/awards/compute")
|
||||
@ResponseBody
|
||||
public ResponseEntity<Map<String, Object>> computeAwards(
|
||||
@PathVariable("jamId") long jamId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
boolean evalEnded = jam.getEvalEndAt() != null && OffsetDateTime.now().isAfter(jam.getEvalEndAt());
|
||||
boolean computable = "CLOSED".equals(jam.getStatus()) || evalEnded;
|
||||
if (!computable) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "시상 산정 가능 상태가 아닙니다.");
|
||||
}
|
||||
|
||||
Map<String, Integer> awardCounts = jamAwardService.recompute(jam.getId());
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "시상을 산정했습니다.");
|
||||
body.put("jamId", jam.getId());
|
||||
body.put("awardCounts", awardCounts);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> requireJamManage(HttpSession session) {
|
||||
if (!gate.isAuthenticated(session)) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamAwardData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class JamAwardController {
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final JamAwardsMapper jamAwardsMapper;
|
||||
|
||||
public JamAwardController(JamsMapper jamsMapper, JamAwardsMapper jamAwardsMapper) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.jamAwardsMapper = jamAwardsMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/jams/{slug}/results")
|
||||
public String results(@PathVariable("slug") String slug, Model model) {
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null || Boolean.FALSE.equals(jam.getIsVisible())) {
|
||||
return "redirect:/jams";
|
||||
}
|
||||
|
||||
List<JamAwardData> awards = jamAwardsMapper.listByJamWithGame(jam.getId());
|
||||
Map<String, List<JamAwardData>> byTrack = new LinkedHashMap<>();
|
||||
for (JamAwardData award : awards) {
|
||||
byTrack.computeIfAbsent(award.getAwardTrack(), track -> new ArrayList<>()).add(award);
|
||||
}
|
||||
|
||||
model.addAttribute("jam", jam);
|
||||
model.addAttribute("byTrack", byTrack);
|
||||
model.addAttribute("grand", byTrack.getOrDefault("GRAND", List.of()));
|
||||
return "jam-results";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.JamEntryData;
|
||||
import com.pandoli365.bibimbap.data.JamTeamData;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class JamController {
|
||||
|
||||
private static final int PAGE_SIZE = 20;
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final JamEntriesMapper jamEntriesMapper;
|
||||
private final JamTeamsMapper jamTeamsMapper;
|
||||
private final JamTeamMembersMapper jamTeamMembersMapper;
|
||||
private final GamesMapper gamesMapper;
|
||||
private final JamAwardsMapper jamAwardsMapper;
|
||||
|
||||
public JamController(
|
||||
JamsMapper jamsMapper,
|
||||
JamEntriesMapper jamEntriesMapper,
|
||||
JamTeamsMapper jamTeamsMapper,
|
||||
JamTeamMembersMapper jamTeamMembersMapper,
|
||||
GamesMapper gamesMapper,
|
||||
JamAwardsMapper jamAwardsMapper
|
||||
) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.jamEntriesMapper = jamEntriesMapper;
|
||||
this.jamTeamsMapper = jamTeamsMapper;
|
||||
this.jamTeamMembersMapper = jamTeamMembersMapper;
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.jamAwardsMapper = jamAwardsMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/jams")
|
||||
public String list(@RequestParam(name = "cursor", required = false) String cursor, Model model) {
|
||||
OffsetDateTime cursorCreatedAt = null;
|
||||
Long cursorId = null;
|
||||
if (cursor != null && !cursor.isBlank()) {
|
||||
int separator = cursor.lastIndexOf('_');
|
||||
if (separator > 0 && separator < cursor.length() - 1) {
|
||||
try {
|
||||
cursorCreatedAt = OffsetDateTime.parse(cursor.substring(0, separator));
|
||||
cursorId = Long.parseLong(cursor.substring(separator + 1));
|
||||
} catch (RuntimeException e) {
|
||||
cursorCreatedAt = null;
|
||||
cursorId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<JamData> jams = jamsMapper.listVisibleKeyset(cursorCreatedAt, cursorId, PAGE_SIZE + 1);
|
||||
boolean hasNext = jams.size() > PAGE_SIZE;
|
||||
if (hasNext) {
|
||||
jams = jams.subList(0, PAGE_SIZE);
|
||||
}
|
||||
String nextCursor = null;
|
||||
if (hasNext) {
|
||||
JamData lastJam = jams.get(jams.size() - 1);
|
||||
nextCursor = lastJam.getCreatedAt().toString() + "_" + lastJam.getId();
|
||||
}
|
||||
|
||||
model.addAttribute("jams", jams);
|
||||
model.addAttribute("nextCursor", nextCursor);
|
||||
return "jam-list";
|
||||
}
|
||||
|
||||
@GetMapping("/jams/{slug}")
|
||||
public String detail(@PathVariable("slug") String slug, Model model, HttpServletRequest request) {
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null || Boolean.FALSE.equals(jam.getIsVisible())) {
|
||||
return "redirect:/jams";
|
||||
}
|
||||
model.addAttribute("jam", jam);
|
||||
model.addAttribute("entries", jamEntriesMapper.listByJam(jam.getId()));
|
||||
model.addAttribute("teams", jamTeamsMapper.listByJam(jam.getId()));
|
||||
model.addAttribute("awardsSummary", jamAwardsMapper.listByJamWithGame(jam.getId()));
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||
return "jam-detail";
|
||||
}
|
||||
|
||||
@PostMapping("/jams/{slug}/entries")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> submitEntry(
|
||||
@PathVariable("slug") String slug,
|
||||
@RequestParam("gameId") long gameId,
|
||||
@RequestParam(name = "jamTeamId", required = false) Long jamTeamId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
if (!"RECRUIT".equals(jam.getStatus()) && !"DEV".equals(jam.getStatus())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "지금은 출품할 수 없는 상태입니다.");
|
||||
}
|
||||
GameData game = gamesMapper.getGame(gameId);
|
||||
if (game == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
|
||||
}
|
||||
if (jamEntriesMapper.exists(jam.getId(), gameId)) {
|
||||
return response(HttpStatus.CONFLICT, "이미 출품된 게임입니다.");
|
||||
}
|
||||
|
||||
String entrantType;
|
||||
Long entrantUserId;
|
||||
if (jamTeamId == null) {
|
||||
entrantType = "USER";
|
||||
if (!userId.equals(game.getUserId())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "본인 게임만 출품할 수 있습니다.");
|
||||
}
|
||||
entrantUserId = userId;
|
||||
} else {
|
||||
entrantType = "TEAM";
|
||||
if (!jamTeamMembersMapper.exists(jamTeamId, userId)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "팀 멤버만 출품할 수 있습니다.");
|
||||
}
|
||||
entrantUserId = null;
|
||||
}
|
||||
|
||||
JamEntryData entry = new JamEntryData();
|
||||
entry.setJamId(jam.getId());
|
||||
entry.setGameId(gameId);
|
||||
entry.setEntrantType(entrantType);
|
||||
entry.setEntrantUserId(entrantUserId);
|
||||
entry.setJamTeamId(jamTeamId);
|
||||
try {
|
||||
jamEntriesMapper.insert(entry);
|
||||
} catch (DuplicateKeyException e) {
|
||||
return response(HttpStatus.CONFLICT, "이미 출품된 게임입니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("entryId", entry.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/jams/{slug}/teams")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> submitTeam(
|
||||
@PathVariable("slug") String slug,
|
||||
@RequestParam("name") String name,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
if (!"RECRUIT".equals(jam.getStatus()) && !"DEV".equals(jam.getStatus())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "지금은 출품할 수 없는 상태입니다.");
|
||||
}
|
||||
String normalizedName = trimToNull(name);
|
||||
if (normalizedName == null || normalizedName.length() > 120) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "팀 이름을 120자 이내로 입력해 주세요.");
|
||||
}
|
||||
|
||||
JamTeamData team = new JamTeamData();
|
||||
team.setJamId(jam.getId());
|
||||
team.setName(normalizedName);
|
||||
team.setOwnerUserId(userId);
|
||||
jamTeamsMapper.insert(team);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("jamTeamId", team.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/jams/{slug}/teams/{teamId}/members")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> addMember(
|
||||
@PathVariable("slug") String slug,
|
||||
@PathVariable("teamId") long teamId,
|
||||
@RequestParam(name = "userId") long memberUserId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long actorId = sessionUserId(session);
|
||||
if (actorId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
JamTeamData team = jamTeamsMapper.getById(teamId);
|
||||
if (team == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "팀을 찾을 수 없습니다.");
|
||||
}
|
||||
if (!actorId.equals(team.getOwnerUserId())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "팀장만 멤버를 추가할 수 있습니다.");
|
||||
}
|
||||
if (jamTeamMembersMapper.exists(teamId, memberUserId)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "이미 팀 멤버입니다.");
|
||||
}
|
||||
try {
|
||||
jamTeamMembersMapper.insert(teamId, memberUserId);
|
||||
} catch (DuplicateKeyException e) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "이미 팀 멤버입니다.");
|
||||
}
|
||||
return response(HttpStatus.OK, "팀 멤버가 추가되었습니다.");
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.JamJudgeData;
|
||||
import com.pandoli365.bibimbap.data.UserData;
|
||||
import com.pandoli365.bibimbap.mapper.JamJudgesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class JamJudgeAdminController {
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final UsersMapper usersMapper;
|
||||
private final JamJudgesMapper jamJudgesMapper;
|
||||
private final PermissionGate gate;
|
||||
|
||||
public JamJudgeAdminController(JamsMapper jamsMapper,
|
||||
UsersMapper usersMapper,
|
||||
JamJudgesMapper jamJudgesMapper,
|
||||
PermissionGate gate) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.usersMapper = usersMapper;
|
||||
this.jamJudgesMapper = jamJudgesMapper;
|
||||
this.gate = gate;
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/judges")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> assign(
|
||||
@PathVariable("jamId") long jamId,
|
||||
@RequestParam("userId") long userId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임잼을 찾을 수 없습니다.");
|
||||
}
|
||||
UserData target = usersMapper.getUser(userId);
|
||||
if (target == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다.");
|
||||
}
|
||||
if (jamJudgesMapper.exists(jamId, userId)) {
|
||||
return response(HttpStatus.CONFLICT, "이미 심사위원입니다.");
|
||||
}
|
||||
|
||||
long actorId = sessionUserId(session);
|
||||
try {
|
||||
jamJudgesMapper.insert(jamId, userId, actorId);
|
||||
} catch (DuplicateKeyException e) {
|
||||
// ux_jam_judges_jam_user 경합 — exists 통과 후 동시 insert 시 409로 수렴
|
||||
return response(HttpStatus.CONFLICT, "이미 심사위원입니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "심사위원을 지정했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
body.put("userId", userId);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/jams/{jamId}/judges/{userId}/remove")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> remove(
|
||||
@PathVariable("jamId") long jamId,
|
||||
@PathVariable("userId") long userId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
int affected = jamJudgesMapper.delete(jamId, userId);
|
||||
if (affected == 0) {
|
||||
return response(HttpStatus.NOT_FOUND, "지정된 심사위원이 아닙니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("message", "심사위원을 해제했습니다.");
|
||||
body.put("jamId", jamId);
|
||||
body.put("userId", userId);
|
||||
body.put("removed", true);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/admin/jams/{jamId}/judges")
|
||||
public ResponseEntity<Map<String, Object>> list(
|
||||
@PathVariable("jamId") long jamId,
|
||||
HttpSession session) {
|
||||
ResponseEntity<Map<String, Object>> denied = requireJamManage(session);
|
||||
if (denied != null) {
|
||||
return denied;
|
||||
}
|
||||
|
||||
List<JamJudgeData> judges = jamJudgesMapper.listByJam(jamId);
|
||||
List<Map<String, Object>> items = new ArrayList<>(judges.size());
|
||||
for (JamJudgeData judge : judges) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("userId", judge.getUserId());
|
||||
item.put("displayName", judge.getDisplayName());
|
||||
item.put("assignedBy", judge.getAssignedBy());
|
||||
item.put("createdAt", judge.getCreatedAt());
|
||||
items.add(item);
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", HttpStatus.OK.value());
|
||||
body.put("judges", items);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> requireJamManage(HttpSession session) {
|
||||
if (!gate.isAuthenticated(session)) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamCriterionData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.JamScoreData;
|
||||
import com.pandoli365.bibimbap.jam.JamEvalWindow;
|
||||
import com.pandoli365.bibimbap.mapper.JamCriteriaMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamScoresMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.JamRoleGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
public class JamScoringController {
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final JamEntriesMapper jamEntriesMapper;
|
||||
private final JamCriteriaMapper jamCriteriaMapper;
|
||||
private final JamScoresMapper jamScoresMapper;
|
||||
private final JamScoreStatsMapper jamScoreStatsMapper;
|
||||
private final JamRoleGate jamRoleGate;
|
||||
private final PermissionGate permissionGate;
|
||||
|
||||
public JamScoringController(
|
||||
JamsMapper jamsMapper,
|
||||
JamEntriesMapper jamEntriesMapper,
|
||||
JamCriteriaMapper jamCriteriaMapper,
|
||||
JamScoresMapper jamScoresMapper,
|
||||
JamScoreStatsMapper jamScoreStatsMapper,
|
||||
JamRoleGate jamRoleGate,
|
||||
PermissionGate permissionGate
|
||||
) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.jamEntriesMapper = jamEntriesMapper;
|
||||
this.jamCriteriaMapper = jamCriteriaMapper;
|
||||
this.jamScoresMapper = jamScoresMapper;
|
||||
this.jamScoreStatsMapper = jamScoreStatsMapper;
|
||||
this.jamRoleGate = jamRoleGate;
|
||||
this.permissionGate = permissionGate;
|
||||
}
|
||||
|
||||
@RequestMapping(value = "/jams/{jamId}/games/{gameId}/scores",
|
||||
method = {RequestMethod.POST, RequestMethod.PUT})
|
||||
@ResponseBody
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> submitScores(
|
||||
@PathVariable long jamId,
|
||||
@PathVariable long gameId,
|
||||
@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
// 게이트 1 — CSRF (난제1 순서: 가장 먼저)
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
// 게이트 2 — 인증
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
// 게이트 3 — 심사 자격 (W2-2)
|
||||
if (!jamRoleGate.isJudge(session, jamId)) {
|
||||
return response(HttpStatus.FORBIDDEN, "심사 권한이 없습니다.");
|
||||
}
|
||||
// 게이트 4 — 잼 존재
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
// 게이트 5 — 평가 기간 (W2-3 F6)
|
||||
if (!JamEvalWindow.isOpen(jam, OffsetDateTime.now())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "평가 기간이 아닙니다.");
|
||||
}
|
||||
// 게이트 6 — 출품작 존재
|
||||
if (!jamEntriesMapper.exists(jamId, gameId)) {
|
||||
return response(HttpStatus.NOT_FOUND, "출품작을 찾을 수 없습니다.");
|
||||
}
|
||||
// 게이트 7 — 자기 출품작 차단 (W2-2)
|
||||
if (jamRoleGate.isOwnEntry(jamId, gameId, userId)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "자기 출품작은 심사할 수 없습니다.");
|
||||
}
|
||||
|
||||
// 게이트 8 — body 파싱 + 전수 검증 (통과 후에만 UPSERT, 부분 저장 금지)
|
||||
Object rawScores = body == null ? null : body.get("scores");
|
||||
if (!(rawScores instanceof List<?> scoreList) || scoreList.isEmpty()) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "입력된 점수가 없습니다.");
|
||||
}
|
||||
|
||||
Set<String> allowedKeys = new HashSet<>();
|
||||
for (JamCriterionData criterion : jamCriteriaMapper.listByJam(jamId)) {
|
||||
allowedKeys.add(criterion.getCriterionKey());
|
||||
}
|
||||
|
||||
// 검증과 변환을 먼저 전수 수행 — 1건이라도 실패하면 upsert 미호출
|
||||
Map<String, Integer> validated = new LinkedHashMap<>();
|
||||
for (Object element : scoreList) {
|
||||
if (!(element instanceof Map<?, ?> entry)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "점수 항목 형식이 올바르지 않습니다.");
|
||||
}
|
||||
String criterionKey = String.valueOf(entry.get("criterionKey"));
|
||||
if (!allowedKeys.contains(criterionKey)) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "등록되지 않은 평가 기준입니다.");
|
||||
}
|
||||
Integer score = parseScore(entry.get("score"));
|
||||
if (score == null || score < 1 || score > 5) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "점수는 1~5 사이여야 합니다.");
|
||||
}
|
||||
validated.put(criterionKey, score);
|
||||
}
|
||||
|
||||
int savedCount = 0;
|
||||
for (Map.Entry<String, Integer> e : validated.entrySet()) {
|
||||
jamScoresMapper.upsertScore(jamId, gameId, userId, e.getKey(), e.getValue());
|
||||
savedCount++;
|
||||
}
|
||||
|
||||
Map<String, Object> success = new LinkedHashMap<>();
|
||||
success.put("status", 200);
|
||||
success.put("message", "점수가 저장되었습니다.");
|
||||
success.put("jamId", jamId);
|
||||
success.put("gameId", gameId);
|
||||
success.put("savedCount", savedCount);
|
||||
return ResponseEntity.ok(success);
|
||||
}
|
||||
|
||||
@GetMapping("/jams/{jamId}/games/{gameId}/scores/mine")
|
||||
@ResponseBody
|
||||
public ResponseEntity<Map<String, Object>> myScores(
|
||||
@PathVariable long jamId,
|
||||
@PathVariable long gameId,
|
||||
HttpSession session
|
||||
) {
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!jamRoleGate.isJudge(session, jamId)) {
|
||||
return response(HttpStatus.FORBIDDEN, "심사 권한이 없습니다.");
|
||||
}
|
||||
|
||||
List<JamScoreData> mine = jamScoresMapper.listByJudge(jamId, gameId, userId);
|
||||
List<JamCriterionData> criteria = jamCriteriaMapper.listByJam(jamId);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("status", 200);
|
||||
result.put("scores", mine);
|
||||
result.put("criteria", criteria);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/jams/{jamId}/scores/summary")
|
||||
@ResponseBody
|
||||
public ResponseEntity<Map<String, Object>> summary(
|
||||
@PathVariable long jamId,
|
||||
HttpSession session
|
||||
) {
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
boolean evalEnded = jam.getEvalEndAt() != null
|
||||
&& OffsetDateTime.now().isAfter(jam.getEvalEndAt());
|
||||
boolean publiclyVisible = "CLOSED".equals(jam.getStatus()) || evalEnded;
|
||||
if (!publiclyVisible
|
||||
&& !permissionGate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "집계는 평가 종료 후 공개됩니다.");
|
||||
}
|
||||
|
||||
List<Map<String, Object>> stats = jamScoreStatsMapper.listStatsByJam(jamId);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("status", 200);
|
||||
result.put("stats", stats);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/jams/{jamId}/games/{gameId}/score")
|
||||
public String scoringForm(
|
||||
@PathVariable long jamId,
|
||||
@PathVariable long gameId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session,
|
||||
Model model
|
||||
) {
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return "redirect:/login";
|
||||
}
|
||||
if (!jamRoleGate.isJudge(session, jamId)) {
|
||||
return "redirect:/jams";
|
||||
}
|
||||
JamData jam = jamsMapper.getById(jamId);
|
||||
if (jam == null) {
|
||||
return "redirect:/jams";
|
||||
}
|
||||
|
||||
model.addAttribute("jam", jam);
|
||||
model.addAttribute("jamId", jamId);
|
||||
model.addAttribute("gameId", gameId);
|
||||
model.addAttribute("criteria", jamCriteriaMapper.listByJam(jamId));
|
||||
model.addAttribute("mine", jamScoresMapper.listByJudge(jamId, gameId, userId));
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||
return "jam-scoring";
|
||||
}
|
||||
|
||||
private Integer parseScore(Object raw) {
|
||||
if (raw instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (raw instanceof String text) {
|
||||
try {
|
||||
return Integer.parseInt(text.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.jam.JamEvalWindow;
|
||||
import com.pandoli365.bibimbap.mapper.JamEntriesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class JamVoteController {
|
||||
|
||||
private final JamsMapper jamsMapper;
|
||||
private final JamEntriesMapper jamEntriesMapper;
|
||||
private final JamVotesMapper jamVotesMapper;
|
||||
|
||||
public JamVoteController(
|
||||
JamsMapper jamsMapper,
|
||||
JamEntriesMapper jamEntriesMapper,
|
||||
JamVotesMapper jamVotesMapper
|
||||
) {
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.jamEntriesMapper = jamEntriesMapper;
|
||||
this.jamVotesMapper = jamVotesMapper;
|
||||
}
|
||||
|
||||
@PostMapping("/jams/{slug}/vote")
|
||||
@ResponseBody
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> vote(
|
||||
@PathVariable("slug") String slug,
|
||||
@RequestParam("gameId") long gameId,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
// 게이트 1 — CSRF
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
// 게이트 2 — 인증
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
// 게이트 3 — 잼 존재
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
// 게이트 4 — 평가 기간
|
||||
if (!JamEvalWindow.isOpen(jam, OffsetDateTime.now())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "투표 기간이 아닙니다.");
|
||||
}
|
||||
// 게이트 5 — 출품작 존재
|
||||
if (!jamEntriesMapper.exists(jam.getId(), gameId)) {
|
||||
return response(HttpStatus.NOT_FOUND, "출품작을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 토글: 미투표→신규, 동일 대상→멱등 no-op, 다른 대상→교체
|
||||
Long current = jamVotesMapper.findVotedGameId(jam.getId(), userId);
|
||||
boolean changed;
|
||||
if (current == null) {
|
||||
jamVotesMapper.castVote(jam.getId(), gameId, userId);
|
||||
changed = false;
|
||||
} else if (current.longValue() == gameId) {
|
||||
changed = false;
|
||||
} else {
|
||||
jamVotesMapper.updateVote(jam.getId(), userId, gameId);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 진행 중에는 집계를 노출하지 않는다(밴드왜건 회피) — 본인 표만 반환
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("votedGameId", gameId);
|
||||
body.put("changed", changed);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@DeleteMapping("/jams/{slug}/vote")
|
||||
@ResponseBody
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> cancelVote(
|
||||
@PathVariable("slug") String slug,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
// 게이트 1 — CSRF
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
// 게이트 2 — 인증
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
// 게이트 3 — 잼 존재
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
// 게이트 4 — 평가 기간
|
||||
if (!JamEvalWindow.isOpen(jam, OffsetDateTime.now())) {
|
||||
return response(HttpStatus.UNPROCESSABLE_ENTITY, "투표 기간이 아닙니다.");
|
||||
}
|
||||
|
||||
int affected = jamVotesMapper.deleteVote(jam.getId(), userId);
|
||||
if (affected == 0) {
|
||||
return response(HttpStatus.NOT_FOUND, "취소할 표가 없습니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("votedGameId", null);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/jams/{slug}/vote/mine")
|
||||
@ResponseBody
|
||||
public ResponseEntity<Map<String, Object>> myVote(
|
||||
@PathVariable("slug") String slug,
|
||||
HttpSession session
|
||||
) {
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 본인 표는 진행 중에도 노출
|
||||
Long votedGameId = jamVotesMapper.findVotedGameId(jam.getId(), userId);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("votedGameId", votedGameId);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/jams/{slug}/vote/results")
|
||||
@ResponseBody
|
||||
public ResponseEntity<Map<String, Object>> results(
|
||||
@PathVariable("slug") String slug
|
||||
) {
|
||||
JamData jam = jamsMapper.getBySlug(slug);
|
||||
if (jam == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 노출 게이트(밴드왜건 회피) — 종료 전에는 출품작별 집계를 숨긴다
|
||||
boolean evalEnded = jam.getEvalEndAt() != null
|
||||
&& OffsetDateTime.now().isAfter(jam.getEvalEndAt());
|
||||
boolean publiclyVisible = "CLOSED".equals(jam.getStatus()) || evalEnded;
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
if (publiclyVisible) {
|
||||
List<Map<String, Object>> results = jamVotesMapper.listCountsByJam(jam.getId());
|
||||
long total = 0;
|
||||
for (Map<String, Object> row : results) {
|
||||
total += ((Number) row.get("voteCount")).longValue();
|
||||
}
|
||||
body.put("results", results);
|
||||
body.put("total", total);
|
||||
} else {
|
||||
body.put("open", true);
|
||||
body.put("results", null);
|
||||
body.put("total", jamVotesMapper.countByJam(jam.getId()));
|
||||
}
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.PostCategoryData;
|
||||
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class PostAdminController {
|
||||
|
||||
private final PostCategoriesMapper postCategoriesMapper;
|
||||
|
||||
public PostAdminController(PostCategoriesMapper postCategoriesMapper) {
|
||||
this.postCategoriesMapper = postCategoriesMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/admin/post-categories")
|
||||
public String postCategoriesPage(HttpServletRequest request, Model model) {
|
||||
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||
return "admin-post-categories";
|
||||
}
|
||||
|
||||
@PostMapping("/admin/post-categories")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> createCategory(
|
||||
@RequestParam(name = "name", required = false) String name,
|
||||
@RequestParam(name = "slug", required = false) String slug,
|
||||
@RequestParam(name = "sortOrder", required = false) Integer sortOrder,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
String normalizedName = trimToNull(name);
|
||||
String normalizedSlug = trimToNull(slug);
|
||||
if (normalizedName == null || normalizedName.length() > 80) {
|
||||
return response(HttpStatus.BAD_REQUEST, "카테고리 이름을 80자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (normalizedSlug == null || normalizedSlug.length() > 80) {
|
||||
return response(HttpStatus.BAD_REQUEST, "슬러그를 80자 이내로 입력해 주세요.");
|
||||
}
|
||||
int order = sortOrder == null ? 0 : sortOrder;
|
||||
|
||||
PostCategoryData category = new PostCategoryData();
|
||||
category.setName(normalizedName);
|
||||
category.setSlug(normalizedSlug);
|
||||
category.setSortOrder(order);
|
||||
category.setIsActive(true);
|
||||
|
||||
try {
|
||||
postCategoriesMapper.insert(category);
|
||||
} catch (DuplicateKeyException e) {
|
||||
return response(HttpStatus.CONFLICT, "이미 사용 중인 슬러그입니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "카테고리를 등록했습니다.");
|
||||
body.put("categoryId", category.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/post-categories/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> updateCategory(
|
||||
@PathVariable("id") long id,
|
||||
@RequestParam(name = "name", required = false) String name,
|
||||
@RequestParam(name = "slug", required = false) String slug,
|
||||
@RequestParam(name = "sortOrder", required = false) Integer sortOrder,
|
||||
@RequestParam(name = "isActive", required = false) Boolean isActive,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
PostCategoryData existing = postCategoriesMapper.getById(id);
|
||||
if (existing == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String normalizedName = trimToNull(name);
|
||||
if (normalizedName != null) {
|
||||
if (normalizedName.length() > 80) {
|
||||
return response(HttpStatus.BAD_REQUEST, "카테고리 이름을 80자 이내로 입력해 주세요.");
|
||||
}
|
||||
existing.setName(normalizedName);
|
||||
}
|
||||
String normalizedSlug = trimToNull(slug);
|
||||
if (normalizedSlug != null) {
|
||||
if (normalizedSlug.length() > 80) {
|
||||
return response(HttpStatus.BAD_REQUEST, "슬러그를 80자 이내로 입력해 주세요.");
|
||||
}
|
||||
existing.setSlug(normalizedSlug);
|
||||
}
|
||||
if (sortOrder != null) {
|
||||
existing.setSortOrder(sortOrder);
|
||||
}
|
||||
if (isActive != null) {
|
||||
existing.setIsActive(isActive);
|
||||
}
|
||||
|
||||
try {
|
||||
postCategoriesMapper.update(existing);
|
||||
} catch (DuplicateKeyException e) {
|
||||
return response(HttpStatus.CONFLICT, "이미 사용 중인 슬러그입니다.");
|
||||
}
|
||||
|
||||
return response(HttpStatus.OK, "카테고리를 수정했습니다.");
|
||||
}
|
||||
|
||||
@PostMapping("/admin/post-categories/{id}/delete")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> deleteCategory(
|
||||
@PathVariable("id") long id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
PostCategoryData existing = postCategoriesMapper.getById(id);
|
||||
if (existing == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
}
|
||||
if (postCategoriesMapper.countPostsByCategory(id) > 0) {
|
||||
return response(HttpStatus.CONFLICT,
|
||||
"소속 PUBLISHED 포스트가 있어 삭제할 수 없습니다. 비활성화(is_active=false)를 권장합니다.");
|
||||
}
|
||||
|
||||
postCategoriesMapper.delete(id);
|
||||
return response(HttpStatus.OK, "카테고리를 삭제했습니다.");
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.PostCategoryData;
|
||||
import com.pandoli365.bibimbap.data.PostData;
|
||||
import com.pandoli365.bibimbap.mapper.PostCategoriesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.PostsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import com.pandoli365.bibimbap.service.OgPreviewService;
|
||||
import com.pandoli365.bibimbap.service.OgPreviewService.OgPreview;
|
||||
import com.pandoli365.bibimbap.service.PostMarkdownService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
public class PostController {
|
||||
|
||||
private static final int PAGE_SIZE = 20;
|
||||
private static final int MAX_TITLE = 200;
|
||||
private static final int MAX_BODY = 20000;
|
||||
private static final int MAX_LINK_URL = 2048;
|
||||
private static final Set<String> STATUSES = Set.of("DRAFT", "PUBLISHED");
|
||||
private static final String DEFAULT_STATUS = "PUBLISHED";
|
||||
|
||||
private final PostsMapper postsMapper;
|
||||
private final PostCategoriesMapper postCategoriesMapper;
|
||||
private final PermissionGate gate;
|
||||
private final PostMarkdownService postMarkdownService;
|
||||
private final OgPreviewService ogPreviewService;
|
||||
|
||||
public PostController(PostsMapper postsMapper,
|
||||
PostCategoriesMapper postCategoriesMapper,
|
||||
PermissionGate gate,
|
||||
PostMarkdownService postMarkdownService,
|
||||
OgPreviewService ogPreviewService) {
|
||||
this.postsMapper = postsMapper;
|
||||
this.postCategoriesMapper = postCategoriesMapper;
|
||||
this.gate = gate;
|
||||
this.postMarkdownService = postMarkdownService;
|
||||
this.ogPreviewService = ogPreviewService;
|
||||
}
|
||||
|
||||
@GetMapping("/posts")
|
||||
public String list(
|
||||
@RequestParam(name = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(name = "cursorCreatedAt", required = false) String cursorCreatedAt,
|
||||
@RequestParam(name = "cursorId", required = false) Long cursorId,
|
||||
Model model
|
||||
) {
|
||||
OffsetDateTime cursor = parseOffsetDateTime(cursorCreatedAt);
|
||||
List<PostData> rows = postsMapper.listPublishedKeyset(cursor, cursorId, categoryId, PAGE_SIZE + 1);
|
||||
|
||||
boolean hasNext = rows.size() > PAGE_SIZE;
|
||||
if (hasNext) {
|
||||
rows = rows.subList(0, PAGE_SIZE);
|
||||
}
|
||||
|
||||
model.addAttribute("posts", rows);
|
||||
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||
model.addAttribute("categoryId", categoryId);
|
||||
model.addAttribute("hasNext", hasNext);
|
||||
if (hasNext && !rows.isEmpty()) {
|
||||
PostData last = rows.get(rows.size() - 1);
|
||||
model.addAttribute("nextCursorCreatedAt", last.getCreatedAt());
|
||||
model.addAttribute("nextCursorId", last.getId());
|
||||
}
|
||||
return "posts-list";
|
||||
}
|
||||
|
||||
@GetMapping("/posts/{id}")
|
||||
public String detail(@PathVariable("id") long id, Model model) {
|
||||
PostData post = postsMapper.getPublished(id);
|
||||
if (post == null) {
|
||||
return "redirect:/posts";
|
||||
}
|
||||
model.addAttribute("post", post);
|
||||
return "posts-detail";
|
||||
}
|
||||
|
||||
@GetMapping("/posts/new")
|
||||
public String createForm(HttpSession session, Model model) {
|
||||
if (sessionUserId(session) == null) {
|
||||
return "redirect:/login";
|
||||
}
|
||||
requireWritePermission(session);
|
||||
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(session));
|
||||
model.addAttribute("mode", "new");
|
||||
return "posts-form";
|
||||
}
|
||||
|
||||
@PostMapping("/posts")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> create(
|
||||
@RequestParam(name = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(name = "title", required = false) String title,
|
||||
@RequestParam(name = "bodyMarkdown", required = false) String bodyMarkdown,
|
||||
@RequestParam(name = "linkUrl", required = false) String linkUrl,
|
||||
@RequestParam(name = "status", required = false) String status,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
|
||||
Validated validated = validate(title, bodyMarkdown, linkUrl, status, categoryId);
|
||||
if (validated.error != null) {
|
||||
return validated.error;
|
||||
}
|
||||
|
||||
PostData post = new PostData();
|
||||
post.setCategoryId(categoryId);
|
||||
post.setAuthorUserId(userId);
|
||||
post.setTitle(validated.title);
|
||||
post.setBodyMarkdown(validated.bodyMarkdown);
|
||||
post.setBodySanitizedHtml(postMarkdownService.render(validated.bodyMarkdown));
|
||||
post.setLinkUrl(validated.linkUrl);
|
||||
post.setStatus(validated.status);
|
||||
applyOgPreview(post, validated.linkUrl);
|
||||
|
||||
postsMapper.insert(post);
|
||||
if (post.getId() == null) {
|
||||
return response(HttpStatus.INTERNAL_SERVER_ERROR, "포스트 등록 결과를 확인하지 못했습니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "포스트가 등록되었습니다.");
|
||||
body.put("postId", post.getId());
|
||||
body.put("location", "/posts/" + post.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/posts/{id}/edit")
|
||||
public String editForm(@PathVariable("id") long id, HttpSession session, Model model) {
|
||||
if (sessionUserId(session) == null) {
|
||||
return "redirect:/login";
|
||||
}
|
||||
requireWritePermission(session);
|
||||
PostData post = postsMapper.getPublished(id);
|
||||
if (post == null) {
|
||||
return "redirect:/posts";
|
||||
}
|
||||
model.addAttribute("post", post);
|
||||
model.addAttribute("categories", postCategoriesMapper.listActive());
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(session));
|
||||
model.addAttribute("mode", "edit");
|
||||
return "posts-form";
|
||||
}
|
||||
|
||||
@PostMapping("/posts/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> update(
|
||||
@PathVariable("id") long id,
|
||||
@RequestParam(name = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(name = "title", required = false) String title,
|
||||
@RequestParam(name = "bodyMarkdown", required = false) String bodyMarkdown,
|
||||
@RequestParam(name = "linkUrl", required = false) String linkUrl,
|
||||
@RequestParam(name = "status", required = false) String status,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
|
||||
Validated validated = validate(title, bodyMarkdown, linkUrl, status, categoryId);
|
||||
if (validated.error != null) {
|
||||
return validated.error;
|
||||
}
|
||||
|
||||
PostData existing = postsMapper.getPublished(id);
|
||||
if (existing == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
existing.setCategoryId(categoryId);
|
||||
existing.setTitle(validated.title);
|
||||
existing.setBodyMarkdown(validated.bodyMarkdown);
|
||||
existing.setBodySanitizedHtml(postMarkdownService.render(validated.bodyMarkdown));
|
||||
existing.setStatus(validated.status);
|
||||
|
||||
boolean linkChanged = !equalsLink(existing.getLinkUrl(), validated.linkUrl);
|
||||
existing.setLinkUrl(validated.linkUrl);
|
||||
if (linkChanged) {
|
||||
clearOgPreview(existing);
|
||||
applyOgPreview(existing, validated.linkUrl);
|
||||
}
|
||||
|
||||
postsMapper.update(existing);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "포스트가 수정되었습니다.");
|
||||
body.put("postId", existing.getId());
|
||||
body.put("location", "/posts/" + existing.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/posts/{id}/delete")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> delete(
|
||||
@PathVariable("id") long id,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
|
||||
PostData existing = postsMapper.getPublished(id);
|
||||
if (existing == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
postsMapper.softDelete(id);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "포스트가 삭제되었습니다.");
|
||||
body.put("postId", id);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private void requireWritePermission(HttpSession session) {
|
||||
if (!gate.has(session, PermissionKeys.POST_WRITE.name())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private Validated validate(String title, String bodyMarkdown, String linkUrl, String status, Long categoryId) {
|
||||
Validated v = new Validated();
|
||||
|
||||
String normalizedTitle = trimToNull(title);
|
||||
if (normalizedTitle == null || normalizedTitle.length() > MAX_TITLE) {
|
||||
v.error = response(HttpStatus.BAD_REQUEST, "제목을 " + MAX_TITLE + "자 이내로 입력해 주세요.");
|
||||
return v;
|
||||
}
|
||||
v.title = normalizedTitle;
|
||||
|
||||
String normalizedBody = bodyMarkdown == null ? "" : bodyMarkdown;
|
||||
if (normalizedBody.length() > MAX_BODY) {
|
||||
v.error = response(HttpStatus.BAD_REQUEST, "본문은 " + MAX_BODY + "자 이내로 입력해 주세요.");
|
||||
return v;
|
||||
}
|
||||
v.bodyMarkdown = normalizedBody;
|
||||
|
||||
v.linkUrl = normalizeLinkUrl(linkUrl);
|
||||
if (v.linkUrl != null && v.linkUrl.length() > MAX_LINK_URL) {
|
||||
v.error = response(HttpStatus.BAD_REQUEST, "링크 URL은 " + MAX_LINK_URL + "자 이내로 입력해 주세요.");
|
||||
return v;
|
||||
}
|
||||
|
||||
String normalizedStatus = trimToNull(status);
|
||||
if (normalizedStatus == null) {
|
||||
normalizedStatus = DEFAULT_STATUS;
|
||||
}
|
||||
if (!STATUSES.contains(normalizedStatus)) {
|
||||
v.error = response(HttpStatus.BAD_REQUEST, "공개 상태를 확인해 주세요.");
|
||||
return v;
|
||||
}
|
||||
v.status = normalizedStatus;
|
||||
|
||||
if (categoryId == null) {
|
||||
v.error = response(HttpStatus.BAD_REQUEST, "카테고리를 선택해 주세요.");
|
||||
return v;
|
||||
}
|
||||
PostCategoryData category = postCategoriesMapper.getActive(categoryId);
|
||||
if (category == null) {
|
||||
v.error = response(HttpStatus.NOT_FOUND, "카테고리를 찾을 수 없습니다.");
|
||||
return v;
|
||||
}
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
// OG fetch 는 graceful: 실패해도 작성/수정은 성공시킨다.
|
||||
private void applyOgPreview(PostData post, String linkUrl) {
|
||||
if (linkUrl == null) {
|
||||
return;
|
||||
}
|
||||
Optional<OgPreview> preview = ogPreviewService.fetch(linkUrl);
|
||||
if (preview.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
OgPreview og = preview.get();
|
||||
post.setOgTitle(og.title());
|
||||
post.setOgDescription(og.description());
|
||||
post.setOgImageUrl(og.imageUrl());
|
||||
post.setOgSiteName(og.siteName());
|
||||
post.setOgFetchedAt(OffsetDateTime.now());
|
||||
}
|
||||
|
||||
private void clearOgPreview(PostData post) {
|
||||
post.setOgTitle(null);
|
||||
post.setOgDescription(null);
|
||||
post.setOgImageUrl(null);
|
||||
post.setOgSiteName(null);
|
||||
post.setOgFetchedAt(null);
|
||||
}
|
||||
|
||||
private boolean equalsLink(String a, String b) {
|
||||
if (a == null) {
|
||||
return b == null;
|
||||
}
|
||||
return a.equals(b);
|
||||
}
|
||||
|
||||
private String normalizeLinkUrl(String linkUrl) {
|
||||
String text = trimToNull(linkUrl);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
String lower = text.toLowerCase();
|
||||
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private OffsetDateTime parseOffsetDateTime(String value) {
|
||||
String text = trimToNull(value);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OffsetDateTime.parse(text);
|
||||
} catch (DateTimeParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
|
||||
private static final class Validated {
|
||||
private String title;
|
||||
private String bodyMarkdown;
|
||||
private String linkUrl;
|
||||
private String status;
|
||||
private ResponseEntity<Map<String, Object>> error;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.SearchCriteria;
|
||||
import com.pandoli365.bibimbap.data.TagData;
|
||||
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.TagsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
|
||||
import com.pandoli365.bibimbap.util.TagSanitizer;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
public class SearchController {
|
||||
|
||||
private static final Set<String> SORT_KEYS = Set.of("latest", "likes", "views", "reviews", "rating", "relevance");
|
||||
private static final Set<String> TAG_MODES = Set.of("and", "or");
|
||||
|
||||
private final GamesMapper gamesMapper;
|
||||
private final TagsMapper tagsMapper;
|
||||
private final UserBadgesQueryMapper userBadgesQueryMapper;
|
||||
|
||||
public SearchController(GamesMapper gamesMapper, TagsMapper tagsMapper,
|
||||
UserBadgesQueryMapper userBadgesQueryMapper) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.tagsMapper = tagsMapper;
|
||||
this.userBadgesQueryMapper = userBadgesQueryMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/games/search")
|
||||
public String search(
|
||||
@RequestParam(name = "keyword", required = false) String keyword,
|
||||
@RequestParam(name = "creator", required = false) String creator,
|
||||
@RequestParam(name = "tags", required = false) String tagsCsv,
|
||||
@RequestParam(name = "tagMode", required = false, defaultValue = "and") String tagMode,
|
||||
@RequestParam(name = "sort", required = false, defaultValue = "latest") String sort,
|
||||
@RequestParam(name = "jam", required = false) String jam,
|
||||
Model model
|
||||
) {
|
||||
String normalizedKeyword = trimToNull(keyword);
|
||||
String normalizedCreator = trimToNull(creator);
|
||||
|
||||
List<String> tagSlugs = parseTagSlugs(tagsCsv);
|
||||
|
||||
String normalizedTagMode = TAG_MODES.contains(tagMode) ? tagMode : "and";
|
||||
String normalizedSort = SORT_KEYS.contains(sort) ? sort : "latest";
|
||||
|
||||
String normalizedJam = trimToNull(jam);
|
||||
|
||||
SearchCriteria criteria = new SearchCriteria();
|
||||
criteria.setKeyword(normalizedKeyword);
|
||||
criteria.setCreator(normalizedCreator);
|
||||
criteria.setTags(tagSlugs.isEmpty() ? null : tagSlugs);
|
||||
criteria.setTagCount(tagSlugs.size());
|
||||
criteria.setTagMode(normalizedTagMode);
|
||||
criteria.setSort(normalizedSort);
|
||||
criteria.setJamSlug(normalizedJam);
|
||||
|
||||
List<GameData> games = gamesMapper.searchGamesAdvanced(criteria);
|
||||
List<TagData> availableTags = tagsMapper.listActiveByType("GAME", null);
|
||||
|
||||
model.addAttribute("games", games);
|
||||
List<Long> creatorUserIds = games.stream()
|
||||
.map(GameData::getUserId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, List<String>> creatorBadges = new LinkedHashMap<>();
|
||||
if (!creatorUserIds.isEmpty()) {
|
||||
for (UserBadgeKeyRow row : userBadgesQueryMapper.listActiveBadgeKeysByUserIds(creatorUserIds)) {
|
||||
creatorBadges.computeIfAbsent(row.getUserId(), k -> new ArrayList<>()).add(row.getBadgeKey());
|
||||
}
|
||||
}
|
||||
model.addAttribute("creatorBadges", creatorBadges);
|
||||
model.addAttribute("searching", true);
|
||||
model.addAttribute("searchKeyword", normalizedKeyword == null ? "" : normalizedKeyword);
|
||||
model.addAttribute("searchCreator", normalizedCreator == null ? "" : normalizedCreator);
|
||||
model.addAttribute("searchSort", normalizedSort);
|
||||
model.addAttribute("searchTagMode", normalizedTagMode);
|
||||
model.addAttribute("selectedTagSlugs", tagSlugs);
|
||||
model.addAttribute("availableTags", availableTags);
|
||||
model.addAttribute("searchQuery", "");
|
||||
|
||||
return "index";
|
||||
}
|
||||
|
||||
private List<String> parseTagSlugs(String tagsCsv) {
|
||||
Set<String> slugs = new LinkedHashSet<>();
|
||||
if (tagsCsv != null) {
|
||||
for (String token : tagsCsv.split(",")) {
|
||||
String slug = TagSanitizer.toSlug(token);
|
||||
if (TagSanitizer.isAllowed(slug)) {
|
||||
slugs.add(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(slugs);
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.TagData;
|
||||
import com.pandoli365.bibimbap.mapper.GameTagsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.TagsMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.security.PermissionKeys;
|
||||
import com.pandoli365.bibimbap.util.TagSanitizer;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
public class TagController {
|
||||
|
||||
private static final Set<String> TAG_TYPES = Set.of("GAME", "JAM", "COMMON");
|
||||
|
||||
private final TagsMapper tagsMapper;
|
||||
private final GameTagsMapper gameTagsMapper;
|
||||
private final GamesMapper gamesMapper;
|
||||
private final PermissionGate permissionGate;
|
||||
|
||||
public TagController(TagsMapper tagsMapper,
|
||||
GameTagsMapper gameTagsMapper,
|
||||
GamesMapper gamesMapper,
|
||||
PermissionGate permissionGate) {
|
||||
this.tagsMapper = tagsMapper;
|
||||
this.gameTagsMapper = gameTagsMapper;
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.permissionGate = permissionGate;
|
||||
}
|
||||
|
||||
@PostMapping("/tags")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> createUserTag(
|
||||
@RequestParam(name = "name", required = false) String name,
|
||||
@RequestParam(name = "tagType", required = false) String tagType,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
String normalizedType = trimToNull(tagType);
|
||||
if (!isValidTagType(normalizedType)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "태그 유형을 확인해 주세요.");
|
||||
}
|
||||
String slug = TagSanitizer.toSlug(name);
|
||||
if (!TagSanitizer.isAllowed(slug)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "태그 이름을 확인해 주세요.");
|
||||
}
|
||||
|
||||
TagData existing = tagsMapper.findBySlug(slug);
|
||||
if (existing != null) {
|
||||
return tagBody(existing.getId(), existing.getName(), existing.getSlug(), existing.getIsActive());
|
||||
}
|
||||
|
||||
TagData tag = new TagData();
|
||||
tag.setName(name.trim());
|
||||
tag.setSlug(slug);
|
||||
tag.setTagType(normalizedType);
|
||||
tag.setIsActive(false);
|
||||
tag.setCreatedBy(userId);
|
||||
tagsMapper.insertTag(tag);
|
||||
|
||||
return tagBody(tag.getId(), tag.getName(), tag.getSlug(), false);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/tags")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> createAdminTag(
|
||||
@RequestParam(name = "name", required = false) String name,
|
||||
@RequestParam(name = "tagType", required = false) String tagType,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
if (!permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
String normalizedType = trimToNull(tagType);
|
||||
if (!isValidTagType(normalizedType)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "태그 유형을 확인해 주세요.");
|
||||
}
|
||||
String slug = TagSanitizer.toSlug(name);
|
||||
if (!TagSanitizer.isAllowed(slug)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "태그 이름을 확인해 주세요.");
|
||||
}
|
||||
|
||||
TagData existing = tagsMapper.findBySlug(slug);
|
||||
if (existing != null) {
|
||||
return tagBody(existing.getId(), existing.getName(), existing.getSlug(), existing.getIsActive());
|
||||
}
|
||||
|
||||
TagData tag = new TagData();
|
||||
tag.setName(name.trim());
|
||||
tag.setSlug(slug);
|
||||
tag.setTagType(normalizedType);
|
||||
tag.setIsActive(true);
|
||||
tag.setCreatedBy(userId);
|
||||
tagsMapper.insertTag(tag);
|
||||
|
||||
return tagBody(tag.getId(), tag.getName(), tag.getSlug(), true);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/tags/{id}/approve")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> approveTag(
|
||||
@PathVariable("id") long id,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
if (!permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
tagsMapper.approve(id);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("id", id);
|
||||
body.put("isActive", true);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/tags/{id}/deactivate")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> deactivateTag(
|
||||
@PathVariable("id") long id,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
if (!permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name())) {
|
||||
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
|
||||
}
|
||||
tagsMapper.deactivate(id);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("id", id);
|
||||
body.put("isActive", false);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/games/{gameId}/tags")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> attachGameTags(
|
||||
@PathVariable("gameId") long gameId,
|
||||
@RequestParam(name = "tagIds", required = false) List<Long> tagIds,
|
||||
HttpServletRequest request,
|
||||
HttpSession session
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
return response(HttpStatus.BAD_REQUEST, "부착할 태그를 선택해 주세요.");
|
||||
}
|
||||
|
||||
GameData game = gamesMapper.getGame(gameId);
|
||||
if (game == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
|
||||
}
|
||||
boolean owner = userId.equals(game.getUserId());
|
||||
boolean moderator = permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name());
|
||||
if (!owner && !moderator) {
|
||||
return response(HttpStatus.FORBIDDEN, "게임 소유자 또는 모더레이터만 태그를 부착할 수 있습니다.");
|
||||
}
|
||||
|
||||
gameTagsMapper.attachTags(gameId, tagIds);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("gameId", gameId);
|
||||
body.put("tagIds", tagIds);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/tags")
|
||||
@ResponseBody
|
||||
public List<Map<String, Object>> listTags(
|
||||
@RequestParam(name = "type", required = false) String type,
|
||||
@RequestParam(name = "q", required = false) String q
|
||||
) {
|
||||
List<TagData> tags = tagsMapper.listActiveByType(trimToNull(type), trimToNull(q));
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (TagData tag : tags) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("id", tag.getId());
|
||||
item.put("name", tag.getName());
|
||||
item.put("slug", tag.getSlug());
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> tagBody(Long id, String name, String slug, Boolean isActive) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("id", id);
|
||||
body.put("name", name);
|
||||
body.put("slug", slug);
|
||||
body.put("isActive", isActive);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private boolean isValidTagType(String tagType) {
|
||||
return tagType != null && TAG_TYPES.contains(tagType);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object userId = session.getAttribute("userId");
|
||||
if (userId instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (userId instanceof String text) {
|
||||
try {
|
||||
return Long.parseLong(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.UnityFeedSourceData;
|
||||
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.SsrfSafeFetcher;
|
||||
import com.pandoli365.bibimbap.service.UnityFeedPoller;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
public class UnityFeedAdminController {
|
||||
|
||||
private final UnityFeedSourcesMapper sourcesMapper;
|
||||
private final UnityFeedItemsMapper itemsMapper;
|
||||
private final SsrfSafeFetcher ssrfSafeFetcher;
|
||||
private final UnityFeedPoller unityFeedPoller;
|
||||
|
||||
public UnityFeedAdminController(
|
||||
UnityFeedSourcesMapper sourcesMapper,
|
||||
UnityFeedItemsMapper itemsMapper,
|
||||
SsrfSafeFetcher ssrfSafeFetcher,
|
||||
UnityFeedPoller unityFeedPoller
|
||||
) {
|
||||
this.sourcesMapper = sourcesMapper;
|
||||
this.itemsMapper = itemsMapper;
|
||||
this.ssrfSafeFetcher = ssrfSafeFetcher;
|
||||
this.unityFeedPoller = unityFeedPoller;
|
||||
}
|
||||
|
||||
@GetMapping("/admin/unity-feeds")
|
||||
public String unityFeedsPage(HttpServletRequest request, Model model) {
|
||||
model.addAttribute("sources", sourcesMapper.listAll());
|
||||
model.addAttribute("unackItems", itemsMapper.listUnacknowledged(50));
|
||||
model.addAttribute("unackCount", itemsMapper.countUnacknowledged());
|
||||
model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession()));
|
||||
return "admin-unity-feeds";
|
||||
}
|
||||
|
||||
@PostMapping("/admin/unity-feeds")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> createSource(
|
||||
@RequestParam(name = "name", required = false) String name,
|
||||
@RequestParam(name = "feedUrl", required = false) String feedUrl,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
String normalizedName = trimToNull(name);
|
||||
String normalizedUrl = trimToNull(feedUrl);
|
||||
if (normalizedName == null || normalizedName.length() > 120) {
|
||||
return response(HttpStatus.BAD_REQUEST, "피드 이름을 120자 이내로 입력해 주세요.");
|
||||
}
|
||||
if (normalizedUrl == null || normalizedUrl.length() > 2048) {
|
||||
return response(HttpStatus.BAD_REQUEST, "피드 URL 을 2048자 이내로 입력해 주세요.");
|
||||
}
|
||||
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(normalizedUrl);
|
||||
} catch (URISyntaxException e) {
|
||||
return response(HttpStatus.BAD_REQUEST, "피드 URL 이 안전하지 않거나 접근 불가합니다.");
|
||||
}
|
||||
if (!ssrfSafeFetcher.isFetchableUrl(uri)) {
|
||||
return response(HttpStatus.BAD_REQUEST, "피드 URL 이 안전하지 않거나 접근 불가합니다.");
|
||||
}
|
||||
|
||||
UnityFeedSourceData source = new UnityFeedSourceData();
|
||||
source.setName(normalizedName);
|
||||
source.setFeedUrl(normalizedUrl);
|
||||
|
||||
try {
|
||||
sourcesMapper.insert(source);
|
||||
} catch (DuplicateKeyException e) {
|
||||
return response(HttpStatus.CONFLICT, "이미 등록된 피드 URL 입니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "피드 소스를 등록했습니다.");
|
||||
body.put("sourceId", source.getId());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/unity-feeds/{id}/toggle")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> toggleSource(
|
||||
@PathVariable("id") long id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
UnityFeedSourceData existing = sourcesMapper.getById(id);
|
||||
if (existing == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "피드 소스를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
sourcesMapper.toggle(id);
|
||||
UnityFeedSourceData updated = sourcesMapper.getById(id);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "피드 소스 상태를 변경했습니다.");
|
||||
body.put("isActive", updated == null ? null : updated.getIsActive());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/unity-feeds/{id}/delete")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> deleteSource(
|
||||
@PathVariable("id") long id,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
UnityFeedSourceData existing = sourcesMapper.getById(id);
|
||||
if (existing == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "피드 소스를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
sourcesMapper.delete(id);
|
||||
return response(HttpStatus.OK, "피드 소스를 삭제했습니다.");
|
||||
}
|
||||
|
||||
@PostMapping("/admin/unity-feeds/items/{itemId}/ack")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> acknowledgeItem(
|
||||
@PathVariable("itemId") long itemId,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
itemsMapper.acknowledge(itemId);
|
||||
return response(HttpStatus.OK, "확인 처리했습니다.");
|
||||
}
|
||||
|
||||
@PostMapping("/admin/unity-feeds/poll")
|
||||
public ResponseEntity<Map<String, Object>> pollNow(HttpServletRequest request) {
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
|
||||
int newCount = 0;
|
||||
List<UnityFeedSourceData> sources = sourcesMapper.listAll();
|
||||
if (sources != null) {
|
||||
for (UnityFeedSourceData source : sources) {
|
||||
if (source == null || source.getId() == null) {
|
||||
continue;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(source.getIsActive())) {
|
||||
continue;
|
||||
}
|
||||
newCount += unityFeedPoller.pollOnce(source.getId());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "폴링을 완료했습니다.");
|
||||
body.put("newCount", newCount);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.trim();
|
||||
return text.isBlank() ? null : text;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
body.put("message", message);
|
||||
return ResponseEntity.status(status).body(body);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
|
@ -13,17 +18,30 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
//이곳에서는 뷰만 제어
|
||||
@Controller
|
||||
public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
||||
|
||||
private final GamesMapper gamesMapper;
|
||||
private static final int HUB_PAGE_SIZE = 24;
|
||||
|
||||
public WebMvcController(GamesMapper gamesMapper) {
|
||||
private final GamesMapper gamesMapper;
|
||||
private final JamsMapper jamsMapper;
|
||||
private final UserBadgesQueryMapper userBadgesQueryMapper;
|
||||
|
||||
public WebMvcController(GamesMapper gamesMapper, JamsMapper jamsMapper,
|
||||
UserBadgesQueryMapper userBadgesQueryMapper) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.userBadgesQueryMapper = userBadgesQueryMapper;
|
||||
}
|
||||
|
||||
@RequestMapping("/error")
|
||||
|
|
@ -50,8 +68,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
}
|
||||
|
||||
@GetMapping("/")
|
||||
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query) {
|
||||
return indexModelAndView(query);
|
||||
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query,
|
||||
@RequestParam(name = "cursor", required = false) String cursor) {
|
||||
return indexModelAndView(query, cursor);
|
||||
}
|
||||
|
||||
@GetMapping("/{pageName}")
|
||||
|
|
@ -83,6 +102,10 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
return new ModelAndView("redirect:/login");
|
||||
}
|
||||
mv.addObject("myGames", gamesMapper.getGamesByUserId(sessionUserId(session)));
|
||||
List<String> myBadges = userBadgesQueryMapper
|
||||
.listActiveBadgeKeysByUserIds(java.util.List.of(sessionUserId(session)))
|
||||
.stream().map(UserBadgeKeyRow::getBadgeKey).toList();
|
||||
mv.addObject("myBadges", myBadges);
|
||||
mv.setViewName("profile");
|
||||
break;
|
||||
case "signup":
|
||||
|
|
@ -95,22 +118,92 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
mv.setViewName("operation-policy");
|
||||
break;
|
||||
default:
|
||||
return indexModelAndView(query);
|
||||
return indexModelAndView(query, null);
|
||||
}
|
||||
|
||||
return mv;
|
||||
}
|
||||
|
||||
private ModelAndView indexModelAndView(String query) {
|
||||
private ModelAndView indexModelAndView(String query, String cursor) {
|
||||
String normalizedQuery = query == null ? "" : query.trim();
|
||||
HubCursor parsed = parseCursor(cursor); // null = 첫 페이지
|
||||
Integer cSort = parsed == null ? null : parsed.sortOrder();
|
||||
OffsetDateTime cCreated = parsed == null ? null : parsed.createdAt();
|
||||
Long cId = parsed == null ? null : parsed.id();
|
||||
|
||||
List<GameData> rows = normalizedQuery.isBlank()
|
||||
? gamesMapper.listVisibleKeyset(cSort, cCreated, cId, HUB_PAGE_SIZE + 1)
|
||||
: gamesMapper.searchVisibleKeyset(normalizedQuery, cSort, cCreated, cId, HUB_PAGE_SIZE + 1);
|
||||
|
||||
String nextCursor = null;
|
||||
if (rows.size() > HUB_PAGE_SIZE) {
|
||||
rows = new ArrayList<>(rows.subList(0, HUB_PAGE_SIZE));
|
||||
GameData last = rows.get(HUB_PAGE_SIZE - 1);
|
||||
nextCursor = encodeCursor(last);
|
||||
}
|
||||
|
||||
ModelAndView mv = new ModelAndView("index");
|
||||
mv.addObject("games", normalizedQuery.isBlank()
|
||||
? gamesMapper.getVisibleGames()
|
||||
: gamesMapper.searchVisibleGames(normalizedQuery));
|
||||
mv.addObject("games", rows);
|
||||
List<Long> creatorUserIds = rows.stream()
|
||||
.map(GameData::getUserId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, List<String>> creatorBadges = new LinkedHashMap<>();
|
||||
if (!creatorUserIds.isEmpty()) {
|
||||
for (UserBadgeKeyRow row : userBadgesQueryMapper.listActiveBadgeKeysByUserIds(creatorUserIds)) {
|
||||
creatorBadges.computeIfAbsent(row.getUserId(), k -> new ArrayList<>()).add(row.getBadgeKey());
|
||||
}
|
||||
}
|
||||
mv.addObject("creatorBadges", creatorBadges);
|
||||
mv.addObject("searchQuery", normalizedQuery);
|
||||
mv.addObject("nextCursor", nextCursor); // nullable — JSP 가 null 시 더보기 미표시
|
||||
|
||||
// 진행중 잼 배너 (activeCount==0 → attr 미주입 → 배너 미렌더)
|
||||
int activeCount = jamsMapper.countActive();
|
||||
if (activeCount > 0) {
|
||||
List<JamData> active = jamsMapper.listActive(1);
|
||||
if (!active.isEmpty()) {
|
||||
mv.addObject("activeJam", active.get(0));
|
||||
mv.addObject("activeJamCount", activeCount);
|
||||
}
|
||||
}
|
||||
return mv;
|
||||
}
|
||||
|
||||
private String encodeCursor(GameData last) {
|
||||
if (last == null || last.getSortOrder() == null
|
||||
|| last.getCreatedAt() == null || last.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return last.getSortOrder() + "_"
|
||||
+ last.getCreatedAt().toInstant().toEpochMilli() + "_"
|
||||
+ last.getId();
|
||||
}
|
||||
|
||||
// "10_1718000000000_57" → HubCursor | null. 사용자 입력 신뢰 금지 — 형식오류는 첫 페이지 폴백.
|
||||
private HubCursor parseCursor(String cursor) {
|
||||
if (cursor == null || cursor.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = cursor.split("_");
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int sortOrder = Integer.parseInt(parts[0]);
|
||||
long millis = Long.parseLong(parts[1]);
|
||||
long id = Long.parseLong(parts[2]);
|
||||
OffsetDateTime createdAt = OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(millis), ZoneOffset.UTC);
|
||||
return new HubCursor(sortOrder, createdAt, id);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record HubCursor(Integer sortOrder, OffsetDateTime createdAt, Long id) {}
|
||||
|
||||
private boolean isLoggedIn(HttpSession session) {
|
||||
return session != null && session.getAttribute("userId") != null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.GameCommentData;
|
|||
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.security.PermissionGate;
|
||||
import com.pandoli365.bibimbap.util.TextNormalizer;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
|
@ -28,14 +29,15 @@ public class GameCommentController {
|
|||
|
||||
private static final int CONTENT_MAX = 200;
|
||||
private static final int PAGE_SIZE = 20;
|
||||
private static final String ROLE_ADMIN = "ADMIN";
|
||||
|
||||
private final GameCommentsMapper gameCommentsMapper;
|
||||
private final GamesMapper gamesMapper;
|
||||
private final PermissionGate permissionGate;
|
||||
|
||||
public GameCommentController(GameCommentsMapper gameCommentsMapper, GamesMapper gamesMapper) {
|
||||
public GameCommentController(GameCommentsMapper gameCommentsMapper, GamesMapper gamesMapper, PermissionGate permissionGate) {
|
||||
this.gameCommentsMapper = gameCommentsMapper;
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.permissionGate = permissionGate;
|
||||
}
|
||||
|
||||
@GetMapping("/game/{id}/comments")
|
||||
|
|
@ -129,7 +131,7 @@ public class GameCommentController {
|
|||
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
|
||||
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
|
||||
}
|
||||
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
|
||||
if (!canModify(userId, comment.getUserId(), session)) {
|
||||
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +170,7 @@ public class GameCommentController {
|
|||
if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) {
|
||||
return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다.");
|
||||
}
|
||||
if (!canModify(userId, comment.getUserId(), sessionRole(session))) {
|
||||
if (!canModify(userId, comment.getUserId(), session)) {
|
||||
return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다.");
|
||||
}
|
||||
|
||||
|
|
@ -197,12 +199,8 @@ public class GameCommentController {
|
|||
return "newest".equals(sort) ? "newest" : "oldest";
|
||||
}
|
||||
|
||||
private boolean isOperator(String role) {
|
||||
return ROLE_ADMIN.equals(role);
|
||||
}
|
||||
|
||||
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
|
||||
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
|
||||
private boolean canModify(Long currentUserId, Long authorUserId, HttpSession session) {
|
||||
return (authorUserId != null && authorUserId.equals(currentUserId)) || permissionGate.canModerate(session);
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
|
|
@ -231,14 +229,6 @@ public class GameCommentController {
|
|||
return displayName instanceof String text ? text : null;
|
||||
}
|
||||
|
||||
private String sessionRole(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
}
|
||||
Object role = session.getAttribute("role");
|
||||
return role instanceof String text ? text : null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import com.pandoli365.bibimbap.data.GameData;
|
|||
import com.pandoli365.bibimbap.game.GameCatalog;
|
||||
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.service.GameAssetCleanupService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
|
|
@ -28,19 +30,30 @@ import java.util.Map;
|
|||
@Controller
|
||||
public class GameController {
|
||||
|
||||
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GameController.class);
|
||||
|
||||
private final GamesMapper gamesMapper;
|
||||
private final GameCommentsMapper gameCommentsMapper;
|
||||
private final GameReviewsMapper gameReviewsMapper;
|
||||
private final GameViewsMapper gameViewsMapper;
|
||||
private final GameAssetCleanupService assetCleanupService;
|
||||
private final com.pandoli365.bibimbap.badge.ReputationService reputationService;
|
||||
|
||||
@Value("${app.webgl.asset-origin:}")
|
||||
private String webglAssetOrigin;
|
||||
|
||||
public GameController(GamesMapper gamesMapper,
|
||||
GameCommentsMapper gameCommentsMapper,
|
||||
GameReviewsMapper gameReviewsMapper) {
|
||||
GameReviewsMapper gameReviewsMapper,
|
||||
GameViewsMapper gameViewsMapper,
|
||||
GameAssetCleanupService assetCleanupService,
|
||||
com.pandoli365.bibimbap.badge.ReputationService reputationService) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.gameCommentsMapper = gameCommentsMapper;
|
||||
this.gameReviewsMapper = gameReviewsMapper;
|
||||
this.gameViewsMapper = gameViewsMapper;
|
||||
this.assetCleanupService = assetCleanupService;
|
||||
this.reputationService = reputationService;
|
||||
}
|
||||
|
||||
public static String webglUrlForGame(int gameId) {
|
||||
|
|
@ -101,6 +114,13 @@ public class GameController {
|
|||
return response(HttpStatus.INTERNAL_SERVER_ERROR, "게임 등록 결과를 확인하지 못했습니다.");
|
||||
}
|
||||
|
||||
// W4 평판 훅(best-effort): 게임 업로드 신호 기록 + TECHNICIAN 임계 평가. 실패해도 게임 등록 본흐름 롤백 금지.
|
||||
try {
|
||||
reputationService.record(userId, "GAME_UPLOADED", "game:" + game.getId());
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[BADGE] 게임 업로드 평판 훅 실패 userId={} gameId={}", userId, game.getId(), e);
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "게임 등록이 완료되었습니다.");
|
||||
|
|
@ -110,9 +130,11 @@ public class GameController {
|
|||
}
|
||||
|
||||
@GetMapping("/game/{id}")
|
||||
@Transactional
|
||||
public String gameDetail(@PathVariable("id") long id, Model model, HttpSession session) {
|
||||
GameData game = gamesMapper.getGame(id);
|
||||
if (game != null) {
|
||||
recordView(id, session);
|
||||
addGameModel(model, game, sessionUserId(session));
|
||||
model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20));
|
||||
model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id, "newest", 0, 20));
|
||||
|
|
@ -219,6 +241,7 @@ public class GameController {
|
|||
return response(HttpStatus.BAD_REQUEST, "소개는 600자 이하로 입력해 주세요.");
|
||||
}
|
||||
|
||||
String oldWebglPath = existing.getWebglPath();
|
||||
existing.setName(normalizedName);
|
||||
existing.setCreatorNote(normalizedCreatorNote);
|
||||
existing.setGitUrl(safeExternalUrl(gitUrl));
|
||||
|
|
@ -227,6 +250,13 @@ public class GameController {
|
|||
existing.setVisible(isChecked(visible));
|
||||
gamesMapper.updateGame(existing);
|
||||
|
||||
// 자산 생명주기(U7): webgl 경로의 UUID 가 교체되면 구 디렉터리 정리(고아 방지)
|
||||
String oldUuid = gameUuidFromPath(oldWebglPath);
|
||||
String newUuid = gameUuidFromPath(normalizedWebglPath);
|
||||
if (!oldUuid.isBlank() && !oldUuid.equals(newUuid)) {
|
||||
assetCleanupService.purgeByWebglPath(oldWebglPath);
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("message", "게임이 수정되었습니다.");
|
||||
|
|
@ -260,6 +290,8 @@ public class GameController {
|
|||
gamesMapper.softDeleteGameReviews(id);
|
||||
gamesMapper.deleteGameLikes(id);
|
||||
gamesMapper.softDeleteGame(id);
|
||||
// 자산 생명주기(U7): 메타는 soft-delete, 자산은 hard-delete(재업로드로 갈음, 디스크 누수 방지)
|
||||
assetCleanupService.purgeByWebglPath(existing.getWebglPath());
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
|
|
@ -306,6 +338,22 @@ public class GameController {
|
|||
return lower.startsWith("http://") || lower.startsWith("https://") ? text : "";
|
||||
}
|
||||
|
||||
private void recordView(long gameId, HttpSession session) {
|
||||
String vKey = viewerKey(session);
|
||||
if (!gameViewsMapper.existsRecentView(gameId, vKey)) {
|
||||
gameViewsMapper.insertView(gameId, vKey);
|
||||
gameViewsMapper.incrementViewCount(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
private String viewerKey(HttpSession session) {
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId != null) {
|
||||
return "u:" + userId;
|
||||
}
|
||||
return "s:" + session.getId();
|
||||
}
|
||||
|
||||
private Long sessionUserId(HttpSession session) {
|
||||
if (session == null) {
|
||||
return null;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue