---
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 별도 테이블로 풀지 (설계 결정 — 의도적 비해소).
