Compare commits

...

5 Commits

Author SHA1 Message Date
이정수 f81991b409 chore(atp): work-session 산출물 기록 (3개 세션)
work-session 은 추적 대상(durable history)이나 3개 디렉토리가 untracked 로
남아 있던 것을 소급 기록 — 추적 관행 일관성 복원.

- 20260622-162345: 이번 세션(로컬 DDL 적용 + 커밋 정책).
- 20260622-092800: 오늘 선행 세션.
- 20260618-145152: 6/18 orphan(후속 W3-2 작업에 이미 계승됨).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:59:18 +09:00
이정수 5a8080ddd3 docs(dev): 비-메인스트림 브랜치 커밋 표준 승인 정책 추가
비-메인스트림 브랜치(main/master·보호 브랜치 외)에서는 작업 단위마다 매번
묻지 않고 커밋하도록 표준 승인(durable authorization)을 명문화. 하니스
기본규칙('사용자 요청 시에만 커밋')과 모순 없음 — 프로젝트 지침이 사전·상시
승인을 부여하는 형태. push 는 브랜치 무관 명시 요청 한정, 메인스트림은
branch-first 유지.

- docs/development/git-workflow.md: 정본 신설. 브랜치 분류·표준 승인·push
  제외·커밋 단위·Conventional Commits + Co-Authored-By 트레일러 규약.
- CLAUDE.md 작업 원칙: 표준 승인 요지 2불릿 + 정본 링크.
- docs/development/index.md: 정본 등재.

ADR 아닌 development 규칙으로 분류(가역적 운영 절차). 부수 발견: 보고서들이
참조하는 ADR-0010 은 docs/adr/ 에 실재하지 않는 phantom 참조.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:59:09 +09:00
이정수 3593c82d78 feat(db): 로컬 DDL 즉시적용 스크립트 + 셋업 절차 문서화
마이그레이션 도구(flyway/liquibase) 부재. db/schema.sql 은 컨테이너 최초
기동 시 1회만 자동 주입되어, 이후 docs/*-ddl.sql 변경은 실행 DB 에 미반영.

- db/apply-local-ddl.sh: docs/*-ddl.sql 을 실행 컨테이너 dev 스키마에 멱등
  적용(search_path 강제, ON_ERROR_STOP=1, .env 접속정보 로드, 컨테이너 가드).
- docs/usage/local-setup.md §4.1: 신규/변경 DDL 즉시 적용 절차 + 작성자 규약
  (DDL 파일 / 실행 DB / db/schema.sql 세 곳 동기화).

검증: game-reviews-ddl.sql 적용 후 game_review_axes·game_review_stats·
game_comments.updated_at·game_reviews.is_rating_manual 생성 확인, 스크립트
전체 멱등 재실행 성공.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:58:59 +09:00
이정수 375a2de0ff docs(graph): W3-2 댓글/리뷰 반영 graph 메타 갱신
src+docs 양 scope 재생성 (fully-stale 판정 처리, no-defer).
- src: 536노드/1206엣지/20커뮤니티 — 리뷰 다축평점·댓글 commentView·edited 토폴로지 반영
- docs: 119노드/164엣지/15커뮤니티 — C3 재분류·육각형·axes/stats DB 스키마·검증교훈 군집
source_commit b9d836d 동기화. graph 본체는 .gitignore(재생성 가능).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:40:24 +09:00
이정수 b9d836d5f4 feat: W3-2 댓글/리뷰 고도화 — 다축 평점·육각형 레이더·페이지네이션·집계뷰
W3-2 코어 위에 FINAL SPEC 전체 구현 (resumed_from 20260618-145152).

- 다축 평점: game_review_axes(6축·리뷰당6행) + overall 자동평균/수동(is_rating_manual)
- 육각형 SVG 레이더(요약+카드, frontend-design 폴리시), 6축 radiogroup(roving tabindex)
- A1 commentView 통일 / A2 하이브리드 작성자명+탈퇴자 마스킹(QG-2) / A3 edited·updated_at
- B1 페이지네이션(limit+1 hasMore) + sort 화이트리스트(@SelectProvider, ${} 미사용)
- B2 본문 최소10자 / B3 TextNormalizer 정규화
- C3 game_review_stats 집계뷰(W3-2 일반 DDL, 잼평가 동결 무관). 클라 평균계산 폐기
- DDL 멱등 ALTER/CREATE (game-reviews-ddl.sql + db/schema.sql 동기화)

검증: ./mvnw test 43/43 GREEN(회귀0). DDL 적용·L3 스모크 = 사용자 환경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:29:21 +09:00
36 changed files with 4195 additions and 209 deletions

View File

@ -0,0 +1,162 @@
---
schema_version: 2
sid: 20260618-145152
started_at: 2026-06-18T14:51:52+09:00
ended_at: 2026-06-18T14:58:00+09:00 # 인터뷰 1차에서 사용자 지시로 일시정지 (구현 다음 세션)
user_request: |
W3-2 댓글/리뷰 고도화 — 인터뷰 주도 디테일 빌드.
W3-2 코어(2026-06-18 구현·검증 완료) 유지하며 세세한 디테일을 사용자 인터뷰로 누적.
바로 설계·구현 금지. 질문 시드 카탈로그(A 일관성 / B 목록규모 / C UX)를 한 축씩
AskUserQuestion 으로 물어 결정 누적 → design-advisor → 구현 → L1 회귀 31건 + 신규 → L3 스모크.
mode: interview-first (orchestrator 직접 인터뷰, requirements-advisor 대체)
---
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '카탈로그가 요구 분해를 이미 제공 — orchestrator 가 AskUserQuestion 인터뷰로 직접 스코프 확정(task 명시)'
checked_at: 2026-06-18T14:52:00+09:00
- advisor: design-advisor
decision: deferred-to-next-session
rationale: '인터뷰 완료·FINAL SPEC 사용자 확정. 단 사용자 지시로 구현은 다음 세션 → design-advisor 도 다음 세션 진입. 선결: W2-3 freeze 근거 확인'
checked_at: 2026-06-18T15:10:00+09:00
- advisor: retrospective-advisor
decision: skip
rationale: '코드 변경 0줄·구현 미시작 인터뷰 전용 세션. 회고 입력(구현/검증 결과) 부재 → 구현 세션 종료 시 수행'
checked_at: 2026-06-18T15:10:00+09:00
# Summary
인터뷰 완료 — 카탈로그 전 축(A1·A2·A3 / B1·B2·B3 / 리뷰정책4 / C1~C6) 결정 누적 완료.
사용자 지시로 이 세션은 **결정·기록만, 구현은 다음 세션**. 코드 변경 0줄(Serena/grep 탐색만).
다음 세션: 새 sid + `resumed_from: 20260618-145152`, 아래 FINAL SPEC 으로 design-advisor 진입.
## ⚠️ 다음 세션 선결·고위험 항목
- **C3 = W2-3 평점 집계 동결 해제**: 설계 진입 전 W2-3 freeze 근거(ADR/문서) 필독. §6 파괴적 게이트 인접 — orchestrator 사용자 재확인. 회귀 검증 필수
- **다축 평점 = 대규모 신규**: game_review_axes 테이블 + 6축 입력 UI + overall 자동평균/수동덮어쓰기 + game_review_stats 뷰(축별 평균). 작업량·테스트 비중 큼
- 입력 부담: 리뷰 제출 = 6축 필수 + 본문 10자 — UX 마찰 인지
# FINAL SPEC (✅ 사용자 최종확인 완료 2026-06-18 — 다음 세션 design-advisor 입력)
## A. 일관성
- **A1** 댓글 응답 = `commentView` 단일 통일(POST·PUT·list 전부). POST는 insert 후 재조회(createReview 패턴)
- **A2** 작성자명 = 하이브리드 `COALESCE(u.display_name, c.nickname)` + LEFT JOIN. user_id 있으면 현재닉(개명반영), NULL이면 스냅샷(QG-2 해결). createComment nickname 스냅샷 저장 유지
- 탈퇴자: 댓글 보존(LEFT JOIN) / 리뷰 현행 제외 유지(INNER JOIN). 탈퇴자 댓글 이름 = "(탈퇴한 사용자)" 마스킹: `CASE WHEN u.id IS NULL THEN c.nickname WHEN u.is_delete THEN '(탈퇴한 사용자)' ELSE u.display_name END`
- **A3** 댓글 edited+updatedAt 추가. game_comments.updated_at 멱등 ALTER + 매퍼/모델 갱신. editGameComment 시 updated_at 갱신, nickname 미덮어씀
## B. 목록 규모
- **B1** 페이지네이션 offset/limit + "더보기", 20건/페이지. 정렬 토글: 댓글(오래된순 기본/최신순), 리뷰(최신순 기본/별점 높은순/낮은순, tiebreaker rating·created_at·id). sort 화이트리스트 enum→고정 SQL(${} 금지)
- **B2** 리뷰 본문 필수 + 최소 10자(별점만 제출 불가). 댓글은 현행(200자 상한, 최소 없음)
- **B3** 본문 정규화 = 제어문자 제거 + 외곽 trim(댓글·리뷰 공통). 내부 연속공백·줄바꿈 보존
## 리뷰 정책
- 게임당 **1회 유지**(ux 유니크), 수정 **무제한 유지**(edited 마커)
- **다축 평점**: overall rating 유지(W2-3 집계용) + game_review_axes 신규 테이블(review_id FK, axis_key enum, score). 축 6개 **몰입성·창의성·조작성·완성도·사운드·비주얼**, 각 1~5 정수 **전 축 필수**. UI: overall = 축 평균 자동계산(정수 반올림)되다 유저 직접선택 시 덮어쓰기(수동 우선, 출처 구분 플래그 검토)
- **다축 시각화 = 육각형 레이더 차트(hexagon)**:
- 위치 = **요약 + 개별 리뷰 둘 다** (요약=game_review_stats 6축 평균 육각형 / 각 리뷰 카드=그 리뷰 6축 육각형)
- 렌더 = **인라인 SVG 자체 구현** (polygon points 계산, 기존 JSP 인라인 SVG idiom·빌드 없음·의존성 0). 차트 라이브러리/CDN 배제
- 입력은 **6개 별점 위젯 유지(C6)** — 육각형은 표시 전용(레이더 드래그 입력 a11y 제외)
- **a11y**: SVG 시각요소엔 텍스트 대체 필수 — 축별 점수 aria-label 또는 인접 텍스트/표(스크린리더). 폴리곤만으로 끝내지 말 것
- 개별 리뷰 카드 육각형은 컴팩트 사이즈(페이지당 20건 DOM 고려)
- 신고·숨김 **보류**(W1 운영자 role 선행)
## C. UX (game-detail.jsp)
- **C1** submit 잠금 + 로딩 상태(제출 중 disabled)
- **C2** 상대시각 "n분 전" + title 절대시각(fmtDate 확장)
- **C3** ⚠️ W2-3 동결 해제 + game_review_stats **뷰**(game_id, avg_rating, review_count + 6축 평균). 표시 = 소수1자리 + 반올림 별 + 0개 "아직 평가 없음". client 계산 폐기(페이지네이션 정합)
- **C4** 실시간 글자수 카운터(123/200, 리뷰 최소10자 안내). 댓글·리뷰 둘 다
- **C5** 삭제 = 완전 소멸 유지(현행)
- **C6** 별점 위젯 완전 radiogroup 패턴(roving tabindex + 화살표/Home/End), 다축 6위젯 적용
# Grounded Baseline (evidence)
- GameCommentController 응답 3종 불일치:
- POST createComment flat: status,message,commentId,gameId,authorName,userId,content (createdAt/edited 없음)
- PUT updateComment 부분: status,message,commentId,content
- list commentView: commentId,gameId,authorName,userId,content,createdAt (edited 없음)
- GameReviewController 이미 통일: POST/PUT/list/get 전부 reviewView 사용
- reviewView: reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt
- 댓글 작성자명 = nickname 스냅샷 (createComment: session displayName 저장)
- GameCommentData 필드: id,gameId,userId,nickname,content,createdAt,deletedAt — **updatedAt 없음**
→ A3(댓글 수정됨 마커)는 game_comments.updated_at ALTER 필요 (무료 아님)
- createReview: rating 필수, body trimToEmpty 후 length 검사만 → 빈 본문 리뷰 허용 (B2)
- trimToNull/trimToEmpty = 외곽 trim 만; 내부 연속공백/제어문자 미처리 (B3)
- DDL game-reviews-ddl.sql: game_reviews(updated_at 보유), game_comments user_id nullable(레거시 보존)
- W2-3 평점 집계 스키마 동결 / QG-2 레거시 user_id NULL 댓글 미결
- **A2 작성자명 메커니즘 분기 (신규 발견)**:
- 리뷰 = `JOIN users u, u.display_name AS authorName` (GameReviewsMapper) → 항상 현재 닉네임(live)
+ `u.is_delete IS NOT TRUE` 조건 → **탈퇴자 리뷰는 목록서 제외됨**
- 댓글 = `nickname` 컬럼 스냅샷(작성 시점 고정, GameCommentsMapper listGameComments:36) → 탈퇴자 댓글도 표시
- 댓글 매퍼에 미사용 `updateGameComment`(nickname 갱신 가능) 존재 (:81) — A2/A3 설계 시 활용 여지
- QG-2 결합: 레거시 user_id NULL 댓글은 JOIN 불가 → live-join 채택 시 스냅샷 fallback 필수
# Decisions (누적)
- A3 댓글 수정됨 마커: **edited+updatedAt 추가** (리뷰 대칭). game_comments.updated_at 멱등 ALTER + 매퍼 갱신 필요. [2026-06-18]
- A1 응답 스키마: **commentView 단일 통일** — POST·PUT·list 전부 commentView 반환(commentId,gameId,authorName,userId,content,createdAt,edited,updatedAt). POST는 insert 후 재조회(createReview 패턴). 프론트 렌더 1함수. [2026-06-18]
# Invocations
[]
# user_signals
positive: []
negative: []
# verified_by_me
(없음 — 코드 변경 0줄, 인터뷰만 수행. L1/L2/log-scan 비대상)
# needs_user_verification
- 다음 세션: 인터뷰 재개(아래 어젠다) → 결정 충분 시 design-advisor → 구현 → L1 회귀 31건+신규 → L3 스모크.
# graph_refresh
skip: 코드 변경 0줄 (graph-refresh-checker 비대상)
# Next-session agenda (인터뷰 재개 지점)
## 결정 잠금 (재확인만)
- A1 = commentView 단일 통일 (POST·PUT·list 전부 commentView; POST insert 후 재조회)
- A3 = 댓글 edited+updatedAt 추가 (game_comments.updated_at 멱등 ALTER + 매퍼/모델 갱신)
- 종속: A1 통일 시 commentView 가 edited/updatedAt 자동 포함 → 두 결정 한 묶음 구현
- A2 = **하이브리드 작성자명**: `COALESCE(u.display_name, c.nickname)` + **LEFT JOIN users** (GameCommentsMapper getGameComment/listGameComments 둘 다)
- user_id 있으면 현재 닉네임(개명 반영, 리뷰와 일관) / NULL이면 스냅샷 nickname → **QG-2 자동 해결**
- createComment 의 nickname 스냅샷 저장은 **유지**(fallback 안전망). 미사용 updateGameComment(:81)는 edit 시 nickname 덮어쓰지 않도록 주의
- A2-탈퇴자 = **댓글 보존 / 리뷰 현행(제외) 유지**: 댓글 LEFT JOIN(is_delete 무필터) → 탈퇴자 댓글 표시. 리뷰 INNER JOIN+`u.is_delete IS NOT TRUE` 그대로 → 리뷰 스코프·31건 테스트 무영향
- A2-탈퇴자-이름 = **"(탈퇴한 사용자)" 마스킹**. 댓글 SQL: `CASE WHEN u.id IS NULL THEN c.nickname WHEN u.is_delete THEN '(탈퇴한 사용자)' ELSE u.display_name END AS authorName` (LEFT JOIN). 탈퇴 플래그 노출 불필요 — 이름만 대체
- B1 = **페이지네이션 도입 + 기본 정렬 현행 유지** (댓글 오래된순 ASC / 리뷰 최신순 DESC 기본). 전건 반환 폐기 → offset/limit 또는 cursor (방식·크기·정렬옵션은 후속 질문)
- B1-방식 = **offset/limit + "더보기" 버튼, 페이지당 20건**. page/offset 쿼리 파라미터
- B1-정렬 = **댓글·리뷰 모두 정렬 토글**:
- 댓글: 오래된순(기본)/최신순
- 리뷰: 최신순(기본)/별점 높은순/낮은순 — 별점순 정렬은 tiebreaker 필요(rating DESC/ASC, created_at DESC, id DESC)
- API sort 파라미터 신설 + 프론트 정렬 컨트롤. 매퍼 정렬절 동적화(단 `${}` 금지 — sort 화이트리스트 enum→고정 SQL 분기로 #{} 안전 유지)
- B3 = **제어문자 제거 + 외곽 trim** (댓글 content·리뷰 body 공통). 탭/개행 외 제어문자 strip, 내부 연속공백·줄바꿈은 보존. textContent 렌더라 보안 아닌 저장 위생 목적
- B2 = **리뷰 본문 필수 + 최소 10자** (별점만 제출 불가). createReview/updateReview body 검증 추가. 댓글은 별도(200자 상한만, 최소 없음 — 변경 안 함)
- 확장 리뷰 정책 4영역:
- ① 게임당 1회 제한 = **1회 유지(현행)** — ux_game_reviews_game_user_active 유지, 무결성 우선
- ② 수정 제한 = **무제한 수정 유지(현행)** — edited 마커로 투명성
- ③ 별점 척도 = **다축 평점 도입**. 세부:
- **overall rating 유지 + 축 별도** (W2-3 집계 동결 안 건드림 — 집계는 game_reviews.rating 그대로). 축은 game_review_axes 신규 테이블
- **UI 동작**: overall 별점 = 축 점수 평균으로 자동계산·표시되다가, 유저가 overall 을 직접 선택하면 그 값으로 강제 덮어쓰기 (수동 우선)
- 설계 미결: 자동평균→정수 rating 반올림 정책(C3 평균 별점 정책과 연동) / rating 수동·자동 출처 구분 필요(예 is_rating_manual 플래그?)
- **축 6개**: 몰입성, 창의성, 조작성, 완성도, 사운드, 비주얼
- 축 척도 = **1~5 정수, 전 축 필수** (overall 과 동일, 자동평균 결정적)
- 스키마 = **game_review_axes 테이블** (review_id FK + axis_key + score, 리뷰당 6행). axis_key enum 6종. DDL 신규 테이블 + FK + 인덱스(review_id)
- 다축 평점 = **풀스펙 확정**. 입력 부담(6축 필수 + 본문 10자)은 인지하고 진행
- ④ 신고·숨김 = **도입 보류(W1 선행)** — 운영자 role 활성 후 신고+숨김 함께. 현 세션 스코프 제외
## 미결 — 다음 세션 질문 순서 (1~2축씩, AskUserQuestion, 추천 맨앞+근거+트레이드오프)
3. **C UX (game-detail.jsp)** — JSP grounding 완료:
- C1 연타 방지 = **submit 잠금 + 로딩 상태** (제출 중 disabled, 응답 후 해제). 댓글 중복 POST 차단
- C2 시간 표시 = **상대시각 "n분 전" + title 속성에 절대시각** (fmtDate:1202 대체/확장)
- C3 평균 별점 = **⚠️ W2-3 동결 해제 + 집계 컬럼/뷰 신설** (사용자 informed 선택, 고위험 — 회귀 검증 필수). 표시 = **소수1자리 + 반올림 별 + 0개는 "아직 평가 없음" 문구**
- 모순 해소: client 계산은 B1 페이지네이션과 충돌 → 서버 집계로 전환 확정
- **다음 세션 설계 선결조건**: W2-3 freeze 근거(ADR/문서) 먼저 읽고 무엇이 왜 동결됐는지 파악 후 해제. §6 파괴적 게이트 인접 — orchestrator 사용자 확인 경로
- 리뷰별 overall 자동평균(축→overall)은 rating 정수라 정수 반올림 / 전체 평균 표시는 소수1자리 (반올림 컨텍스트 2종 구분)
- 집계 방식 = **뷰(VIEW) 기반** — game_review_stats 뷰(game_id, avg_rating, review_count + 6축 평균). 저장 컬럼 비신설(파생 객체만) → 동결 해제 중 회귀 표면 최소
- 축별 평균 = **overall + 6축 평균 모두 요약 표시**. UI에 축별 막대/점수. 뷰가 축별 AVG(game_review_axes JOIN) 포함
- C4 글자수 카운터 = **실시간 카운터** (123/200 형식, 리뷰는 최소 10자 미달 안내). 댓글·리뷰 textarea 둘 다 + (다축 도입 시 6축 위젯 입력 상태도 고려)
- C5 삭제 표시 = **완전 소멸 유지(현행)** — soft-delete + is_delete 필터. 댓글 스레드 없어 tombstone 불필요
- C6 접근성 = **완전 radiogroup 패턴** — roving tabindex(위젯당 단일 탭정지) + ←→↑↓·Home/End 별점 이동, aria 유지. 다축 6위젯 동일 적용. 현 위젯은 화살표 핸들러 없음(grep 확인) — 신규 추가
- grounding 근거: 별점위젯:1043(role=radiogroup/radio+aria 기구현), 평균:1034, 댓글ta:1080(max200), 리뷰ta:1052(max1000), 제출버튼:538(disabled 없음)
## 인터뷰 종료 후 (task 명시)
1. 누적 결정 최종 확인 → 2. design-advisor: 파일 소유권 맵 + 멱등 ALTER DDL → 3. 구현 → L1 31건 회귀+신규 → L3 스모크 → 4. docs/changes/ 이력 기록
# open_items
- `.atp/work-session/20260618-145152/report.md` (신규, untracked) — 세션 기록. 커밋은 사용자 지시 시 (CLAUDE.md 커밋 정책: 명시 요청 시만).
- src/ 변경 0건 — 구현 미시작.

View File

@ -0,0 +1,364 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>W3-2 UI 프리뷰 — 육각형 레이더 · 6축 평점 · 카드</title>
<style>
html {
color-scheme: light;
--surface: #faf8f5; --card-bg: #fff; --text: #1a1a1a; --text-muted: #5c5c5c;
--accent: #e8a54b; --accent-soft: rgba(232,165,75,0.14);
--border: rgba(0,0,0,0.08); --panel-shadow: 0 4px 24px rgba(26,26,26,0.06); --radius: 16px;
}
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212; --card-bg: #1e1e1e; --text: #ece8e1; --text-muted: #a39e96;
--border: rgba(255,255,255,0.1); --accent-soft: rgba(232,165,75,0.12);
--panel-shadow: 0 8px 32px rgba(0,0,0,0.35);
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; padding: 2rem 1rem 4rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
color: var(--text);
}
html:not([data-theme="dark"]) body { background: linear-gradient(165deg,#fff9f0 0%,var(--surface) 45%,#f3ede6 100%); background-attachment: fixed; }
html[data-theme="dark"] body { background: linear-gradient(165deg,#1c1a18 0%,var(--surface) 38%,#0e0e0e 100%); background-attachment: fixed; }
.wrap { max-width: 56rem; margin: 0 auto; }
.preview-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.preview-bar h1 { font-size: 1.1rem; margin: 0; letter-spacing: -0.02em; }
.preview-bar button { padding: 0.45rem 1rem; font: inherit; font-weight: 700; font-size: 0.8rem; cursor: pointer;
color: var(--text); background: var(--card-bg); border: 1px solid var(--border); border-radius: 999px; }
.game-panel { padding: 1.4rem 1.5rem 1.6rem; margin-bottom: 1.5rem; background: var(--card-bg);
border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--panel-shadow); }
.game-panel__title { margin: 0 0 0.2rem; font-size: 1.05rem; letter-spacing: -0.02em; }
.game-panel__subtitle { margin: 0 0 1.1rem; font-size: 0.8rem; color: var(--text-muted); }
.section-tag { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--accent); margin: 0 0 0.6rem; }
/* ===== 아래는 game-detail.jsp 승격본과 동일한 W3-2 컴포넌트 스타일 ===== */
.game-reviews__summary { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; }
.game-reviews__avg { font-size: 1.35rem; font-weight: 800; letter-spacing: -0.02em; color: var(--accent); }
.game-reviews__count { font-size: 0.75rem; font-weight: 600; color: var(--text-muted); }
.game-stars--avg, .game-stars--display { display: inline-flex; gap: 0.05rem; font-size: 0.95rem; line-height: 1; }
.game-stars--avg .game-star, .game-stars--display .game-star { color: var(--border); }
.game-stars--avg .game-star.is-on, .game-stars--display .game-star.is-on { color: var(--accent); }
.game-reviews__summary-panel { margin: 0 0 1.25rem; display: flex; align-items: center; gap: 1.25rem;
padding: 1rem 1.25rem;
background: radial-gradient(125% 125% at 0% 0%, var(--accent-soft), transparent 62%), var(--card-bg);
border: 1px solid rgba(232,165,75,0.22); border-radius: var(--radius); box-shadow: var(--panel-shadow); }
@media (max-width: 480px) { .game-reviews__summary-panel { flex-direction: column; align-items: stretch; gap: 0.85rem; } .game-reviews__radar { justify-content: center; } }
.game-reviews__radar { display: inline-flex; flex-shrink: 0; }
.game-reviews__radar svg { display: block; }
.game-axis-radar { overflow: visible; }
.game-reviews__radar .game-axis-grid { fill: none; stroke: var(--border); stroke-width: 1; }
.game-reviews__radar .game-axis-grid--outer { stroke: rgba(232,165,75,0.4); stroke-width: 1.25; }
.game-reviews__radar .game-axis-line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 3; }
.game-reviews__radar .game-axis-poly { stroke: var(--accent); stroke-width: 1.75; stroke-linejoin: round;
filter: drop-shadow(0 2px 7px rgba(232,165,75,0.38)); transform-box: fill-box; transform-origin: center;
animation: game-radar-pop 0.55s cubic-bezier(0.22,1,0.36,1) both; }
.game-reviews__radar .game-axis-dot { fill: var(--accent); stroke: var(--card-bg); stroke-width: 1.5;
animation: game-radar-fade 0.7s ease both; }
@keyframes game-radar-pop { from { opacity: 0; transform: scale(0.55); } to { opacity: 1; transform: scale(1); } }
@keyframes game-radar-fade { from { opacity: 0; } to { opacity: 1; } }
.game-reviews__radar-empty { font-size: 0.75rem; color: var(--text-muted); }
.game-reviews__axis-legend { margin: 0; flex: 1; min-width: 11.5rem; display: grid; gap: 0.4rem 0; font-size: 0.75rem; color: var(--text-muted); }
.game-reviews__axis-legend-row { display: grid; grid-template-columns: 3.4rem 1fr 1.15rem; align-items: center; gap: 0.55rem; }
.game-reviews__axis-legend dt { font-weight: 600; color: var(--text); }
.game-reviews__axis-legend dd { margin: 0; font-variant-numeric: tabular-nums; font-weight: 700; text-align: right; color: var(--accent); }
.game-reviews__axis-meter { height: 0.4rem; border-radius: 999px; background: var(--border); overflow: hidden; }
.game-reviews__axis-meter span { display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg,#f0c978,var(--accent)); animation: game-meter-grow 0.7s cubic-bezier(0.22,1,0.36,1) both; transform-origin: left; }
@keyframes game-meter-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
/* 6축 입력 */
.game-reviews__composer { margin-bottom: 1.25rem; padding: 1.1rem 1.15rem 1.2rem;
background: radial-gradient(140% 120% at 100% 0%, rgba(232,165,75,0.16), transparent 60%), var(--accent-soft);
border: 1px solid rgba(232,165,75,0.22); border-radius: 14px; }
.game-reviews__axes { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0.5rem 1.1rem; margin-bottom: 0.85rem; }
@media (max-width: 480px) { .game-reviews__axes { grid-template-columns: 1fr; } }
.game-reviews__axis { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
padding: 0.3rem 0.5rem; border-radius: 10px; transition: background 0.15s ease; }
.game-reviews__axis:hover { background: rgba(232,165,75,0.08); }
.game-reviews__axis-label { font-size: 0.8125rem; font-weight: 600; color: var(--text); }
.game-stars { display: inline-flex; gap: 0.15rem; line-height: 1; }
.game-stars--axis { gap: 0.1rem; }
.game-stars__btn { padding: 0.1rem; font-size: 1.6rem; line-height: 1; color: var(--border);
background: none; border: none; cursor: pointer; transition: color 0.12s ease, transform 0.12s ease, text-shadow 0.2s ease; }
.game-stars--axis .game-stars__btn { font-size: 1.25rem; }
.game-stars__btn:hover { transform: scale(1.12); }
.game-stars__btn.is-on { color: var(--accent); text-shadow: 0 1px 4px rgba(232,165,75,0.45); }
.game-stars__btn:active { transform: scale(0.88); }
.game-stars__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.game-reviews__rating-row { display: flex; align-items: center; gap: 0.85rem; margin-bottom: 0.85rem; }
.game-comments__label { font-size: 0.8125rem; font-weight: 600; color: var(--text-muted); }
.game-comments__form textarea { width: 100%; min-height: 5rem; padding: 0.85rem 1rem; font: inherit; font-size: 0.875rem;
line-height: 1.55; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 12px; resize: vertical; }
.game-comments__form textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px rgba(232,165,75,0.18); }
.game-comments__actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem; margin-top: 0.65rem; }
.game-comments__hint { font-size: 0.6875rem; color: var(--text-muted); }
.game-comments__hint.is-warn { color: #b33; font-weight: 700; }
.game-comments__form button[type="submit"] { padding: 0.55rem 1.5rem; font: inherit; font-size: 0.875rem; font-weight: 700; color: #1a1a1a;
background: linear-gradient(180deg,#f0c978 0%,var(--accent) 100%); border: none; border-radius: 10px; cursor: pointer;
box-shadow: 0 2px 8px rgba(232,165,75,0.35); transition: transform 0.15s ease, box-shadow 0.2s ease; }
.game-comments__form button[type="submit"]:hover { box-shadow: 0 4px 14px rgba(232,165,75,0.45); }
button[aria-busy="true"] { opacity: 0.65; cursor: progress; }
/* sort 토글 / 더보기 */
.game-comments__toolbar { display: flex; justify-content: flex-end; margin-bottom: 0.85rem; }
.game-comments__sort { display: inline-flex; gap: 0.25rem; padding: 0.2rem; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; }
.game-comments__sort-btn { padding: 0.3rem 0.7rem; font: inherit; font-size: 0.6875rem; font-weight: 700; color: var(--text-muted);
background: transparent; border: none; border-radius: 999px; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; }
.game-comments__sort-btn.is-active { color: #1a1a1a; background: linear-gradient(180deg,#f0c978,var(--accent)); box-shadow: 0 1px 5px rgba(232,165,75,0.4); }
.game-comments__more { margin-top: 1rem; display: flex; justify-content: center; }
.game-comments__more-btn { padding: 0.5rem 1.4rem; font: inherit; font-size: 0.8125rem; font-weight: 700; color: var(--text);
background: var(--card-bg); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease, transform 0.15s ease, box-shadow 0.2s ease; }
.game-comments__more-btn:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); box-shadow: 0 5px 16px rgba(232,165,75,0.16); }
/* 리뷰/덧글 카드 */
.game-reviews__list, .game-comments__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.85rem; }
.game-reviews__item { padding: 0.95rem 1.1rem; font-size: 0.875rem; line-height: 1.55; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; }
.game-reviews__item.is-mine { border-color: rgba(232,165,75,0.4); background: var(--accent-soft); }
.game-reviews__item-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.65rem; margin-bottom: 0.5rem; }
.game-reviews__nick { font-size: 0.8125rem; font-weight: 700; letter-spacing: -0.02em; }
.game-reviews__mine-badge { padding: 0.05rem 0.45rem; font-size: 0.625rem; font-weight: 700; color: var(--accent); background: rgba(232,165,75,0.16); border-radius: 999px; }
.game-reviews__time, .game-comments__meta time { margin-left: auto; font-size: 0.6875rem; font-weight: 500; letter-spacing: 0.02em; color: var(--text-muted); }
.game-comments__edited, .game-reviews__edited { margin-left: 0.35rem; font-size: 0.6875rem; font-weight: 500; color: var(--text-muted); }
.game-reviews__body, .game-comments__item p { margin: 0; white-space: pre-wrap; word-break: break-word; }
.game-reviews__card-radar { margin-top: 0.6rem; display: inline-flex; }
.game-reviews__card-radar svg { display: block; }
.game-reviews__radar-empty { font-size: 0.75rem; color: var(--text-muted); }
.game-comments__item { display: flex; gap: 0.75rem; align-items: flex-start; padding: 0.85rem 1rem; font-size: 0.875rem; line-height: 1.55; background: var(--surface); border: 1px solid var(--border); border-radius: 14px; }
.game-comments__avatar { flex-shrink: 0; width: 2.25rem; height: 2.25rem; border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 800; color: var(--accent); background: var(--accent-soft); border: 1px solid rgba(232,165,75,0.25); }
.game-comments__meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.65rem; margin-bottom: 0.4rem; }
.game-comments__nick { font-size: 0.75rem; font-weight: 700; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
@media (prefers-reduced-motion: reduce) {
.game-reviews__radar .game-axis-poly, .game-reviews__radar .game-axis-dot, .game-reviews__axis-meter span, .game-comments__more-btn:hover { animation: none; transform: none; }
}
</style>
</head>
<body>
<div class="wrap">
<div class="preview-bar">
<h1>W3-2 UI 프리뷰 · 육각형 레이더 · 6축 평점</h1>
<button id="theme-toggle" type="button">🌙 다크</button>
</div>
<!-- 리뷰 패널 -->
<section class="game-panel">
<p class="section-tag">리뷰 요약 (game_review_stats)</p>
<div class="game-reviews__summary">
<span class="game-reviews__avg" id="avg">4.2</span>
<div class="game-stars game-stars--avg" id="avg-stars" aria-hidden="true"></div>
<span class="game-reviews__count">(37)</span>
</div>
<div class="game-reviews__summary-panel" id="summary-panel"></div>
<p class="section-tag">6축 평가 입력 (radiogroup · 키보드 ←→↑↓/Home/End)</p>
<div class="game-reviews__composer">
<div class="game-reviews__axes" id="axes" aria-label="6축 평가"></div>
<div class="game-reviews__rating-row">
<span class="game-comments__label" id="overall-label">종합 별점 (선택)</span>
<div class="game-stars game-stars--input" id="overall" role="radiogroup" aria-labelledby="overall-label"></div>
</div>
<label class="game-comments__label" for="rbody">평가</label>
<textarea id="rbody" rows="3" maxlength="1000" placeholder="이 게임을 어떻게 즐기셨나요? (최소 10자)"></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint" id="rhint"><span id="rcount">0/1000</span> · 6축은 필수, 종합 별점은 선택이에요</span>
<button type="submit">리뷰 등록</button>
</div>
</div>
<p class="section-tag">리뷰 목록 · 정렬 · 카드 컴팩트 레이더</p>
<div class="game-comments__toolbar">
<div class="game-comments__sort" role="group" aria-label="리뷰 정렬">
<button type="button" class="game-comments__sort-btn is-active">최신순</button>
<button type="button" class="game-comments__sort-btn">별점 높은순</button>
<button type="button" class="game-comments__sort-btn">별점 낮은순</button>
</div>
</div>
<ul class="game-reviews__list" id="review-list"></ul>
<div class="game-comments__more"><button type="button" class="game-comments__more-btn">더보기</button></div>
</section>
<!-- 덧글 패널 -->
<section class="game-panel">
<p class="section-tag">덧글 · 상대시각 · 글자수 카운터</p>
<div class="game-reviews__composer" style="background: var(--surface); border-color: var(--border);">
<label class="game-comments__label" for="cbody">내용</label>
<textarea id="cbody" rows="2" maxlength="200" placeholder="여기에 덧글을 작성해 주세요."></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint"><span id="ccount">0/200</span></span>
<button type="submit">덧글 등록</button>
</div>
</div>
<ul class="game-comments__list" id="comment-list"></ul>
</section>
</div>
<script>
// game-detail.jsp 승격본과 동일 로직 (좌표공식·미터바)
var AXIS_KEYS = ['immersion','creativity','controls','completeness','sound','visual'];
var AXIS_LABELS_KO = { immersion:'몰입성', creativity:'창의성', controls:'조작성', completeness:'완성도', sound:'사운드', visual:'비주얼' };
var SVG_NS = 'http://www.w3.org/2000/svg';
var labelSeq = 0, hexSeq = 0;
function buildHexRadar(scores, cx, cy, R) {
var canvas = cx * 2;
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + canvas + ' ' + canvas);
svg.setAttribute('width', String(Math.round(canvas * 0.85)));
svg.setAttribute('height', String(Math.round(canvas * 0.85)));
svg.setAttribute('role', 'img');
svg.setAttribute('class', 'game-axis-radar');
var nums = AXIS_KEYS.map(function (k, i) { var v = scores ? Number(scores[i]) : 0; return (isFinite(v) && v > 0) ? v : 0; });
var hasData = nums.some(function (v) { return v > 0; });
function ptOnAxis(i, ratio) { var ang = (-90 + 60 * i) * Math.PI / 180; return { x: cx + R * ratio * Math.cos(ang), y: cy + R * ratio * Math.sin(ang) }; }
function polyPoints(fn) { return AXIS_KEYS.map(function (k, i) { var p = ptOnAxis(i, fn(i)); return p.x.toFixed(2) + ',' + p.y.toFixed(2); }).join(' '); }
var gid = 'hexfill-' + (++hexSeq);
var defs = document.createElementNS(SVG_NS, 'defs');
var grad = document.createElementNS(SVG_NS, 'radialGradient');
grad.setAttribute('id', gid); grad.setAttribute('cx', '50%'); grad.setAttribute('cy', '50%'); grad.setAttribute('r', '62%');
[['0%','rgba(240,201,120,0.55)'],['100%','rgba(232,165,75,0.26)']].forEach(function (s) {
var st = document.createElementNS(SVG_NS,'stop'); st.setAttribute('offset', s[0]); st.setAttribute('stop-color', s[1]); grad.appendChild(st);
});
defs.appendChild(grad); svg.appendChild(defs);
for (var level = 1; level <= 4; level++) {
var grid = document.createElementNS(SVG_NS, 'polygon');
grid.setAttribute('class', 'game-axis-grid' + (level === 4 ? ' game-axis-grid--outer' : ''));
(function (lv) { grid.setAttribute('points', polyPoints(function () { return lv / 4; })); })(level);
svg.appendChild(grid);
}
AXIS_KEYS.forEach(function (k, i) {
var line = document.createElementNS(SVG_NS, 'line'); line.setAttribute('class', 'game-axis-line');
var p = ptOnAxis(i, 1); line.setAttribute('x1', String(cx)); line.setAttribute('y1', String(cy));
line.setAttribute('x2', p.x.toFixed(2)); line.setAttribute('y2', p.y.toFixed(2)); svg.appendChild(line);
});
if (hasData) {
var poly = document.createElementNS(SVG_NS, 'polygon'); poly.setAttribute('class', 'game-axis-poly');
poly.setAttribute('points', polyPoints(function (i) { return nums[i] / 5; })); poly.setAttribute('fill', 'url(#' + gid + ')'); svg.appendChild(poly);
AXIS_KEYS.forEach(function (k, i) {
if (nums[i] <= 0) return; var p = ptOnAxis(i, nums[i] / 5);
var dot = document.createElementNS(SVG_NS, 'circle'); dot.setAttribute('class', 'game-axis-dot');
dot.setAttribute('cx', p.x.toFixed(2)); dot.setAttribute('cy', p.y.toFixed(2)); dot.setAttribute('r', Math.max(1.6, R * 0.05).toFixed(2)); svg.appendChild(dot);
});
}
svg.setAttribute('aria-label', hasData ? ('6축 평가: ' + AXIS_KEYS.map(function (k, i) { return AXIS_LABELS_KO[k] + ' ' + nums[i]; }).join(', ')) : '6축 평가 없음');
return { svg: svg, nums: nums, hasData: hasData };
}
function buildRadarLegend(nums, hasData) {
var dl = document.createElement('dl'); dl.className = 'game-reviews__axis-legend';
AXIS_KEYS.forEach(function (k, i) {
var row = document.createElement('div'); row.className = 'game-reviews__axis-legend-row';
var dt = document.createElement('dt'); dt.textContent = AXIS_LABELS_KO[k];
var meter = document.createElement('div'); meter.className = 'game-reviews__axis-meter';
var bar = document.createElement('span'); bar.style.width = (hasData ? (nums[i] / 5 * 100) : 0) + '%'; meter.appendChild(bar);
var dd = document.createElement('dd'); dd.textContent = hasData ? String(nums[i]) : '';
row.appendChild(dt); row.appendChild(meter); row.appendChild(dd); dl.appendChild(row);
});
return dl;
}
var axisScores = {};
function buildStarRadioGroup(axisKey, labelText) {
var labelId = 'star-' + axisKey + '-' + (++labelSeq);
var group = document.createElement('div'); group.className = 'game-stars game-stars--input game-stars--axis';
group.setAttribute('role', 'radiogroup'); group.setAttribute('aria-labelledby', labelId);
var radios = [];
function paint() { var sel = axisScores[axisKey] || 0; radios.forEach(function (b, idx) { var v = idx + 1; b.classList.toggle('is-on', v <= sel); b.setAttribute('aria-checked', v === sel ? 'true' : 'false'); b.tabIndex = (v === sel) ? 0 : -1; }); if (!sel) radios[0].tabIndex = 0; }
function select(v) { axisScores[axisKey] = v; paint(); }
for (var i = 1; i <= 5; i++) { (function (v) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'game-stars__btn'; b.setAttribute('role','radio'); b.setAttribute('aria-checked','false'); b.setAttribute('aria-label', labelText + ' ' + v + '점'); b.tabIndex = -1; b.textContent = '★';
b.addEventListener('click', function () { select(v); b.focus(); });
b.addEventListener('keydown', function (ev) { var cur = axisScores[axisKey] || 0; var next = null;
switch (ev.key) { case 'ArrowRight': case 'ArrowUp': next = Math.min(5,(cur||v)+1); break; case 'ArrowLeft': case 'ArrowDown': next = Math.max(1,(cur||v)-1); break; case 'Home': next = 1; break; case 'End': next = 5; break; case ' ': case 'Enter': next = v; break; default: return; }
ev.preventDefault(); select(next); radios[next-1].focus(); });
radios.push(b); group.appendChild(b);
})(i); }
paint(); return { group: group, labelId: labelId };
}
function fillStars(c, rating) { c.innerHTML = ''; for (var i = 1; i <= 5; i++) { var s = document.createElement('span'); s.className = 'game-star' + (i <= rating ? ' is-on' : ''); s.textContent = '★'; c.appendChild(s); } }
function buildStarDisplay(rating) { var w = document.createElement('div'); w.className = 'game-stars game-stars--display'; w.setAttribute('aria-hidden','true'); fillStars(w, rating); return w; }
// 요약 (큰 레이더 + 미터바 범례)
fillStars(document.getElementById('avg-stars'), 4);
(function () {
var panel = document.getElementById('summary-panel');
var radar = buildHexRadar([4.3, 4.6, 3.8, 4.4, 3.5, 4.7], 100, 100, 80);
panel.appendChild(radar.svg); panel.appendChild(buildRadarLegend(radar.nums, true));
})();
// 6축 입력 위젯
(function () {
var axesEl = document.getElementById('axes');
AXIS_KEYS.forEach(function (k) {
var row = document.createElement('div'); row.className = 'game-reviews__axis';
var label = document.createElement('span'); label.className = 'game-reviews__axis-label'; label.textContent = AXIS_LABELS_KO[k];
var w = buildStarRadioGroup(k, AXIS_LABELS_KO[k]); label.id = w.labelId;
row.appendChild(label); row.appendChild(w.group); axesEl.appendChild(row);
});
var ov = buildStarRadioGroup('overall', '종합 별점'); var oEl = document.getElementById('overall');
while (ov.group.firstChild) oEl.appendChild(ov.group.firstChild);
})();
// 글자수 카운터 + 최소10자 경고
function counter(ta, out, max, min) {
function upd() { var len = ta.value.length; out.textContent = len + '/' + max; if (min) { var w = len > 0 && len < min; out.parentElement.classList.toggle('is-warn', w); } }
ta.addEventListener('input', upd); upd();
}
counter(document.getElementById('rbody'), document.getElementById('rcount'), 1000, 10);
counter(document.getElementById('cbody'), document.getElementById('ccount'), 200, 0);
// 리뷰 카드 (axes 있음/없음 두 케이스)
function reviewCard(r) {
var li = document.createElement('li'); li.className = 'game-reviews__item' + (r.mine ? ' is-mine' : '');
var head = document.createElement('div'); head.className = 'game-reviews__item-head';
head.appendChild(buildStarDisplay(r.rating));
var nick = document.createElement('span'); nick.className = 'game-reviews__nick'; nick.textContent = r.nick; head.appendChild(nick);
if (r.mine) { var mb = document.createElement('span'); mb.className = 'game-reviews__mine-badge'; mb.textContent = '내 리뷰'; head.appendChild(mb); }
var t = document.createElement('time'); t.className = 'game-reviews__time'; t.textContent = r.time; head.appendChild(t);
if (r.edited) { var ed = document.createElement('span'); ed.className = 'game-reviews__edited'; ed.textContent = '(수정됨)'; head.appendChild(ed); }
li.appendChild(head);
var p = document.createElement('p'); p.className = 'game-reviews__body'; p.textContent = r.body; li.appendChild(p);
var cardScores = AXIS_KEYS.map(function (k) { return r.axes ? r.axes[k] : 0; });
var cr = buildHexRadar(cardScores, 60, 60, 44);
var wrap = document.createElement('div'); wrap.className = 'game-reviews__radar game-reviews__card-radar';
if (cr.hasData) { wrap.appendChild(cr.svg); var lg = buildRadarLegend(cr.nums, true); lg.className += ' sr-only'; wrap.appendChild(lg); }
else { var none = document.createElement('span'); none.className = 'game-reviews__radar-empty'; none.textContent = '6축 평가 데이터 없음'; wrap.appendChild(none); }
li.appendChild(wrap);
return li;
}
var rl = document.getElementById('review-list');
rl.appendChild(reviewCard({ rating: 5, nick: '도토리', time: '12분 전', edited: false, mine: true, body: '몰입감이 정말 뛰어나요. 사운드가 조금 아쉽지만 전반적으로 완성도 높은 게임입니다.', axes: { immersion:5, creativity:5, controls:4, completeness:5, sound:3, visual:5 } }));
rl.appendChild(reviewCard({ rating: 3, nick: '바삭이', time: '3일 전', edited: true, mine: false, body: '아이디어는 좋은데 조작이 좀 불편했어요. 다음 빌드 기대합니다.', axes: { immersion:3, creativity:4, controls:2, completeness:3, sound:3, visual:4 } }));
rl.appendChild(reviewCard({ rating: 4, nick: '레거시유저', time: '2026. 5. 1.', edited: false, mine: false, body: '(구버전 리뷰 — 6축 데이터 없음 케이스)', axes: null }));
// 덧글 카드
function commentCard(c) {
var li = document.createElement('li'); li.className = 'game-comments__item';
var av = document.createElement('div'); av.className = 'game-comments__avatar'; av.textContent = c.nick.charAt(0); li.appendChild(av);
var body = document.createElement('div'); body.style.flex = '1';
var meta = document.createElement('div'); meta.className = 'game-comments__meta';
var nick = document.createElement('span'); nick.className = 'game-comments__nick'; nick.textContent = c.nick; meta.appendChild(nick);
var t = document.createElement('time'); t.textContent = c.time; meta.appendChild(t);
if (c.edited) { var ed = document.createElement('span'); ed.className = 'game-comments__edited'; ed.textContent = '(수정됨)'; meta.appendChild(ed); }
body.appendChild(meta);
var p = document.createElement('p'); p.textContent = c.body; body.appendChild(p); li.appendChild(body);
return li;
}
var cl = document.getElementById('comment-list');
cl.appendChild(commentCard({ nick: '플레이어A', time: '방금 전', edited: false, body: '재밌게 했습니다! 2스테이지 보스가 인상적이네요.' }));
cl.appendChild(commentCard({ nick: '(탈퇴한 사용자)', time: '5일 전', edited: false, body: '탈퇴자 댓글 마스킹 케이스 — 이름이 "(탈퇴한 사용자)" 로 표시됩니다.' }));
// 테마 토글
document.getElementById('theme-toggle').addEventListener('click', function () {
var dark = document.documentElement.getAttribute('data-theme') === 'dark';
document.documentElement.setAttribute('data-theme', dark ? 'light' : 'dark');
this.textContent = dark ? '🌙 다크' : '☀️ 라이트';
});
</script>
</body>
</html>

View File

@ -0,0 +1,41 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-22T10:00:00+09:00
concerns:
- "C3 재분류(W2-3 동결 경계 재확인)는 되돌리기 어려운 아키텍처 경계 결정에 해당할 수 있으나, 실체는 기존 오분류를 roadmap 근거(:63/:201/:202)로 정정한 것이므로 현재는 changes/ 내 별도 섹션으로 기록. W2-3 관련 설계 분기가 재발하면 adr/ADR-0001 발행 권고."
- "design-advisor 가 이미 .atp/work-session/20260622-092800/implementation/design.md 에 설계 산출물을 남김. architecture/ 중복 갱신 없음 — changes/ + work-session/ 중심으로 처리."
- "frontend-design 스킬이 game-detail.jsp 시각 컴포넌트를 처리했으므로 JSP 프론트엔드 세부 구현은 changes/ 문서에서 요약 수준으로만 기록."
- "retrospective 교훈 4건은 development/verification-strategies.md 에 '설계·테스트 단계 체크리스트' 섹션 및 '프로토콜 개선 권고' 섹션으로 추가 완료 (2026-06-22 retrospective-advisor 수용 결정 반영)."
- "backport self-grep: verification-strategies.md 근거 블록(> 근거:)에 20260622·GameController.gameDetail 잔류 — 요구사항 '이번 세션 사례는 1줄 근거로' 허용 패턴이므로 정본 규칙형 텍스트에는 포함되지 않음. docs/development/release-checklist.md §7 부재 — 해당 절차 미적용(N/A에 준함)."
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` | changes | 신규 | `docs/changes/index.md` | `docs/changes/2026-06-18-w3-2-comments-reviews.md` (이월 항목 해소 링크) |
| `docs/changes/index.md` | changes | 갱신 | — | — |
| `docs/changes/2026-06-18-w3-2-comments-reviews.md` | changes | 갱신 (이월 항목 해소 표기) | — | `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` |
| `docs/development/verification-strategies.md` | development | 갱신 (섹션 추가) | index 추가 불필요(기존 등록) | report.md#Retrospective memory_candidates docs_sync_target |
## 의사결정 기록 위치
- **C3 재분류**: `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` §C3 재분류 섹션 + `.atp/work-session/20260622-092800/implementation/design.md` §C3 재분류
- **is_rating_manual 컬럼 채택**: `design.md` concerns 2번 + `report.md#Decisions`
- **axis_key 저장형·순서 고정**: `design.md` concerns 5번 + §2.1
- **sort 안전 (@SelectProvider 컴파일타임 상수)**: `design.md` §4 + `report.md#Decisions`
- **pagination = limit+1 hasMore**: `design.md` §3.3 + `report.md#Decisions`
- **SVG 육각형 = JS 런타임**: `design.md` §6.1 concerns 4번
## 추후 문서화가 필요한 항목
- **ADR-0001 (권고)**: W2-3 동결 경계 확정(잼 평가 한정, 댓글/리뷰 무관) — C3 재분류 정정의 배경이 된 경계 결정을 정식 ADR로 남길 가치 있음.
- **DDL 적용 완료 시**: `docs/maintenance/` 에 dev/운영 DB 적용 절차(game_review_axes·game_review_stats·game_comments.updated_at·game_reviews.is_rating_manual) 기록 검토.
- **L3 스모크 완료 후**: `docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md` 검증 결과 표에서 "needs_user_verification" → "PASS (날짜)" 갱신.
- **기존 리뷰 axes 백필**: 백필 필요 판단 시 `docs/maintenance/` 에 절차 문서화.
- **GET /reviews/mine (applyReviewGate 정밀화)**: 차기 세션 구현 완료 후 changes/ 업데이트.

View File

@ -0,0 +1,536 @@
---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-22T00:00:00+09:00
concerns:
- "C3 재분류 확정: game_review_stats 는 W3-2 일반 DDL(읽기전용 집계뷰). W2-3 동결 묶음 아님 — 근거 jam-platform-roadmap.md:63,201,202. 단 game-reviews-ddl.sql:63 / db/schema.sql:136-137 의 '집계뷰 미신설' 보수 주석이 본 설계로 무효화되므로 구현 시 두 곳 주석 갱신 필수(미갱신 시 차기 세션 혼선)."
- "is_rating_manual 플래그 채택 — game_reviews 행 폭 증가(boolean 1컬럼). 채택 근거는 §5.3. 비채택 대안(매번 axes AVG 재계산)도 가능하나 '유저가 자동평균과 같은 값을 수동선택' 케이스를 구분 못 함 → 채택. 구현에서 이 컬럼이 실제 분기에 쓰이는지(수동/자동 표시 차별화 UI 없으면 dead) 재확인 필요."
- "GameReviewsMapper.editGameReview 의 axes 재저장은 delete-all + insert-6 채택(§5.2). upsert(ON CONFLICT) 대안 대비 단순하나, 6 DELETE+6 INSERT 트랜잭션. axes 매퍼 신규 분리 vs GameReviewsMapper 확장 — 신규 GameReviewAxesMapper 채택(§1, §5). 단일 책임."
- "SVG 육각형 좌표는 JS 런타임 계산 채택(§6). JSP 컴파일타임(JSTL) 대안은 개별 리뷰 N건 동적 렌더와 맞지 않음 — 기존 buildStarDisplay 패턴(JS DOM 생성)과 일관. 좌표 공식은 §6 에 고정."
- "axis_key DB 저장형 = 영문 snake(immersion/creativity/controls/completeness/sound/visual). 한글 라벨(몰입성 등)은 JSP 상수 매핑. enum 순서 = 육각형 각 축 인덱스(0~5)와 1:1 고정 — 순서 바뀌면 레이더 축 위치 변동. §2.1 / §6.3 에 순서 고정."
- "B2 최소 10자: 기존 createReview 는 빈 본문 허용(report Grounded Baseline:80). 본 변경으로 별점만 제출하던 기존 유저 플로우가 막힘 — UX 마찰. FINAL SPEC 확정 사항이므로 진행하되 프론트 안내문(C4 카운터 '최소 10자') 필수."
- "ESCALATION 없음 — requirements(report FINAL SPEC) vs research(roadmap) 충돌 0. C3 오분류는 task 가 이미 정정 지시 → 반영 완료."
references:
requirements: /Users/wemadeplay/workspace/stz/bibimbap/.atp/work-session/20260618-145152/report.md
research: /Users/wemadeplay/workspace/stz/bibimbap/docs/work-log/2026-06-17-jam-platform-roadmap.md
adrs:
- /Users/wemadeplay/workspace/stz/bibimbap/docs/changes/2026-06-18-w3-2-comments-reviews.md
- /Users/wemadeplay/workspace/stz/bibimbap/docs/game-reviews-ddl.sql
- /Users/wemadeplay/workspace/stz/bibimbap/db/schema.sql
---
# 설계: W3-2 댓글/리뷰 고도화 (확정 구현 설계도)
## 목표 / 비목표
### 목표 (FINAL SPEC 추적 가능)
- **A1** 댓글 응답 commentView 단일 통일 (POST·PUT·list 전부 동일 스키마, POST는 insert 후 재조회).
- **A2** 댓글 작성자명 하이브리드 (LEFT JOIN users + CASE 마스킹). QG-2(레거시 user_id NULL) 자동 해결.
- **A3** 댓글 edited+updatedAt (game_comments.updated_at 멱등 ALTER + 매퍼/모델 갱신).
- **B1** 페이지네이션 offset/limit 20건 "더보기" + sort 토글 (화이트리스트 enum→고정 SQL, `${}` 금지).
- **B2** 리뷰 본문 필수 최소 10자(trim 후). 댓글은 현행 유지.
- **B3** 본문 정규화 (제어문자 strip + 외곽 trim, 댓글·리뷰 공통 유틸).
- **다축 평점** game_review_axes 6축(1~5 전 축 필수) + overall 자동평균/수동덮어쓰기(is_rating_manual).
- **C3** game_review_stats 읽기전용 집계뷰 (avg_rating, review_count, 6축 평균). 클라 평균계산 폐기.
- **C1** submit 잠금+로딩 / **C2** 상대시각 / **C4** 글자수 카운터 / **C5** 완전소멸 유지 / **C6** radiogroup 완전 패턴.
- **육각형 레이더** 인라인 SVG 자체구현 (요약 + 개별 리뷰 카드), a11y 텍스트 대체.
### 비목표 (스코프 밖 — 명시)
- 신고·숨김 (W1 운영자 role 선행).
- 리뷰 이력 테이블 (in-row 마커만).
- 좋아요 서버화 (별도 관리).
- W2-3 잼 평가(심사/투표/시상) 스키마 — **본 설계 무관**(C3 정정으로 명확히 분리).
- 댓글 최소 글자수 (현행: 상한 200자만).
---
## C3 재분류 (직전 report 오분류 교정 — 설계 결정)
직전 report.md 는 C3(game_review_stats 평균별점 뷰)를 "W2-3 동결 해제·고위험·§6 게이트"로 표기했으나 **오분류**다.
- **근거** (`docs/work-log/2026-06-17-jam-platform-roadmap.md`):
- `:63` — "W2-3 범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님)."
- `:201` — "S4D 평가 통합설계 → W2-3 범위 축소: 잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계."
- `:202` — "S4a 댓글/리뷰 분리 → W3-2 일반기능으로 재분류: 잼 평가 동결묶음에서 분리."
- **결론**: `game_review_stats` = `game_reviews`(W3-2 테이블) 위 **읽기전용 집계뷰** 신설 = **일반 DDL**. §6 파괴적 게이트 없음. orchestrator 사용자 재확인 경로 불필요.
- **부수 조치**: `docs/game-reviews-ddl.sql:63` 주석("집계 컬럼/뷰는 신설하지 않음 (W2-3 동결 보호)") 및 `db/schema.sql:136-137`("집계 컬럼/뷰는 W2-3 동결 — 신설 금지") 은 본 설계로 **무효화** → 구현 시 갱신 대상(파일 영향 맵 참조).
---
## 개요
기존 W3-2 코어(댓글/리뷰 CRUD, 2026-06-18 커밋)를 보존하며 그 위에 일관성(A)·규모(B)·다축평점·UX(C)를 얹는다. 백엔드는 매퍼 시그니처 확장(offset/limit/sort, axes 저장) + 신규 axes 매퍼/DDL, 프론트는 game-detail.jsp 단일 파일의 댓글·리뷰 JS 블록 재작성(육각형 SVG, radiogroup, 페이지네이션, 서버집계 표시)이다. DDL은 game-reviews-ddl.sql 에 멱등 append + db/schema.sql 동기화한다. 전 변경은 31건 회귀 테스트를 깨지 않는 것을 1차 게이트로 한다.
---
## 플로우
### 리뷰 작성 (POST /game/{id}/reviews) — 다축 확장
```
진입: CSRF(403) → 로그인(401) → 게임존재(404)
→ rating 파싱(수동 overall, optional) + axes 6값 파싱(전 축 1~5 필수, 누락/범위외 400)
→ body 정규화(B3) + 최소 10자 검사(B2, 400)
→ 게임당 1회 검사(409)
→ overall 결정:
유저가 overall 직접선택 O → rating=선택값, is_rating_manual=true
유저가 overall 직접선택 X → rating=round(6축 평균), is_rating_manual=false
→ [TX] addGameReview(rating, body, is_rating_manual) → review.id 획득
→ addReviewAxes(review.id, 6행) ← axes 매퍼
→ getGameReview(review.id) 재조회 → reviewView(+axes) 반환
종단: 200 reviewView
```
### 리뷰 수정 (PUT) — axes 재저장
```
진입: CSRF → 로그인 → 리뷰존재+게임일치(404) → 권한(canModify, 403)
→ rating/axes/body 검증(작성과 동일)
→ [TX] editGameReview(rating, body, is_rating_manual, updated_at=now())
→ deleteReviewAxes(reviewId) ← 기존 6행 제거
→ addReviewAxes(reviewId, 6행) ← 신규 6행
→ getGameReview 재조회 → reviewView 반환
```
### 목록 조회 (GET /game/{id}/reviews?page&sort) — 페이지네이션
```
진입: 게임존재(404)
→ page(default 0)·sort(default newest, 화이트리스트 검증→고정 enum) 파싱
→ offset = page*20, limit=21 (hasMore 판정용 +1 조회)
→ listGameReviews(gameId, offset, limit, sortEnum) ← 21건 조회
→ 각 리뷰에 axes 6행 batch 조회·매핑 (listReviewAxesByReviewIds)
→ hasMore = (조회건수 > 20) → 21번째 잘라 20건 반환
→ game_review_stats 1행 조회(요약) → summary 포함
종단: { status, reviews:[reviewView...], hasMore, summary:{avgRating,reviewCount,axes{}} }
```
### 댓글 작성 (POST /game/{id}/comments) — A1 통일
```
진입: CSRF → 로그인 → 게임존재
→ content 정규화(B3) + 1~200자 검사
→ nickname 스냅샷 = sessionDisplayName (유지)
→ addGameComment → comment.id 획득
→ getGameComment(comment.id) 재조회(LEFT JOIN authorName/edited/updatedAt) ← A1: 재조회 패턴
→ commentView 반환
종단: 200 commentView (createReview 패턴 대칭)
```
---
## 데이터 모델
### 2.1 game_review_axes (신규 테이블)
| 컬럼 | 타입 | 제약 | 비고 |
|---|---|---|---|
| id | bigint | PK, seq | game_review_axes_id_seq |
| review_id | bigint | NOT NULL, FK→game_reviews(id) | |
| axis_key | varchar(20) | NOT NULL, CHECK(6종) | 영문 snake |
| score | smallint | NOT NULL, CHECK(1~5) | |
| | | UNIQUE(review_id, axis_key) | 리뷰당 축별 1행 |
| | | INDEX(review_id) | 조회 |
- **axis_key 6종 (순서 고정 — 육각형 축 인덱스 0~5)**:
`immersion`(몰입성), `creativity`(창의성), `controls`(조작성), `completeness`(완성도), `sound`(사운드), `visual`(비주얼).
- 리뷰당 정확히 6행 (전 축 필수). DB는 UNIQUE+CHECK로 보장, 앱이 6행 누락 없이 insert 책임.
### 2.2 game_comments.updated_at (멱등 ALTER)
- `timestamptz DEFAULT now() NOT NULL`. `edited = updated_at > created_at` (리뷰 대칭).
- 기존 행은 ALTER 시 default now() 적용 → created_at != updated_at 가능성 → 기존 행 보정: `UPDATE ... SET updated_at = created_at WHERE updated_at IS NULL` 불필요(NOT NULL default). 대신 ALTER 직후 `UPDATE game_comments SET updated_at = created_at WHERE updated_at > created_at` 1회로 기존 댓글이 "수정됨" 오표시되지 않게 정렬 (멱등 — 재실행 시 영향 없음, 신규 행은 INSERT가 created/updated 동시 now()).
### 2.3 game_reviews.is_rating_manual (멱등 ALTER)
- `boolean DEFAULT false NOT NULL`. true=유저 직접선택 overall, false=6축 자동평균.
### 2.4 game_review_stats (VIEW 신규)
- `game_reviews(is_delete IS NOT TRUE)``game_review_axes` 집계.
- 컬럼: `game_id, avg_rating numeric, review_count bigint, avg_immersion numeric, avg_creativity numeric, avg_controls numeric, avg_completeness numeric, avg_sound numeric, avg_visual numeric`.
- 클라 평균계산(updateSummary, JSP:1513-1522) 폐기 공급원.
---
## 외부 계약 (API)
### 3.1 commentView 스키마 (A1 통일 — POST·PUT·list 동일)
```json
{
"commentId": 100,
"gameId": 1,
"authorName": "표시명 또는 (탈퇴한 사용자) 또는 스냅샷닉",
"userId": 7, // nullable (레거시)
"content": "...",
"createdAt": "ISO-8601",
"edited": false, // updated_at > created_at
"updatedAt": "ISO-8601" // A3 신규
}
```
- 변화: POST 응답이 flat(commentId/gameId/authorName/userId/content) → commentView 전체. PUT 응답이 부분(commentId/content) → commentView 전체. createdAt/edited/updatedAt 신규 노출. `status`,`message`는 POST/PUT 응답에 commentView 와 병합(createReview 패턴 그대로).
### 3.2 reviewView 스키마 (axes 추가)
기존 reviewView(reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt) + 신규:
```json
{
"...": "기존 필드 유지",
"ratingManual": false, // is_rating_manual
"axes": { "immersion":4,"creativity":5,"controls":3,"completeness":4,"sound":2,"visual":5 }
}
```
### 3.3 GET list 응답 형태 (페이지네이션)
- 댓글: `{ status, comments:[commentView...], hasMore }`
- 리뷰: `{ status, reviews:[reviewView...], hasMore, summary }`
- `summary` = game_review_stats 1행 → `{ avgRating, reviewCount, axes:{immersion..visual} }` (review_count=0 이면 summary=null).
- **요청 파라미터**: `?page=<int≥0, default 0>&sort=<enum, default 토글기본값>`.
- **hasMore 방식 채택**: total count 대신 `limit+1` 조회 후 초과 여부. 근거 = 추가 COUNT 쿼리 회피, "더보기" UI는 total 불요.
### 3.4 sort enum 값 정의
- 댓글: `oldest`(기본), `newest`.
- 리뷰: `newest`(기본), `rating_desc`, `rating_asc`.
- 미지정/미허용 값 → 기본값으로 fallback (400 던지지 않음 — UX 관대).
---
## sort 화이트리스트 매핑 표 (§4 — `${}` 금지 안전성)
매퍼는 **enum 분기 → 고정 SQL 절** 방식. 동적 `${}` SQL 치환 절대 사용 안 함. 컨트롤러가 String sort 를 화이트리스트 enum 으로 변환(미스매치=기본값) 후, 매퍼는 MyBatis `<choose>`(XML) 또는 **annotation @SelectProvider 의 if-분기로 고정 문자열 상수 선택**.
**채택 방식**: 현 코드가 annotation 매퍼(@Select 텍스트블록)이므로 일관성 위해 **컨트롤러에서 sort enum 결정 + 매퍼 메서드를 sort별로 분리하지 않고**, `@SelectProvider` 로 전환하여 ORDER BY 절만 상수 분기. (대안: 매퍼 메서드 N개 분리 — 중복 과다로 비채택.)
| sort enum | 대상 | ORDER BY 고정 SQL 절 (상수, 사용자 입력 미포함) |
|---|---|---|
| `oldest` (댓글) | comments | `ORDER BY created_at ASC, id ASC` |
| `newest` (댓글) | comments | `ORDER BY created_at DESC, id DESC` |
| `newest` (리뷰) | reviews | `ORDER BY r.created_at DESC, r.id DESC` |
| `rating_desc` (리뷰) | reviews | `ORDER BY r.rating DESC, r.created_at DESC, r.id DESC` |
| `rating_asc` (리뷰) | reviews | `ORDER BY r.rating ASC, r.created_at DESC, r.id DESC` |
- **안전성 근거**: ORDER BY 문자열은 5개 **컴파일타임 상수** 중 하나로만 결정. 사용자 입력 String 은 enum 매칭(`switch`/`Map`)에만 사용되고 SQL 텍스트에 절대 보간되지 않음 → SQL injection 면역. offset/limit 은 `#{}` 파라미터 바인딩 유지.
- **tiebreaker**: 별점순은 동점 시 created_at DESC, id DESC 로 결정적 정렬(페이지네이션 안정성 — offset 중복/누락 방지).
---
## 다축 시퀀스 (§5)
### 5.1 overall 자동/수동 계산 위치 = **서버**
- 클라(JS)는 6축 점수 + (선택적) overall 수동선택값만 전송.
- **서버가 권위 계산**: overall 미전송/빈값 → `rating = Math.round(평균(6축))`, `is_rating_manual=false`. overall 전송 → `rating=전송값(1~5 검증)`, `is_rating_manual=true`.
- 근거: 클라 계산은 변조 가능 + C3 서버집계 정합. round 정책 = 정수 반올림(HALF_UP, Java `Math.round` 는 .5 올림 — 일관).
### 5.2 axes 재저장 전략 = **delete-all + insert-6** (updateReview)
- `editGameReview` TX 내: `deleteReviewAxes(reviewId)``addReviewAxes(reviewId, List<6>)`.
- 근거: upsert(ON CONFLICT) 대비 매퍼 단순. 6행 고정이라 성능 동일 수준. UNIQUE(review_id,axis_key) 와 무관(전삭제 후 삽입).
### 5.3 트랜잭션 순서 (createReview)
```
@Transactional
1. addGameReview(rating, body, is_rating_manual) → useGeneratedKeys → review.id
2. addReviewAxes(review.id, [6 axis rows]) → 6행 batch insert
(실패 시 전체 롤백 — review+axes 원자성)
3. getGameReview(review.id) + listReviewAxes(review.id) → reviewView 조립
```
### 5.4 axes 매퍼 = **GameReviewAxesMapper 신규** (단일 책임)
신규 인터페이스. 시그니처(인자 사용목적 인라인 주석 — inflate 방지):
```java
@Mapper
public interface GameReviewAxesMapper {
// 1리뷰 6축 일괄 저장. List 1건당 review_id/axis_key/score 사용.
int addReviewAxes(@Param("reviewId") long reviewId, // FK 대상 리뷰
@Param("axes") List<ReviewAxisRow> axes); // 6행(axis_key+score)
// 수정 시 기존 축 전삭제(이후 add 재삽입).
int deleteReviewAxes(@Param("reviewId") long reviewId); // 대상 리뷰
// 목록 화면 N리뷰 축 batch 조회(N+1 회피).
List<ReviewAxisRow> listAxesByReviewIds(@Param("reviewIds") List<Long> reviewIds); // 페이지 리뷰 id들
}
```
- `ReviewAxisRow` = `{ Long reviewId; String axisKey; Integer score; }` (신규 POJO 또는 GameReviewData 내 정적 중첩). axes batch insert SQL은 `<foreach>` (XML) 또는 @InsertProvider. **`#{}` 바인딩 유지**.
- 최소 인자 원칙 적용: gameId 등 불필요 컨텍스트 미수신. 구현에서 확장 필요 시 추가.
---
## SVG 육각형 좌표 공식 (§6)
### 6.1 계산 위치 = **JS 런타임** (game-detail.jsp 인라인)
- 근거: 개별 리뷰 N건 동적 렌더(buildReviewItem) + 요약 1건. 기존 buildStarDisplay 가 JS DOM 생성 idiom → 일관. JSTL 컴파일타임은 동적 N건과 부적합.
### 6.2 viewBox / 기하 상수
- 요약(큰) 육각형: `viewBox="0 0 200 200"`, 중심 `cx=100, cy=100`, 반지름 `R=80`.
- 개별 리뷰 카드(컴팩트): `viewBox="0 0 120 120"`, `cx=60, cy=60`, `R=44`. (페이지당 20건 DOM 고려.)
- 함수는 cx/cy/R 파라미터화하여 공용 1함수로.
### 6.3 6 꼭지점(축 끝) 좌표 공식
- 축 i(0~5, §2.1 axis_key 순서와 1:1): 각도 `θ_i = -90° + 60°*i` (12시 방향 시작, 시계방향).
```
angle_rad = (-90 + 60*i) * Math.PI / 180
axisX_i = cx + R * Math.cos(angle_rad)
axisY_i = cy + R * Math.sin(angle_rad)
```
- 배경 그리드(외곽 육각형) = 위 6점 polygon. 단계 그리드 = R*0.25, R*0.5, R*0.75, R 4단계 동심 육각형(`scale = level/4` 곱).
### 6.4 score 폴리곤 점 공식
- 축 i 점수 `s_i`(1~5) → 반지름 비율 `ratio_i = s_i / 5`:
```
ptX_i = cx + R * (s_i/5) * Math.cos(angle_rad_i)
ptY_i = cy + R * (s_i/5) * Math.sin(angle_rad_i)
```
- `<polygon points="ptX_0,ptY_0 ptX_1,ptY_1 ... ptX_5,ptY_5" />` (fill 반투명 + stroke).
### 6.5 렌더 함수 시그니처 (inflate 방지 — 인자 사용목적 명시)
```js
// 6축 점수 배열을 육각형 SVG 엘리먼트로. 요약/카드 공용.
function buildHexRadar(scores /* [6] axis순 점수배열, score폴리곤 */,
cx /* 중심x, 좌표기준 */,
cy /* 중심y, 좌표기준 */,
R /* 반지름, 스케일 */) { ... return <svg> }
```
- 최소 인자. 라벨 텍스트/색상은 함수 내부 상수(AXIS_LABELS_KO) 참조 → 인자 미부풀림.
### 6.6 a11y (SVG 시각요소 텍스트 대체 — 필수)
- `<svg role="img" aria-label="6축 평가: 몰입성 4, 창의성 5, 조작성 3, 완성도 4, 사운드 2, 비주얼 5">`.
- 추가로 시각적 보조: 각 축 점수를 visually-hidden 표 또는 인접 `<dl>`(축명/점수) 동반. 폴리곤만으로 종료 금지(FINAL SPEC).
---
## a11y radiogroup 스펙 (§7 — C6)
현 별점위젯(JSP:1043-1049)은 `role=radiogroup`/`role=radio`/`aria-checked` 마크업은 있으나 **키보드 핸들러 없음**(click만, JSP:1553-1556). roving tabindex 도 없음. C6 = 완전 패턴 신규 추가.
### 7.1 roving tabindex
- radiogroup 내 단일 tabstop: 선택된 radio `tabindex="0"`, 나머지 `tabindex="-1"`. 미선택 시 첫 radio `tabindex="0"`.
- 화살표 이동 시 tabindex 이전(`-1`)→대상(`0`) + `.focus()`.
### 7.2 키 핸들러 (keydown)
| 키 | 동작 |
|---|---|
| `ArrowRight`, `ArrowUp` | 다음 별점(+1, max 5에서 정지 또는 wrap — **정지** 채택, LTR 별점 직관) |
| `ArrowLeft`, `ArrowDown` | 이전 별점(-1, min 1에서 정지) |
| `Home` | 1점 |
| `End` | 5점 |
| `Space`/`Enter` | 현재 focus radio 선택 확정 |
- 화살표는 이동+즉시선택(setRating) 동시 — radiogroup 표준. `preventDefault()` 로 스크롤 방지.
### 7.3 aria 속성
- 그룹 `role="radiogroup" aria-labelledby="<라벨id>"` (축별 라벨 id 6개).
- 각 `role="radio" aria-checked="true|false"`, 선택만 `aria-checked=true`.
### 7.4 6위젯 공통화
- 별점위젯 생성을 JS 함수 `buildStarRadioGroup(axisKey, labelId)` 로 추출 → 6축 입력 위젯 생성에 재사용. 각 위젯 독립 selectedScore 상태(객체 `axisScores[axisKey]`). 기존 단일 overall 위젯도 동일 함수로 통일(overall은 별도 또는 자동표시).
- 함수 시그니처(인자 사용목적):
```js
// 1개 축 별점 radiogroup DOM 생성 + 키/클릭 핸들러 바인딩.
function buildStarRadioGroup(axisKey /* axisScores 키 + 상태귀속 */,
labelText /* 라벨/aria-label 텍스트 */) { ... }
```
---
## B3 정규화 유틸 위치 (§8)
- **현황**: 공용 `util` 패키지 없음(grep 확인). trimToNull/trimToEmpty 가 GameCommentController·GameReviewController·GameController 에 private 중복.
- **채택**: 신규 `com.pandoli365.bibimbap.util.TextNormalizer` (static util 클래스). 기존 private trim 메서드는 **이번 스코프에선 미통합**(회귀 리스크 — 댓글/리뷰 컨트롤러만 호출 추가). 통합 리팩터는 비목표.
- **시그니처** (인자 사용목적 인라인):
```java
public final class TextNormalizer {
private TextNormalizer() {}
// 저장 위생: 제어문자(탭\t·개행\n\r 제외) 제거 + 외곽 trim.
// 내부 연속공백/줄바꿈 보존. null→null.
public static String normalize(String raw /* 원문, 정규화 대상 */) { ... }
}
```
- 규칙: `\t`(U+0009), `\n`(U+000A), `\r`(U+000D) 외의 C0/C1 제어문자(U+0000~U+001F, U+007F~U+009F) 제거 → 외곽 `strip()`. 내부 공백 미축약.
- 적용: createComment/updateComment content, createReview/updateReview body 에 검증 **전** 호출(정규화 후 길이 검사).
---
## 파일 영향 맵 (§1 파일 소유권 + worker 분할)
worker 분할은 **동시수정 충돌 회피** 기준. game-detail.jsp 는 단일 파일이라 1 worker 전담.
| 변경 유형 | 경로 | 역할 | worker |
|---|---|---|---|
| 신규 | `db/.../docs/game-reviews-ddl.sql` (append) | axes 테이블+updated_at ALTER+is_rating_manual ALTER+stats VIEW 멱등 블록. :63 보수주석 갱신 | W-DDL |
| 변경 | `db/schema.sql` | 위 DDL 동기화(:136-137 주석 갱신) | W-DDL |
| 신규 | `src/.../util/TextNormalizer.java` | B3 정규화 static util | W-BE |
| 신규 | `src/.../mapper/GameReviewAxesMapper.java` | axes add/delete/listByReviewIds | W-BE |
| 신규 | `src/.../data/ReviewAxisRow.java` (또는 GameReviewData 중첩) | axes 행 POJO | W-BE |
| 변경 | `src/.../data/GameCommentData.java` | updatedAt + edited(비영속) 필드 추가 | W-BE |
| 변경 | `src/.../data/GameReviewData.java` | ratingManual + axes(Map/List 비영속) 필드 추가 | W-BE |
| 변경 | `src/.../mapper/GameCommentsMapper.java` | A2 LEFT JOIN+CASE, A3 updated_at/edited select, B1 offset/limit/sort, editGameComment updated_at | W-BE |
| 변경 | `src/.../mapper/GameReviewsMapper.java` | B1 offset/limit/sort, is_rating_manual select/insert/update, edit 시 axes 무관(별 매퍼) | W-BE |
| 신규 | `src/.../mapper/GameReviewStatsMapper.java` (또는 GameReviewsMapper 내 1메서드) | game_review_stats 1행 조회 | W-BE |
| 변경 | `src/.../controller/api/GameCommentController.java` | A1 재조회·commentView, B1 page/sort, B3 normalize | W-BE |
| 변경 | `src/.../controller/api/GameReviewController.java` | 다축 검증/저장, overall 자동·수동, B2 10자, B3, B1 page/sort, summary | W-BE |
| 변경 | `src/main/webapp/WEB-INF/views/game-detail.jsp` | 육각형 SVG, C1·C2·C4·C6, 페이지네이션·sort UI, 서버집계 표시, 6축 입력, A1 commentView 렌더 | W-FE |
| 변경 | `src/test/.../GameCommentControllerTest.java` | A1·A2·B3 신규 + 기존 12 유지 | W-TEST |
| 변경 | `src/test/.../GameReviewControllerTest.java` | 다축·B2·sort 신규 + 기존 13 유지 | W-TEST |
| (무변경 확인) | `src/test/.../BibimbapApplicationTests.java` | @MockBean 신규 매퍼(GameReviewAxesMapper 등) 추가 필요 시만 | W-TEST |
**충돌 회피**: W-BE 가 매퍼/컨트롤러/data/util 동시 소유(상호 의존 — 단일 worker 권장). W-FE 는 jsp 단독. W-TEST 는 BE 완료 후 진행(시그니처 의존). W-DDL 독립 선행 가능.
**BibimbapApplicationTests 주의**: 신규 `@Mapper` 빈(GameReviewAxesMapper, GameReviewStatsMapper)이 컨트롤러 생성자 주입되면 contextLoads 가 빈을 못 찾아 실패 → `@MockBean` 추가 필수. 이 추가는 31건 회귀의 ApplicationTests 1건이 깨지지 않도록 하는 **필수 조치**.
---
## DDL 전문 (§2 — game-reviews-ddl.sql append 블록)
아래를 `docs/game-reviews-ddl.sql` 끝에 append. 전부 IF NOT EXISTS / DO $$ 멱등. `db/schema.sql` 에도 동일 정의 동기화.
```sql
-- ===========================================================================
-- W3-2 고도화: 다축 평점(game_review_axes) + 댓글 updated_at + is_rating_manual
-- + game_review_stats 집계뷰
-- C3 재분류: game_review_stats 는 W3-2 일반 읽기전용 집계뷰. W2-3 잼 평가 동결과 무관
-- (roadmap.md:63,201,202). 아래 위 'idx_game_reviews_game' 주석의 "집계뷰 미신설"
-- 보수 표기는 본 블록으로 갱신됨.
-- ===========================================================================
-- 1) game_comments.updated_at (A3)
ALTER TABLE "game_comments"
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
-- 기존 댓글이 '수정됨' 오표시되지 않도록 정렬(멱등: 이미 정렬된 행엔 무영향)
UPDATE "game_comments" SET "updated_at" = "created_at" WHERE "updated_at" > "created_at";
COMMENT ON COLUMN "game_comments"."updated_at" IS '덧글 마지막 수정 시각. updated_at > created_at 이면 수정됨(리뷰 대칭)';
-- 2) game_reviews.is_rating_manual (overall 출처 구분)
ALTER TABLE "game_reviews"
ADD COLUMN IF NOT EXISTS "is_rating_manual" boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN "game_reviews"."is_rating_manual" IS 'true=유저 직접선택 overall, false=6축 자동평균';
-- 3) game_review_axes (다축 평점, 리뷰당 6행)
CREATE SEQUENCE IF NOT EXISTS "game_review_axes_id_seq";
CREATE TABLE IF NOT EXISTS "game_review_axes" (
"id" bigint DEFAULT nextval('game_review_axes_id_seq'::regclass) NOT NULL,
"review_id" bigint NOT NULL,
"axis_key" character varying(20) NOT NULL,
"score" smallint NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_review_axes_id_seq" OWNED BY "game_review_axes"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_review_id_fkey') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_review_id_fkey"
FOREIGN KEY ("review_id") REFERENCES "game_reviews" ("id");
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_score_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_score_check" CHECK ("score" BETWEEN 1 AND 5);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_axis_key_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_axis_key_check"
CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual'));
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_review_axes_review_axis"
ON "game_review_axes" ("review_id", "axis_key");
CREATE INDEX IF NOT EXISTS "idx_game_review_axes_review"
ON "game_review_axes" ("review_id");
COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6행). axis_key 6종 각 1~5';
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
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",
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"
WHERE r."is_delete" IS NOT TRUE
GROUP BY r."game_id";
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';
```
### db/schema.sql 동기화 판단 = **동기화 필수**
- 근거: schema.sql 은 dev 컨테이너 부트스트랩(line 16-18, docker-entrypoint-initdb.d 자동실행). axes 테이블/뷰/컬럼 없으면 dev 환경 매퍼 실행 실패. game_reviews 가 schema.sql:117-135 에 이미 정의되어 있으므로 동일 위치에 위 4블록 append + `:136-137` 동결 주석을 C3 갱신 주석으로 교체.
---
## 대안 비교 (§ 선택적)
| 안 | 장점 | 단점 | 채택? |
|---|---|---|---|
| axes 매퍼 신규 vs GameReviewsMapper 확장 | 단일책임/리뷰매퍼 비대화 방지 | 클래스 1개 추가 | **신규 채택** |
| hasMore(limit+1) vs total count | COUNT 쿼리 회피 | total 미표시 | **hasMore 채택** |
| sort: @SelectProvider 분기 vs 메서드 N분리 | ORDER BY만 상수분기, 메서드 1개 | provider 클래스 추가 | **provider 채택** |
| overall 계산 서버 vs 클라 | 변조방지·C3정합 | round 1회 서버부담(무시가능) | **서버 채택** |
| axes 재저장 delete+insert vs upsert | 매퍼 단순 | 6 DEL+6 INS | **delete+insert 채택** |
| SVG 좌표 JS vs JSTL | 동적 N건 일관 | 클라 계산 | **JS 채택** |
| is_rating_manual 채택 vs 미채택 | 자동/수동 출처 구분 | 1컬럼 | **채택**(concerns 재확인) |
---
## 롤아웃 / 마이그레이션
1. **순서**: DDL 멱등 적용(dev: schema.sql 재부트 또는 game-reviews-ddl.sql 수동 실행) → BE 빌드/단위테스트 → FE → L3 스모크.
2. **역호환**:
- 기존 game_reviews 행은 axes 0행 상태 → game_review_stats 의 6축 평균 NULL(LEFT JOIN). 프론트는 NULL 축을 "데이터 없음"/0 처리. overall avg_rating 은 정상(rating 컬럼 기반).
- 기존 댓글: updated_at default now() + 정렬 UPDATE 로 edited=false 유지.
- is_rating_manual default false → 기존 리뷰는 자동평균 취급(축 없어도 rating 표시 영향 없음).
3. **롤백 경로**: 신규 객체(axes 테이블/stats 뷰/2 컬럼)는 비파괴(ADD/CREATE). 코드 롤백 시 DB 객체 잔존해도 무해(미참조). 뷰는 `DROP VIEW IF EXISTS game_review_stats`, 컬럼은 보존(비파괴) 권장.
4. **기존 리뷰 axes 백필**: 본 스코프 비대상(NULL 허용). 백필 필요 시 별도.
---
## 검증 포인트 (verification-advisor 점검 acceptance criteria)
### L1 단위 테스트 (회귀 + 신규)
- **AC-1**: 기존 31건 회귀 PASS — `./mvnw test` 결과 GameCommentControllerTest(12) + GameReviewControllerTest(13) + UserControllerCsrfTest(5) + BibimbapApplicationTests(1) 전부 GREEN. (DbUpdateQueryGeneratorTest 1건은 W3-2 무관, 영향 없음 확인.)
- **AC-2**: BibimbapApplicationTests 의 @MockBean 집합이 컨트롤러 생성자 의존 매퍼 전수를 커버 — 신규 매퍼(GameReviewAxesMapper, GameReviewStatsMapper 등) 추가 시 contextLoads PASS.
- **AC-3**: 신규 단위테스트 추가건 PASS (목록 §9).
### L1 안전성 (sort 화이트리스트 — 집합 전수)
- **AC-4**: sort enum 매핑 전수 5건 보존 — 매퍼/provider 의 ORDER BY 분기가 §4 표의 5개 enum(`oldest`,`newest`(댓글),`newest`,`rating_desc`,`rating_asc`(리뷰))을 전수 커버. 검증: `grep -c 'ORDER BY' <provider 또는 매퍼 소스>` >= 5 AND 각 분기 SQL에 사용자 입력 변수 보간(`${`) 0건 — `grep -c '\${' <매퍼소스>` == 0 (전 매퍼 통틀어 W3-2 변경분).
- **AC-5**: SQL injection 면역 — 전 신규/변경 매퍼에서 `${` 동적치환 0건 (`grep -rc '\${' src/main/java/.../mapper/Game*Mapper.java` 합 == 0). offset/limit/sort 전부 `#{}` 또는 enum 상수.
### L1 다축 (집합 전수 — 6축)
- **AC-6**: axis_key 집합 전수 6건 일치 — DDL CHECK·뷰 FILTER·앱 enum·JSP 라벨 4곳이 동일 6키(`immersion,creativity,controls,completeness,sound,visual`). 검증: `grep -c "'immersion'\|'creativity'\|'controls'\|'completeness'\|'sound'\|'visual'"` 패턴이 game-reviews-ddl.sql 의 axis_key CHECK 절에 6개 전수, stats VIEW FILTER 에 6개 전수.
- **AC-7**: createReview 가 6축 미만 입력 시 400 (전 축 필수). 단위테스트로 5축 입력 거부.
- **AC-8**: overall 자동평균 — 6축 [4,5,3,4,2,5] 입력·overall 미전송 시 rating=round(23/6=3.83)=4, is_rating_manual=false. overall=2 전송 시 rating=2, is_rating_manual=true.
### L1 기타 신규
- **AC-9**: B2 — 리뷰 본문 trim 후 9자 거부(400), 10자 통과. 댓글은 최소제한 없음(기존 유지).
- **AC-10**: B3 — TextNormalizer.normalize("ab\tc") == "ab\tc"(NUL 제거, 탭 보존), 외곽 trim, 내부 공백 보존.
- **AC-11**: A1 — POST/PUT 댓글 응답이 commentView 전체 키(commentId,gameId,authorName,userId,content,createdAt,edited,updatedAt) 포함.
- **AC-12**: A2 마스킹 — 매퍼 SQL CASE 3분기 존재(`u.id IS NULL`→nickname, `u.is_delete`→'(탈퇴한 사용자)', else→display_name). LEFT JOIN 확인.
- **AC-13**: A3 — editGameComment 가 updated_at=now() 설정, nickname 미덮어씀.
### L3 브라우저 스모크 체크리스트
- **AC-14**: 별점위젯 키보드 — Tab 으로 위젯 1회 진입(roving), ←→↑↓ 점수 이동, Home=1/End=5, 선택 시 aria-checked 토글. 6축 위젯 각각 독립 동작.
- **AC-15**: 육각형 레이더 — 요약 SVG(6축 평균) + 개별 리뷰 카드 컴팩트 SVG 렌더. `<svg role="img" aria-label>` 6축 점수 텍스트 대체 존재.
- **AC-16**: 페이지네이션 — 21건 이상 리뷰/댓글 시 "더보기" 노출, 클릭 시 다음 20건 append, hasMore=false 면 버튼 숨김. sort 토글 동작(댓글 oldest↔newest, 리뷰 newest/rating_desc/rating_asc).
- **AC-17**: 서버집계 표시 — 요약 평균 소수1자리, review_count=0 시 "아직 평가 없음". 클라 평균계산(updateSummary 구버전) 잔존 0 — `grep -c 'list.reduce' game-detail.jsp` 의 평균계산 블록 제거 확인.
- **AC-18**: C1 submit 잠금 — 제출 중 버튼 disabled, 응답 후 해제(연타 차단). C4 글자수 카운터 실시간(댓글 n/200, 리뷰 n/1000 + 최소10자 안내). C2 상대시각 "n분 전" + title 절대시각.
- **AC-19**: DDL 적용 — game_review_axes·game_review_stats·game_comments.updated_at·game_reviews.is_rating_manual 존재 (psql `\d` 또는 information_schema).
### AC 정식화 self-audit (시점·표현 — 프로토콜 §4.7)
1. **시점 안정성**: AC-1 의 31건은 현재 세션이 W-TEST 로 테스트를 **추가**하므로 verification 시점엔 31+α건이 됨. → 표현 교정: "기존 31건이 전부 PASS(삭제·실패 0)" 불변식으로 측정, 신규는 AC-3 별도. 자기 트리 고정카운트 함정 회피.
2. **표현 견고성**: AC-5/AC-17 의 `grep -c` 는 리터럴 의존 — `${` 는 SQL injection 신호로 안정적(동의표현 없음, 견고). AC-17 'list.reduce' 는 구현이 다른 메서드명 쓸 수 있어 fragile → "클라 평균계산 로직 부재"를 수동 코드리뷰로 보강(grep 은 보조). AC-6 axis_key 는 6키 고정 리터럴 — DDL/뷰/앱 동의표현 없이 정확히 이 6문자열만 유효(견고).
---
## verification 실행 명령 (L1)
- 빌드툴 = **Maven**(pom.xml 확인, build.gradle 없음). 래퍼 `./mvnw`.
- L1: `./mvnw -q test` (전체) 또는 `./mvnw -q test -Dtest=GameCommentControllerTest,GameReviewControllerTest,UserControllerCsrfTest,BibimbapApplicationTests` (회귀 31건 집중).
- 컴파일만: `./mvnw -q test-compile`.

View File

@ -0,0 +1,68 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-22T10:10:00+09:00
fix_loop: L1
concerns: []
concerns_checked: true
planned_workers: 1
actual_workers: 0
workers_spawned: 0
---
# FIX LOOP L1 — GameReviewControllerTest RED 8건 해소
## 근본 원인 (확정)
**가설 1 (기존/신규 테스트 계약 미갱신) 단독.** 컨트롤러 버그(가설 2) 아님.
- 실패 8건 전부 POST body 가 BODY_MIN(10자) 미만 → 컨트롤러가 design.md AC-9
"리뷰 본문 trim 후 9자 거부(400), 10자 통과" 에 따라 **정당하게 400** 반환.
- 컨트롤러 axes 바인딩은 무관: 테스트가 `controller.createReview(...)` 를 6개 위치 인자로
직접 호출(`createReview`/`updateReview` 래퍼, test:409-421) → MockMvc 폼바인딩 경로 미사용.
`@RequestParam` 평면 파라미터(immersion=…) 구성과도 무관. 검증 순서도 design §5.3 정합 확인
(CSRF→auth→game존재→axes파싱→body검증→overall계산→conflict→insert→axes insert).
- conflict 테스트(409 기대)도 동일 원인: 1st POST 의 body 가 3자("또작성") 라 body 검증에서
400 으로 죽어 conflict(169행) 까지 도달 못 함.
### 실패 8건 본문 길이 (수정 전)
| 테스트 | 본문(전) | 길이 | 수정 후 본문 | 길이 |
|---|---|---|---|---|
| createReviewReturnsEditedFalse:72 | 재미있음 | 4 | 정말 재미있는 게임입니다 | 13 |
| updateReviewReturnsEditedTrue:90 | 더 좋아짐 | 5 | 수정 후 훨씬 더 좋아졌습니다 | 16 |
| createSecondReviewReturnsConflict:109 | 또작성 | 3 | 같은 게임에 또 작성 시도합니다 | 17 |
| assertCreateRatingAccepted(1/5 경계) | 내용입니다열자 | 7 | 별점 경계값 통과 검증 본문입니다 | 18 |
| createReviewAutoAveragesOverall…:258 | 자동평균테스트 | 7 | 별점 자동평균 계산 검증 본문 | 16 |
| createReviewUsesManualOverall…:281 | 수동평점테스트 | 7 | 수동 별점 사용 검증 본문입니다 | 17 |
| createReviewPersistsSixAxes:306 | 여섯축저장테스트 | 8 | 여섯 축 저장 검증용 본문입니다 | 17 |
## 수정 내역 (테스트만, 컨트롤러/계약 불변)
파일: `src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java`
7개 지점 본문 문자열 교체(헬퍼 `assertCreateRatingAccepted` 1곳이 rating 1/5 두 boundary 케이스 커버 → 실패 8건):
- 72행, 90행, 109행, 258행, 281행, 306행 직접 교체
- `assertCreateRatingAccepted` 본문(402행) 교체 → createReviewAcceptsRatingOneBoundary / FiveBoundary 동시 해소
추가(우연통과 배제, §2.6 다항목 재검):
- `assertCreateRatingRejected` 본문(382행) "내용입니다열자"(7자)→"별점 범위 검증용 본문입니다"(15자) 로 상향.
기존 7자였다면 rating 0/6 거부를 검증하려 해도 body 검증(400)에서 먼저 걸려 **우연히** 400 통과할
위험이 있었음. body 를 10자 이상으로 올려 rating 검증 분기까지 실제 도달함을 보장.
**행위 검증 전부 보존**: edited 마커(false/true), 게임당1회 409(never addGameReview),
rating 1~5 경계, overall 자동평균(rating=4·ratingManual=false)/수동(rating=2·manual=true),
addReviewAxes 6행 — 모든 assertion 원형 유지. 검증을 무력화하거나 계약을 약화하지 않음.
## 공유 문자열 일괄치환 회피 (정밀성)
"내용입니다열자"(7자) 는 통과 중인 CSRF/forbidden/missing-axis 테스트(190/204/235행)에도 쓰임.
그 테스트들은 본문 검증 **이전 단계**(CSRF 403 / 비작성자 FORBIDDEN / axis 누락 400)에서 분기되어
짧은 본문이어도 정상 통과 → 의도된 단축 본문. 따라서 일괄 치환 금지, 성공경로 헬퍼 2곳만 정밀 교체.
## 실행 결과
- `./mvnw test -Dtest=GameReviewControllerTest`**Tests run: 20, Failures: 0** (GREEN)
- `./mvnw test` (전체) → **Tests run: 43, Failures: 0, Errors: 0, Skipped: 0** (회귀 0)
- GameCommentControllerTest 17/17, GameReviewControllerTest 20/20,
UserControllerCsrfTest 5/5, BibimbapApplicationTests 1/1
## 손대지 않은 항목 (지시 준수)
- GameReviewController.java 등 main 소스 무수정 (계약·검증순서 design §5 정합 확인만).
- ${}0 / sort5 / axis6 grep 검증, DbUpdateQueryGeneratorTest — 미접촉.

View File

@ -0,0 +1,60 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-22T10:00:00+09:00
---
# 파일 소유권 맵 (W3-2 댓글/리뷰 고도화)
설계 §1 파일 영향 맵 기준. 동일 파일 = 정확히 1 worker. 상호의존 파일은 단일 worker 묶음.
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|---|---|---|---|---|
| docs/game-reviews-ddl.sql | migration-writer | W-DDL | append+주석갱신 | - |
| db/schema.sql | migration-writer | W-DDL | 동기화+주석갱신 | - |
| src/.../util/TextNormalizer.java | code-writer | W-BE | create | - |
| src/.../data/ReviewAxisRow.java | code-writer | W-BE | create | - |
| src/.../mapper/GameReviewAxesMapper.java | code-writer | W-BE | create | ReviewAxisRow |
| src/.../mapper/GameReviewStatsMapper.java | code-writer | W-BE | create | - |
| src/.../data/GameCommentData.java | code-writer | W-BE | modify | - |
| src/.../data/GameReviewData.java | code-writer | W-BE | modify | - |
| src/.../mapper/GameCommentsMapper.java | code-writer | W-BE | modify | GameCommentData |
| src/.../mapper/GameReviewsMapper.java | code-writer | W-BE | modify | GameReviewData |
| src/.../controller/api/GameCommentController.java | code-writer | W-BE | modify | 위 매퍼/data/util |
| src/.../controller/api/GameReviewController.java | code-writer | W-BE | modify | 위 매퍼/data/util |
| src/main/webapp/WEB-INF/views/game-detail.jsp | code-writer | W-FE | modify | (BE API 계약 — 설계 §3 고정) |
| src/test/.../GameCommentControllerTest.java | code-writer | W-TEST | modify | W-BE 시그니처 |
| src/test/.../GameReviewControllerTest.java | code-writer | W-TEST | modify | W-BE 시그니처 |
| src/test/.../BibimbapApplicationTests.java | code-writer | W-TEST | modify | 신규 @Mapper |
## 불변식 점검
- W-BE 가 매퍼/data/util/컨트롤러 11파일 동시 소유 (상호 시그니처 의존 → 단일 worker 순차).
- W-FE jsp 단독 — BE 와 파일 충돌 0. API 계약은 설계 §3 으로 고정되어 BE 코드 미참조 가능 → 병렬 가능.
- W-DDL 2파일 독립 — 코드 무참조 → 선행 병렬 가능.
- W-TEST 는 W-BE 시그니처 확정 필요 → W-BE 완료 후 순차 spawn.
## spawn 순서
1. wave A (병렬): W-DDL, W-FE, W-BE ← 상호 파일 충돌 0
2. wave B (순차): W-TEST ← W-BE 완료 후 (컨트롤러 시그니처 의존)
## planned_workers: 4 (W-DDL, W-FE, W-BE, W-TEST)
---
# 파일 소유권 맵 (FIX LOOP L1 — verification RED 8건)
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|---|---|---|---|---|
| src/test/.../GameReviewControllerTest.java | (advisor 직접) | - | modify | - |
## L1 planned_workers vs actual_workers
- planned_workers: 1
- actual_workers: 0 (advisor 직접)
- 전환 사유: 파일 수 1 < 8 AND 예상 변경 줄수 ~8줄 < 500 advisor 직접 실행 선택. 정밀 문자열 치환 8곳, worker spawn overhead 작업량 초과.
## L1 근본 원인 (진단 결과)
- **가설 1 (테스트 계약 미갱신) 단독 확정.** 컨트롤러 버그 아님.
- 실패 8건 전부 POST body 10자 미만 → 컨트롤러가 design.md AC-9(본문 trim 후 10자 미만 거부) 에 따라 정당하게 400 반환.
- 가설 2(axes 바인딩) 무관: 테스트가 controller.createReview(...) 를 6개 위치 인자로 직접 호출 → MockMvc 폼바인딩 경로 미사용. 검증 순서도 design §5.3 정합 확인.
- 수정 방향: 컨트롤러/FINAL SPEC 계약 불변. 테스트 요청 body 만 10자 이상으로 갱신 (행위 검증 보존).

View File

@ -0,0 +1,250 @@
---
schema_version: 2
sid: 20260622-092800
resumed_from: 20260618-145152
started_at: 2026-06-22T09:28:00+09:00
ended_at: 2026-06-22T10:05:00+09:00
user_request: |
W3-2 댓글/리뷰 고도화 구현 — 직전 인터뷰 세션(20260618-145152)의 FINAL SPEC 으로 구현 재개.
진행 방식 = FINAL SPEC 전체 한 번에 구현(옵션1, 사용자 확정). 단계 분리 없음.
스코프: 다축 평점(game_review_axes) + 육각형 SVG 레이더 + A1~A3 일관성 + B1~B3 목록/검증
+ C1~C6 UX + game_review_stats 집계뷰. 검증: L1 31건 회귀 PASS + 신규 단위테스트, L3 스모크.
mode: team (orchestrator + advisors + workers)
---
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '직전 인터뷰 세션이 FINAL SPEC 까지 요구 분해 완료. 사용자 결정 잠금(옵션1). 재분해 불필요.'
checked_at: 2026-06-22T09:28:00+09:00
- advisor: research-advisor
decision: skip
rationale: 'Grounded Baseline(직전 report) + design-advisor 자체 코드 재확인으로 충분. graphify-lookup 도 생략 — 변경 대상 7파일이 직전 세션에 정밀 grounding 됨, 코드 무드리프트(W3-2 코어 이후 dev seed 커밋만).'
checked_at: 2026-06-22T09:28:30+09:00
- advisor: design-advisor
decision: call
rationale: 'FINAL SPEC → 오픈질문0 확정 설계도. 미결 마이크로결정(is_rating_manual·axis 저장형·sort enum·SVG 좌표·a11y·B3 유틸·테스트목록) 확정 필요.'
checked_at: 2026-06-22T09:28:30+09:00
- advisor: implementation-advisor
decision: call
rationale: '14파일 영향(신규6+변경8) 멀티파일 + worker 분산 필요. design 산출이 파일소유권맵까지 확정 — 직접구현 대신 implementation-advisor 의 worker fan-out 으로 충돌회피.'
checked_at: 2026-06-22T09:34:00+09:00
# C3 정정 (직전 report 오분류 교정 — 근거 확정)
# 직전 report: C3 를 "W2-3 동결 해제·고위험·§6 게이트"로 표기 → 과한 분류.
# 근거 (docs/work-log/2026-06-17-jam-platform-roadmap.md):
# :63 "범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님)"
# :201 "잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계"
# :202 "댓글/리뷰 분리 = 일반기능으로 재분류"
# 판정: game_review_stats = game_reviews(W3-2 테이블) 위 읽기전용 집계뷰. 동결 잼평가 테이블 무관.
# → §6 파괴적 게이트·사용자 재확인 불필요. 일반 CREATE VIEW.
# → game-reviews-ddl.sql:63 "집계뷰 미신설" 보수 주석도 갱신.
# Summary
W3-2 댓글/리뷰 고도화 FINAL SPEC 전체 한 묶음 구현 완료. resumed_from 20260618-145152.
- C3 정정: game_review_stats 집계뷰 = W3-2 일반 DDL(고위험 게이트 해제, roadmap:63,201,202 근거).
- DDL: game_review_axes(6축·리뷰당6행)·game_comments.updated_at·game_reviews.is_rating_manual·game_review_stats 뷰 — 멱등 ALTER/CREATE (ddl + db/schema.sql 동기화, C3 보수주석 갱신).
- 백엔드: A1 commentView 통일·A2 하이브리드 작성자명+탈퇴자 마스킹(QG-2 해결)·A3 edited/updated_at, B1 페이지네이션(limit+1 hasMore)+sort 화이트리스트(@SelectProvider, ${}0), B2 본문10자, B3 TextNormalizer, 다축 overall 자동평균/수동(is_rating_manual).
- 프론트(frontend-design 스킬): 육각형 SVG 레이더(요약+카드)·6축 radiogroup(roving tabindex+키핸들러)·C1~C6, production-grade 폴리시.
- 검증: L1 ./mvnw test 43/43 GREEN(회귀0). FIX LOOP 1회(테스트 계약 미갱신 8건, 컨트롤러 무버그 — body<10자 정당거부). L2 skip. L3 = needs_user_verification.
- 신규 4파일 + 13 수정. 미커밋(프로젝트 정책: 명시 요청 시만).
# Invocations
- agent: design-advisor
phase: design
model_choice: { tier: large, slug: opus, effort: high, rationale: '신규 SVG 좌표수학+스키마설계+보안민감 sort enum+a11y — 판단천장 높음' }
artifact: .atp/work-session/20260622-092800/implementation/design.md
result: 'PASS — 오픈질문0 확정 설계도. concerns 7건. self-verification passed.'
- agent: implementation-advisor
phase: implementation
model_choice: { tier: large, slug: opus, effort: high, rationale: '멀티파일 멀티워커 조율 + 보안가드(${}금지)+회귀보호 — 천장 높음' }
result: 'PASS — BUILD SUCCESS(main+test, JDK21). 16파일. ${} 0건(rg 3매치=주석). axis_key 6키 4곳 정합. 리뷰 INNER JOIN 보존. is_rating_manual 실사용. advisor 직접보정 4건(GameController 영향맵누락/테스트 worker충돌 재작성/NUL escape/@MockBean).'
artifact: .atp/work-session/20260622-092800/implementation/ownership.md
concerns: ['설계 §1 영향맵에 GameController.gameDetail SSR 호출지점 누락→컴파일깨짐(보정완료)', 'applyReviewGate: 본인리뷰 첫페이지밖일때 폼노출 가능(서버 409 최종차단, 비차단)', '댓글 더보기 낙관삽입 2페이지+ 정렬어긋남 가능(리뷰는 page0 재조회로 정합, 비차단)']
- agent: frontend-design (skill pass)
phase: implementation-fe
model_choice: { tier: large, slug: opus, effort: high, rationale: 'production-grade UI 디자인 — 사용자 명시 요청(frontend-design 스킬 활용)' }
result: '(L1 green 후 진입)'
- agent: verification-advisor (1차)
phase: verification
model_choice: { tier: medium, slug: sonnet, effort: medium, rationale: 'L1 테스트 실행+AC 판정' }
result: 'RED — GameReviewControllerTest 8건 FAIL(전부 400). AC-5/4/6 PASS. 댓글·CSRF·context 무회귀.'
- agent: implementation-advisor (FIX LOOP §2.6)
phase: implementation-fix
model_choice: { tier: large, slug: opus, effort: high, rationale: '회귀단계 진단+수정' }
result: 'PASS — 근본원인=테스트 계약 미갱신(컨트롤러 무버그). body<10자 7곳10자+ 교체, 행위 assertion 보존, 계약 무약화. main 무수정. ./mvnw test 43/43 GREEN 회귀0.'
artifact: .atp/work-session/20260622-092800/implementation/fix-L1.md
- agent: verification-advisor (2차 재판정)
phase: verification
model_choice: { tier: medium, slug: sonnet, effort: medium, rationale: 'L1 독립 재판정 + 신규행위 AC 커버리지' }
result: 'GREEN 확정 — 43/43(Failures 0). AC-1/3 PASS, AC-7~13 전부 테스트 실재+assertion 확인, AC-4/5/6 재확인. 로그 clean. L2 skip(원격DB). GAP 0.'
artifact: .atp/work-session/20260622-092800/verification.md
- agent: frontend-design (skill pass, fork)
phase: implementation-fe
model_choice: { tier: large, slug: opus, effort: high, rationale: 'production-grade UI — 사용자 명시 요청(frontend-design 스킬)' }
result: 'PASS — game-detail.jsp 단일파일 승격. 육각형 레이더 시그니처화(방사 그라디언트·글로우·등장모션), 미터바 범례, 히어로 요약카드, 6축입력/정렬토글/더보기 폴리시. 기존 앰버/크림 언어 확장(새 디자인시스템0·외부의존0). JS로직 보존(유일변경=poly fill→presentation attr). a11y/데이터계약 보존. 프리뷰 HTML 산출.'
artifact: .atp/work-session/20260622-092800/artifacts/w3-2-ui-preview.html
# Decisions
- C3 재분류: game_review_stats 집계뷰 = 일반 DDL(고위험 게이트 해제). roadmap:63,201,202 근거. [2026-06-22]
- is_rating_manual 컬럼 채택: overall 자동평균(false)/수동선택(true) 출처 구분. 멱등 ADD COLUMN DEFAULT false. [2026-06-22]
- axis_key 저장형 = 영문 snake 6종 순서고정(immersion/creativity/controls/completeness/sound/visual) = 육각형 축 0~5 1:1. [2026-06-22]
- sort 안전 = @SelectProvider 컴파일타임 상수 ORDER BY 5분기. 사용자입력→enum 매칭만, ${} 보간 0. offset/limit=#{}. [2026-06-22]
- pagination = limit+1 hasMore (COUNT 회피). axes 재저장 = delete-all+insert-6 (TX 원자성). [2026-06-22]
- SVG 육각형 = JS 런타임 buildHexRadar(scores,cx,cy,R). role=img + 축점수 텍스트대체. [2026-06-22]
- db/schema.sql 동기화 필수 (dev 컨테이너 부트스트랩). game-reviews-ddl.sql:63 + schema.sql:136-137 C3 보수주석 갱신. [2026-06-22]
# user_signals
positive: []
negative: []
# verified_by_me
- L1: unit+regression — ./mvnw test 43/43 GREEN (Failures 0, Errors 0). 기존 31건 무회귀. verification-advisor 2차 독립 재판정.
- L1 신규행위: AC-7(6축필수 400)·AC-8(자동평균/수동 overall)·AC-9(B2 10자 경계)·AC-11(A1 commentView 8키)·AC-12(A2 마스킹 CASE)·AC-13(A3 updated_at) 전부 테스트 실재+PASS.
- 보안: AC-5 ${} 동적치환 0건(sort=enum 고정 ORDER BY 상수, offset/limit=#{}). AC-4 sort5 분기. AC-6 axis_key 6키 4곳 정합.
- L2: contract-DB — skipped:원격DB/dev 미기동.
- 로그 스캔: clean (Mockito JDK agent warn·trailing-slash warn = known-ok 비차단).
# needs_user_verification
- **DDL 적용**: docs/game-reviews-ddl.sql 멱등 블록(+ db/schema.sql) 을 dev/운영 DB 에 적용. 신규 객체 = game_review_axes 테이블·game_review_stats 뷰·game_comments.updated_at·game_reviews.is_rating_manual. 전부 비파괴 ADD/CREATE. (dev = schema.sql 재부트 or ddl 수동 실행.)
- **L3 브라우저 스모크**(DDL 적용 후 dev 실게임 대상 — seed-dev.sql 더미게임. 폴백 정적게임은 댓글/리뷰 미동작):
- 육각형 레이더 렌더(요약 + 개별 리뷰 카드), 라이트/다크 양 테마.
- 6축 별점 키보드(Tab 1회 진입 roving, ←→↑↓·Home·End, aria-checked 토글), 6위젯 독립.
- 페이지네이션 "더보기"(21건+ append, hasMore=false 숨김), 정렬 토글(댓글 oldest↔newest, 리뷰 newest/rating_desc/rating_asc).
- 다축 입력→overall 자동평균 표시 + 수동 덮어쓰기, B2 10자 안내, C4 글자수 카운터, C2 상대시각, C1 submit 잠금.
- 서버집계 요약(평균 소수1자리, review_count=0 "아직 평가 없음").
- **UI 프리뷰 열람**(선택): .atp/work-session/20260622-092800/artifacts/w3-2-ui-preview.html 브라우저로 열어 컴포넌트 시각 확인.
# graph_refresh
판정: fully-stale (src + docs 양 scope). graph-refresh-checker 근거 = W3-2 코어(커밋됨)+본세션 고도화로 컨트롤러2·매퍼·data·DDL 신규 → 기존 384노드/613엣지에 댓글/리뷰 토폴로지 부재. 삭제대상 없음.
처리: 완료(no-defer 준수). 커밋 b9d836d 직후 /graphify src/ + docs/ 재생성(메인스레드 — fork 의 Agent spawn 제약으로 폴백). src=536노드/1206엣지/20커뮤니티(리뷰 다축·댓글 commentView·edited 토폴로지 반영), docs=119노드/164엣지/15커뮤니티(C3 재분류·육각형·axes/stats DB스키마·검증교훈 군집). docs/graph/index.md source_commit=b9d836d·Scopes 2행 갱신. transient 캐시 정리 확인. graph 메타 커밋 375a2de(본체 .gitignore).
# open_items
- 미커밋 변경(작업단위 속함): 13 수정 + 4 신규(data/ReviewAxisRow.java·mapper/GameReviewAxesMapper.java·mapper/GameReviewStatsMapper.java·util/TextNormalizer.java). 커밋은 CLAUDE.md 정책상 사용자 명시 요청 시만 — 세션 종료 시 사용자 확인.
- work-session 디렉토리(20260622-092800, 20260618-145152) untracked — 동일 커밋 정책.
- 비차단 후속(차기 세션 후보): GET /reviews/mine(본인리뷰 즉시식별 — applyReviewGate 정밀화), 댓글 더보기 page0 재조회 전환(낙관삽입 정렬정합), 기존 리뷰 axes 백필(현재 NULL 허용), 설계 영향맵에 SSR 호출지점(GameController.gameDetail) 포함.
# Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "(세션 report) frontend-design 스킬 fork 위임 → 스킬 호출+단일파일 폴리시+프리뷰 render-verify, 가드레일(JS로직·a11y·계약 보존) 준수. 다축/육각형/sort안전(${}0) 한 묶음 구현 + L1 43/43 GREEN."
about: frontend-design 스킬을 컨텍스트 상속 fork 로 위임하고, 단일 JSP 파일 폴리시·프리뷰 HTML 산출·가드레일 준수까지 한 단계에 완료한 패턴 전체
- quote_or_paraphrase: "(invocation log) implementation-advisor 직접보정 4건 완료 — 영향맵 누락 보정·테스트 worker충돌 재작성·NUL escape·@MockBean"
about: 설계 누락이 발생했음에도 implementation-advisor 가 자가 보정으로 컴파일 깨짐을 세션 내에서 흡수해 사용자 에스컬레이션 없이 완료한 점
negative:
- quote_or_paraphrase: "(implementation-advisor concerns) 설계 §1 영향맵에 GameController.gameDetail SSR 호출지점 누락 → 컴파일깨짐(보정완료)"
about: design-advisor 가 §1 파일 영향맵 작성 시 API 컨트롤러만 기재하고, 변경된 매퍼/data 심볼을 SSR 뷰모델에서 호출하는 GameController.gameDetail 을 누락한 것
structural: true
- quote_or_paraphrase: "(verification-advisor 1차) GameReviewControllerTest 8건 FAIL — 전부 400. 근본원인=테스트 계약 미갱신(body<10자). 컨트롤러 무버그."
about: B2 본문 최소 10자 검증계약 신규 도입 시, W-TEST worker 가 기존 테스트 fixture body 를 계약 정합 감사 없이 재작성하여 기존 픽스처가 여전히 10자 미만 문자열을 전송한 것
structural: true
what_went_well:
- "frontend-design 스킬 fork 위임 패턴: 컨텍스트 상속으로 BE API 계약·기존 JS 로직·a11y 가드레일을 그대로 이어받아, 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 단일 JSP 파일 승격 + 프리뷰 HTML 산출까지 한 묶음 완료. 외부 의존 0·기존 디자인 언어 확장·a11y 보존 전부 통과. 비자명 판단(스킬 fork + 단일파일 폴리시 조합)이 검증됨."
- "C3 phantom 게이트 해소: 직전 report 의 '동결 해제·고위험·§6 게이트' 표기를 근거 문서(roadmap:63,201,202) 직접 확인으로 일반 DDL 재분류. 불필요한 사용자 재확인 경로 없이 세션 내 정정 완료."
- "FIX LOOP 자가흡수: 테스트 RED 8건 발생 후 implementation-advisor 가 '컨트롤러 무버그/테스트 계약 미갱신' 근본원인을 정확히 진단하고, 행위 assertion 전부 보존·계약 약화 없이 픽스처만 교체 → 사용자 에스컬레이션 0."
- "sort 안전 패턴: @SelectProvider + enum 화이트리스트 → 고정 ORDER BY 상수 5분기. ${} 동적 보간 0건 rg 전수 확인. 보안 AC-5 최초 시도 PASS."
what_to_improve:
- "design-advisor 영향맵에 SSR/뷰모델 호출지점 포함 필수: §1 파일 영향맵은 API 컨트롤러뿐 아니라 변경 심볼(매퍼 메서드 시그니처·data 필드)을 호출하는 SSR 컨트롤러(GameController.gameDetail 등) 와 JSP 뷰모델 바인딩 지점까지 포함해야 한다. 누락 시 implementation 단계 컴파일 깨짐."
- "계약 강화(최소길이·필수필드) 시 기존 테스트 fixture 전수 계약 정합 감사 필수: 신규 검증계약(B2 본문 10자·6축 필수)을 도입할 때, 신규 테스트 케이스 추가만이 아니라 기존 테스트 fixture 가 신규 계약을 충족하는지 전수 감사를 W-TEST 체크리스트에 명시해야 한다. 이번 8건 FAIL 은 모두 컨트롤러 버그 없이 fixture 미갱신 단독 원인."
- "freeze/동결 인접성만으로 고위험 게이트 분류 금지 — 근거 문서 직접 확인 선행: '동결 구역 인근 작업'이라는 표면적 유사성만으로 §6 게이트 표기 시 phantom 게이트가 누적된다. 동결 범위를 근거 문서(ADR/roadmap 줄 번호)로 확인 후 분류해야 한다."
memory_candidates:
- name: design-impact-map-includes-ssr-callers
type: feedback
description: "design-advisor §1 영향맵에 변경 심볼의 SSR/뷰모델 호출지점 포함 필수"
body_draft: |
## Why
W3-2 고도화 세션(20260622)에서 design-advisor 가 GameController.gameDetail 의 SSR 호출지점을
영향맵에서 누락 → implementation 단계 컴파일 깨짐, advisor 직접 보정 필요.
API 컨트롤러 목록만으로는 "변경 심볼을 참조하는 비API 코드" 를 놓친다.
## How to apply
design 단계 §1 파일 영향맵 작성 시:
1. 변경 매퍼/data 심볼 목록 확정.
2. 해당 심볼을 호출하는 SSR 컨트롤러(예: GameController.gameDetail) 와
JSP 뷰모델 바인딩 지점을 rg 로 전수 확인(패턴: 심볼명).
3. API 컨트롤러와 SSR 컨트롤러를 구분해 양쪽을 영향맵에 기재.
재현성: Spring MVC SSR + API 컨트롤러 혼재 구조에서 항상 발생 가능.
rationale_for_saving: "API/SSR 혼재 Spring MVC 에서 구조적으로 재발 가능. 코드/git log 으로 유도 불가 — 설계 단계 체크리스트 규약에만 존재."
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
- name: contract-upgrade-fixture-audit
type: feedback
description: "검증계약 강화 시 기존 테스트 fixture 전수 계약 정합 감사를 W-TEST 체크리스트에 포함"
body_draft: |
## Why
W3-2 고도화 세션(20260622)에서 B2(본문 10자)·6축 필수 계약을 신규 도입 후
W-TEST worker 가 기존 fixture body 를 갱신하지 않아 8건 전부 400 FAIL.
컨트롤러는 무버그였고, fixture 미갱신 단독 원인.
신규 테스트 추가만으로는 기존 픽스처가 새 계약을 충족하는지 보장되지 않는다.
## How to apply
W-TEST worker 지시에 다음 감사 항목 추가:
1. 새로 도입된 검증 계약(최소길이·필수필드·enum 범위 등) 목록화.
2. 기존 테스트 fixture(요청 본문·파라미터) 전수 스캔 — 신규 계약 조건 충족 여부 확인.
3. 미충족 fixture 갱신(행위 assertion 보존 전제).
4. "우연통과" 제거: 거부 경로 테스트의 fixture 도 신규 계약을 충족하는 값으로 올려
실제 거부 사유(상위 분기)까지 도달함을 보장.
재현성: 컨트롤러 검증 강화 시마다 발생 가능.
rationale_for_saving: "컨트롤러 검증 강화는 반복 발생. W-TEST spawn 지시에 표준 체크리스트로 넣지 않으면 매번 재발."
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
- name: freeze-scope-requires-doc-evidence
type: feedback
description: "동결/freeze 인접 작업은 근거 문서 줄 번호 확인 후 게이트 분류 — 표면 유사성만으로 §6 게이트 금지"
body_draft: |
## Why
직전 인터뷰 세션(20260618-145152) report 가 C3(game_review_stats 집계뷰)를
"W2-3 동결 해제·고위험·§6 게이트"로 표기. 실제 동결 대상은 잼 평가(심사/투표/시상) 스키마이며
댓글/리뷰 스키마는 W3-2 별도 설계(roadmap:63,201,202). 표면적 "평점 집계" 유사성만으로
동결 묶음에 포함한 오분류. 본 세션에서 근거 문서 직접 확인으로 phantom 게이트 해소.
## How to apply
동결/freeze 인접 표기 전:
1. 동결 선언 근거 문서(ADR·roadmap·work-log)를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
2. 현재 변경 대상 테이블/뷰/심볼이 그 범위 정의에 명시적으로 포함되는지 판단.
3. 포함 확인 시만 §6 게이트 표기. 인접·유사 용어만으로 분류 금지.
rationale_for_saving: "동결 경계 판단은 코드에서 유도 불가. 문서 확인 없이 유사성 추론으로 phantom 게이트가 누적되면 불필요한 사용자 에스컬레이션 반복."
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
- name: frontend-skill-fork-pattern
type: feedback
description: "production-grade UI 구현은 frontend-design 스킬 fork(컨텍스트 상속) 위임 + 단일파일 폴리시 + 프리뷰 render-verify 조합이 검증됨"
body_draft: |
## Why
W3-2 고도화 세션(20260622)에서 육각형 SVG 레이더·6축 radiogroup·C1~C6 를
frontend-design 스킬 fork 로 위임, 단일 JSP 파일(game-detail.jsp) 승격,
프리뷰 HTML 산출, 가드레일(JS로직 보존·a11y·BE API 계약) 전부 준수.
외부 의존 0·기존 앰버/크림 디자인 언어 확장. L1 43/43 GREEN 이후 진입 순서도 검증됨.
## How to apply
복잡 UI(SVG·a11y·다중 JS 인터랙션) 구현 시:
1. L1 전체 GREEN 확인 후 frontend-design 스킬 fork 진입.
2. 단일파일 폴리시: JSP 1파일 안에서 완결(새 파일 0).
3. 가드레일 명시: 기존 JS 로직 보존 / a11y(role, aria, textContent) / BE API 계약 무변경.
4. 프리뷰 HTML 산출(artifacts/): 컨포넌트 시각 확인용.
외부 JS 라이브러리·CDN 도입 없이 인라인 SVG + vanilla JS 패턴 유지.
rationale_for_saving: "비자명 조합 판단(스킬 fork + L1 완료 후 진입 순서 + 단일파일 폴리시)이 실전 검증됨. 동일 패턴 재현 시 참고."
signal_source: positive
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
protocol_feedback:
- "design-advisor 체크리스트 보강(structural): §1 파일 영향맵 작성 규약에 'SSR 컨트롤러·뷰모델 호출지점 포함' 항목 추가 필요. 현행 규약은 API 컨트롤러 나열에 집중하여 SSR 호출지점이 반복 누락될 구조적 허점이 있다. verification-strategies.md 또는 design-advisor 가이드에 명시 권고."
- "W-TEST worker 지시 표준화(structural): 컨트롤러 검증 계약이 강화(최소길이·필수필드 추가)되는 경우, W-TEST worker 지시에 '기존 fixture 전수 계약 정합 감사' 단계를 의무 체크리스트 항목으로 포함 필요. 현행 W-TEST 지시에는 '신규 테스트 추가'만 있고 '기존 fixture 갱신 감사'가 없어 동일 패턴 재발 가능."
- "§6 게이트 분류 기준 명문화: 동결/freeze 경계 판단을 근거 문서(ADR/roadmap 줄 번호) 확인 없이 표면 유사성만으로 수행하는 것을 agent-team-protocol 에서 명시 금지. '동결 구역 인접 = 고위험 게이트' 추론 차단 규칙 추가 권고."
applied_changes: []
```

View File

@ -0,0 +1,194 @@
---
phase: verification
agent: verification-advisor
agent_version: 1
generated_at: 2026-06-22T10:02:30+09:00
concerns:
- "AC-3/AC-1 중복 실패: GameReviewControllerTest 8건이 모두 400 BAD_REQUEST 반환. 동일 패턴(POST 요청 → 400)이므로 요청 파라미터 바인딩 또는 validation 로직 공통 결함 의심. 수정 범위는 implementation 몫."
- "L2 skipped: 원격 DB 미기동 환경. live contract 테스트 실행 조건 미충족."
concerns_checked: true
---
# 검증 결과
## Acceptance Criteria (입력 받은 그대로 인용)
**AC-1 기존 회귀 보존 (불변식)**
기존 31건이 전부 PASS(삭제·실패 0). 31건 = GameCommentControllerTest 기존 12 + GameReviewControllerTest 기존 13 + UserControllerCsrfTest 5 + BibimbapApplicationTests 1. 이번 세션이 테스트를 추가했으므로 총건수는 31+α(증가 정상). 측정 = 기존 테스트 메서드가 사라지거나 실패한 것이 0.
**AC-3 신규 단위테스트 PASS**
신규 추가분(다축 저장/조회, 페이지네이션, sort enum, A2 마스킹, B2 10자, B3 정규화 등) 전부 GREEN. 현재 영향 테스트클래스 = GameCommentControllerTest(총 17), GameReviewControllerTest(총 20), BibimbapApplicationTests(@MockBean 8).
**AC-5 SQL injection 면역 (보안 — 집합 전수)**
W3-2 신규/변경 매퍼에서 `${}` 동적 SQL 치환 0건.
**AC-4 sort 화이트리스트 5분기**
sort enum→고정 ORDER BY 상수 분기가 5종(댓글 oldest/newest, 리뷰 newest/rating_desc/rating_asc) 존재.
**AC-6 axis_key 6키 정합 (집합 전수)**
axis_key 6종(immersion,creativity,controls,completeness,sound,visual)이 DDL CHECK·뷰 FILTER·BE 앱상수에서 동일 철자·6개 전수. 누락/오타 0.
---
## 실행된 전략
| id | cmd | exit | severity | 결과 |
|---|---|---|---|---|
| unit (full suite) | `JAVA_HOME=... ./mvnw -q test` | 1 | blocker | FAIL |
### 단계별 분해
| 단계 | 결과 |
|---|---|
| L1 typecheck (compile) | pass (빌드 성공, 컴파일 에러 0) |
| L1 unit+regression (GameCommentControllerTest) | pass (17/17) |
| L1 unit+regression (GameReviewControllerTest) | **FAIL (12/20 pass, 8 FAILURE)** |
| L1 unit+regression (UserControllerCsrfTest) | pass (5/5) |
| L1 unit+regression (BibimbapApplicationTests) | pass (1/1) |
| 전체 집계 | Tests run: 43, Failures: 8, Errors: 0, Skipped: 0 |
| L2 contract | skipped: 원격 DB 미기동 |
| 로그 스캔 | warn: 1건 — `Appended trailing slash to static resource location` (정적 리소스 경고. 기능 차단 없음) |
---
## 실패 상세
### GameReviewControllerTest — 8건 전부 `400 BAD_REQUEST`
공통 패턴: 모든 실패 테스트가 POST/PATCH 엔드포인트에 정상 요청을 보냈으나 `400 BAD_REQUEST` 반환. 예상값은 `200 OK` 또는 `409 CONFLICT`.
| # | 테스트명 | 파일:라인 | 예상 | 실제 |
|---|---|---|---|---|
| 1 | `createReviewReturnsEditedFalse` | GameReviewControllerTest.java:74 | 200 OK | 400 BAD_REQUEST |
| 2 | `updateReviewReturnsEditedTrue` | GameReviewControllerTest.java:92 | 200 OK | 400 BAD_REQUEST |
| 3 | `createSecondReviewReturnsConflict` | GameReviewControllerTest.java:111 | 409 CONFLICT | 400 BAD_REQUEST |
| 4 | `createReviewAcceptsRatingOneBoundary` | GameReviewControllerTest.java:129→assertCreateRatingAccepted:404 | 200 OK | 400 BAD_REQUEST |
| 5 | `createReviewAcceptsRatingFiveBoundary` | GameReviewControllerTest.java:134→assertCreateRatingAccepted:404 | 200 OK | 400 BAD_REQUEST |
| 6 | `createReviewAutoAveragesOverallWhenRatingOmitted` | GameReviewControllerTest.java:260 | 200 OK | 400 BAD_REQUEST |
| 7 | `createReviewUsesManualOverallWhenRatingProvided` | GameReviewControllerTest.java:283 | 200 OK | 400 BAD_REQUEST |
| 8 | `createReviewPersistsSixAxes` | GameReviewControllerTest.java:308 | 200 OK | 400 BAD_REQUEST |
**재현 명령:**
```
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
JAVA_HOME=$JAVA_HOME ./mvnw test -pl . -Dtest=GameReviewControllerTest
```
개별 테스트 좁힌 재현:
```
JAVA_HOME=$JAVA_HOME ./mvnw test -Dtest=GameReviewControllerTest#createReviewReturnsEditedFalse
```
---
## AC별 판정
### AC-1 기존 회귀 보존
판정: **FAIL**
근거:
- GameCommentControllerTest: 17/17 pass — 기존 12개 메서드 현존·pass 확인
- UserControllerCsrfTest: 5/5 pass
- BibimbapApplicationTests: 1/1 pass
- GameReviewControllerTest: **20건 중 8건 FAIL**
GameReviewControllerTest 기존 13건 중 어느 것이 기존 메서드인지 테스트 출력만으로는 구분 불가. 단, 실패 8건 모두 기능 시나리오 테스트(create/update 성공 경로, 409 충돌)이므로 기존 13건 중 상당수가 이 실패 8건에 포함될 가능성이 높다. "기존 테스트가 0건 실패" 조건 위반 판정.
### AC-3 신규 단위테스트 PASS
판정: **FAIL**
근거: GameReviewControllerTest 20건 중 8건 실패. 실패 테스트에는 `createReviewPersistsSixAxes`(다축 저장), `createReviewAutoAveragesOverallWhenRatingOmitted`(rating 자동 평균), `createReviewAcceptsRatingOneBoundary`/`FiveBoundary`(경계값) 등 신규 추가분이 포함.
### AC-5 SQL injection 면역
판정: **PASS**
근거:
```
rg -n '\$\{' GameCommentsMapper.java GameReviewsMapper.java GameReviewAxesMapper.java GameReviewStatsMapper.java
```
매치 3건 모두 주석 텍스트:
- `GameCommentsMapper.java:97``// ORDER BY 만 sort enum 으로 고정 문자열 분기(${} 미사용).`
- `GameReviewAxesMapper.java:28``// VALUES 다행. 각 값은 #{axes[i].axisKey}/#{axes[i].score} 바인딩(${} 미사용).`
- `GameReviewsMapper.java:92``// SELECT/JOIN/WHERE 는 컴파일타임 리터럴. ORDER BY 만 sort enum 으로 고정 문자열 분기(${} 미사용).`
실제 SQL 절 내 `${}` 동적 치환 0건. 모든 파라미터 바인딩은 `#{}` 사용 확인.
### AC-4 sort 화이트리스트 5분기
판정: **PASS**
근거 — 매퍼 소스에서 확인된 분기:
댓글(GameCommentsMapper.java:100):
```java
String orderBy = "newest".equals(sort)
? "ORDER BY c.created_at DESC, c.id DESC"
: "ORDER BY c.created_at ASC, c.id ASC";
```
→ newest, oldest(default) 2종
리뷰(GameReviewsMapper.java:95-98):
```java
String orderBy = switch (sort == null ? "" : sort) {
case "rating_desc" -> "ORDER BY r.rating DESC, r.created_at DESC, r.id DESC";
case "rating_asc" -> "ORDER BY r.rating ASC, r.created_at DESC, r.id DESC";
default -> "ORDER BY r.created_at DESC, r.id DESC"; // newest
};
```
→ rating_desc, rating_asc, newest(default) 3종
합계 5종. 모두 고정 문자열 리터럴(동적 보간 없음).
### AC-6 axis_key 6키 정합
판정: **PASS**
근거:
DDL CHECK 제약 (`docs/game-reviews-ddl.sql:156-157`):
```sql
CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual'))
```
뷰 FILTER (`docs/game-reviews-ddl.sql:176-181`):
```sql
FILTER (WHERE a."axis_key"='immersion')
FILTER (WHERE a."axis_key"='creativity')
FILTER (WHERE a."axis_key"='controls')
FILTER (WHERE a."axis_key"='completeness')
FILTER (WHERE a."axis_key"='sound')
FILTER (WHERE a."axis_key"='visual')
```
BE 앱상수 (`GameReviewController.java:38`):
```java
private static final String[] AXIS_KEYS = {"immersion", "creativity", "controls", "completeness", "sound", "visual"};
```
3개 위치 전부 동일 철자 6종. 누락/오타 0.
---
## 종합 판정
```
overall: fail
rollback_signal: none
```
- rollback_signal=none 근거: 실패가 `400 BAD_REQUEST` 일관 패턴. DB migration/파괴적 조작과 무관한 컨트롤러 레벨 요청 처리 결함으로 판단. 데이터 오염 없음.
---
## Acceptance 매칭
| criterion | 매칭 전략 | 판정 |
|---|---|---|
| AC-1 기존 회귀 보존 | L1 unit (full suite) | **FAIL** — GameReviewControllerTest 8건 실패 |
| AC-3 신규 단위테스트 PASS | L1 unit (full suite) | **FAIL** — 신규 테스트 포함 8건 400 반환 |
| AC-5 SQL injection 면역 | rg `\$\{` 매퍼 전수 | PASS — 실제 SQL 보간 0건 |
| AC-4 sort 화이트리스트 5분기 | rg ORDER BY 분기 수집 | PASS — 5종 고정 리터럴 확인 |
| AC-6 axis_key 6키 정합 | rg DDL/뷰/앱상수 3위치 전수 | PASS — 3위치 동일 6종 |

View File

@ -0,0 +1,123 @@
---
schema_version: 2
sid: 20260622-162345
started_at: 2026-06-22T16:23:45+09:00
ended_at:
user_request: |
docs/game-reviews-ddl.sql 같이 sql 추가되면 로컬엔 테스트 가능하게 바로 적용하도록
docs 반영해주고, sql도 지금 바로 반영해줘.
Summary: |
신규 docs/*-ddl.sql 을 실행 중 로컬 DB 에 즉시 적용하는 반복 절차를 docs 에 명문화하고,
현재 미적용 상태인 game-reviews-ddl.sql 의 W3-2 고도화 블록을 실행 DB(dev)에 즉시 적용한다.
Invocations: []
Decisions: []
---
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확 — 절차 문서화 + 멱등 DDL 즉시 적용. 스코프 모호 없음.'
checked_at: 2026-06-22T16:23:45+09:00
- advisor: graphify-lookup-advisor / research-advisor
decision: skip
rationale: 'DB/docs 상태를 orchestrator 가 inline 으로 직접 조사 완료(실행 DB 델타·local-setup.md·schema.sql).'
checked_at: 2026-06-22T16:24:00+09:00
- advisor: design-advisor
decision: skip
rationale: '결정 축 단일·이산(스크립트+doc vs doc-only). AskUserQuestion 으로 직접 위임.'
checked_at: 2026-06-22T16:24:30+09:00
- advisor: implementation-advisor
decision: skip
rationale: '산출=docs 1~2파일 편집 + 멱등 DDL psql 적용. 마이크로 편집 범위, orchestrator 직접 수행.'
checked_at: 2026-06-22T16:24:30+09:00
# 조사 결과 (inline research)
- 마이그레이션 도구 없음(flyway/liquibase 부재). 권위 DDL = docs/*-ddl.sql, Docker init = db/schema.sql.
- db/schema.sql 은 컨테이너 *최초* 기동 시 docker-entrypoint-initdb.d 로 1회만 자동 주입.
이후 추가된 DDL 은 실행 DB 에 자동 반영 안 됨 → down -v 재기동(데이터 소실) 외엔 수동 적용 필요.
- 실행 컨테이너: bibimbap-db (postgres:16, host:5433), db=bibimbap user=bibimbap schema=dev.
- 실행 DB dev 스키마 델타 (game-reviews-ddl.sql 기준):
- 적용됨: game_reviews 테이블, game_comments.user_id
- 누락됨: game_comments.updated_at, game_reviews.is_rating_manual,
game_review_axes 테이블, game_review_stats 뷰 (= DDL L102~187 고도화 블록)
- DDL 전체 멱등(IF NOT EXISTS / DO $$ guard / CREATE OR REPLACE VIEW) → 전체 재실행 안전.
Decisions:
- id: D1
decision: '반복 절차 형태 = 헬퍼 스크립트 + 문서 (Option A, 사용자 선택)'
rationale: 'DDL 전부 멱등 + 마이그레이션 도구 부재 → "바로 적용" 을 한 명령으로 만드는 스크립트가 적합.'
- id: D2
decision: 'DDL 적용은 데이터 보존(비파괴) — docker exec psql 직접 적용. down -v 미사용.'
rationale: 'down -v 는 로컬 데이터 소실. 모든 docs/*-ddl.sql 멱등이라 직접 적용이 안전.'
- id: D3
decision: 'search_path 강제(PGOPTIONS -c search_path=dev) 를 스크립트/문서에 명시.'
rationale: 'docs/*-ddl.sql 은 unqualified 테이블명 사용. dev 스키마 타겟 보장 필수.'
# verified_by_me
- 'L1(typecheck/unit): N/A — src/ 코드 변경 0건 (DDL 적용 + 운영 스크립트 + docs 만).'
- '게임리뷰 DDL 적용: docs/game-reviews-ddl.sql → bibimbap-db dev 스키마, ON_ERROR_STOP=1 무에러.'
- '적용 후 객체 검증(전부 존재): game_review_axes 테이블 / game_review_stats 뷰 / game_comments.updated_at / game_reviews.is_rating_manual / axis_key check 제약.'
- 'game_review_stats 뷰 SELECT 정상 동작 (기존 시드 game_id=3, review_count=5 집계 반환).'
- 'db/apply-local-ddl.sh 전체 실행 검증: docs/*-ddl.sql 3개 멱등 재실행 성공(기존 객체 skip, 누락분 생성, 종료 0).'
- '나머지 DDL 멱등성 사전 확인: recruit-posts / security-hardening 둘 다 가드≥문장, DROP/TRUNCATE/DELETE/bare INSERT 0.'
# needs_user_verification
- '(선택) 앱 기동 후 리뷰 다축평점/집계뷰 기능 스모크 — 실행 DB 에 객체는 적용됐으나 UI 경로 E2E 는 미수행.'
- '커밋: 사용자 요청 시 진행 (CLAUDE.md/하니스 정책 — 커밋은 명시 요청 시에만). 변경 파일: docs/usage/local-setup.md (M), db/apply-local-ddl.sh (신규).'
# graph_refresh
- 'graph-refresh-checker 판정: fresh (src·docs 양 scope). 재생성 불필요.'
- '근거: src 변경 0; docs 변경(local-setup.md §4.1 추가)은 기존 문서 내 산문 보강 — 신규 md/카테고리/DDL 0, 구조 시그널 0. 참조 DDL 3종은 이미 docs scope 인덱싱됨.'
- 'db/apply-local-ddl.sh 는 scope(src/,docs/) 밖 — 신규 scope 신설 불요(향후 db/ 운영 스크립트 다수화 시 재검토 권고).'
- 'source_commit b9d836d (HEAD=375a2de) 차이는 graph 메타 커밋 1건뿐 — 다음 실질 변경 시 동기화. 단독 갱신 불요.'
# Invocations
- agent: atp-graphify:graph-refresh-checker
tier: large
result: 'fresh (src·docs). 재생성/삭제/index 갱신 모두 불필요.'
tokens: 12263
# user_signals
positive:
- 'AskUserQuestion 반복절차 형태 결정에서 Recommended(스크립트+문서) 1회 선택 — 옵션 설계 수용.'
negative: []
# retrospective
- 'retrospective-advisor 정식 호출 skip — 마이크로 편집 경로(advisor 전체 skip), negative 시그널 0, 구조적 피드백 0.'
- 'memory_candidate: 없음 — 핵심 교훈(schema.sql 최초 1회만 자동주입 → 신규 DDL 수동 적용 필요)은 산출물 자체(local-setup.md §4.1 + db/apply-local-ddl.sh)에 docs-first 로 이미 codify 됨. 별도 MEMORY 불요.'
# follow_on (동일 세션 후속 요청)
followup_request: |
커밋 진행 + "비-메인스트림 브랜치면 커밋 전제로 작업" 정책을 docs 에 반영.
followup_method: |
read-only 조사 워크플로우(Survey 4 병렬 explorer → Design 합성, ultracode) 로
정책 문서 위치·기존 규칙·하니스 충돌 표면 전수 조사 후 plan-gate → 적용.
followup_decisions:
- id: D4
decision: '커밋 정책 = development 규칙 급(신규 ADR 미발행).'
rationale: 'ADR 은 기술/아키텍처 되돌리기 어려운 결정 한정(docs/adr/index.md). 본건은 가역적 운영 절차 → document-category-classification 상 development/. 부수 발견: ADR-0010 은 work-session 보고서들의 phantom 참조(docs/adr/ 에 번호 ADR 미실재).'
- id: D5
decision: '하니스 기본규칙과 정합 = durable authorization 프레이밍.'
rationale: "비-메인스트림 브랜치 커밋을 '프로젝트 지침이 부여한 표준 승인' 으로 명문화 → '사용자 요청 시에만 커밋' 위반 아님. push 는 명시 요청 한정 유지, 메인스트림 branch-first 유지."
- id: D6
decision: 'CLAUDE.md=선언+링크, docs/development/git-workflow.md=정본 신설, index.md 등재.'
rationale: 'CLAUDE.md 는 하니스 상시 로드 → 표준 승인 선언 효력. 상세 규약은 재사용 개발규칙이라 development/ 정본.'
followup_edits:
- 'CLAUDE.md 작업원칙: 비-메인스트림 커밋 표준승인 + 메인스트림 branch-first 불릿 2개.'
- 'docs/development/git-workflow.md 신규: 브랜치 분류·표준승인·push 제외·커밋 단위·Conventional Commits + Co-Authored-By 트레일러.'
- 'docs/development/index.md: git-workflow.md 등재.'
followup_commits:
- 'feat(db): 로컬 DDL 즉시적용 스크립트 + 셋업 절차 문서화 (db/apply-local-ddl.sh, docs/usage/local-setup.md)'
- 'docs(dev): 비-메인스트림 브랜치 커밋 표준 승인 정책 추가 (CLAUDE.md, git-workflow.md, dev/index.md)'
- 'chore(atp): work-session 산출물 기록 (20260618-145152, 20260622-092800, 20260622-162345 — 사용자 "셋 다 커밋" 선택)'
user_signals_followup:
positive:
- 'orphan work-session 처리 "셋 다 커밋" 1회 선택 — 추적 관행 일관성 복원 승인.'
# ended_at
ended_at: 2026-06-22T16:48:00+09:00
# open_items
- '미커밋 변경 2건 — docs/usage/local-setup.md(M), db/apply-local-ddl.sh(신규). 이번 작업 단위. 사용자 커밋 지시 대기.'
- '.atp/work-session/ untracked 디렉토리 3개 — 세션 산출물(ADR-0010 추적 대상이나 별도 커밋 정책).'

View File

@ -12,6 +12,8 @@
## 작업 원칙 ## 작업 원칙
- 사용자 변경을 보호한다. 작업 전후로 `git status --short`를 확인한다. - 사용자 변경을 보호한다. 작업 전후로 `git status --short`를 확인한다.
- 비-메인스트림 브랜치(메인스트림 = `main`/`master` 및 보호 브랜치, 그 외 `feat/*`·`fix/*`·`chore/*`·`docs/*` 등 작업 브랜치)에서는 커밋이 표준 승인된다. 작업 단위가 끝날 때마다 사용자에게 매번 묻지 않고 커밋하며, 메시지 규약·커밋 단위·트레일러는 `docs/development/git-workflow.md`를 따른다. `push`는 브랜치와 무관하게 사용자 명시 요청 시에만 수행한다.
- 메인스트림 브랜치에서는 커밋 표준 승인이 적용되지 않는다. 기본 브랜치(`main`) 위라면 먼저 작업 브랜치를 생성한 뒤 작업한다.
- 검색은 우선 `rg`를 사용한다. - 검색은 우선 `rg`를 사용한다.
- 문서-only 분석 요청에서는 `src/``pom.xml`을 수정하지 않는다. - 문서-only 분석 요청에서는 `src/``pom.xml`을 수정하지 않는다.
- 보안 발견 사항은 `docs/analysis/``file:line` 근거와 함께 기록한다. - 보안 발견 사항은 `docs/analysis/``file:line` 근거와 함께 기록한다.

66
db/apply-local-ddl.sh Executable file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env bash
# =============================================================================
# db/apply-local-ddl.sh
# -----------------------------------------------------------------------------
# 권위 DDL(docs/*-ddl.sql)을 실행 중인 로컬 DB 컨테이너에 즉시 적용한다.
#
# 배경: 이 프로젝트는 flyway/liquibase 가 없다(docs/usage/local-setup.md §4).
# db/schema.sql 은 컨테이너 *최초* 기동 시 docker-entrypoint-initdb.d 로 1회만
# 자동 주입되므로, 그 이후 docs/*-ddl.sql 로 추가된 스키마 변경은 실행 DB 에
# 자동 반영되지 않는다. 데이터를 잃는 `docker compose down -v` 대신, 이 스크립트로
# 변경분을 실행 DB 에 비파괴 적용해 곧바로 로컬 테스트할 수 있다.
#
# 안전성: 모든 docs/*-ddl.sql 은 멱등(IF NOT EXISTS / DO $$ guard / CREATE OR
# REPLACE)하게 작성한다. 따라서 전체를 몇 번 재실행해도 기존 객체는 NOTICE 후
# skip 되고 누락분만 생성된다. ON_ERROR_STOP=1 로 첫 에러에서 즉시 중단한다.
#
# 사용:
# db/apply-local-ddl.sh # docs/*-ddl.sql 전체 적용(기본)
# db/apply-local-ddl.sh docs/game-reviews-ddl.sql # 특정 파일만 적용
#
# 환경변수 override(기본값은 .env 에서 로드):
# DB_CONTAINER (기본 bibimbap-db) / PG_USER / PG_DB / PG_SCHEMA(기본 dev)
# =============================================================================
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
# .env 에서 기본 접속 정보 로드(있으면). 없으면 컨테이너 기본값 사용.
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
CONTAINER="${DB_CONTAINER:-bibimbap-db}"
PGUSER_="${PG_USER:-${POSTGRES_USER:-bibimbap}}"
PGDB_="${PG_DB:-${POSTGRES_DB:-bibimbap}}"
SCHEMA="${PG_SCHEMA:-${APP_SCHEMA:-dev}}"
# 적용 대상: 인자가 있으면 그 파일들, 없으면 docs/*-ddl.sql 전체.
if [ "$#" -gt 0 ]; then
files=("$@")
else
files=(docs/*-ddl.sql)
fi
# 컨테이너 가동 확인.
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "✗ DB 컨테이너 '$CONTAINER' 가 떠 있지 않다. 먼저: docker compose up -d db" >&2
exit 1
fi
echo "▶ 대상 컨테이너=$CONTAINER db=$PGDB_ schema=$SCHEMA"
for f in "${files[@]}"; do
if [ ! -f "$f" ]; then
echo "✗ 파일 없음: $f" >&2
exit 1
fi
echo "▶ 적용: $f"
# docs/*-ddl.sql 은 unqualified 테이블명을 쓰므로 search_path 로 스키마를 강제한다.
docker exec -e PGOPTIONS="-c search_path=$SCHEMA" -i "$CONTAINER" \
psql -U "$PGUSER_" -d "$PGDB_" -v ON_ERROR_STOP=1 -f - < "$f"
done
echo "✓ 완료 — docs/*-ddl.sql → '$SCHEMA' 스키마 적용됨"

View File

@ -133,8 +133,88 @@ CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active"
ON "game_reviews" ("game_id", "user_id") WHERE "is_delete" IS NOT TRUE; ON "game_reviews" ("game_id", "user_id") WHERE "is_delete" IS NOT TRUE;
CREATE INDEX IF NOT EXISTS "idx_game_reviews_game" CREATE INDEX IF NOT EXISTS "idx_game_reviews_game"
ON "game_reviews" ("game_id") WHERE "is_delete" = false; ON "game_reviews" ("game_id") WHERE "is_delete" = false;
-- 향후 다축(육각형) 확장 시: rating 유지 + game_review_axes(review_id, axis, score) 별도 테이블 분리. -- C3 재분류: 다축(육각형) game_review_axes + 집계뷰 game_review_stats 는 아래에 W3-2 일반 DDL 로 신설(roadmap.md:63,201,202, W2-3 동결 무관).
-- 집계 컬럼/뷰는 W2-3 동결 — 신설 금지.
-- ---------------------------------------------------------------------------
-- game_review_axes / is_rating_manual / updated_at / game_review_stats (W3-2 고도화, docs/game-reviews-ddl.sql 와 동일)
-- 1) game_comments.updated_at (A3)
ALTER TABLE "game_comments"
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
-- 기존 댓글이 '수정됨' 오표시되지 않도록 정렬(멱등: 이미 정렬된 행엔 무영향)
UPDATE "game_comments" SET "updated_at" = "created_at" WHERE "updated_at" > "created_at";
COMMENT ON COLUMN "game_comments"."updated_at" IS '덧글 마지막 수정 시각. updated_at > created_at 이면 수정됨(리뷰 대칭)';
-- 2) game_reviews.is_rating_manual (overall 출처 구분)
ALTER TABLE "game_reviews"
ADD COLUMN IF NOT EXISTS "is_rating_manual" boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN "game_reviews"."is_rating_manual" IS 'true=유저 직접선택 overall, false=6축 자동평균';
-- 3) game_review_axes (다축 평점, 리뷰당 6행)
CREATE SEQUENCE IF NOT EXISTS "game_review_axes_id_seq";
CREATE TABLE IF NOT EXISTS "game_review_axes" (
"id" bigint DEFAULT nextval('game_review_axes_id_seq'::regclass) NOT NULL,
"review_id" bigint NOT NULL,
"axis_key" character varying(20) NOT NULL,
"score" smallint NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_review_axes_id_seq" OWNED BY "game_review_axes"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_review_id_fkey') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_review_id_fkey"
FOREIGN KEY ("review_id") REFERENCES "game_reviews" ("id");
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_score_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_score_check" CHECK ("score" BETWEEN 1 AND 5);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_axis_key_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_axis_key_check"
CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual'));
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_review_axes_review_axis"
ON "game_review_axes" ("review_id", "axis_key");
CREATE INDEX IF NOT EXISTS "idx_game_review_axes_review"
ON "game_review_axes" ("review_id");
COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6행). axis_key 6종 각 1~5';
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
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",
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"
WHERE r."is_delete" IS NOT TRUE
GROUP BY r."game_id";
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- game_likes (비권위 복원본 — 매퍼는 hard delete 사용, is_delete 컬럼 없음) -- game_likes (비권위 복원본 — 매퍼는 hard delete 사용, is_delete 컬럼 없음)

View File

@ -115,8 +115,8 @@ related_design: "../../.atp/work-session/20260618-104034/design.md"
## 이월 항목 ## 이월 항목
1. W2-3 평점 집계 계약 (SELECT AVG/COUNT) — W2-6 시상 후속. 1. ~~W2-3 평점 집계 계약 (SELECT AVG/COUNT) — W2-6 시상 후속.~~ → **해소**: C3 재분류 확정(W2-3 동결 무관). game_review_stats 집계뷰 [2026-06-22 고도화에서 신설](./2026-06-22-w3-2-comments-reviews-enhancement.md#c3-재분류--직전-코어-이월-항목-해소-중요).
2. 운영자 role 부여 경로 — W1 RBAC/Interceptor 연결 시 활성. 2. 운영자 role 부여 경로 — W1 RBAC/Interceptor 연결 시 활성.
3. 다축(육각형) 평점 — 현재 단일 rating, game_review_axes 분리 여지(주석). 3. ~~다축(육각형) 평점 — 현재 단일 rating, game_review_axes 분리 여지(주석).~~ → **해소**: [2026-06-22 고도화](./2026-06-22-w3-2-comments-reviews-enhancement.md)에서 game_review_axes 6축 + 육각형 SVG 레이더 구현 완료.
4. 리뷰 이력 열람 권한 — 열람 정책 확정 시 별도 이력 테이블. 4. 리뷰 이력 열람 권한 — 열람 정책 확정 시 별도 이력 테이블.
5. GameCatalog 정적 폴백 게임 대상 댓글/리뷰 — DB 실제 게임만 기능 동작(폴백은 404). 5. GameCatalog 정적 폴백 게임 대상 댓글/리뷰 — DB 실제 게임만 기능 동작(폴백은 404).

View File

@ -0,0 +1,286 @@
---
kind: change
title: "W3-2 댓글/리뷰 고도화 — 다축 평점·일관성·목록규모·UX"
session_id: 20260622-092800
resumed_from: 20260618-145152
created_at: 2026-06-22
status: implemented
related_prev_change: "./2026-06-18-w3-2-comments-reviews.md"
related_security_checklist: "../security/security-remediation-checklist.md"
related_work_log: "../work-log/2026-06-17-jam-platform-roadmap.md"
related_design: "../../.atp/work-session/20260622-092800/implementation/design.md"
related_report: "../../.atp/work-session/20260622-092800/report.md"
---
# W3-2 댓글/리뷰 고도화 변경 이력
상위 코어 이력: [2026-06-18 W3-2 댓글/리뷰 분리 구현](./2026-06-18-w3-2-comments-reviews.md)
## 개요
2026-06-18 코어 구현(댓글/리뷰 서버 영속화·CRUD) 위에 4개 축의 고도화를 얹었다.
| 고도화 축 | 주요 내용 |
|---|---|
| **A 일관성** | commentView 단일 통일(A1) + 작성자명 하이브리드 마스킹 QG-2 해결(A2) + edited/updatedAt(A3) |
| **B 목록규모** | 페이지네이션 더보기 20건(B1) + 리뷰 본문 최소 10자(B2) + TextNormalizer 제어문자 정규화(B3) |
| **다축 평점** | game_review_axes 6축(리뷰당 6행) + overall 자동평균/수동덮어쓰기(is_rating_manual) + game_review_stats 집계뷰 |
| **C UX** | submit 잠금(C1) + 상대시각(C2) + 글자수 카운터(C4) + 완전소멸 유지(C5) + radiogroup 완전 패턴 roving tabindex(C6) + 육각형 SVG 레이더 |
프론트엔드 구현(game-detail.jsp 시각 컴포넌트)은 frontend-design 스킬 패스로 처리됐다.
---
## C3 재분류 — 직전 코어 이월 항목 해소 (중요)
### 배경
직전 코어 이력(`2026-06-18-w3-2-comments-reviews.md`) 이월 항목 1번:
> "W2-3 평점 집계 계약 (SELECT AVG/COUNT) — **W2-6 시상 후속**."
이 이월 표기는 game_review_stats 집계뷰가 W2-3 동결 묶음에 속한다는 **오분류**에 근거했다. 본 세션에서 정정 확인 후 해소했다.
### 정정 근거
`docs/work-log/2026-06-17-jam-platform-roadmap.md` 3개 지점:
- `:63` — "W2-3 범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님)."
- `:201` — "잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계."
- `:202` — "댓글/리뷰 분리 → W3-2 일반기능으로 재분류: 잼 평가 동결묶음에서 분리."
### 결론
`game_review_stats` = `game_reviews`(W3-2 테이블) 위 **읽기전용 집계뷰** 신설 = **W3-2 일반 DDL**. W2-3 잼 평가(심사/투표/시상) 스키마 동결과 완전히 무관하다. `§6 파괴적 게이트 / 사용자 재확인 경로` 불필요 — 본 세션에서 일반 CREATE VIEW 로 처리 완료.
### 부수 무효화
아래 두 보수 주석이 본 변경으로 무효화됐다(구현 완료 시 갱신 대상):
| 위치 | 기존 보수 주석 | 상태 |
|---|---|---|
| `docs/game-reviews-ddl.sql:63` | "집계 컬럼/뷰는 신설하지 않음 (W2-3 동결 보호)" | 본 DDL 블록 추가로 **무효화** |
| `db/schema.sql:136-137` | "집계 컬럼/뷰는 W2-3 동결 — 신설 금지" | 동기화 DDL 블록으로 **무효화** |
> **ADR 권고**: W2-3 동결 경계 재확인(잼 평가 한정, 댓글/리뷰 무관)은 미래 세션에서 `adr/ADR-0001` 로 정식화할 가치가 있다. 현재는 직전 오분류를 정정하는 수준이므로 `changes/` 내 섹션으로 기록하되, W2-3 관련 설계 분기가 재발하면 ADR 발행을 권고한다.
---
## DDL 변경
### 신규 테이블: game_review_axes
리뷰당 정확히 6행. `UNIQUE(review_id, axis_key)`로 중복 방지.
| 컬럼 | 타입 | 비고 |
|---|---|---|
| `id` | bigint PK | `game_review_axes_id_seq` |
| `review_id` | bigint NOT NULL | FK → `game_reviews(id)` |
| `axis_key` | varchar(20) NOT NULL | CHECK 6종 (아래 참조) |
| `score` | smallint NOT NULL | CHECK(1~5) |
`axis_key` 6종 (육각형 축 인덱스 0~5 순서 고정):
`immersion`(몰입성) / `creativity`(창의성) / `controls`(조작성) / `completeness`(완성도) / `sound`(사운드) / `visual`(비주얼)
### game_comments.updated_at (멱등 ALTER)
`timestamptz DEFAULT now() NOT NULL` 추가. `edited = updated_at > created_at` (리뷰 대칭). ALTER 직후 기존 댓글 `updated_at = created_at` 정렬 UPDATE 1회 적용(오표시 방지, 멱등).
### game_reviews.is_rating_manual (멱등 ALTER)
`boolean DEFAULT false NOT NULL` 추가. `true` = 유저 직접 선택 overall, `false` = 6축 자동평균.
### game_review_stats (VIEW 신규)
`game_reviews(is_delete IS NOT TRUE)` LEFT JOIN `game_review_axes` 집계.
컬럼: `game_id, avg_rating numeric, review_count bigint, avg_immersion numeric, avg_creativity numeric, avg_controls numeric, avg_completeness numeric, avg_sound numeric, avg_visual numeric`
클라이언트 평균계산(`updateSummary`, JSP:1513-1522) 폐기 공급원.
### 적용 대상 파일
- `docs/game-reviews-ddl.sql` — 멱등 블록 append(+ :63 보수주석 갱신)
- `db/schema.sql` — 동일 정의 동기화(+ :136-137 주석 갱신)
**적용 상태**: needs_user_verification (dev: schema.sql 재부트 or ddl 수동 실행)
---
## 런타임 동작 변화
### A1 — commentView 단일 통일
| 항목 | 이전 | 이후 |
|---|---|---|
| POST /comments 응답 | flat(commentId/gameId/authorName/userId/content) | commentView 전체(+createdAt/edited/updatedAt) |
| PUT /comments/{id} 응답 | 부분(commentId/content) | commentView 전체 |
| commentView 키 수 | 가변 | 고정 8키 |
### A2 — 하이브리드 작성자명 + 탈퇴자 마스킹 (QG-2 해결)
`GameCommentsMapper` LEFT JOIN `users` + CASE 3분기:
1. `u.id IS NULL` → 스냅샷 닉네임(레거시 user_id NULL 레코드 역호환)
2. `u.is_delete``"(탈퇴한 사용자)"` 마스킹
3. else → `u.display_name`
QG-2(레거시 user_id NULL 레코드 `authorName` 빈값) 자동 해결.
### A3 — 댓글 edited/updatedAt
댓글 수정 시 `updated_at = now()` 갱신. `edited = (updated_at > created_at)`. nickname 덮어쓰기 없음.
### B1 — 페이지네이션 (limit+1 hasMore 방식)
| 항목 | 이전 | 이후 |
|---|---|---|
| 목록 반환 형태 | `{ status, items[] }` | `{ status, items[], hasMore }` |
| 요청 파라미터 | 없음 | `?page=<int≥0>&sort=<enum>` |
| 더보기 방식 | 전체 반환 | 20건 고정, `limit+1` 조회 후 hasMore 판정 |
sort enum:
| 대상 | 허용값 | 기본값 |
|---|---|---|
| 댓글 | `oldest`, `newest` | `oldest` |
| 리뷰 | `newest`, `rating_desc`, `rating_asc` | `newest` |
미허용 값 → 기본값 fallback (400 미반환). ORDER BY는 `@SelectProvider` 컴파일타임 상수 분기 — `${}` 동적치환 0건. `offset`/`limit`은 `#{}` 바인딩.
### B2 — 리뷰 본문 최소 10자
`TextNormalizer.normalize()` 후 trim 10자 미만 → 400. 댓글은 현행 유지(상한 200자만).
### B3 — TextNormalizer 제어문자 정규화
신규 `com.pandoli365.bibimbap.util.TextNormalizer.normalize(String raw)`:
- C0/C1 제어문자(U+0000~U+001F, U+007F~U+009F) 중 `\t`(U+0009)·`\n`·`\r` 제외 후 제거
- 외곽 `strip()`. 내부 공백/줄바꿈 보존.
- 댓글 content / 리뷰 body 검증 **전** 적용.
### 다축 평점 — overall 자동/수동
| 입력 | overall 결정 | is_rating_manual |
|---|---|---|
| overall 미전송/빈값 | `rating = Math.round(6축 평균)` (서버 계산, HALF_UP) | `false` |
| overall 직접 전송 | `rating = 전송값(1~5 검증)` | `true` |
axes 재저장 전략(editReview): `deleteReviewAxes(reviewId)``addReviewAxes(reviewId, 6행)` (TX 원자성).
---
## 프론트엔드 (frontend-design 스킬 패스)
frontend-design 스킬로 처리된 `game-detail.jsp` 변경 요약. 기존 앰버/크림 디자인 언어 확장, 외부 의존성 추가 없음.
| 컴포넌트 | 내용 |
|---|---|
| 육각형 SVG 레이더 | 인라인 SVG 자체 구현. 요약(viewBox 200×200, R=80) + 개별 리뷰 카드 컴팩트(viewBox 120×120, R=44). 방사 그라디언트·글로우·등장 모션. `role="img" aria-label` 6축 점수 텍스트 대체(a11y 필수). |
| 6축 radiogroup (C6) | `buildStarRadioGroup(axisKey, labelText)` 공용 함수. roving tabindex(선택 radio `tabindex="0"`, 나머지 `-1`). 키보드: ←→↑↓ 이동+즉시선택, Home=1/End=5, Space/Enter 확정, `preventDefault()` 스크롤 방지. |
| C1 submit 잠금 | 제출 중 버튼 disabled + 로딩 표시, 응답 후 해제. 연타 차단. |
| C2 상대시각 | "n분 전" 표시 + title 절대시각. |
| C4 글자수 카운터 | 댓글 n/200, 리뷰 n/1000 + 최소 10자 안내 실시간 표시. |
| C5 완전소멸 | 삭제 항목 DOM 제거 유지. |
| 서버집계 표시 | game_review_stats 평균 소수 1자리, review_count=0 → "아직 평가 없음". 클라 평균계산(updateSummary 구버전) 제거. |
---
## 신규/변경 파일
### 신규 (4개)
| 파일 | 설명 |
|---|---|
| `src/.../data/ReviewAxisRow.java` | 축 평점 행 POJO (reviewId/axisKey/score) |
| `src/.../mapper/GameReviewAxesMapper.java` | axes add/deleteAll/listByReviewIds |
| `src/.../mapper/GameReviewStatsMapper.java` | game_review_stats 1행 조회 |
| `src/.../util/TextNormalizer.java` | B3 제어문자 정규화 static util |
### 변경 (13개)
| 파일 | 변경 내용 |
|---|---|
| `docs/game-reviews-ddl.sql` | 고도화 DDL 블록 append(:63 보수주석 갱신) |
| `db/schema.sql` | 동기화(:136-137 동결주석 C3 갱신) |
| `src/.../data/GameCommentData.java` | updatedAt + edited 필드 추가 |
| `src/.../data/GameReviewData.java` | ratingManual + axes(Map) 필드 추가 |
| `src/.../mapper/GameCommentsMapper.java` | A2 LEFT JOIN+CASE, A3 updated_at, B1 offset/limit/sort |
| `src/.../mapper/GameReviewsMapper.java` | B1 offset/limit/sort, is_rating_manual, @SelectProvider |
| `src/.../controller/api/GameCommentController.java` | A1 재조회·commentView, B1 page/sort, B3 normalize |
| `src/.../controller/api/GameReviewController.java` | 다축 검증/저장, overall 자동·수동, B2 10자, B3, B1, summary |
| `src/.../controller/api/GameController.java` | gameDetail SSR 신규 매퍼 주입 (영향맵 누락→보정 완료) |
| `src/main/webapp/WEB-INF/views/game-detail.jsp` | 육각형 SVG, C1~C6, 페이지네이션·sort UI, 서버집계, 6축 입력 |
| `src/test/.../GameCommentControllerTest.java` | A1·A2·B3 신규 + 기존 12건 유지 |
| `src/test/.../GameReviewControllerTest.java` | 다축·B2·sort 신규 + 기존 13건 유지 |
| `src/test/.../BibimbapApplicationTests.java` | 신규 매퍼(GameReviewAxesMapper·GameReviewStatsMapper) @MockBean 추가 |
---
## API 계약 변화
### commentView 스키마 (A1 통일 — POST·PUT·list 동일)
```json
{
"commentId": 100,
"gameId": 1,
"authorName": "표시명 또는 (탈퇴한 사용자) 또는 스냅샷닉",
"userId": 7,
"content": "...",
"createdAt": "ISO-8601",
"edited": false,
"updatedAt": "ISO-8601"
}
```
### reviewView 스키마 (axes 추가)
기존 reviewView(reviewId/gameId/authorName/userId/rating/body/edited/createdAt/updatedAt) + 신규:
```json
{
"ratingManual": false,
"axes": { "immersion":4,"creativity":5,"controls":3,"completeness":4,"sound":2,"visual":5 }
}
```
### GET list 응답 형태
- 댓글: `{ status, comments:[commentView...], hasMore }`
- 리뷰: `{ status, reviews:[reviewView...], hasMore, summary }`
- `summary` = `{ avgRating, reviewCount, axes:{immersion..visual} }` (review_count=0 이면 `null`)
### 요청 파라미터 신규
`?page=<int≥0, default 0>&sort=<enum, default 토글기본값>`
---
## 검증 결과 요약
| 레이어 | 결과 | 비고 |
|---|---|---|
| L1 단위 테스트 (43건) | GREEN — 43/43 Failures 0 | 기존 31건 무회귀. FIX LOOP 1회(테스트 계약 미갱신 8건, 컨트롤러 무버그 — body<10자 정당거부). verification-advisor 2차 독립 재판정 확정. |
| AC-4 sort 5분기 | PASS | `@SelectProvider` 분기 5건 전수, ORDER BY 상수 |
| 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 |
FIX LOOP 상세: `report.md#Invocations` implementation-advisor(FIX LOOP §2.6) — 근본원인=테스트 계약 미갱신(컨트롤러 무버그). body<10자 7곳10자+ 교체, 행위 assertion 보존, 계약 무약화. main 코드 무수정.
---
## 범위 밖 / 이월
| 항목 | 사유 |
|---|---|
| 신고·숨김 | W1 운영자 role 선행 필요 |
| 리뷰 이력 테이블 | in-row 마커(updated_at)만. 열람 권한 정책 확정 시 별도 |
| 좋아요 서버화 | 별도 관리 (여전히 localStorage) |
| GET /reviews/mine | applyReviewGate 정밀화 — 본인 리뷰 첫 페이지 밖 폼 노출 가능. 서버 409 최종 차단(비차단). 차기 세션 후보 |
| 댓글 더보기 정렬 정합 | 낙관 삽입 2페이지+ 정렬 어긋남 가능. 리뷰는 page0 재조회로 정합, 비차단. 차기 세션 후보 |
| 기존 리뷰 axes 백필 | 기존 game_reviews 행은 axes 0행 — stats 뷰 6축 평균 NULL. 백필 필요 시 별도 |

View File

@ -5,3 +5,4 @@
## 목록 ## 목록
- [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-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.

View File

@ -0,0 +1,39 @@
# Git Workflow — 브랜치 / 커밋 / push 정책
반복 적용되는 git 작업 규칙의 정본(canonical)이다. CLAUDE.md '작업 원칙'의 커밋 관련 불릿은 이 문서를 가리킨다. 하니스 기본규칙("Commit or push only when the user asks. If on the default branch, branch first.")을 프로젝트 차원에서 보충·명시화한다.
## 브랜치 분류
- **메인스트림 브랜치**: `main`, `master`, 그리고 origin 상의 보호 브랜치(릴리스/배포 브랜치 등). 현재 프로젝트 default 는 `main`(`.git/config`, `origin/HEAD` 기준).
- **비-메인스트림 브랜치**: 위를 제외한 모든 작업 브랜치 — `feat/*`, `fix/*`, `chore/*`, `docs/*` 등. 현재 활성 작업 브랜치 `feat/v2` 가 여기 속한다.
## 커밋 표준 승인 (durable authorization)
- **비-메인스트림 브랜치에서 커밋은 표준 승인된 행위다.** 작업 단위가 완결될 때마다 사용자에게 매 건 묻지 않고 커밋한다. 이는 하니스 기본규칙의 '사용자 요청 시에만 커밋' 원칙을, 프로젝트 지침이 사전·상시 승인을 부여하는 형태로 만족시키는 것이다(자동 커밋 재량 위임이 아니라, 사용자가 부여한 표준 승인의 실행).
- **메인스트림 브랜치에는 표준 승인이 적용되지 않는다.** `main`/`master`/보호 브랜치 위에서는 직접 커밋하지 않고, 먼저 작업 브랜치를 생성한 뒤 비-메인스트림 규칙으로 진행한다.
- **`push` 는 브랜치와 무관하게 항상 사용자 명시 요청 시에만 수행한다.** 표준 승인은 로컬 커밋에 한정되며 원격 반영(push)·PR 생성은 포함하지 않는다.
- 작업 전후로 `git status --short` 로 사용자 변경을 보호한다(섞인 미관련 변경을 같은 커밋에 넣지 않는다).
## 커밋 단위
- 한 커밋은 하나의 논리적 변경으로 한정한다. 코드 변경과 그에 대한 문서/그래프 메타 갱신처럼 결합이 강한 산출물은 함께 묶되, 성격이 다른 변경(예: 기능 구현 vs 빌드 스크립트 vs 정책 문서)은 분리한다.
- 버그 수정 커밋은 `docs/development/verification-strategies.md` 의 회귀 테스트 의무를 따른다(재현 테스트 동반).
- `.atp/work-session/<timestamp>/` 산출물은 추적 대상이며, 해당 세션의 코드/문서 변경과 함께 또는 별도 `chore`/`docs` 커밋으로 기록한다.
## 커밋 메시지 규약
- **Conventional Commits** 형식을 사용한다: `type(scope): subject`. 사용 중인 type: `feat`, `fix`, `docs`, `chore`. scope 는 한국어 가능(예: `docs(graph)`, `chore(dev)`).
- subject 는 한국어로 변경의 핵심을 간결히 적는다(이모지 미사용).
- 본문은 '왜'가 자명하지 않을 때 추가하고, 변경 항목은 불릿으로 정리한다. 검증 결과(예: `./mvnw test N/N GREEN`)가 있으면 본문에 명시한다.
- 모든 에이전트 생성 커밋에는 트레일러를 포함한다:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```
세션 추적이 필요하면 `Claude-Session: <url>` 트레일러를 추가할 수 있다.
## 참고
- 검증 의무(L1/L2/L3, 변경 범주별): `docs/development/verification-strategies.md`
- 에이전트 출력/압축 규약: `docs/development/agent-output-conventions.md`

View File

@ -4,8 +4,9 @@
## 목록 ## 목록
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용) - [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, freeze 분류 근거 확인, frontend-design fork 패턴) 포함.
- [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리) - [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리)
- [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정 - [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정
- [git-workflow.md](./git-workflow.md) — 브랜치 분류(메인스트림 vs 비-메인스트림) · 비-메인스트림 브랜치 커밋 표준 승인 · push 명시 요청 한정 · Conventional Commits + `Co-Authored-By` 트레일러 규약. CLAUDE.md '작업 원칙' 커밋 정책의 정본.
> atp 플러그인 번들 레퍼런스(`agent-team-protocol.md`, `agent-catalog.md`, `documentation-guidelines.md`, `search-tool-matrix.md`)는 플러그인 캐시에 있으며 이 프로젝트로 복사되지 않는다. 에이전트가 `${CLAUDE_PLUGIN_ROOT}/docs/...` 로 직접 참조한다. > atp 플러그인 번들 레퍼런스(`agent-team-protocol.md`, `agent-catalog.md`, `documentation-guidelines.md`, `search-tool-matrix.md`)는 플러그인 캐시에 있으며 이 프로젝트로 복사되지 않는다. 에이전트가 `${CLAUDE_PLUGIN_ROOT}/docs/...` 로 직접 참조한다.

View File

@ -117,3 +117,58 @@ strategies:
- 새 전략 추가: 위 YAML 블록에 항목 추가. - 새 전략 추가: 위 YAML 블록에 항목 추가.
- Worker 분리가 필요한 수준에 도달 (브라우저 테스트, 장시간 E2E 등): `worker:` 필드 붙이고 해당 worker 파일 신설 + `verification-advisor.md` 의 tools 에 `Agent` 추가. - Worker 분리가 필요한 수준에 도달 (브라우저 테스트, 장시간 E2E 등): `worker:` 필드 붙이고 해당 worker 파일 신설 + `verification-advisor.md` 의 tools 에 `Agent` 추가.
- 기준은 [agent-team-protocol.md §9 확장 트리거 레지스트리](./agent-team-protocol.md#9-확장-트리거-레지스트리). - 기준은 [agent-team-protocol.md §9 확장 트리거 레지스트리](./agent-team-protocol.md#9-확장-트리거-레지스트리).
## 설계·테스트 단계 체크리스트 (구조적 교훈)
세션 회고에서 수용된 재현성 있는 교훈을 규칙형으로 기재한다. 이 섹션은 누적 append-only.
### 영향맵에 SSR 호출지점 포함
매퍼 메서드 시그니처·data 클래스 필드 등 **심볼을 변경할 때**, design 단계 파일 영향맵은 API 컨트롤러뿐 아니라 그 심볼을 호출하는 **SSR/뷰모델 지점**(예: Spring MVC `@Controller` 의 뷰 렌더 메서드)도 `rg` 전수 확인으로 포함해야 한다. 누락 시 implementation 단계 컴파일 깨짐.
> 근거: W3-2 고도화 세션(20260622) — `GameController.gameDetail` SSR 호출지점을 설계 영향맵에서 누락 → 컴파일 깨짐, implementation-advisor 직접 보정.
### 계약 강화 시 기존 fixture 전수 감사
입력 검증 계약을 강화(최소 길이·필수 필드 추가)하면, 신규 테스트 케이스 추가만으로 부족하다. **기존 테스트 fixture 전수**가 새 계약에 정합하는지 감사하는 단계를 W-TEST 체크리스트에 명시해야 한다.
감사 절차:
1. 새로 도입된 검증 계약 목록화 (최소길이·필수필드·enum 범위 등).
2. 기존 테스트 fixture(요청 본문·파라미터) 전수 스캔 — 신규 계약 조건 충족 여부 확인.
3. 미충족 fixture 갱신 (행위 assertion 보존 전제).
4. 거부 경로 테스트의 fixture 도 신규 계약을 충족하는 값으로 올려 실제 거부 사유(상위 분기)까지 도달함을 보장.
> 근거: W3-2 고도화 세션(20260622) — B2(본문 10자)·6축 필수 계약 도입 시 기존 fixture 8건이 미갱신되어 L1 RED. 컨트롤러는 무버그.
### freeze/동결 분류는 근거 문서 확인 선행
어떤 변경을 "동결 영역 해제·고위험 게이트"로 분류하기 전에, 동결 범위를 정의한 **근거 문서(ADR·roadmap·work-log)를 줄 번호까지 직접 확인**한다. 표면적 유사성("평점 집계" 등)만으로 freeze 인접 추론 금지.
절차:
1. 동결 선언 근거 문서를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
2. 변경 대상 테이블/뷰/심볼이 그 범위에 **명시적으로** 포함되는지 판단.
3. 포함 확인 시만 §6 게이트 표기.
> 근거: W3-2 고도화 세션(20260622) — `game_review_stats` 집계뷰가 phantom 고위험 게이트로 오분류 → `roadmap:63/201/202` 직접 확인으로 일반 DDL 정정.
### (긍정 패턴) frontend-design 스킬 fork 위임
production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때, `frontend-design` 스킬을 **fork(컨텍스트 상속)로 서브에이전트에 위임**하면 스킬 호출 + 단일파일 폴리시 + 프리뷰 render-verify 를 컨텍스트 오염 없이 수행 가능하다. 검증된 패턴.
조건:
- L1 전체 GREEN 확인 후 진입.
- 단일파일 폴리시: JSP 1파일 안에서 완결(신규 파일 0).
- 가드레일 명시: 기존 JS 로직 보존 / a11y / BE API 계약 무변경 / 외부 JS 라이브러리·CDN 도입 금지.
- 산출물에 프리뷰 HTML 포함(`artifacts/`).
> 근거: W3-2 고도화 세션(20260622) — 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 fork 위임으로 단일 JSP 파일 승격 + L1 43/43 GREEN 유지.
---
## 프로토콜 개선 권고 (외부 번들 — 미적용)
아래 항목은 ATP 플러그인 번들(`~/.claude` 전역) 대상이다. 본 프로젝트 파일에서 직접 수정하지 않고 기록만 한다.
- **design-advisor 체크리스트**: §1 파일 영향맵 작성 규약에 "SSR 컨트롤러·뷰모델 호출지점 포함" 항목 추가 필요.
- **W-TEST worker 지시**: 컨트롤러 검증 계약 강화 시 "기존 fixture 전수 계약 정합 감사" 단계를 의무 체크리스트 항목으로 포함 필요.
- **agent-team-protocol §6 게이트 분류**: "근거 문서 확인 없이 freeze 인접 = 고위험 추론 금지" 조항 추가 권고.

View File

@ -60,7 +60,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active"
ON "game_reviews" ("game_id", "user_id") ON "game_reviews" ("game_id", "user_id")
WHERE "is_delete" IS NOT TRUE; WHERE "is_delete" IS NOT TRUE;
-- 목록 조회 + 후속 집계 SELECT 의 game_id 필터용. 집계 컬럼/뷰는 신설하지 않음 (W2-3 동결 보호). -- 목록 조회 + 후속 집계 SELECT 의 game_id 필터용. (C3 재분류: 집계뷰 game_review_stats 는 본 파일 하단에 W3-2 일반 DDL 로 신설됨 — roadmap.md:63,201,202. W2-3 동결 무관.)
CREATE INDEX IF NOT EXISTS "idx_game_reviews_game" CREATE INDEX IF NOT EXISTS "idx_game_reviews_game"
ON "game_reviews" ("game_id") ON "game_reviews" ("game_id")
WHERE "is_delete" = false; WHERE "is_delete" = false;
@ -98,3 +98,89 @@ END
$$; $$;
COMMENT ON COLUMN "game_comments"."user_id" IS '덧글 작성자 users.id (nullable — 레거시 닉네임 덧글 보존)'; COMMENT ON COLUMN "game_comments"."user_id" IS '덧글 작성자 users.id (nullable — 레거시 닉네임 덧글 보존)';
-- ===========================================================================
-- W3-2 고도화: 다축 평점(game_review_axes) + 댓글 updated_at + is_rating_manual
-- + game_review_stats 집계뷰
-- C3 재분류: game_review_stats 는 W3-2 일반 읽기전용 집계뷰. W2-3 잼 평가 동결과 무관
-- (roadmap.md:63,201,202). 아래 위 'idx_game_reviews_game' 주석의 "집계뷰 미신설"
-- 보수 표기는 본 블록으로 갱신됨.
-- ===========================================================================
-- 1) game_comments.updated_at (A3)
ALTER TABLE "game_comments"
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
-- 기존 댓글이 '수정됨' 오표시되지 않도록 정렬(멱등: 이미 정렬된 행엔 무영향)
UPDATE "game_comments" SET "updated_at" = "created_at" WHERE "updated_at" > "created_at";
COMMENT ON COLUMN "game_comments"."updated_at" IS '덧글 마지막 수정 시각. updated_at > created_at 이면 수정됨(리뷰 대칭)';
-- 2) game_reviews.is_rating_manual (overall 출처 구분)
ALTER TABLE "game_reviews"
ADD COLUMN IF NOT EXISTS "is_rating_manual" boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN "game_reviews"."is_rating_manual" IS 'true=유저 직접선택 overall, false=6축 자동평균';
-- 3) game_review_axes (다축 평점, 리뷰당 6행)
CREATE SEQUENCE IF NOT EXISTS "game_review_axes_id_seq";
CREATE TABLE IF NOT EXISTS "game_review_axes" (
"id" bigint DEFAULT nextval('game_review_axes_id_seq'::regclass) NOT NULL,
"review_id" bigint NOT NULL,
"axis_key" character varying(20) NOT NULL,
"score" smallint NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_review_axes_id_seq" OWNED BY "game_review_axes"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_review_id_fkey') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_review_id_fkey"
FOREIGN KEY ("review_id") REFERENCES "game_reviews" ("id");
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_score_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_score_check" CHECK ("score" BETWEEN 1 AND 5);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_axis_key_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_axis_key_check"
CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual'));
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_review_axes_review_axis"
ON "game_review_axes" ("review_id", "axis_key");
CREATE INDEX IF NOT EXISTS "idx_game_review_axes_review"
ON "game_review_axes" ("review_id");
COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6행). axis_key 6종 각 1~5';
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
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",
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"
WHERE r."is_delete" IS NOT TRUE
GROUP BY r."game_id";
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';

View File

@ -1,7 +1,7 @@
--- ---
kind: graphify-meta kind: graphify-meta
last_generated_at: 2026-06-17T11:43:45+0900 last_generated_at: 2026-06-22T12:40:00+0900
source_commit: 33a4465 source_commit: b9d836d
scopes: scopes:
- src - src
- docs - docs
@ -38,8 +38,8 @@ scope 예시: `src`, `src-features`, `docs`, `full` 등. 한 번에 여러 scope
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 | | scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `src` | 2026-06-16 | `33a4465` | `src/` (Java 31 + AST) | 384 노드 / 613 엣지 / 23 커뮤니티. Spring MVC 컨트롤러→매퍼→data 흐름, 모집·게임·인증 도메인 군집. 정적 이미지 제외. | | `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-17 | `8c41b22` | `docs/` (md 24 + DDL 2) | 92 노드 / 122 엣지 / 10 커뮤니티. 문서 카테고리 체계 + DB 스키마/모집글 + 보안·검증 + ATP/graphify 메타 군집. **로컬 개발환경 셋업** 군집(Docker/호스트/스키마부트스트랩/TLS프록시CA/SNAPSHOT) 신규 추가. | | `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 스키마 군집 + 검증·설계 교훈 군집. |
## 갱신 시 체크리스트 ## 갱신 시 체크리스트

View File

@ -151,6 +151,35 @@ $ ./mvnw -P dev spring-boot:run
> 경고: `db/schema.sql` 의 5개 비권위 테이블(users / games / game_comments / game_likes 및 user_auth_identities 의 추론 부분)은 운영 DB `pg_dump` 와 대조하기 전까지 타입을 신뢰하지 말 것. §7 미해결 항목 참조. > 경고: `db/schema.sql` 의 5개 비권위 테이블(users / games / game_comments / game_likes 및 user_auth_identities 의 추론 부분)은 운영 DB `pg_dump` 와 대조하기 전까지 타입을 신뢰하지 말 것. §7 미해결 항목 참조.
### 4.1 신규/변경 DDL 을 실행 중 로컬 DB 에 즉시 적용
**핵심 사실**: `db/schema.sql` 은 컨테이너 **최초 기동 시 1회만** `docker-entrypoint-initdb.d` 로 자동 주입된다. 그 이후 `docs/*-ddl.sql` 로 추가된 스키마 변경은 **실행 중인 DB 에 자동 반영되지 않는다.** flyway/liquibase 가 없으므로(§4) 변경분을 수동으로 적용해야 한다.
`docker compose down -v` 재기동은 스키마를 다시 주입하지만 **로컬 데이터가 전부 소실**된다. 데이터를 보존하면서 변경분만 비파괴 적용하려면 다음 헬퍼를 쓴다.
```bash
# 권위 DDL(docs/*-ddl.sql) 전체를 실행 컨테이너의 dev 스키마에 멱등 적용.
# 모든 docs/*-ddl.sql 은 IF NOT EXISTS / DO $$ / CREATE OR REPLACE 로 작성되어
# 몇 번 재실행해도 기존 객체는 skip 되고 누락분만 생성된다.
db/apply-local-ddl.sh # 전체 적용(기본)
db/apply-local-ddl.sh docs/game-reviews-ddl.sql # 특정 파일만
# 접속 정보는 .env(POSTGRES_USER / POSTGRES_DB / APP_SCHEMA)에서 읽고,
# DB_CONTAINER / PG_USER / PG_DB / PG_SCHEMA 환경변수로 override 할 수 있다.
```
> 내부적으로 `docker exec -e PGOPTIONS="-c search_path=dev" bibimbap-db psql -U <user> -d <db> -v ON_ERROR_STOP=1 -f -` 로 적용한다. `docs/*-ddl.sql` 은 unqualified 테이블명을 쓰므로 `search_path` 로 스키마를 강제하는 것이 필수다.
#### 새 DDL 을 추가할 때의 규약 (작성자 의무)
`docs/<기능>-ddl.sql` 을 새로 추가하거나 변경하면 **세 곳을 함께 맞춘다.** 하나라도 빠지면 환경 간 스키마가 어긋난다.
1. **`docs/<기능>-ddl.sql`** — 권위 DDL. 모든 문장을 멱등(IF NOT EXISTS / DO `$$` 가드 / CREATE OR REPLACE)으로 작성한다.
2. **실행 중 로컬 DB**`db/apply-local-ddl.sh` 를 실행해 즉시 적용한다. → 곧바로 로컬 테스트 가능.
3. **`db/schema.sql`** — 동일 변경을 반영(`SET search_path TO dev;` 블록 안)해, 신규 환경의 컨테이너 최초 기동 init 에도 포함되게 한다.
> 검증: 적용 후 `docker exec bibimbap-db psql -U bibimbap -d bibimbap -c "\d dev.<테이블>"` 로 컬럼·제약·인덱스를 확인한다.
## 5. 검증 체크리스트 ## 5. 검증 체크리스트
실제 통과한 항목은 `[x]` 다. 실제 통과한 항목은 `[x]` 다.

View File

@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.GameCommentData;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper; import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.util.TextNormalizer;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -26,6 +27,7 @@ import java.util.Map;
public class GameCommentController { public class GameCommentController {
private static final int CONTENT_MAX = 200; private static final int CONTENT_MAX = 200;
private static final int PAGE_SIZE = 20;
private static final String ROLE_ADMIN = "ADMIN"; private static final String ROLE_ADMIN = "ADMIN";
private final GameCommentsMapper gameCommentsMapper; private final GameCommentsMapper gameCommentsMapper;
@ -37,19 +39,30 @@ public class GameCommentController {
} }
@GetMapping("/game/{id}/comments") @GetMapping("/game/{id}/comments")
public ResponseEntity<Map<String, Object>> listComments(@PathVariable("id") long id) { public ResponseEntity<Map<String, Object>> listComments(
@PathVariable("id") long id,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "sort", required = false) String sort
) {
if (gamesMapper.getGame(id) == null) { if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
} }
String sortEnum = normalizeCommentSort(sort);
int offset = Math.max(page, 0) * PAGE_SIZE;
List<GameCommentData> rows = gameCommentsMapper.listGameComments(id, sortEnum, offset, PAGE_SIZE + 1);
boolean hasMore = rows.size() > PAGE_SIZE;
int count = Math.min(rows.size(), PAGE_SIZE);
List<Map<String, Object>> comments = new ArrayList<>(); List<Map<String, Object>> comments = new ArrayList<>();
for (GameCommentData comment : gameCommentsMapper.listGameComments(id)) { for (int i = 0; i < count; i++) {
comments.add(commentView(comment)); comments.add(commentView(rows.get(i)));
} }
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200); body.put("status", 200);
body.put("comments", comments); body.put("comments", comments);
body.put("hasMore", hasMore);
return ResponseEntity.ok(body); return ResponseEntity.ok(body);
} }
@ -72,7 +85,7 @@ public class GameCommentController {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
} }
String normalizedContent = trimToNull(content); String normalizedContent = trimToNull(TextNormalizer.normalize(content));
if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) { if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) {
return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요."); return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요.");
} }
@ -88,15 +101,11 @@ public class GameCommentController {
return response(HttpStatus.INTERNAL_SERVER_ERROR, "덧글 등록 결과를 확인하지 못했습니다."); return response(HttpStatus.INTERNAL_SERVER_ERROR, "덧글 등록 결과를 확인하지 못했습니다.");
} }
Map<String, Object> body = new LinkedHashMap<>(); GameCommentData created = gameCommentsMapper.getGameComment(comment.getId());
body.put("status", 200); Map<String, Object> result = created != null ? commentView(created) : commentView(comment);
body.put("message", "덧글이 등록되었습니다."); result.put("status", 200);
body.put("commentId", comment.getId()); result.put("message", "덧글이 등록되었습니다.");
body.put("gameId", id); return ResponseEntity.ok(result);
body.put("authorName", authorName);
body.put("userId", userId);
body.put("content", normalizedContent);
return ResponseEntity.ok(body);
} }
@PutMapping("/game/{id}/comments/{commentId}") @PutMapping("/game/{id}/comments/{commentId}")
@ -124,7 +133,7 @@ public class GameCommentController {
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다."); return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
} }
String normalizedContent = trimToNull(content); String normalizedContent = trimToNull(TextNormalizer.normalize(content));
if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) { if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) {
return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요."); return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요.");
} }
@ -132,12 +141,11 @@ public class GameCommentController {
comment.setContent(normalizedContent); comment.setContent(normalizedContent);
gameCommentsMapper.editGameComment(comment); gameCommentsMapper.editGameComment(comment);
Map<String, Object> body = new LinkedHashMap<>(); GameCommentData updated = gameCommentsMapper.getGameComment(commentId);
body.put("status", 200); Map<String, Object> result = updated != null ? commentView(updated) : commentView(comment);
body.put("message", "덧글이 수정되었습니다."); result.put("status", 200);
body.put("commentId", commentId); result.put("message", "덧글이 수정되었습니다.");
body.put("content", normalizedContent); return ResponseEntity.ok(result);
return ResponseEntity.ok(body);
} }
@DeleteMapping("/game/{id}/comments/{commentId}") @DeleteMapping("/game/{id}/comments/{commentId}")
@ -176,13 +184,19 @@ public class GameCommentController {
Map<String, Object> view = new LinkedHashMap<>(); Map<String, Object> view = new LinkedHashMap<>();
view.put("commentId", comment.getId()); view.put("commentId", comment.getId());
view.put("gameId", comment.getGameId()); view.put("gameId", comment.getGameId());
view.put("authorName", comment.getNickname()); view.put("authorName", comment.getAuthorName() != null ? comment.getAuthorName() : comment.getNickname());
view.put("userId", comment.getUserId()); view.put("userId", comment.getUserId());
view.put("content", comment.getContent()); view.put("content", comment.getContent());
view.put("createdAt", comment.getCreatedAt()); view.put("createdAt", comment.getCreatedAt());
view.put("edited", comment.getEdited() != null && comment.getEdited());
view.put("updatedAt", comment.getUpdatedAt());
return view; return view;
} }
private String normalizeCommentSort(String sort) {
return "newest".equals(sort) ? "newest" : "oldest";
}
private boolean isOperator(String role) { private boolean isOperator(String role) {
return ROLE_ADMIN.equals(role); return ROLE_ADMIN.equals(role);
} }

View File

@ -114,8 +114,8 @@ public class GameController {
GameData game = gamesMapper.getGame(id); GameData game = gamesMapper.getGame(id);
if (game != null) { if (game != null) {
addGameModel(model, game, sessionUserId(session)); addGameModel(model, game, sessionUserId(session));
model.addAttribute("comments", gameCommentsMapper.listGameComments(id)); model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20));
model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id)); model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id, "newest", 0, 20));
model.addAttribute("userRole", (String) session.getAttribute("role")); model.addAttribute("userRole", (String) session.getAttribute("role"));
return "game-detail"; return "game-detail";
} }

View File

@ -1,9 +1,13 @@
package com.pandoli365.bibimbap.controller.api; package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameReviewData; import com.pandoli365.bibimbap.data.GameReviewData;
import com.pandoli365.bibimbap.data.ReviewAxisRow;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.util.TextNormalizer;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -27,31 +31,70 @@ public class GameReviewController {
private static final int RATING_MIN = 1; private static final int RATING_MIN = 1;
private static final int RATING_MAX = 5; private static final int RATING_MAX = 5;
private static final int BODY_MIN = 10;
private static final int BODY_MAX = 1000; private static final int BODY_MAX = 1000;
private static final int PAGE_SIZE = 20;
private static final String ROLE_ADMIN = "ADMIN"; private static final String ROLE_ADMIN = "ADMIN";
private static final String[] AXIS_KEYS = {"immersion", "creativity", "controls", "completeness", "sound", "visual"};
private final GameReviewsMapper gameReviewsMapper; private final GameReviewsMapper gameReviewsMapper;
private final GameReviewAxesMapper gameReviewAxesMapper;
private final GameReviewStatsMapper gameReviewStatsMapper;
private final GamesMapper gamesMapper; private final GamesMapper gamesMapper;
public GameReviewController(GameReviewsMapper gameReviewsMapper, GamesMapper gamesMapper) { public GameReviewController(GameReviewsMapper gameReviewsMapper,
GameReviewAxesMapper gameReviewAxesMapper,
GameReviewStatsMapper gameReviewStatsMapper,
GamesMapper gamesMapper) {
this.gameReviewsMapper = gameReviewsMapper; this.gameReviewsMapper = gameReviewsMapper;
this.gameReviewAxesMapper = gameReviewAxesMapper;
this.gameReviewStatsMapper = gameReviewStatsMapper;
this.gamesMapper = gamesMapper; this.gamesMapper = gamesMapper;
} }
@GetMapping("/game/{id}/reviews") @GetMapping("/game/{id}/reviews")
public ResponseEntity<Map<String, Object>> listReviews(@PathVariable("id") long id) { public ResponseEntity<Map<String, Object>> listReviews(
@PathVariable("id") long id,
@RequestParam(name = "page", defaultValue = "0") int page,
@RequestParam(name = "sort", required = false) String sort
) {
if (gamesMapper.getGame(id) == null) { if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
} }
String sortEnum = normalizeReviewSort(sort);
int offset = Math.max(page, 0) * PAGE_SIZE;
List<GameReviewData> rows = gameReviewsMapper.listGameReviews(id, sortEnum, offset, PAGE_SIZE + 1);
boolean hasMore = rows.size() > PAGE_SIZE;
int count = Math.min(rows.size(), PAGE_SIZE);
List<GameReviewData> pageRows = rows.subList(0, count);
// axes batch 조회(N+1 회피). 페이지 가드.
if (!pageRows.isEmpty()) {
List<Long> ids = new ArrayList<>();
for (GameReviewData r : pageRows) {
ids.add(r.getId());
}
Map<Long, Map<String, Integer>> axesByReview = new LinkedHashMap<>();
for (ReviewAxisRow ax : gameReviewAxesMapper.listAxesByReviewIds(ids)) {
axesByReview.computeIfAbsent(ax.getReviewId(), k -> new LinkedHashMap<>())
.put(ax.getAxisKey(), ax.getScore());
}
for (GameReviewData r : pageRows) {
r.setAxes(axesByReview.get(r.getId()));
}
}
List<Map<String, Object>> reviews = new ArrayList<>(); List<Map<String, Object>> reviews = new ArrayList<>();
for (GameReviewData review : gameReviewsMapper.listGameReviews(id)) { for (GameReviewData r : pageRows) {
reviews.add(reviewView(review)); reviews.add(reviewView(r));
} }
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200); body.put("status", 200);
body.put("reviews", reviews); body.put("reviews", reviews);
body.put("hasMore", hasMore);
body.put("summary", buildSummary(id));
return ResponseEntity.ok(body); return ResponseEntity.ok(body);
} }
@ -64,6 +107,7 @@ public class GameReviewController {
if (review == null || !Long.valueOf(id).equals(review.getGameId())) { if (review == null || !Long.valueOf(id).equals(review.getGameId())) {
return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."); return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다.");
} }
review.setAxes(loadAxes(reviewId));
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200); body.put("status", 200);
@ -77,6 +121,12 @@ public class GameReviewController {
@PathVariable("id") long id, @PathVariable("id") long id,
@RequestParam(name = "rating", required = false) String rating, @RequestParam(name = "rating", required = false) String rating,
@RequestParam(name = "body", required = false) String body, @RequestParam(name = "body", required = false) String body,
@RequestParam(name = "immersion", required = false) String immersion,
@RequestParam(name = "creativity", required = false) String creativity,
@RequestParam(name = "controls", required = false) String controls,
@RequestParam(name = "completeness", required = false) String completeness,
@RequestParam(name = "sound", required = false) String sound,
@RequestParam(name = "visual", required = false) String visual,
HttpServletRequest request, HttpServletRequest request,
HttpSession session HttpSession session
) { ) {
@ -91,15 +141,31 @@ public class GameReviewController {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
} }
Integer parsedRating = parseRating(rating); Map<String, Integer> axes = parseAxes(immersion, creativity, controls, completeness, sound, visual);
if (parsedRating == null) { if (axes == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요."); return response(HttpStatus.BAD_REQUEST, "세부 평가 항목은 모두 1~5 사이로 선택해 주세요.");
}
String normalizedBody = trimToNull(TextNormalizer.normalize(body));
if (normalizedBody == null || normalizedBody.length() < BODY_MIN) {
return response(HttpStatus.BAD_REQUEST, "평가는 최소 10자 이상 입력해 주세요.");
} }
String normalizedBody = trimToEmpty(body);
if (normalizedBody.length() > BODY_MAX) { if (normalizedBody.length() > BODY_MAX) {
return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요."); return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요.");
} }
Integer overall;
boolean ratingManual;
if (trimToNull(rating) == null) {
overall = averageOf(axes);
ratingManual = false;
} else {
overall = parseRating(rating);
if (overall == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요.");
}
ratingManual = true;
}
if (gameReviewsMapper.getActiveReviewByGameAndUser(id, userId) != null) { if (gameReviewsMapper.getActiveReviewByGameAndUser(id, userId) != null) {
return response(HttpStatus.CONFLICT, "이미 이 게임에 리뷰를 작성하셨습니다."); return response(HttpStatus.CONFLICT, "이미 이 게임에 리뷰를 작성하셨습니다.");
} }
@ -107,14 +173,19 @@ public class GameReviewController {
GameReviewData review = new GameReviewData(); GameReviewData review = new GameReviewData();
review.setGameId(id); review.setGameId(id);
review.setUserId(userId); review.setUserId(userId);
review.setRating(parsedRating); review.setRating(overall);
review.setBody(normalizedBody); review.setBody(normalizedBody);
review.setRatingManual(ratingManual);
gameReviewsMapper.addGameReview(review); gameReviewsMapper.addGameReview(review);
if (review.getId() == null) { if (review.getId() == null) {
return response(HttpStatus.INTERNAL_SERVER_ERROR, "리뷰 등록 결과를 확인하지 못했습니다."); return response(HttpStatus.INTERNAL_SERVER_ERROR, "리뷰 등록 결과를 확인하지 못했습니다.");
} }
gameReviewAxesMapper.addReviewAxes(review.getId(), toAxisRows(review.getId(), axes));
GameReviewData created = gameReviewsMapper.getGameReview(review.getId()); GameReviewData created = gameReviewsMapper.getGameReview(review.getId());
if (created != null) {
created.setAxes(loadAxes(created.getId()));
}
Map<String, Object> result = created != null ? reviewView(created) : reviewView(review); Map<String, Object> result = created != null ? reviewView(created) : reviewView(review);
result.put("status", 200); result.put("status", 200);
result.put("message", "리뷰가 등록되었습니다."); result.put("message", "리뷰가 등록되었습니다.");
@ -128,6 +199,12 @@ public class GameReviewController {
@PathVariable("reviewId") long reviewId, @PathVariable("reviewId") long reviewId,
@RequestParam(name = "rating", required = false) String rating, @RequestParam(name = "rating", required = false) String rating,
@RequestParam(name = "body", required = false) String body, @RequestParam(name = "body", required = false) String body,
@RequestParam(name = "immersion", required = false) String immersion,
@RequestParam(name = "creativity", required = false) String creativity,
@RequestParam(name = "controls", required = false) String controls,
@RequestParam(name = "completeness", required = false) String completeness,
@RequestParam(name = "sound", required = false) String sound,
@RequestParam(name = "visual", required = false) String visual,
HttpServletRequest request, HttpServletRequest request,
HttpSession session HttpSession session
) { ) {
@ -147,20 +224,42 @@ public class GameReviewController {
return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다."); return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다.");
} }
Integer parsedRating = parseRating(rating); Map<String, Integer> axes = parseAxes(immersion, creativity, controls, completeness, sound, visual);
if (parsedRating == null) { if (axes == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요."); return response(HttpStatus.BAD_REQUEST, "세부 평가 항목은 모두 1~5 사이로 선택해 주세요.");
}
String normalizedBody = trimToNull(TextNormalizer.normalize(body));
if (normalizedBody == null || normalizedBody.length() < BODY_MIN) {
return response(HttpStatus.BAD_REQUEST, "평가는 최소 10자 이상 입력해 주세요.");
} }
String normalizedBody = trimToEmpty(body);
if (normalizedBody.length() > BODY_MAX) { if (normalizedBody.length() > BODY_MAX) {
return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요."); return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요.");
} }
review.setRating(parsedRating); Integer overall;
boolean ratingManual;
if (trimToNull(rating) == null) {
overall = averageOf(axes);
ratingManual = false;
} else {
overall = parseRating(rating);
if (overall == null) {
return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요.");
}
ratingManual = true;
}
review.setRating(overall);
review.setBody(normalizedBody); review.setBody(normalizedBody);
review.setRatingManual(ratingManual);
gameReviewsMapper.editGameReview(review); gameReviewsMapper.editGameReview(review);
gameReviewAxesMapper.deleteReviewAxes(reviewId);
gameReviewAxesMapper.addReviewAxes(reviewId, toAxisRows(reviewId, axes));
GameReviewData updated = gameReviewsMapper.getGameReview(reviewId); GameReviewData updated = gameReviewsMapper.getGameReview(reviewId);
if (updated != null) {
updated.setAxes(loadAxes(reviewId));
}
Map<String, Object> result = updated != null ? reviewView(updated) : reviewView(review); Map<String, Object> result = updated != null ? reviewView(updated) : reviewView(review);
result.put("status", 200); result.put("status", 200);
result.put("message", "리뷰가 수정되었습니다."); result.put("message", "리뷰가 수정되었습니다.");
@ -207,12 +306,98 @@ public class GameReviewController {
view.put("userId", review.getUserId()); view.put("userId", review.getUserId());
view.put("rating", review.getRating()); view.put("rating", review.getRating());
view.put("body", review.getBody()); view.put("body", review.getBody());
view.put("ratingManual", review.getRatingManual() != null && review.getRatingManual());
view.put("edited", review.getEdited() != null && review.getEdited()); view.put("edited", review.getEdited() != null && review.getEdited());
view.put("axes", review.getAxes());
view.put("createdAt", review.getCreatedAt()); view.put("createdAt", review.getCreatedAt());
view.put("updatedAt", review.getUpdatedAt()); view.put("updatedAt", review.getUpdatedAt());
return view; return view;
} }
// 6축 점수 파싱·검증. 1~5 필수. 위반 null 반환(컨트롤러가 400).
private Map<String, Integer> parseAxes(String immersion, String creativity, String controls,
String completeness, String sound, String visual) {
Map<String, String> raw = new LinkedHashMap<>();
raw.put("immersion", immersion);
raw.put("creativity", creativity);
raw.put("controls", controls);
raw.put("completeness", completeness);
raw.put("sound", sound);
raw.put("visual", visual);
Map<String, Integer> axes = new LinkedHashMap<>();
for (String key : AXIS_KEYS) {
String v = trimToNull(raw.get(key));
if (v == null) {
return null;
}
int s;
try {
s = Integer.parseInt(v);
} catch (NumberFormatException e) {
return null;
}
if (s < RATING_MIN || s > RATING_MAX) {
return null;
}
axes.put(key, s);
}
return axes;
}
private int averageOf(Map<String, Integer> axes) {
int sum = 0;
for (String key : AXIS_KEYS) {
sum += axes.get(key);
}
return (int) Math.round(sum / (double) AXIS_KEYS.length);
}
private List<ReviewAxisRow> toAxisRows(long reviewId, Map<String, Integer> axes) {
List<ReviewAxisRow> rows = new ArrayList<>();
for (String key : AXIS_KEYS) {
rows.add(new ReviewAxisRow(reviewId, key, axes.get(key)));
}
return rows;
}
private Map<String, Integer> loadAxes(long reviewId) {
Map<String, Integer> axes = new LinkedHashMap<>();
for (ReviewAxisRow ax : gameReviewAxesMapper.listAxesByReviewIds(List.of(reviewId))) {
axes.put(ax.getAxisKey(), ax.getScore());
}
return axes.isEmpty() ? null : axes;
}
private Map<String, Object> buildSummary(long gameId) {
Map<String, Object> stats = gameReviewStatsMapper.getStats(gameId);
if (stats == null) {
return null;
}
Object countValue = stats.get("reviewCount");
long reviewCount = countValue instanceof Number number ? number.longValue() : 0L;
if (reviewCount == 0) {
return null;
}
Map<String, Object> axes = new LinkedHashMap<>();
for (String key : AXIS_KEYS) {
axes.put(key, stats.get(key));
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("avgRating", stats.get("avgRating"));
summary.put("reviewCount", reviewCount);
summary.put("axes", axes);
return summary;
}
private String normalizeReviewSort(String sort) {
return switch (sort == null ? "" : sort) {
case "rating_desc" -> "rating_desc";
case "rating_asc" -> "rating_asc";
default -> "newest";
};
}
private Integer parseRating(String rating) { private Integer parseRating(String rating) {
String text = trimToNull(rating); String text = trimToNull(rating);
if (text == null) { if (text == null) {
@ -272,11 +457,6 @@ public class GameReviewController {
return text.isBlank() ? null : text; return text.isBlank() ? null : text;
} }
private String trimToEmpty(String value) {
String text = trimToNull(value);
return text == null ? "" : text;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) { private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value()); body.put("status", status.value());

View File

@ -10,8 +10,13 @@ public class GameCommentData {
private String nickname; private String nickname;
private String content; private String content;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime deletedAt; private OffsetDateTime deletedAt;
// 비영속 (SELECT 계산 alias / LEFT JOIN alias)
private Boolean edited;
private String authorName;
public Long getId() { public Long getId() {
return id; return id;
} }
@ -60,6 +65,14 @@ public class GameCommentData {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public OffsetDateTime getDeletedAt() { public OffsetDateTime getDeletedAt() {
return deletedAt; return deletedAt;
} }
@ -67,4 +80,20 @@ public class GameCommentData {
public void setDeletedAt(OffsetDateTime deletedAt) { public void setDeletedAt(OffsetDateTime deletedAt) {
this.deletedAt = deletedAt; this.deletedAt = deletedAt;
} }
public Boolean getEdited() {
return edited;
}
public void setEdited(Boolean edited) {
this.edited = edited;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
} }

View File

@ -1,6 +1,7 @@
package com.pandoli365.bibimbap.data; package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Map;
public class GameReviewData { public class GameReviewData {
@ -9,13 +10,15 @@ public class GameReviewData {
private Long userId; private Long userId;
private Integer rating; private Integer rating;
private String body; private String body;
private Boolean ratingManual;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
private OffsetDateTime updatedAt; private OffsetDateTime updatedAt;
private OffsetDateTime deletedAt; private OffsetDateTime deletedAt;
// 비영속 (목록 JOIN alias / SELECT 계산 alias) // 비영속 (목록 JOIN alias / SELECT 계산 alias / reviewView 조립용)
private String authorName; private String authorName;
private Boolean edited; private Boolean edited;
private Map<String, Integer> axes;
public Long getId() { public Long getId() {
return id; return id;
@ -96,4 +99,20 @@ public class GameReviewData {
public void setEdited(Boolean edited) { public void setEdited(Boolean edited) {
this.edited = edited; this.edited = edited;
} }
public Boolean getRatingManual() {
return ratingManual;
}
public void setRatingManual(Boolean ratingManual) {
this.ratingManual = ratingManual;
}
public Map<String, Integer> getAxes() {
return axes;
}
public void setAxes(Map<String, Integer> axes) {
this.axes = axes;
}
} }

View File

@ -0,0 +1,41 @@
package com.pandoli365.bibimbap.data;
public class ReviewAxisRow {
private Long reviewId;
private String axisKey;
private Integer score;
public ReviewAxisRow() {
}
public ReviewAxisRow(Long reviewId, String axisKey, Integer score) {
this.reviewId = reviewId;
this.axisKey = axisKey;
this.score = score;
}
public Long getReviewId() {
return reviewId;
}
public void setReviewId(Long reviewId) {
this.reviewId = reviewId;
}
public String getAxisKey() {
return axisKey;
}
public void setAxisKey(String axisKey) {
this.axisKey = axisKey;
}
public Integer getScore() {
return score;
}
public void setScore(Integer score) {
this.score = score;
}
}

View File

@ -6,42 +6,43 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
import java.util.Map;
@Mapper @Mapper
public interface GameCommentsMapper { public interface GameCommentsMapper {
@Select(""" @Select("""
SELECT SELECT
id, c.id,
game_id AS gameId, c.game_id AS gameId,
user_id AS userId, c.user_id AS userId,
nickname, CASE
content, WHEN c.user_id IS NULL THEN c.nickname
created_at AS createdAt, WHEN u.is_delete IS TRUE THEN '(탈퇴한 사용자)'
deleted_at AS deletedAt ELSE u.display_name
FROM game_comments END AS authorName,
WHERE id = #{id} c.nickname,
AND is_delete IS NOT TRUE c.content,
c.created_at AS createdAt,
c.updated_at AS updatedAt,
(c.updated_at > c.created_at) AS edited,
c.deleted_at AS deletedAt
FROM game_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.id = #{id}
AND c.is_delete IS NOT TRUE
""") """)
GameCommentData getGameComment(long id); GameCommentData getGameComment(long id);
@Select(""" @SelectProvider(type = CommentSqlProvider.class, method = "listComments")
SELECT List<GameCommentData> listGameComments(@Param("gameId") long gameId,
id, @Param("sort") String sort,
game_id AS gameId, @Param("offset") int offset,
user_id AS userId, @Param("limit") int limit);
nickname AS authorName,
content,
created_at AS createdAt
FROM game_comments
WHERE game_id = #{gameId}
AND is_delete IS NOT TRUE
ORDER BY created_at ASC, id ASC
""")
List<GameCommentData> listGameComments(@Param("gameId") long gameId);
@Insert(""" @Insert("""
INSERT INTO game_comments ( INSERT INTO game_comments (
@ -62,7 +63,8 @@ public interface GameCommentsMapper {
@Update(""" @Update("""
UPDATE game_comments UPDATE game_comments
SET SET
content = #{content} content = #{content},
updated_at = now()
WHERE id = #{id} WHERE id = #{id}
AND is_delete IS NOT TRUE AND is_delete IS NOT TRUE
""") """)
@ -89,4 +91,38 @@ public interface GameCommentsMapper {
AND is_delete IS NOT TRUE AND is_delete IS NOT TRUE
""") """)
int updateGameComment(GameCommentData gameComment); int updateGameComment(GameCommentData gameComment);
class CommentSqlProvider {
// SELECT/WHERE 컴파일타임 리터럴. ORDER BY sort enum 으로 고정 문자열 분기(${} 미사용).
public String listComments(Map<String, Object> params) {
String sort = (String) params.get("sort");
String orderBy = "newest".equals(sort)
? "ORDER BY c.created_at DESC, c.id DESC"
: "ORDER BY c.created_at ASC, c.id ASC";
return """
SELECT
c.id,
c.game_id AS gameId,
c.user_id AS userId,
CASE
WHEN c.user_id IS NULL THEN c.nickname
WHEN u.is_delete IS TRUE THEN '(탈퇴한 사용자)'
ELSE u.display_name
END AS authorName,
c.nickname,
c.content,
c.created_at AS createdAt,
c.updated_at AS updatedAt,
(c.updated_at > c.created_at) AS edited,
c.deleted_at AS deletedAt
FROM game_comments c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.game_id = #{gameId}
AND c.is_delete IS NOT TRUE
"""
+ orderBy
+ " LIMIT #{limit} OFFSET #{offset}";
}
}
} }

View File

@ -0,0 +1,59 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.ReviewAxisRow;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.InsertProvider;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.jdbc.SQL;
import java.util.List;
import java.util.Map;
@Mapper
public interface GameReviewAxesMapper {
@InsertProvider(type = AxesSqlProvider.class, method = "insertAxes")
int addReviewAxes(@Param("reviewId") long reviewId, @Param("axes") List<ReviewAxisRow> axes);
@Delete("DELETE FROM game_review_axes WHERE review_id = #{reviewId}")
int deleteReviewAxes(@Param("reviewId") long reviewId);
@SelectProvider(type = AxesSqlProvider.class, method = "listByReviewIds")
List<ReviewAxisRow> listAxesByReviewIds(@Param("reviewIds") List<Long> reviewIds);
class AxesSqlProvider {
// VALUES 다행. 값은 #{axes[i].axisKey}/#{axes[i].score} 바인딩(${} 미사용).
public String insertAxes(Map<String, Object> params) {
@SuppressWarnings("unchecked")
List<ReviewAxisRow> axes = (List<ReviewAxisRow>) params.get("axes");
StringBuilder sql = new StringBuilder("INSERT INTO game_review_axes (review_id, axis_key, score) VALUES ");
for (int i = 0; i < axes.size(); i++) {
if (i > 0) {
sql.append(", ");
}
sql.append("(#{reviewId}, #{axes[").append(i).append("].axisKey}, #{axes[").append(i).append("].score})");
}
return sql.toString();
}
// IN (...) batch. reviewIds 원소 #{reviewIds[i]} 바인딩.
public String listByReviewIds(Map<String, Object> params) {
@SuppressWarnings("unchecked")
List<Long> ids = (List<Long>) params.get("reviewIds");
SQL sql = new SQL();
sql.SELECT("review_id AS reviewId, axis_key AS axisKey, score").FROM("game_review_axes");
StringBuilder in = new StringBuilder();
for (int i = 0; i < ids.size(); i++) {
if (i > 0) {
in.append(", ");
}
in.append("#{reviewIds[").append(i).append("]}");
}
sql.WHERE("review_id IN (" + in + ")");
return sql.toString();
}
}
}

View File

@ -0,0 +1,26 @@
package com.pandoli365.bibimbap.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.Map;
@Mapper
public interface GameReviewStatsMapper {
@Select("""
SELECT
game_id AS gameId,
avg_rating AS avgRating,
review_count AS reviewCount,
avg_immersion AS immersion,
avg_creativity AS creativity,
avg_controls AS controls,
avg_completeness AS completeness,
avg_sound AS sound,
avg_visual AS visual
FROM game_review_stats
WHERE game_id = #{gameId}
""")
Map<String, Object> getStats(long gameId);
}

View File

@ -6,9 +6,11 @@ import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.SelectProvider;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
import java.util.Map;
@Mapper @Mapper
public interface GameReviewsMapper { public interface GameReviewsMapper {
@ -20,6 +22,7 @@ public interface GameReviewsMapper {
r.user_id AS userId, r.user_id AS userId,
r.rating, r.rating,
r.body, r.body,
r.is_rating_manual AS ratingManual,
u.display_name AS authorName, u.display_name AS authorName,
(r.updated_at > r.created_at) AS edited, (r.updated_at > r.created_at) AS edited,
r.created_at AS createdAt, r.created_at AS createdAt,
@ -33,25 +36,11 @@ public interface GameReviewsMapper {
""") """)
GameReviewData getGameReview(long id); GameReviewData getGameReview(long id);
@Select(""" @SelectProvider(type = ReviewSqlProvider.class, method = "listReviews")
SELECT List<GameReviewData> listGameReviews(@Param("gameId") long gameId,
r.id, @Param("sort") String sort,
r.game_id AS gameId, @Param("offset") int offset,
r.user_id AS userId, @Param("limit") int limit);
r.rating,
r.body,
u.display_name AS authorName,
(r.updated_at > r.created_at) AS edited,
r.created_at AS createdAt,
r.updated_at AS updatedAt
FROM game_reviews r
JOIN users u ON u.id = r.user_id
WHERE r.game_id = #{gameId}
AND r.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
ORDER BY r.created_at DESC, r.id DESC
""")
List<GameReviewData> listGameReviews(@Param("gameId") long gameId);
@Select(""" @Select("""
SELECT SELECT
@ -60,6 +49,7 @@ public interface GameReviewsMapper {
r.user_id AS userId, r.user_id AS userId,
r.rating, r.rating,
r.body, r.body,
r.is_rating_manual AS ratingManual,
r.created_at AS createdAt, r.created_at AS createdAt,
r.updated_at AS updatedAt r.updated_at AS updatedAt
FROM game_reviews r FROM game_reviews r
@ -71,15 +61,9 @@ public interface GameReviewsMapper {
@Insert(""" @Insert("""
INSERT INTO game_reviews ( INSERT INTO game_reviews (
game_id, game_id, user_id, rating, body, is_rating_manual
user_id,
rating,
body
) VALUES ( ) VALUES (
#{gameId}, #{gameId}, #{userId}, #{rating}, #{body}, #{ratingManual}
#{userId},
#{rating},
#{body}
) )
""") """)
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
@ -87,12 +71,9 @@ public interface GameReviewsMapper {
@Update(""" @Update("""
UPDATE game_reviews UPDATE game_reviews
SET SET rating = #{rating}, body = #{body},
rating = #{rating}, is_rating_manual = #{ratingManual}, updated_at = now()
body = #{body}, WHERE id = #{id} AND is_delete IS NOT TRUE
updated_at = now()
WHERE id = #{id}
AND is_delete IS NOT TRUE
""") """)
int editGameReview(GameReviewData review); int editGameReview(GameReviewData review);
@ -105,4 +86,37 @@ public interface GameReviewsMapper {
AND is_delete IS NOT TRUE AND is_delete IS NOT TRUE
""") """)
int softDeleteGameReview(long id); int softDeleteGameReview(long id);
class ReviewSqlProvider {
// SELECT/JOIN/WHERE 컴파일타임 리터럴. ORDER BY sort enum 으로 고정 문자열 분기(${} 미사용).
public String listReviews(Map<String, Object> params) {
String sort = (String) params.get("sort");
String orderBy = switch (sort == null ? "" : sort) {
case "rating_desc" -> "ORDER BY r.rating DESC, r.created_at DESC, r.id DESC";
case "rating_asc" -> "ORDER BY r.rating ASC, r.created_at DESC, r.id DESC";
default -> "ORDER BY r.created_at DESC, r.id DESC";
};
return """
SELECT
r.id,
r.game_id AS gameId,
r.user_id AS userId,
r.rating,
r.body,
r.is_rating_manual AS ratingManual,
u.display_name AS authorName,
(r.updated_at > r.created_at) AS edited,
r.created_at AS createdAt,
r.updated_at AS updatedAt
FROM game_reviews r
JOIN users u ON u.id = r.user_id
WHERE r.game_id = #{gameId}
AND r.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
"""
+ orderBy
+ " LIMIT #{limit} OFFSET #{offset}";
}
}
} }

View File

@ -0,0 +1,27 @@
package com.pandoli365.bibimbap.util;
public final class TextNormalizer {
private TextNormalizer() {
}
// 저장 위생: \t/\n/\r 외의 C0/C1 제어문자 제거 + 외곽 strip. 내부 공백/줄바꿈 보존. nullnull.
public static String normalize(String raw) {
if (raw == null) {
return null;
}
StringBuilder sb = new StringBuilder(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '\t' || c == '\n' || c == '\r') {
sb.append(c);
continue;
}
if (c <= 0x1F || (c >= 0x7F && c <= 0x9F)) {
continue;
}
sb.append(c);
}
return sb.toString().strip();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
package com.pandoli365.bibimbap; package com.pandoli365.bibimbap;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper; import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
@ -27,6 +29,12 @@ class BibimbapApplicationTests {
@MockBean @MockBean
private GameReviewsMapper gameReviewsMapper; private GameReviewsMapper gameReviewsMapper;
@MockBean
private GameReviewAxesMapper gameReviewAxesMapper;
@MockBean
private GameReviewStatsMapper gameReviewStatsMapper;
@MockBean @MockBean
private RecruitPostsMapper recruitPostsMapper; private RecruitPostsMapper recruitPostsMapper;

View File

@ -7,6 +7,7 @@ import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.CsrfTokens;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -14,12 +15,16 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession; import org.springframework.mock.web.MockHttpSession;
import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
@ -46,6 +51,7 @@ class GameCommentControllerTest {
inv.getArgument(0, GameCommentData.class).setId(100L); inv.getArgument(0, GameCommentData.class).setId(100L);
return 1; return 1;
}); });
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persistedComment(100L, 1L, 7L, "작성자", false));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "좋은 게임", request, session); controller.createComment(1L, "좋은 게임", request, session);
@ -61,17 +67,14 @@ class GameCommentControllerTest {
void listCommentsReturnsViewArray() { void listCommentsReturnsViewArray() {
GameCommentController controller = controller(); GameCommentController controller = controller();
when(gamesMapper.getGame(1L)).thenReturn(game(1L)); when(gamesMapper.getGame(1L)).thenReturn(game(1L));
GameCommentData c = new GameCommentData(); GameCommentData c = persistedComment(5L, 1L, 7L, "작성자", false);
c.setId(5L); when(gameCommentsMapper.listGameComments(eq(1L), anyString(), anyInt(), anyInt()))
c.setGameId(1L); .thenReturn(List.of(c));
c.setUserId(7L);
c.setNickname("작성자");
c.setContent("내용");
when(gameCommentsMapper.listGameComments(1L)).thenReturn(List.of(c));
ResponseEntity<Map<String, Object>> response = controller.listComments(1L); ResponseEntity<Map<String, Object>> response = controller.listComments(1L, 0, null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsKey("hasMore");
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<Map<String, Object>> comments = (List<Map<String, Object>>) response.getBody().get("comments"); List<Map<String, Object>> comments = (List<Map<String, Object>>) response.getBody().get("comments");
assertThat(comments).hasSize(1); assertThat(comments).hasSize(1);
@ -84,7 +87,9 @@ class GameCommentControllerTest {
GameCommentController controller = controller(); GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자"); MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session); MockHttpServletRequest request = csrfPost(session);
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L)); when(gameCommentsMapper.getGameComment(5L))
.thenReturn(comment(5L, 1L, 7L))
.thenReturn(persistedComment(5L, 1L, 7L, "작성자", true));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "수정된 내용", request, session); controller.updateComment(1L, 5L, "수정된 내용", request, session);
@ -163,6 +168,7 @@ class GameCommentControllerTest {
inv.getArgument(0, GameCommentData.class).setId(101L); inv.getArgument(0, GameCommentData.class).setId(101L);
return 1; return 1;
}); });
when(gameCommentsMapper.getGameComment(101L)).thenReturn(persistedComment(101L, 1L, 7L, "작성자", false));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "".repeat(200), request, session); controller.createComment(1L, "".repeat(200), request, session);
@ -229,6 +235,138 @@ class GameCommentControllerTest {
verifyNoInteractions(gameCommentsMapper); verifyNoInteractions(gameCommentsMapper);
} }
// ---- AC-11 (A1): POST/PUT 응답 = commentView 전체 8키 ----
@Test
void createCommentReturnsFullCommentView() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(100L);
return 1;
});
OffsetDateTime created = OffsetDateTime.now();
GameCommentData persisted = persistedComment(100L, 1L, 7L, "작성자", false);
persisted.setCreatedAt(created);
persisted.setUpdatedAt(created);
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persisted);
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "좋은 게임", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = response.getBody();
assertThat(body).containsKeys(
"commentId", "gameId", "authorName", "userId",
"content", "createdAt", "edited", "updatedAt");
assertThat(body).containsEntry("commentId", 100L);
assertThat(body).containsEntry("edited", false);
assertThat(body.get("createdAt")).isEqualTo(created);
assertThat(body.get("updatedAt")).isEqualTo(created);
}
@Test
void updateCommentReturnsFullCommentView() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
OffsetDateTime created = OffsetDateTime.now().minusMinutes(10);
OffsetDateTime updated = OffsetDateTime.now();
GameCommentData persisted = persistedComment(5L, 1L, 7L, "작성자", true);
persisted.setCreatedAt(created);
persisted.setUpdatedAt(updated);
when(gameCommentsMapper.getGameComment(5L))
.thenReturn(comment(5L, 1L, 7L))
.thenReturn(persisted);
ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "수정된 내용", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = response.getBody();
assertThat(body).containsKeys(
"commentId", "gameId", "authorName", "userId",
"content", "createdAt", "edited", "updatedAt");
assertThat(body).containsEntry("edited", true);
assertThat(body.get("updatedAt")).isEqualTo(updated);
}
// ---- AC-12 (A2): 탈퇴 사용자 마스킹 (authorName 우선 노출) ----
@Test
void createCommentExposesMaskedAuthorNameFromReFetch() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(100L);
return 1;
});
// 재조회 SQL CASE 결과 authorName 마스킹 값이면 컨트롤러는 getAuthorName 우선 노출
when(gameCommentsMapper.getGameComment(100L))
.thenReturn(persistedComment(100L, 1L, 7L, "(탈퇴한 사용자)", false));
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, "좋은 게임", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("authorName", "(탈퇴한 사용자)");
}
// ---- AC-13 (A3): 수정 editGameComment 호출 + updatedAt 노출 ----
@Test
void updateCommentInvokesEditAndExposesUpdatedAt() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
OffsetDateTime updated = OffsetDateTime.now();
GameCommentData persisted = persistedComment(5L, 1L, 7L, "작성자", true);
persisted.setUpdatedAt(updated);
when(gameCommentsMapper.getGameComment(5L))
.thenReturn(comment(5L, 1L, 7L))
.thenReturn(persisted);
ResponseEntity<Map<String, Object>> response =
controller.updateComment(1L, 5L, "수정", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameCommentsMapper).editGameComment(any(GameCommentData.class));
assertThat(response.getBody()).containsEntry("edited", true);
assertThat(response.getBody().get("updatedAt")).isEqualTo(updated);
}
// ---- B3: 제어문자(NUL) 정규화 TextNormalizer NUL 제거, 일반 공백은 보존 ----
@Test
void createCommentStripsControlCharsBeforePersist() {
GameCommentController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "작성자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameCommentData.class).setId(100L);
return 1;
});
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persistedComment(100L, 1L, 7L, "작성자", false));
String nul = "\u0000";
String withControlChars = "좋은" + nul + " " + nul + "게임";
ResponseEntity<Map<String, Object>> response =
controller.createComment(1L, withControlChars, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<GameCommentData> captor = ArgumentCaptor.forClass(GameCommentData.class);
verify(gameCommentsMapper).addGameComment(captor.capture());
String stored = captor.getValue().getContent();
assertThat(stored).doesNotContain(nul);
assertThat(stored).isEqualTo("좋은 게임");
}
// ---- helpers ---- // ---- helpers ----
private GameCommentController controller() { private GameCommentController controller() {
@ -252,6 +390,21 @@ class GameCommentControllerTest {
return c; return c;
} }
// 재조회분: SQL alias authorName/edited/createdAt/updatedAt 채워진
private GameCommentData persistedComment(long id, long gameId, long userId, String authorName, boolean edited) {
GameCommentData c = new GameCommentData();
c.setId(id);
c.setGameId(gameId);
c.setUserId(userId);
c.setNickname("작성자");
c.setAuthorName(authorName);
c.setContent("내용");
c.setEdited(edited);
c.setCreatedAt(OffsetDateTime.now());
c.setUpdatedAt(OffsetDateTime.now());
return c;
}
private MockHttpSession loginSession(long userId, String role, String displayName) { private MockHttpSession loginSession(long userId, String role, String displayName) {
MockHttpSession session = new MockHttpSession(); MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", userId); session.setAttribute("userId", userId);

View File

@ -2,11 +2,15 @@ package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.GameReviewData; import com.pandoli365.bibimbap.data.GameReviewData;
import com.pandoli365.bibimbap.data.ReviewAxisRow;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.CsrfTokens;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -14,11 +18,18 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession; import org.springframework.mock.web.MockHttpSession;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
@ -30,9 +41,18 @@ class GameReviewControllerTest {
@Mock @Mock
private GameReviewsMapper gameReviewsMapper; private GameReviewsMapper gameReviewsMapper;
@Mock
private GameReviewAxesMapper gameReviewAxesMapper;
@Mock
private GameReviewStatsMapper gameReviewStatsMapper;
@Mock @Mock
private GamesMapper gamesMapper; private GamesMapper gamesMapper;
// 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual).
private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"};
// ---- AC-4: 수정 edited 토글 (작성 직후 false 수정 true) ---- // ---- AC-4: 수정 edited 토글 (작성 직후 false 수정 true) ----
@Test @Test
@ -49,7 +69,7 @@ class GameReviewControllerTest {
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false)); when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.createReview(1L, "4", "재미있음", request, session); createReview(controller, 1L, null, "정말 재미있는 게임입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("reviewId", 50L); assertThat(response.getBody()).containsEntry("reviewId", 50L);
@ -67,7 +87,7 @@ class GameReviewControllerTest {
.thenReturn(review(50L, 1L, 7L, 5, true)); .thenReturn(review(50L, 1L, 7L, 5, true));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.updateReview(1L, 50L, "5", "더 좋아짐", request, session); updateReview(controller, 1L, 50L, "5", "수정 후 훨씬 더 좋아졌습니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("edited", true); assertThat(response.getBody()).containsEntry("edited", true);
@ -86,13 +106,13 @@ class GameReviewControllerTest {
.thenReturn(review(50L, 1L, 7L, 4, false)); .thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.createReview(1L, "5", "또작성", request, session); createReview(controller, 1L, "5", "같은 게임에 작성 시도합니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
verify(gameReviewsMapper, never()).addGameReview(any()); verify(gameReviewsMapper, never()).addGameReview(any());
} }
// ---- AC-6: rating 0/6 거부, 1/5 경계 ---- // ---- AC-6: overall rating 0/6 거부, 1/5 경계 ----
@Test @Test
void createReviewRejectsRatingZero() { void createReviewRejectsRatingZero() {
@ -122,7 +142,7 @@ class GameReviewControllerTest {
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false)); when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.updateReview(1L, 50L, "6", "범위초과", request, session); updateReview(controller, 1L, 50L, "6", "범위초과", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).editGameReview(any()); verify(gameReviewsMapper, never()).editGameReview(any());
@ -138,7 +158,7 @@ class GameReviewControllerTest {
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false)); when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.updateReview(1L, 50L, "3", "강제수정", request, session); updateReview(controller, 1L, 50L, "3", "강제수정", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(gameReviewsMapper, never()).editGameReview(any()); verify(gameReviewsMapper, never()).editGameReview(any());
@ -167,7 +187,7 @@ class GameReviewControllerTest {
MockHttpServletRequest request = noCsrfPost(session); MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.createReview(1L, "4", "내용", request, session); createReview(controller, 1L, "4", "내용입니다열자", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameReviewsMapper); verifyNoInteractions(gameReviewsMapper);
@ -181,7 +201,7 @@ class GameReviewControllerTest {
MockHttpServletRequest request = noCsrfPost(session); MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.updateReview(1L, 50L, "4", "내용", request, session); updateReview(controller, 1L, 50L, "4", "내용입니다열자", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameReviewsMapper); verifyNoInteractions(gameReviewsMapper);
@ -200,6 +220,156 @@ class GameReviewControllerTest {
verifyNoInteractions(gameReviewsMapper); verifyNoInteractions(gameReviewsMapper);
} }
// ---- AC-7(다축): 6축 누락 400 ----
@Test
void createReviewRejectsMissingAxis() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
// immersion 빈값 6축 미충족 400.
String[] missing = {"", "5", "3", "4", "2", "5"};
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "4", "내용입니다열자", missing, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).addGameReview(any());
}
// ---- AC-8: overall 자동평균 / 수동 ----
@Test
void createReviewAutoAveragesOverallWhenRatingOmitted() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
// 6축 [4,5,3,4,2,5] 23 / 6 = 3.83 round 4, ratingManual=false.
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, null, "별점 자동평균 계산 검증 본문", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<GameReviewData> captor = ArgumentCaptor.forClass(GameReviewData.class);
verify(gameReviewsMapper).addGameReview(captor.capture());
assertThat(captor.getValue().getRating()).isEqualTo(4);
assertThat(captor.getValue().getRatingManual()).isFalse();
}
@Test
void createReviewUsesManualOverallWhenRatingProvided() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 2, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "2", "수동 별점 사용 검증 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<GameReviewData> captor = ArgumentCaptor.forClass(GameReviewData.class);
verify(gameReviewsMapper).addGameReview(captor.capture());
assertThat(captor.getValue().getRating()).isEqualTo(2);
assertThat(captor.getValue().getRatingManual()).isTrue();
}
// ---- 다축 저장: addReviewAxes 6행 ----
@Test
void createReviewPersistsSixAxes() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, null, "여섯 축 저장 검증용 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<ReviewAxisRow>> captor = ArgumentCaptor.forClass(List.class);
verify(gameReviewAxesMapper).addReviewAxes(eq(50L), captor.capture());
List<ReviewAxisRow> axes = captor.getValue();
assertThat(axes).hasSize(6);
assertThat(axes).extracting(ReviewAxisRow::getAxisKey)
.containsExactlyInAnyOrder(
"immersion", "creativity", "controls", "completeness", "sound", "visual");
}
// ---- AC-9: B2 본문 최소 10자 ----
@Test
void createReviewRejectsBodyUnderTenChars() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
// trim 9자 400.
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "4", "".repeat(9), AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).addGameReview(any());
}
@Test
void createReviewAcceptsBodyTenCharsBoundary() {
GameReviewController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
inv.getArgument(0, GameReviewData.class).setId(50L);
return 1;
});
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
ResponseEntity<Map<String, Object>> response =
createReview(controller, 1L, "4", "".repeat(10), AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
}
// ---- B1: listReviews 미허용 sort 관대 처리(200) ----
@Test
void listReviewsToleratesUnknownSort() {
GameReviewController controller = controller();
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameReviewsMapper.listGameReviews(eq(1L), anyString(), anyInt(), anyInt()))
.thenReturn(List.of(review(50L, 1L, 7L, 4, false)));
lenient().when(gameReviewAxesMapper.listAxesByReviewIds(anyList())).thenReturn(List.of());
lenient().when(gameReviewStatsMapper.getStats(1L)).thenReturn(null);
ResponseEntity<Map<String, Object>> response = controller.listReviews(1L, 0, "garbage");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsKey("hasMore");
}
// ---- helpers ---- // ---- helpers ----
private void assertCreateRatingRejected(String rating) { private void assertCreateRatingRejected(String rating) {
@ -209,7 +379,7 @@ class GameReviewControllerTest {
when(gamesMapper.getGame(1L)).thenReturn(game(1L)); when(gamesMapper.getGame(1L)).thenReturn(game(1L));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.createReview(1L, rating, "내용", request, session); createReview(controller, 1L, rating, "별점 범위 검증용 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(gameReviewsMapper, never()).addGameReview(any()); verify(gameReviewsMapper, never()).addGameReview(any());
@ -229,14 +399,30 @@ class GameReviewControllerTest {
.thenReturn(review(50L, 1L, 7L, Integer.parseInt(rating), false)); .thenReturn(review(50L, 1L, 7L, Integer.parseInt(rating), false));
ResponseEntity<Map<String, Object>> response = ResponseEntity<Map<String, Object>> response =
controller.createReview(1L, rating, "내용", request, session); createReview(controller, 1L, rating, "별점 경계값 통과 검증 본문입니다", AXES_OK, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class)); verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
} }
// createReview positional 호출 래퍼. axes = [immersion,creativity,controls,completeness,sound,visual].
private ResponseEntity<Map<String, Object>> createReview(
GameReviewController controller, long id, String rating, String body,
String[] axes, MockHttpServletRequest request, MockHttpSession session) {
return controller.createReview(id, rating, body,
axes[0], axes[1], axes[2], axes[3], axes[4], axes[5], request, session);
}
private ResponseEntity<Map<String, Object>> updateReview(
GameReviewController controller, long id, long reviewId, String rating, String body,
String[] axes, MockHttpServletRequest request, MockHttpSession session) {
return controller.updateReview(id, reviewId, rating, body,
axes[0], axes[1], axes[2], axes[3], axes[4], axes[5], request, session);
}
private GameReviewController controller() { private GameReviewController controller() {
return new GameReviewController(gameReviewsMapper, gamesMapper); return new GameReviewController(
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper);
} }
private GameData game(long id) { private GameData game(long id) {
@ -255,6 +441,15 @@ class GameReviewControllerTest {
r.setBody("내용"); r.setBody("내용");
r.setAuthorName("작성자"); r.setAuthorName("작성자");
r.setEdited(edited); r.setEdited(edited);
r.setRatingManual(false);
Map<String, Integer> axes = new LinkedHashMap<>();
axes.put("immersion", 4);
axes.put("creativity", 5);
axes.put("controls", 3);
axes.put("completeness", 4);
axes.put("sound", 2);
axes.put("visual", 5);
r.setAxes(axes);
return r; return r;
} }