bibimbap/.atp/work-session/20260623-104307/implementation/W2-6-award-aggregation-desi...

42 KiB
Raw Blame History

phase agent agent_version generated_at workstream concerns concerns_checked self_verification references
design design-advisor 1 2026-06-23T16:30:00+09:00 W2-6-시상 집계/결과
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 경계는 구현 점검 항목.
true
checklist_passed
true
requirements research adrs
docs/work-log/2026-06-23-w2-w4-feature-skeletons.md .atp/work-session/20260623-104307/research/W2-W4-grounding.md
.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 재정의 안 함, 소비만.
  • 심사 점수 입력·집계 VIEWW2-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 동결 — 재정의 금지, 인용만)

-- 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 방지)

// 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 로 이관.