Compare commits
No commits in common. "f81991b409aeb8cacf79e5881096ac9a567a583d" and "3a06b39d76a64d9e60b6ad432ab906b4d3ba8715" have entirely different histories.
f81991b409
...
3a06b39d76
|
|
@ -1,162 +0,0 @@
|
||||||
---
|
|
||||||
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건 — 구현 미시작.
|
|
||||||
|
|
@ -1,364 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
---
|
|
||||||
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/ 업데이트.
|
|
||||||
|
|
@ -1,536 +0,0 @@
|
||||||
---
|
|
||||||
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("a | |||||||