--- phase: design agent: design-advisor agent_version: 1 generated_at: 2026-06-23T16:30:00+09:00 workstream: W2-4-심사위원 평가(점수 입력/집계 소비) concerns: - "★게이트 의존 시그니처 — 점수 입력은 W2-2 의 `JamRoleGate.isJudge(session, jamId)`(자격) + W2-2 자기출품 충돌규칙에 의존한다. 본 W2-4 는 이 게이트를 호출만 하고 소유하지 않는다(W2-2 미설계 시점이므로 시그니처는 orchestrator 확정값 `isJudge(session, jamId)` 를 계약으로 가정). 구현 단계에서 W2-2 산출 게이트의 실제 시그니처(인자·반환·자기출품 충돌 포함 여부)를 재확인 필요 — W2-2 가 충돌검사를 isJudge 안에 합칠지 별도 메서드로 둘지에 따라 본 컨트롤러 호출 코드가 갈린다. crossRefs 참조." - "신규 컨트롤러(JamScoringController) + 신규 매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 의존 추가 — verification-strategies §30 에 따라 implementation 단계에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 @Mapper @MockBean 수동 등록 의무(JamRoleGate/PermissionGate 빈도 컨트롤러 주입 시 동일). 누락 시 contextLoads NoSuchBeanDefinitionException." - "jam_score_stats 는 집계 VIEW → 소비 매퍼(JamScoreStatsMapper) alias 는 camelCase 큰따옴표(AS \"weightedTotal\") 필수(케이스 폴딩 함정, verification-strategies §33, GameReviewStatsMapper.java:13 선례). criterion 단위 펼침 조회(criterion별 평균)는 VIEW 가 종합 1행만 노출하므로 W2-4 가 jam_scores 직접 GROUP BY 매퍼로 별도 작성 — 이 펼침 쿼리도 집계라 큰따옴표 alias. dev DB contract(L2)로 fan-out·0-division·NULL 실측 권장." - "JamScoringController 헬퍼(requireEvalOpen/requireJudge/resolveScores) 시그니처는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 평가기간 게이트가 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽음 → 필요 필드로 좁혀질 수 있음." - "jam_scores UPSERT 는 PostgreSQL `INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key) DO UPDATE SET score=EXCLUDED.score, updated_at=now()` 단일문 권장(exists→update 2쿼리 inflate 회피). ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 의존. 이 UNIQUE 가 W2-3 동결 후 변경되면 ON CONFLICT 타깃이 깨진다 — crossRefs 동결 유지 필수. dev DB contract 로 ON CONFLICT 실측." - "criterion_key 검증 — 입력 score 의 criterion_key 가 해당 잼의 jam_criteria 에 실재하는지 앱계층 검증(미등록 키 점수 거부, 422). jam_scores.criterion_key 는 jam_criteria.criterion_key 의 논리참조(W2-3: FK 아님)이므로 DB 가 막지 않음 → 앱계층 화이트리스트 필수(보안/무결성)." concerns_checked: true self_verification: checklist_passed: true references: requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md adrs: - .atp/work-session/20260623-104307/implementation/W2-3-eval-freeze-design.md - .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md - .atp/work-session/20260622-180054/implementation/W1-design.md - docs/work-log/2026-06-17-jam-platform-roadmap.md - docs/development/verification-strategies.md - docs/jam-eval-ddl.sql --- # 설계: W2-4 — 심사위원 평가 (점수 입력 API + 3중 게이트 + criterion 단위 채점 + 가중 집계 소비) > ⚠️ **W2-3 동결 스키마 소비 워크스트림**. 본 설계는 신규 테이블·VIEW 를 **만들지 않는다**. W2-3 이 동결한 `jam_criteria`/`jam_scores`/`jam_score_stats`(VIEW)를 **그대로 소비**하고, **API·로직·게이트·매퍼만** 설계한다. 동결 스키마 재정의 금지(W2-3 권위). ## 목표 / 비목표 ### 목표 (FR/NFR 추적 — 골자 W2-4 + 확정 결정) - **G1 점수 입력 API**: 심사위원이 출품작(`(jam_id, game_id)` 자연키)에 jam_criteria 기준별 점수(1~5)를 입력. `jam_scores` UPSERT(criterion 단위 멱등 재입력). - **G2 3중 진입 게이트(정석 보안)**: ① 심사위원 자격 = `JamRoleGate.isJudge(session, jamId)`(W2-2) ② 평가기간 게이트 = `jam.status='EVAL' AND now() ∈ [eval_start_at, eval_end_at]`(W2-3 F6) ③ 자기출품 충돌 = 심사위원이 자기 출품작에 채점 불가(W2-2 충돌규칙). + 상태변경 전수 CSRF. - **G3 집계 소비**: 심사 집계 = `jam_score_stats` VIEW(criterion별 평균의 가중 종합 `weighted_total` + 비가중 `simple_total` + scored_criteria + judge_count). criterion별 펼침 평균은 jam_scores 직접 GROUP BY 매퍼(VIEW 는 종합 1행만 — W2-3 결정). 심사위원 평균·동률 처리 명시. - **G4 수정/재입력**: 평가기간 내 점수 수정 허용(UPSERT → updated_at 갱신). 평가 종료(EVAL 이탈 or now>eval_end) 후 입력·수정 잠금(422). - **G5 부분 입력·미채점 처리**: 심사위원이 일부 criterion 만 입력(부분입력) 허용 — 미입력 criterion 은 jam_scores 행 부재 → 집계에서 그 criterion 제외(W2-3 VIEW per_criterion 선집계). 점수 미입력 criterion·심사위원 부분입력의 집계 의미 명시. - **G6 본인 입력 현황 조회**: 심사위원이 본인이 출품작에 매긴 점수 현황 조회(폼 prefill·수정용). - **NFR**: 상태변경 CSRF 전수, `#{}` 바인딩(`${}` 금지), 입력 sanitize(score 범위·criterion_key 화이트리스트), 임시 role 직접체크 금지(W1/W2-2 게이트 위), 신규 테이블 0(동결 소비). ### 비목표 (스코프 밖) - **심사 스키마 정의**(jam_criteria/jam_scores/jam_score_stats DDL) — **W2-3 동결 소유**. 본 설계는 소비만(DDL 0). - **심사위원 자격 판정·지정·자기출품 충돌규칙 본체**(`JamRoleGate.isJudge` / jam_judges) — **W2-2 소유**. 본 설계는 게이트를 호출만. - **잼 심사 기준(jam_criteria) 등록 UI·CRUD** — 관리자 잼 설정의 일부. 본 설계는 criterion 조회(점수 폼 라벨·검증)만 소비하고, criterion 등록 API 는 W2-1 관리자 콘솔 확장 또는 별도 — 본 설계 비목표(아래 §criterion 등록 소유 결정). - **인기투표**(W2-5), **시상 집계 산정**(W2-6) — 별도. 본 설계는 jam_score_stats 를 W2-6 JUDGE 트랙이 소비하도록 제공만. - **점수 입력 이력 감사 로그(누가 언제 무엇을)** — 1차는 jam_scores.updated_at 으로 최종 상태만. 변경 이력 테이블은 후속(선택, 아래 §대안). - **잼 평가단위 = jam_entries.id vs (jam_id,game_id) 재논의** — W2-3 동결(F8: (jam_id,game_id) 자연키) 그대로 채택. 재정의 안 함. --- ## 개요 bibimbap 은 Spring Boot WAR + 톰캣 in-memory HttpSession + MyBatis annotation `@Mapper`(`#{}` only) + JSP 스택이다. W2-3 이 잼 평가 스키마를 동결했다 — `jam_criteria`(잼별 설정형 심사 기준, criterion_key/weight) + `jam_scores`(criterion 단위 점수 1~5, UNIQUE(jam_id,game_id,judge_user_id,criterion_key)) + `jam_score_stats`(출품작 단위 가중 종합 VIEW, fan-out 방지 선집계). W2-1 이 `jams`(status CHECK RECRUIT/DEV/EVAL/CLOSED + eval_start_at/eval_end_at) + `jam_entries`(잼당 game 활성 UNIQUE = 출품작)를 제공한다. W1 RBAC 의 `PermissionGate.has(session, key)`(2인자, PermissionGate.java:22 직접 확인) + epoch 전파(`refreshIfStale`, PermissionGate.java:86) + `CsrfTokens.isValid(request)`(CsrfTokens.java:35 직접 확인)가 인프라로 완비됐다. 본 설계는 **심사위원이 출품작에 criterion 단위 점수를 입력하는 API + 3중 진입 게이트 + 집계 소비 매퍼**를 설계한다. 신규 테이블·VIEW 는 0(W2-3 동결 소비). 컨트롤러 패턴은 RecruitController(읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity>` status/message + CSRF, RecruitController.java:140,186 직접 확인)를 따른다. 확정된 정석 결정(전제 — 재논의 금지, orchestrator 확정): - **점수 입력 단위 = criterion 단위 UPSERT** : `jam_scores` 의 UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위에 ON CONFLICT UPSERT. 한 출품작에 여러 criterion 을 한 요청으로 batch 입력하되 각 criterion 은 멱등 upsert. - **3중 게이트(앱계층)** : isJudge(W2-2) + 평가기간(W2-3 F6) + 자기출품 충돌(W2-2) + CSRF. DB CHECK 는 score 범위·UNIQUE 만 강제(시각·자격은 런타임). - **집계 = jam_score_stats VIEW(종합) + jam_scores GROUP BY(criterion 펼침)** : VIEW 는 출품작 종합 1행(W2-3 결정), criterion별 평균 펼침은 W2-4 매퍼가 jam_scores 직접 집계. - **수정 = 평가기간 내 UPSERT, 종료 후 잠금** : 평가기간 게이트가 입력·수정 양쪽을 막음(같은 게이트). 가장 까다로운 세 난제 확정: - **난제1 (3중 게이트 순서·401/403/422 정합)**: 게이트는 **CSRF → 인증(401) → isJudge 자격(403) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트(422)** 순으로 평가한다(아래 §게이트 연동 확정). 인증 실패=401, 인가(심사위원 아님)=403, 도메인 상태 위반(기간 외/자기출품/미등록 criterion)=422 — W1-design/W2-1/W2-3 의 401≠403≠422 정책과 일치. 순서 근거: 싼 검사(CSRF/세션)·보안 경계(자격) 먼저, DB 조회 필요한 검사(출품작/criterion) 뒤. - **난제2 (부분 입력·미채점 집계 의미)**: 심사위원은 criterion 일부만 입력 가능(부분입력). 미입력 criterion 은 jam_scores 행 자체가 없음 → W2-3 `jam_score_stats` VIEW 의 `per_criterion` 선집계가 **채점된 criterion 만** 가중 종합에 반영(미채점 criterion 은 자동 제외, weight 분모에서도 빠짐). 따라서 "심사위원 A 가 immersion 만 채점" 해도 종합은 immersion 가중으로만 계산 — 부분입력이 종합을 왜곡하지 않고 채점된 범위로만 산정된다. 출품작별 `judge_count`(criterion별 DISTINCT judge 의 MAX)로 심사 참여 규모를 노출해 부분참여를 가시화. - **난제3 (동률·심사위원 평균 처리)**: 출품작 종합점수(`weighted_total`) 동률은 **DB 가 강제하지 않음**(VIEW 는 산정만). 동률 정렬 tie-break 는 소비처(W2-4 정렬 목록·W2-6 JUDGE 트랙 rank)가 **2차 키(game_id ASC 또는 judge_count DESC)로 결정론적 정렬**. 본 설계 정렬 목록은 `ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC`(미채점 출품작 말단, 심사 많은 작품 우선, 최종 id tie-break). 시상 rank 부여(동률 시 같은 rank)는 W2-6 소관 — 본 설계는 종합 점수 + 결정론 정렬만 제공. --- ## 핵심 결정 요약 (전제 — 재논의 금지. orchestrator 확정 + W2-3 동결 소비) | 결정 | 확정값 | 본 설계의 구체화 | |---|---|---| | S1 입력 API | POST/PUT `/jams/{jamId}/games/{gameId}/scores` | criterion별 점수(1~5) batch UPSERT. jamId path = 게이트 스코프, gameId path = 출품작 | | S2 진입 게이트 | isJudge + 평가기간 + 자기출품충돌 + CSRF | 앱계층 3중(+CSRF). isJudge/충돌=W2-2, 기간=W2-3 F6 | | S3 점수 저장 | jam_scores UPSERT(ON CONFLICT) | UNIQUE(jam_id,game_id,judge_user_id,criterion_key) 위 멱등. updated_at 갱신 | | S4 집계 | jam_score_stats VIEW(종합) + jam_scores GROUP BY(펼침) | VIEW=가중종합 1행(W2-3), criterion 펼침=W2-4 매퍼. 큰따옴표 alias | | S5 수정/잠금 | 평가기간 내 수정, 종료 후 잠금 | 입력·수정 동일 게이트(평가기간). EVAL 이탈/now>eval_end → 422 | | S6 부분입력 | 일부 criterion 입력 허용 | 미입력=행 부재→집계 제외(VIEW per_criterion). judge_count 로 가시화 | | S7 동률/평균 | VIEW 산정 + 소비처 결정론 정렬 | weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC | | S8 criterion_key | jam_criteria 논리참조(앱계층 화이트리스트) | 입력 키가 잼 criteria 에 실재해야 점수 수용(미등록→422) | --- ## 데이터 모델 (DDL — 신규 0. W2-3 동결 스키마 소비) > **본 설계는 DDL 을 작성하지 않는다.** W2-3 `docs/jam-eval-ddl.sql`(권위) 의 `jam_criteria`/`jam_scores`/`jam_score_stats`(VIEW)를 그대로 소비한다. 아래는 **소비 형태 확인용 동결 스키마 요약**(W2-3 동결 — 재정의 금지). ### 소비하는 동결 테이블/뷰 (W2-3 권위 — 변경 0) | 객체 | 종류 | 본 설계 소비 컬럼 | 동결 제약(의존) | |---|---|---|---| | `jam_criteria` | 테이블 | jam_id / criterion_key / display_name / sort_order / weight | ux_jam_criteria_jam_key(잼 내 키 UNIQUE) — criterion 화이트리스트·라벨 소스 | | `jam_scores` | 테이블 | jam_id / game_id / judge_user_id / criterion_key / score / updated_at | **ux_jam_scores_jam_game_judge_criterion**(UPSERT ON CONFLICT 타깃) + score_check(1~5) | | `jam_score_stats` | **VIEW** | jam_id / game_id / weighted_total / simple_total / scored_criteria / judge_count | per_criterion 선집계(fan-out 방지) + NULLIF 0-division 가드 | - **신규 0 확인**: 본 W2-4 는 테이블·VIEW·컬럼·인덱스·CHECK 를 추가하지 않는다. `docs/*-ddl.sql` 신규 파일 0, `db/schema.sql` 수정 0. (점수 입력 이력 감사 테이블은 비목표 — 후속.) - **criterion 등록 소유 결정**: 본 설계는 jam_criteria 를 **읽기만**(화이트리스트·라벨). criterion 등록(INSERT) API 는 W2-1 관리자 잼 CRUD(`/admin/jams/{jamId}` 확장) 또는 별도 워크스트림 소유 — 본 W2-4 비목표. W2-3 동결 매퍼 시그니처 `JamCriteriaMapper.insertCriterion` 은 등록 소유자가 구현(본 설계는 `listByJam` 만 소비). --- ## 외부 계약 (API) > 공통: 모든 상태변경은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`, CsrfTokens.java:35,51 직접 확인). 응답은 RecruitController 패턴 — 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity>`(status/message, RecruitController.java:186 직접 확인). 점수 입력은 컨트롤러 진입부에서 3중 게이트(isJudge + 평가기간 + 자기출품) 통과 후 본문 수행. ### 401 vs 403 vs 422 정책 (W1-design / W2-1 / W2-3 과 일치 — 본 설계 준수) - **미인증**(세션 `userId` 없음): API **401** JSON `{status:401, message:"로그인이 필요합니다."}`. 점수 폼 페이지(인증 필요)는 `redirect:/login`. - **인증·미인가**(심사위원 아님 = `isJudge` false): **403** JSON `{status:403, message:"심사 권한이 없습니다."}`(리다이렉트 금지). - **도메인 상태 위반**(평가기간 외 / 자기출품 충돌 / 미등록 criterion / score 범위 외): **422** JSON `{status:422, message:<구체사유>}`. 인가는 됐으나 도메인 규칙 위반 → 403 아님 422(W2-3 §401vs403 정책 일치). - **출품작 없음**(jam_entries 활성행 부재 또는 game 없음): **404**. - **CSRF 실패**: **403** + `CsrfTokens.errorBody()`. ### 점수 입력/수정 (상태변경 API — CSRF + 3중 게이트) | 액션 | method | path | 요청 body | 응답(200) | 에러 | |---|---|---|---|---|---| | 점수 입력/수정 | POST | `/jams/{jamId}/games/{gameId}/scores` | `{"scores":[{"criterionKey":"...","score":1~5}, ...]}` | `{status:200, message, jamId, gameId, savedCount}` | 401(미인증), 403(CSRF/심사권한), 404(잼·출품작 없음), 422(평가기간 외/자기출품/미등록 criterion/score 범위) | | 점수 입력/수정(멱등 별칭) | PUT | `/jams/{jamId}/games/{gameId}/scores` | (POST 와 동일) | (POST 와 동일) | (동일) | - **POST vs PUT 둘 다 채택 근거(orchestrator 확정 "POST/PUT")**: UPSERT 의미는 PUT(멱등 전체 교체)에 정합하나, 기존 RecruitController 등 상태변경이 전부 POST + ResponseEntity JSON 패턴이고 JSP form 은 PUT 미지원(method 오버라이드 필요)이다. 따라서 **POST 를 1차 경로**(JSP form 호환), **PUT 을 동일 핸들러로 추가 매핑**(REST 멱등 의도 명시, API 클라이언트용)한다. 두 매핑은 같은 컨트롤러 메서드(`@RequestMapping(method={POST,PUT})`)로 동작 동일 — 중복 0. - **batch 입력 채택 근거**: 한 출품작에 criterion 이 여러 개(예: 4개)이고 심사위원은 보통 한 화면에서 전부 매긴다. criterion별 단건 요청은 round-trip N배 + 부분 실패 정합 복잡. body 배열 1요청으로 트랜잭션 내 전 criterion UPSERT(부분입력 시 입력된 것만 배열에 포함). - **savedCount**: 실제 UPSERT 된 criterion 수(입력 배열 길이와 검증 통과 수 일치 — 미등록 criterion 이 하나라도 있으면 전체 422 거부, 부분 저장 안 함 = 트랜잭션 원자성). - **자기 채점 금지(자기출품 충돌, W2-2)**: 심사위원이 자신이 출품한(개인 entrant_user_id==judge 또는 팀 멤버) 출품작에 채점 시도 → 422 `{message:"자기 출품작은 심사할 수 없습니다."}`. 충돌 판정은 W2-2 규칙 위임(아래 §게이트 연동). ### 점수 조회 (읽기 — 심사위원 본인 현황 / 집계) | 액션 | method | path | 권한 | 응답 | |---|---|---|---|---| | 본인 입력 현황 | GET | `/jams/{jamId}/games/{gameId}/scores/mine` | isJudge | `{status:200, scores:[{criterionKey, score}], criteria:[{criterionKey, displayName, sortOrder, weight}]}` (폼 prefill·수정용) | | 출품작 심사 집계 | GET | `/jams/{jamId}/scores/summary` | 공개 또는 관리자(아래 노출시점 결정) | `{status:200, stats:[{gameId, weightedTotal, simpleTotal, scoredCriteria, judgeCount}]}` (정렬 적용) | - **집계 노출 시점 결정(정석)**: 심사 진행 중(EVAL) 실시간 집계 노출은 심사위원 간 점수 동조(anchoring) 편향을 유발한다. 따라서 `/jams/{jamId}/scores/summary` 는 **`jam.status='CLOSED'` 또는 now()>eval_end_at 이후에만 공개**(EVAL 중에는 GAME_JAM_MANAGE 보유 관리자만 조회 — 운영 모니터링). 미개방 시 비관리자는 403/빈 결과. (W2-6 시상 집계도 CLOSED 이후 — W2-3 F6 정합.) - **본인 현황(`/scores/mine`)은 EVAL 중에도 isJudge 본인에게 허용**(자기 입력 prefill — 동조 편향 무관, 본인 점수만). ### 게이트 진입 계약 (점수 입력 — 본 설계 핵심) - 입력: `HttpServletRequest`(CSRF) + `HttpSession`(userId/role/permissions) + path(jamId, gameId) + body(scores). - 출력: 200(저장) 또는 401/403/404/422. - 판정 순서(난제1): CSRF → 인증 → isJudge(자격, 403) → 잼 조회(404) → 평가기간(422) → 출품작 존재(404) → 자기출품 충돌(422) → criterion_key 화이트리스트·score 범위(422) → UPSERT. --- ## 인터셉터 / 게이트 연동 (S2 — 3중 게이트, 앱계층) ### 인터셉터 미개입 (확정) - 점수 경로 `/jams/**` 는 RbacInterceptor 등록 대상(`/admin/**`)이 **아니다**(InterceptorConfig.java:18-20, grounding R-A 직접 확인). 따라서 인터셉터는 점수 입력에 개입하지 않고, **게이트는 전부 컨트롤러 진입부 앱계층**에서 수행한다(W2-1 D4-A "소비 액션=게이트 헬퍼" 선례와 동형). 임시 role 직접체크 금지 — 자격은 `JamRoleGate.isJudge`, 권한은 W1 `PermissionGate`(관리자 집계 노출 시). ### 3중 게이트 구성 (각 게이트의 소유·시그니처) 1. **심사위원 자격 (W2-2 소유 — 호출만)**: `jamRoleGate.isJudge(session, jamId)` → false 면 403. **시그니처는 orchestrator 확정값**(`isJudge(session, jamId)`). 본 설계는 이 메서드를 호출만 하고 구현하지 않는다(concern 1 — W2-2 미설계 시점, 구현 단계 시그니처 재확인). 2. **평가기간 (W2-3 F6 계약 — 본 설계가 검증 로직 구현)**: `jam.status == 'EVAL' AND now() ∈ [jam.eval_start_at, jam.eval_end_at]` → 위반 시 422. `JamEvalWindow` 헬퍼(본 설계 신규, 아래 §파일영향맵)가 JamData 의 status/eval_start_at/eval_end_at 로 판정. eval_start_at/eval_end_at NULL 이면 "기간 미설정" → 422(EVAL 인데 기간 미설정은 운영 오류). 3. **자기출품 충돌 (W2-2 규칙 — 호출/위임)**: 심사위원이 자기 출품작(개인 entrant 본인 또는 팀 멤버) 채점 금지. **충돌 판정 책임은 W2-2** — 본 설계는 두 경로 중 하나를 호출(concern 1): - (a) W2-2 가 `isJudge` 에 자기출품 제외를 포함하면 → 별도 호출 불필요(isJudge 가 자기 출품작 게임에 대해 false 반환하도록 jamId+gameId 받는 변형이 필요할 수 있음 — 시그니처 재확인). - (b) W2-2 가 충돌을 별도 메서드(`isOwnEntry(jamId, gameId, userId)` 등)로 두면 → 본 설계가 isJudge 통과 후 그 메서드 호출해 422. - **본 설계 기본 가정**: (b) 별도 충돌 검사. isJudge 는 "잼 심사위원인가"(스코프 자격)만, 자기출품 충돌은 출품작 단위라 gameId 가 필요하므로 분리가 자연스럽다. 구현 시 W2-2 산출과 정합(concern 1). - **epoch 전파 연동(W1 결정4)**: 관리자 집계 노출 게이트에서 `PermissionGate.has(session, GAME_JAM_MANAGE)` 사용 시 `refreshIfStale`(PermissionGate.java:86)로 요청당 epoch 대조 → 권한 부여/회수 즉시 반영(W1 메커니즘 그대로, 본 설계 추가 작업 0). isJudge 의 즉시성은 W2-2 소관. - **중복 0**: 점수 입력 메서드(POST/PUT 공용) 진입부의 게이트 시퀀스를 private 헬퍼 `requireScoringAllowed(session, request, jamId, gameId)` 로 단일화 — 401/403/404/422 응답 작성 포함. --- ## 시퀀스 (주요 플로우 의사코드) ### S1. 심사위원 점수 입력/수정 (3중 게이트 → batch UPSERT) ``` [심사위원 세션] POST /jams/42/games/777/scores (CSRF, {scores:[{immersion:5},{fun:4}]}) → JamScoringController.submitScores(jamId=42, gameId=777, body) → CsrfTokens.isValid(request) (아니면 403 errorBody) → userId = sessionUserId(session) (없으면 401) → jamRoleGate.isJudge(session, 42)? (아니면 403 "심사 권한 없음") [W2-2] → jam = jamsMapper.getById(42) (없으면 404) [W2-1 매퍼 소비] → JamEvalWindow.isOpen(jam, now)? (아니면 422 "평가 기간 아님") [W2-3 F6] → jamEntriesMapper.exists(42, 777)? (아니면 404 "출품작 아님") [W2-1 매퍼 소비] → 자기출품 충돌(W2-2): isOwnEntry(42, 777, userId)? (참이면 422 "자기 출품작 심사 불가") [W2-2] → criteria = jamCriteriaMapper.listByJam(42) # 화이트리스트·라벨 [W2-3 매퍼 소비] → for each {criterionKey, score} in body.scores: criterionKey ∈ criteria.keys? (아니면 422 "미등록 기준") [S8] 1 <= score <= 5? (아니면 422 "점수 범위") # 검증 전수 통과 후 트랜잭션 내 일괄 UPSERT(원자성 — 부분저장 안 함) → @Transactional: for each {criterionKey, score}: jamScoresMapper.upsertScore(42, 777, userId, criterionKey, score) # INSERT ... ON CONFLICT (jam_id,game_id,judge_user_id,criterion_key) # DO UPDATE SET score=EXCLUDED.score, updated_at=now() → 200 {jamId:42, gameId:777, savedCount:N} # jam_score_stats VIEW 가 다음 집계 조회 시 자동 반영(읽기 시점 계산). ``` ### S2. 본인 입력 현황 조회 (폼 prefill / 수정) ``` [심사위원] GET /jams/42/games/777/scores/mine → JamScoringController.myScores(42, 777) → userId = sessionUserId (없으면 401) → jamRoleGate.isJudge(session, 42)? (아니면 403) → mine = jamScoresMapper.listByJudge(42, 777, userId) # 본인 criterion별 점수 → criteria = jamCriteriaMapper.listByJam(42) # 라벨·순서·가중 → 200 {scores: mine, criteria} # 미입력 criterion 은 scores 에 부재 → 폼은 criteria 전체 표시 + 입력값만 prefill(부분입력 가시화). ``` ### S3. 심사 집계 조회 (CLOSED 이후 공개 / EVAL 중 관리자만) ``` [조회자] GET /jams/42/scores/summary → JamScoringController.summary(42) → jam = jamsMapper.getById(42) (없으면 404) → 노출 게이트: jam.status=='CLOSED' OR now()>jam.eval_end_at → 공개 허용 else (EVAL 진행 중) → PermissionGate.has(session, GAME_JAM_MANAGE)? (아니면 403/빈) [W1] → stats = jamScoreStatsMapper.listStatsByJam(42) # jam_score_stats VIEW # ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC (난제3 결정론) → 200 {stats} # W2-6 JUDGE 트랙이 같은 VIEW 의 weighted_total DESC 를 rank 소스로 소비(crossRefs). ``` --- ## 파일 영향 맵 > 소유권 분할 가이드(implementation-advisor worker 단위 후보): > **K-DOMAIN**(JamEvalWindow + data POJO) · **K-MAPPER**(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper — W2-3 동결 시그니처 구현) · **K-CTRL**(JamScoringController + JSP, 3중 게이트) · **K-TEST**(@MockBean + 게이트/UPSERT/집계 테스트). > 의존: W2-3 동결(DDL) + W2-2 게이트(JamRoleGate) + W2-1 매퍼(JamsMapper.getById/JamEntriesMapper.exists) **선행**. K-DOMAIN → K-MAPPER → K-CTRL. | 변경 유형 | 경로 | 역할 | 소유 | |---|---|---|---| | 신규 | `src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java` | 평가기간 게이트 판정(status='EVAL' AND now∈[eval_start,eval_end]). W2-3 F6 계약 구현 | K-DOMAIN | | 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamCriterionData.java` | jam_criteria 행 POJO(criterionKey/displayName/sortOrder/weight) — 폼 라벨·화이트리스트 | K-DOMAIN | | 신규 | `src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java` | jam_scores 행 POJO(criterionKey/score[/judgeUserId/updatedAt]) — 본인 현황 | K-DOMAIN | | 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java` | `@Mapper` `listByJam(jamId)`(`#{}`, snake→camel 직접 alias). W2-3 동결 시그니처. insertCriterion 은 등록 소유자 소관(본 설계 미구현 가능 — listByJam 만) | K-MAPPER | | 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java` | `@Mapper` `upsertScore`(ON CONFLICT) + `listByJudge`(`#{}`, snake→camel) | K-MAPPER | | 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java` | `@Mapper` `listStatsByJam`(jam_score_stats VIEW — **집계 VIEW → 큰따옴표 alias**) | K-MAPPER | | 신규 | `src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java` | 점수 입력(POST/PUT)/본인현황(GET)/집계(GET). 3중 게이트 헬퍼 requireScoringAllowed | K-CTRL | | 신규 | `src/main/webapp/WEB-INF/views/jam-scoring.jsp` | 심사위원 채점 폼(criterion별 1~5, CSRF hidden, prefill). 표시용 — 게이트 아님 | K-CTRL | | 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 3매퍼 @MockBean 등록(JamRoleGate/PermissionGate 컨트롤러 주입분도 — contextLoads 보존, §30) | (검증) | | 신규 | `src/test/.../JamScoringControllerTest.java` | 3중 게이트(isJudge/평가기간/자기출품) + CSRF + 401/403/404/422 + UPSERT 멱등 + 부분입력 | (검증) | | 신규 | `src/test/.../JamEvalWindowTest.java` | 평가기간 경계(EVAL+구간내/구간밖/status≠EVAL/기간 NULL) 단위 | (검증) | > **신규 테이블/VIEW 0 확인**: 본 설계 파일 영향에 `docs/*-ddl.sql` 신규·`db/schema.sql` 수정 **없음**(W2-3 동결 소비). 매퍼는 동결 스키마를 읽고/UPSERT 만. > SSR 호출지점 영향(verification §영향맵): 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 매퍼/JSP/컨트롤러 소비처 0 영향. jam_score_stats VIEW·jam_scores 는 W2-3 동결이라 본 설계가 스키마를 건드리지 않음. ### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지) ```java // JamEvalWindow — 평가기간 게이트 판정(W2-3 F6 구현). jam + now 만으로 판정(최소). boolean isOpen(JamData jam, // status/eval_start_at/eval_end_at 3필드만 읽음(inflate 마킹) java.time.OffsetDateTime now) // 비교 기준 시각(테스트 주입 가능 — Clock 대신 인자) // JamCriteriaMapper (@Mapper, #{} only, snake→camel 직접 alias) List listByJam(long jamId) // 점수 폼 라벨 + criterion_key 화이트리스트 소스 // JamScoresMapper (@Mapper, #{} only) int upsertScore(long jamId, // 평가단위 1/2 long gameId, // 평가단위 2/2(출품작) long judgeUserId, // 심사위원(세션 userId) String criterionKey, // 채점 기준(jam_criteria 화이트리스트 통과분) int score) // 1~5. INSERT ON CONFLICT DO UPDATE(멱등 재입력) List listByJudge(long jamId, // 잼 스코프 long gameId, // 출품작 long judgeUserId)// 본인 현황(폼 prefill) — 3키로 본인 criterion별 점수 // JamScoreStatsMapper (@Mapper, #{} only, 집계 VIEW → camelCase 큰따옴표 alias) List> listStatsByJam(long jamId) // 출품작별 종합(정렬 적용, 시상/목록 소스) ``` > ⚠️ inflate 마킹(concern 4): `JamEvalWindow.isOpen(jam, now)` 의 `jam` 은 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽는다 — 구현에서 필요 시 `isOpen(String status, OffsetDateTime start, OffsetDateTime end, OffsetDateTime now)` 로 좁힐 수 있음(최소 인자 원칙). `now` 를 Clock 대신 인자로 둔 이유: 테스트에서 경계 시각 주입(고정 Clock 빈 DI 보다 단순). `JamScoresMapper.upsertScore` 는 ON CONFLICT 단일문 권장 — exists→update 2메서드로 쪼개면 inflate(concern 5). > ⚠️ criterion 펼침 평균 조회(criterion별 평균 화면 필요 시): W2-3 결정상 VIEW 는 종합 1행만 노출하므로, criterion별 평균이 화면에 필요하면 `JamScoresMapper` 에 `listCriterionAvgByJam(long jamId)`(jam_scores GROUP BY jam_id,game_id,criterion_key) 를 **구현 단계에서 필요 확인 후 추가**(현 설계는 종합 VIEW 소비로 충분 — 선제 추가 안 함, 최소 인자/메서드 원칙). --- ## 대안 비교 | 주제 | 안 | 장점 | 단점 | 채택 | |---|---|---|---|---| | 점수 저장 | (A) jam_scores UPSERT(ON CONFLICT) | 멱등 재입력, 단일문, 동결 UNIQUE 활용 | PostgreSQL 의존(ON CONFLICT) | **채택(S3)** | | | (B) exists→insert/update 2쿼리 | 방언 독립 | round-trip 2배·경합 윈도우, 메서드 inflate | 기각 | | | (C) DELETE→INSERT | 단순 | updated_at 이력 소실, 트랜잭션 내 빈 구간 | 기각 | | 입력 단위 | (A) criterion batch(body 배열) 1요청 | round-trip 1회, 원자성, 부분입력 자연 | 배열 검증 | **채택(S1)** | | | (B) criterion 단건 요청 N회 | 단순 매핑 | N round-trip, 부분실패 정합, 폼 1화면과 불일치 | 기각 | | HTTP method | (A) POST 1차 + PUT 동일핸들러 | JSP form 호환 + REST 멱등 의도, 중복 0 | 매핑 2개 | **채택(S1, orchestrator "POST/PUT")** | | | (B) PUT 단독 | REST 정석 | JSP form method 오버라이드 필요, 기존 POST 패턴 이탈 | 기각 | | 자기출품 충돌 위치 | (A) W2-2 별도 메서드 호출(gameId 필요) | 자격(잼)과 충돌(출품작) 관심사 분리, isJudge 단순 | 호출 1회 추가 | **채택(S2 기본가정, concern 1)** | | | (B) isJudge 에 충돌 합침 | 호출 1회 | isJudge 가 gameId 받아야 함(스코프 자격과 출품작 충돌 혼합) | 조건부(W2-2 결정 따름) | | 집계 노출 시점 | (A) CLOSED/eval종료 후 공개, EVAL 중 관리자만 | 심사위원 동조(anchoring) 편향 방지(정석) | 시점 분기 | **채택(§API)** | | | (B) EVAL 중 실시간 공개 | 투명 | 점수 동조 편향(심사 무결성 저해) | 기각 | | criterion 검증 | (A) 앱계층 화이트리스트(jam_criteria) | DB FK 부재(W2-3 논리참조) 보완, 미등록 키 422 | 조회 1회 | **채택(S8)** | | | (B) 검증 없이 INSERT | 단순 | 오타·미등록 criterion 점수 오염(무결성/집계 왜곡) | 기각 | | 입력 이력 | (A) updated_at 최종상태만(1차) | 단순, 동결 스키마 그대로 | 변경 이력 부재 | **채택(비목표 분리)** | | | (B) jam_score_audit 테이블 | 감사 추적 | 신규 테이블(동결 외)·over-engineering(1차 불요) | 기각(후속) | --- ## 롤아웃 / 마이그레이션 ### 순서 1. **스키마 선행(W2-3)**: `docs/jam-eval-ddl.sql`(jam_criteria/jam_scores/jam_score_stats VIEW) 적용 완료가 본 설계 전제. 본 W2-4 는 DDL 0 — 스키마 적용 단계 없음. 2. **선행 의존 코드**: W2-1(JamsMapper.getById/JamEntriesMapper.exists) + W2-2(JamRoleGate.isJudge + 자기출품 충돌) 가 본 설계의 게이트 호출 대상. **W2-2 미배포 시 점수 입력 게이트가 컴파일/동작 불가** → W2-2 와 동시 또는 후행 배포. 3. **코드 배포(본 설계)**: K-DOMAIN → K-MAPPER → K-CTRL. JamScoringController 가 JamRoleGate·PermissionGate·JamsMapper·JamEntriesMapper·3 신규 매퍼 주입. 4. **운영**: 관리자가 잼 criteria 등록(W2-1 콘솔 확장 또는 등록 소유자) + 심사위원 지정(W2-2) → EVAL 전이 후(W2-1 상태전이) 심사위원 채점 가능. ### 역호환 - 신규 컨트롤러·매퍼·JSP·POJO 만 추가 → 기존 게임/리뷰/잼 동작 0 영향. jam_scores/jam_score_stats 는 W2-3 동결이라 본 설계가 스키마 무변경. - jam_criteria 등록 전(criteria 0행)이면 화이트리스트가 비어 모든 criterion_key 가 422 — 운영상 criteria 선등록 필요(순서 4). ### 롤백 - 코드 롤백: JamScoringController/3매퍼/JamEvalWindow 되돌리면 점수 경로 미노출. 동결 스키마는 추가 전용이라 잔존 무해(데이터 보존, 비파괴). jam_scores 행은 남아도 다른 도메인에 영향 0(읽는 곳이 본 설계뿐). - 스키마 롤백 없음(DDL 0). --- ## AC 매핑 | AC | 요구(골자 W2-4 + 확정 결정) | 만족 설계 요소 | 비고 | |---|---|---|---| | AC-1 | 점수 입력 API POST/PUT `/jams/{jamId}/games/{gameId}/scores` | JamScoringController submitScores(POST+PUT 동일핸들러) | S1, §API | | AC-2 | 심사위원 자격 게이트(isJudge) | jamRoleGate.isJudge(session, jamId) → 아니면 403 | S2-1, §게이트연동 | | AC-3 | 평가기간 게이트(EVAL + now∈구간) | JamEvalWindow.isOpen(jam, now) → 아니면 422 | S2-2, W2-3 F6 | | AC-4 | 자기출품 충돌(자기 출품작 채점 불가) | W2-2 isOwnEntry 호출 → 참이면 422 | S2-3, W2-2 | | AC-5 | 상태변경 CSRF 전수 | submitScores 진입부 CsrfTokens.isValid → 403 | §API 공통 | | AC-6 | criterion별 점수 1~5 입력 | jam_scores score 1~5(동결 CHECK) + 앱 범위 검증 | S1, S8 | | AC-7 | jam_scores UPSERT(멱등 재입력) | upsertScore ON CONFLICT (4키) DO UPDATE | S3, 동결 UNIQUE | | AC-8 | 집계 = jam_score_stats VIEW(가중 종합) | JamScoreStatsMapper.listStatsByJam(큰따옴표 alias) | S4, W2-3 G6 | | AC-9 | 심사위원 평균 + 동률 처리 명시 | VIEW weighted_total/simple_total + 결정론 정렬(judge_count,game_id) | S7/난제3 | | AC-10 | 평가기간 내 수정 허용, 종료 후 잠금 | 입력·수정 동일 평가기간 게이트(EVAL 이탈→422) | S5, S1 | | AC-11 | 점수 미입력 criterion 처리 | 미입력=행 부재→VIEW per_criterion 에서 제외(가중 분모도) | S6/난제2 | | AC-12 | 심사위원 부분입력 처리 | batch 배열에 입력분만 포함, judge_count 로 참여 가시화 | S6/난제2 | | AC-13 | criterion_key 화이트리스트(미등록 거부) | jamCriteriaMapper.listByJam 화이트리스트 → 미등록 422 | S8 | | AC-14 | 권한/평가 SQL `${}` 0 | 신규 3매퍼 `#{}` only | §파일영향맵 | | AC-15 | 신규 테이블 0(동결 소비) | DDL 0, schema.sql 무변경 | §데이터모델 | --- ## 검증 포인트 (verification-advisor 점검 대상) > L레벨 매핑(verification-strategies): 자격/기간/충돌 게이트·점수 입력 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias·UPSERT ON CONFLICT·집계 VIEW 소비 = **L1+L2(dev DB contract)**. 신규 컨트롤러·매퍼 의존 = full `./mvnw -o test` 의무(§30). ### 시나리오 검증 - **VP-1 (AC-2/3/4 3중 게이트, L1+L3)**: JamScoringControllerTest — isJudge 통과/미심사위원 403, EVAL+구간내 통과/기간밖·status≠EVAL 422, 자기출품 충돌 422, 미인증 401. 게이트 순서(CSRF→인증→자격→기간→출품작→충돌→criterion)대로 첫 실패 지점 응답 검증. L3 스모크: 심사위원이 EVAL 잼 출품작 채점 성공. - **VP-2 (AC-7 UPSERT 멱등, L1+L2)**: 같은 (jam,game,judge,criterion) 재입력 시 INSERT 아닌 UPDATE(행 수 불변, score·updated_at 갱신). dev DB contract: ON CONFLICT (4키) 실측 — ux_jam_scores_jam_game_judge_criterion(W2-3 동결) 타깃 정합. - **VP-3 (AC-8/9 집계 정합, L2)**: jam_score_stats 소비 — 다수 심사위원·다수 criterion 입력 시 fan-out 없이 weighted_total 정확, 정렬(weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC) 결정론. (W2-3 가 VIEW 자체 fan-out/0-division 을 검증 — 본 설계는 소비 정렬·alias 정합 검증.) - **VP-4 (AC-11/12 부분입력·미채점, L1+L2)**: criterion 일부만 입력 시 미입력 criterion 이 종합에서 제외(weight 분모 포함), judge_count 가 부분참여 반영. 트랜잭션 원자성: 미등록 criterion 포함 배열은 전체 422(부분저장 0). - **VP-5 (AC-5 CSRF, L1)**: 점수 입력 CSRF 누락 → 403 + mapper 미호출(deleteCommentRejectsMissingCsrfBeforeMapperAccess 패턴 준용, grounding 선례). - **VP-6 (AC-13 화이트리스트, L1)**: jam_criteria 미등록 criterion_key 입력 → 422, jamScoresMapper.upsertScore 미호출. - **VP-7 (DB-방언 계약, L2)**: JamScoreStatsMapper 반환 키가 weightedTotal/simpleTotal/scoredCriteria/judgeCount 로 정합(집계 VIEW camelCase 큰따옴표 alias 확인 — GameReviewStatsMapper.java:13 케이스폴딩 BUG-2 선례 회피). JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias(일반 매퍼 표준). - **VP-8 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 3매퍼 @MockBean 등록 후 PASS(§30). JamScoringController 가 주입하는 JamRoleGate(W2-2)·PermissionGate·JamsMapper·JamEntriesMapper 빈 가용성 확인. 누락 시 NoSuchBeanDefinitionException. ### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용) > self-audit(시점): 아래 카운트는 **본 W2-4 가 신규 생성하는 정적 산출물**(매퍼·게이트·API 핸들러)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 동결 스키마(jam_scores/jam_score_stats/jam_criteria)는 W2-3 권위 — 본 설계가 수정 0이므로 그 카운트는 W2-3 검증 소관(여기서 재카운트 안 함). > self-audit(표현): 게이트 호출·핸들러 열거는 단일 리터럴 grep 취약성을 피해 **메서드 열거 + 진입부 헬퍼 호출** 구조적 불변식에 앵커(수동 판정). 매퍼 `${` 0건·신규 DDL 0건은 부재 검증이라 리터럴 정당. - **AC-T1 점수 입력 핸들러 3중 게이트 전수** — 점수를 **저장하는** 핸들러(submitScores: POST+PUT 동일 메서드 1개)가 진입부에서 게이트 3종(isJudge / JamEvalWindow.isOpen / 자기출품충돌) + CSRF 전수 통과: 수동 판정으로 submitScores 진입 시퀀스에 4가드(CSRF+3게이트) 전수 존재 확인. 1건이라도 누락 = 인가/기간/충돌 우회 보안결함 → FAIL. **이 전수 AC 가 S2 enforcement 의 핵심 가드**(리터럴 grep 단독 의존 회피 — 메서드 본문 게이트 호출 열거). - **AC-T2 신규 매퍼 전수 3개 `${` 0건** — 신규 매퍼 3파일(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper)에 `${` 매치 0: `grep -rc '\${' <매퍼 3파일>` == 0 (AC-14, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당. - **AC-T3 집계 VIEW 매퍼 alias 큰따옴표 전수** — JamScoreStatsMapper(집계 VIEW 소비)의 camelCase alias 전수가 큰따옴표: jam_score_stats 종합 4컬럼(weightedTotal/simpleTotal/scoredCriteria/judgeCount) 의 alias 가 `AS "..."` 형태(케이스 폴딩 회피, §33). 검증: JamScoreStatsMapper 의 `AS "` 출현이 camelCase alias 수와 일치(수동 판정 — 일반 매퍼 JamCriteriaMapper/JamScoresMapper 는 snake→camel 직접 alias 라 큰따옴표 불요·있으면 안 됨). - **AC-T4 신규 테이블/VIEW 0건 불변식** — 본 W2-4 가 스키마를 추가/변경하지 않음: 본 워크스트림 파일 영향에 `docs/*-ddl.sql` 신규 0 AND `db/schema.sql` diff 0 AND 신규 매퍼에 `CREATE TABLE`/`ALTER TABLE`/`CREATE VIEW` 토큰 0. 검증: `grep -rE 'CREATE TABLE|ALTER TABLE|CREATE OR REPLACE VIEW' ` == 0(매퍼는 SELECT/INSERT ON CONFLICT 만). 동결 소비 계약(W2-3 권위) 위반(W2-4 가 스키마 손대기) 즉시 검출. - **AC-T5 401/403/422 정책 응답 전수** — 점수 입력 게이트 실패 분기 전수가 정책대로: 미인증→401, 미심사위원→403, CSRF→403, 평가기간외/자기출품/미등록criterion/score범위→422, 출품작없음→404. 검증: JamScoringControllerTest 가 5분류(401/403/404/422 각) 케이스 전수 보유(테스트 메서드 열거) AND 각 응답 status 코드 정합. 정책 분류 누락(예: 자기출품을 403 으로 잘못 응답) 검출. - **AC-T6 신규 매퍼 @MockBean 전수 3건** — BibimbapApplicationTests 에 신규 3매퍼 @MockBean 전수 등록: contextLoads PASS AND 3매퍼(JamCriteriaMapper/JamScoresMapper/JamScoreStatsMapper) 등록 수동 확인. 1건 누락 시 contextLoads FAIL 로 즉시 검출(§30, verification 시점 자기 검증). --- ## 잔여 오픈 질문 없음(0). 확정 결정 S1~S8 전제 고정. 세 난제(3중 게이트 순서·401/403/422 정합 / 부분입력·미채점 집계 의미 / 동률·심사위원 평균)는 본 설계가 구체 메커니즘으로 확정. 집계 노출 시점(EVAL 중 관리자만/CLOSED 후 공개)·POST+PUT 동일핸들러·UPSERT ON CONFLICT·criterion 화이트리스트도 확정. 구현 점검 항목(W2-2 게이트 시그니처/자기출품 충돌 위치 재확인·신규 매퍼 @MockBean full-test·VIEW alias 큰따옴표·ON CONFLICT dev contract·criterion_key 화이트리스트·헬퍼 시그니처 inflate)은 오픈 질문이 아니라 `concerns` 로 이관.