diff --git a/db/schema.sql b/db/schema.sql index 5593ec4..d196992 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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; CREATE INDEX IF NOT EXISTS "idx_game_reviews_game" ON "game_reviews" ("game_id") WHERE "is_delete" = false; --- 향후 다축(육각형) 확장 시: rating 유지 + game_review_axes(review_id, axis, score) 별도 테이블 분리. --- 집계 컬럼/뷰는 W2-3 동결 — 신설 금지. +-- C3 재분류: 다축(육각형) game_review_axes + 집계뷰 game_review_stats 는 아래에 W3-2 일반 DDL 로 신설(roadmap.md:63,201,202, 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 컬럼 없음) diff --git a/docs/changes/2026-06-18-w3-2-comments-reviews.md b/docs/changes/2026-06-18-w3-2-comments-reviews.md index b06f3dc..fea2eb3 100644 --- a/docs/changes/2026-06-18-w3-2-comments-reviews.md +++ b/docs/changes/2026-06-18-w3-2-comments-reviews.md @@ -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 연결 시 활성. -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. 리뷰 이력 열람 권한 — 열람 정책 확정 시 별도 이력 테이블. 5. GameCatalog 정적 폴백 게임 대상 댓글/리뷰 — DB 실제 게임만 기능 동작(폴백은 404). diff --git a/docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md b/docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md new file mode 100644 index 0000000..c17001a --- /dev/null +++ b/docs/changes/2026-06-22-w3-2-comments-reviews-enhancement.md @@ -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=&sort=` | +| 더보기 방식 | 전체 반환 | 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=&sort=` + +--- + +## 검증 결과 요약 + +| 레이어 | 결과 | 비고 | +|---|---|---| +| 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. 백필 필요 시 별도 | diff --git a/docs/changes/index.md b/docs/changes/index.md index a841e93..2d62adf 100644 --- a/docs/changes/index.md +++ b/docs/changes/index.md @@ -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-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. diff --git a/docs/development/index.md b/docs/development/index.md index 3c799bd..1faba9c 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -4,7 +4,7 @@ ## 목록 -- [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) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리) - [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정 diff --git a/docs/development/verification-strategies.md b/docs/development/verification-strategies.md index 50926c3..f900554 100644 --- a/docs/development/verification-strategies.md +++ b/docs/development/verification-strategies.md @@ -117,3 +117,58 @@ strategies: - 새 전략 추가: 위 YAML 블록에 항목 추가. - Worker 분리가 필요한 수준에 도달 (브라우저 테스트, 장시간 E2E 등): `worker:` 필드 붙이고 해당 worker 파일 신설 + `verification-advisor.md` 의 tools 에 `Agent` 추가. - 기준은 [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 인접 = 고위험 추론 금지" 조항 추가 권고. diff --git a/docs/game-reviews-ddl.sql b/docs/game-reviews-ddl.sql index 939bbe5..0ae0ef8 100644 --- a/docs/game-reviews-ddl.sql +++ b/docs/game-reviews-ddl.sql @@ -60,7 +60,7 @@ 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; --- 목록 조회 + 후속 집계 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" ON "game_reviews" ("game_id") WHERE "is_delete" = false; @@ -98,3 +98,89 @@ END $$; 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축평균'; diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java index a254529..d84d04a 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java @@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.GameCommentData; import com.pandoli365.bibimbap.mapper.GameCommentsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.util.TextNormalizer; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.http.HttpStatus; @@ -26,6 +27,7 @@ import java.util.Map; public class GameCommentController { private static final int CONTENT_MAX = 200; + private static final int PAGE_SIZE = 20; private static final String ROLE_ADMIN = "ADMIN"; private final GameCommentsMapper gameCommentsMapper; @@ -37,19 +39,30 @@ public class GameCommentController { } @GetMapping("/game/{id}/comments") - public ResponseEntity> listComments(@PathVariable("id") long id) { + public ResponseEntity> listComments( + @PathVariable("id") long id, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "sort", required = false) String sort + ) { if (gamesMapper.getGame(id) == null) { return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); } + String sortEnum = normalizeCommentSort(sort); + int offset = Math.max(page, 0) * PAGE_SIZE; + List rows = gameCommentsMapper.listGameComments(id, sortEnum, offset, PAGE_SIZE + 1); + boolean hasMore = rows.size() > PAGE_SIZE; + int count = Math.min(rows.size(), PAGE_SIZE); + List> comments = new ArrayList<>(); - for (GameCommentData comment : gameCommentsMapper.listGameComments(id)) { - comments.add(commentView(comment)); + for (int i = 0; i < count; i++) { + comments.add(commentView(rows.get(i))); } Map body = new LinkedHashMap<>(); body.put("status", 200); body.put("comments", comments); + body.put("hasMore", hasMore); return ResponseEntity.ok(body); } @@ -72,7 +85,7 @@ public class GameCommentController { return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); } - String normalizedContent = trimToNull(content); + String normalizedContent = trimToNull(TextNormalizer.normalize(content)); if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) { return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요."); } @@ -88,15 +101,11 @@ public class GameCommentController { return response(HttpStatus.INTERNAL_SERVER_ERROR, "덧글 등록 결과를 확인하지 못했습니다."); } - Map body = new LinkedHashMap<>(); - body.put("status", 200); - body.put("message", "덧글이 등록되었습니다."); - body.put("commentId", comment.getId()); - body.put("gameId", id); - body.put("authorName", authorName); - body.put("userId", userId); - body.put("content", normalizedContent); - return ResponseEntity.ok(body); + GameCommentData created = gameCommentsMapper.getGameComment(comment.getId()); + Map result = created != null ? commentView(created) : commentView(comment); + result.put("status", 200); + result.put("message", "덧글이 등록되었습니다."); + return ResponseEntity.ok(result); } @PutMapping("/game/{id}/comments/{commentId}") @@ -124,7 +133,7 @@ public class GameCommentController { return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다."); } - String normalizedContent = trimToNull(content); + String normalizedContent = trimToNull(TextNormalizer.normalize(content)); if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) { return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요."); } @@ -132,12 +141,11 @@ public class GameCommentController { comment.setContent(normalizedContent); gameCommentsMapper.editGameComment(comment); - Map body = new LinkedHashMap<>(); - body.put("status", 200); - body.put("message", "덧글이 수정되었습니다."); - body.put("commentId", commentId); - body.put("content", normalizedContent); - return ResponseEntity.ok(body); + GameCommentData updated = gameCommentsMapper.getGameComment(commentId); + Map result = updated != null ? commentView(updated) : commentView(comment); + result.put("status", 200); + result.put("message", "덧글이 수정되었습니다."); + return ResponseEntity.ok(result); } @DeleteMapping("/game/{id}/comments/{commentId}") @@ -176,13 +184,19 @@ public class GameCommentController { Map view = new LinkedHashMap<>(); view.put("commentId", comment.getId()); 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("content", comment.getContent()); view.put("createdAt", comment.getCreatedAt()); + view.put("edited", comment.getEdited() != null && comment.getEdited()); + view.put("updatedAt", comment.getUpdatedAt()); return view; } + private String normalizeCommentSort(String sort) { + return "newest".equals(sort) ? "newest" : "oldest"; + } + private boolean isOperator(String role) { return ROLE_ADMIN.equals(role); } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index 09350f1..b76c9bb 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -114,8 +114,8 @@ public class GameController { GameData game = gamesMapper.getGame(id); if (game != null) { addGameModel(model, game, sessionUserId(session)); - model.addAttribute("comments", gameCommentsMapper.listGameComments(id)); - model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id)); + model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20)); + model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id, "newest", 0, 20)); model.addAttribute("userRole", (String) session.getAttribute("role")); return "game-detail"; } diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java index 3dbbc6e..07d426f 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java @@ -1,9 +1,13 @@ package com.pandoli365.bibimbap.controller.api; 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.GamesMapper; import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.util.TextNormalizer; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.springframework.http.HttpStatus; @@ -27,31 +31,70 @@ public class GameReviewController { private static final int RATING_MIN = 1; 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 PAGE_SIZE = 20; 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 GameReviewAxesMapper gameReviewAxesMapper; + private final GameReviewStatsMapper gameReviewStatsMapper; private final GamesMapper gamesMapper; - public GameReviewController(GameReviewsMapper gameReviewsMapper, GamesMapper gamesMapper) { + public GameReviewController(GameReviewsMapper gameReviewsMapper, + GameReviewAxesMapper gameReviewAxesMapper, + GameReviewStatsMapper gameReviewStatsMapper, + GamesMapper gamesMapper) { this.gameReviewsMapper = gameReviewsMapper; + this.gameReviewAxesMapper = gameReviewAxesMapper; + this.gameReviewStatsMapper = gameReviewStatsMapper; this.gamesMapper = gamesMapper; } @GetMapping("/game/{id}/reviews") - public ResponseEntity> listReviews(@PathVariable("id") long id) { + public ResponseEntity> listReviews( + @PathVariable("id") long id, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "sort", required = false) String sort + ) { if (gamesMapper.getGame(id) == null) { return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); } + String sortEnum = normalizeReviewSort(sort); + int offset = Math.max(page, 0) * PAGE_SIZE; + List rows = gameReviewsMapper.listGameReviews(id, sortEnum, offset, PAGE_SIZE + 1); + boolean hasMore = rows.size() > PAGE_SIZE; + int count = Math.min(rows.size(), PAGE_SIZE); + List pageRows = rows.subList(0, count); + + // axes batch 조회(N+1 회피). 빈 페이지 가드. + if (!pageRows.isEmpty()) { + List ids = new ArrayList<>(); + for (GameReviewData r : pageRows) { + ids.add(r.getId()); + } + Map> 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> reviews = new ArrayList<>(); - for (GameReviewData review : gameReviewsMapper.listGameReviews(id)) { - reviews.add(reviewView(review)); + for (GameReviewData r : pageRows) { + reviews.add(reviewView(r)); } Map body = new LinkedHashMap<>(); body.put("status", 200); body.put("reviews", reviews); + body.put("hasMore", hasMore); + body.put("summary", buildSummary(id)); return ResponseEntity.ok(body); } @@ -64,6 +107,7 @@ public class GameReviewController { if (review == null || !Long.valueOf(id).equals(review.getGameId())) { return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."); } + review.setAxes(loadAxes(reviewId)); Map body = new LinkedHashMap<>(); body.put("status", 200); @@ -77,6 +121,12 @@ public class GameReviewController { @PathVariable("id") long id, @RequestParam(name = "rating", required = false) String rating, @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, HttpSession session ) { @@ -91,15 +141,31 @@ public class GameReviewController { return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); } - Integer parsedRating = parseRating(rating); - if (parsedRating == null) { - return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요."); + Map axes = parseAxes(immersion, creativity, controls, completeness, sound, visual); + if (axes == null) { + 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) { 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) { return response(HttpStatus.CONFLICT, "이미 이 게임에 리뷰를 작성하셨습니다."); } @@ -107,14 +173,19 @@ public class GameReviewController { GameReviewData review = new GameReviewData(); review.setGameId(id); review.setUserId(userId); - review.setRating(parsedRating); + review.setRating(overall); review.setBody(normalizedBody); + review.setRatingManual(ratingManual); gameReviewsMapper.addGameReview(review); if (review.getId() == null) { return response(HttpStatus.INTERNAL_SERVER_ERROR, "리뷰 등록 결과를 확인하지 못했습니다."); } + gameReviewAxesMapper.addReviewAxes(review.getId(), toAxisRows(review.getId(), axes)); GameReviewData created = gameReviewsMapper.getGameReview(review.getId()); + if (created != null) { + created.setAxes(loadAxes(created.getId())); + } Map result = created != null ? reviewView(created) : reviewView(review); result.put("status", 200); result.put("message", "리뷰가 등록되었습니다."); @@ -128,6 +199,12 @@ public class GameReviewController { @PathVariable("reviewId") long reviewId, @RequestParam(name = "rating", required = false) String rating, @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, HttpSession session ) { @@ -147,20 +224,42 @@ public class GameReviewController { return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다."); } - Integer parsedRating = parseRating(rating); - if (parsedRating == null) { - return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요."); + Map axes = parseAxes(immersion, creativity, controls, completeness, sound, visual); + if (axes == null) { + 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) { 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.setRatingManual(ratingManual); gameReviewsMapper.editGameReview(review); + gameReviewAxesMapper.deleteReviewAxes(reviewId); + gameReviewAxesMapper.addReviewAxes(reviewId, toAxisRows(reviewId, axes)); GameReviewData updated = gameReviewsMapper.getGameReview(reviewId); + if (updated != null) { + updated.setAxes(loadAxes(reviewId)); + } Map result = updated != null ? reviewView(updated) : reviewView(review); result.put("status", 200); result.put("message", "리뷰가 수정되었습니다."); @@ -207,12 +306,98 @@ public class GameReviewController { view.put("userId", review.getUserId()); view.put("rating", review.getRating()); view.put("body", review.getBody()); + view.put("ratingManual", review.getRatingManual() != null && review.getRatingManual()); view.put("edited", review.getEdited() != null && review.getEdited()); + view.put("axes", review.getAxes()); view.put("createdAt", review.getCreatedAt()); view.put("updatedAt", review.getUpdatedAt()); return view; } + // 6축 점수 파싱·검증. 전 축 1~5 필수. 위반 시 null 반환(컨트롤러가 400). + private Map parseAxes(String immersion, String creativity, String controls, + String completeness, String sound, String visual) { + Map 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 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 axes) { + int sum = 0; + for (String key : AXIS_KEYS) { + sum += axes.get(key); + } + return (int) Math.round(sum / (double) AXIS_KEYS.length); + } + + private List toAxisRows(long reviewId, Map axes) { + List rows = new ArrayList<>(); + for (String key : AXIS_KEYS) { + rows.add(new ReviewAxisRow(reviewId, key, axes.get(key))); + } + return rows; + } + + private Map loadAxes(long reviewId) { + Map axes = new LinkedHashMap<>(); + for (ReviewAxisRow ax : gameReviewAxesMapper.listAxesByReviewIds(List.of(reviewId))) { + axes.put(ax.getAxisKey(), ax.getScore()); + } + return axes.isEmpty() ? null : axes; + } + + private Map buildSummary(long gameId) { + Map 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 axes = new LinkedHashMap<>(); + for (String key : AXIS_KEYS) { + axes.put(key, stats.get(key)); + } + Map 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) { String text = trimToNull(rating); if (text == null) { @@ -272,11 +457,6 @@ public class GameReviewController { return text.isBlank() ? null : text; } - private String trimToEmpty(String value) { - String text = trimToNull(value); - return text == null ? "" : text; - } - private ResponseEntity> response(HttpStatus status, String message) { Map body = new LinkedHashMap<>(); body.put("status", status.value()); diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java b/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java index ed844d2..85a0b53 100644 --- a/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java +++ b/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java @@ -10,8 +10,13 @@ public class GameCommentData { private String nickname; private String content; private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; private OffsetDateTime deletedAt; + // 비영속 (SELECT 계산 alias / LEFT JOIN alias) + private Boolean edited; + private String authorName; + public Long getId() { return id; } @@ -60,6 +65,14 @@ public class GameCommentData { this.createdAt = createdAt; } + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + public OffsetDateTime getDeletedAt() { return deletedAt; } @@ -67,4 +80,20 @@ public class GameCommentData { public void setDeletedAt(OffsetDateTime 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; + } } diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java b/src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java index 4c198b1..9e57293 100644 --- a/src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java +++ b/src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java @@ -1,6 +1,7 @@ package com.pandoli365.bibimbap.data; import java.time.OffsetDateTime; +import java.util.Map; public class GameReviewData { @@ -9,13 +10,15 @@ public class GameReviewData { private Long userId; private Integer rating; private String body; + private Boolean ratingManual; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; private OffsetDateTime deletedAt; - // 비영속 (목록 JOIN alias / SELECT 계산 alias) + // 비영속 (목록 JOIN alias / SELECT 계산 alias / reviewView 조립용) private String authorName; private Boolean edited; + private Map axes; public Long getId() { return id; @@ -96,4 +99,20 @@ public class GameReviewData { public void setEdited(Boolean edited) { this.edited = edited; } + + public Boolean getRatingManual() { + return ratingManual; + } + + public void setRatingManual(Boolean ratingManual) { + this.ratingManual = ratingManual; + } + + public Map getAxes() { + return axes; + } + + public void setAxes(Map axes) { + this.axes = axes; + } } diff --git a/src/main/java/com/pandoli365/bibimbap/data/ReviewAxisRow.java b/src/main/java/com/pandoli365/bibimbap/data/ReviewAxisRow.java new file mode 100644 index 0000000..c3d88fc --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/ReviewAxisRow.java @@ -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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java index 3122a98..99bf0c7 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java @@ -6,42 +6,43 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.SelectProvider; import org.apache.ibatis.annotations.Update; import java.util.List; +import java.util.Map; @Mapper public interface GameCommentsMapper { @Select(""" SELECT - id, - game_id AS gameId, - user_id AS userId, - nickname, - content, - created_at AS createdAt, - deleted_at AS deletedAt - FROM game_comments - WHERE id = #{id} - AND is_delete IS NOT TRUE + 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.id = #{id} + AND c.is_delete IS NOT TRUE """) GameCommentData getGameComment(long id); - @Select(""" - SELECT - id, - game_id AS gameId, - user_id AS userId, - 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 listGameComments(@Param("gameId") long gameId); + @SelectProvider(type = CommentSqlProvider.class, method = "listComments") + List listGameComments(@Param("gameId") long gameId, + @Param("sort") String sort, + @Param("offset") int offset, + @Param("limit") int limit); @Insert(""" INSERT INTO game_comments ( @@ -62,7 +63,8 @@ public interface GameCommentsMapper { @Update(""" UPDATE game_comments SET - content = #{content} + content = #{content}, + updated_at = now() WHERE id = #{id} AND is_delete IS NOT TRUE """) @@ -89,4 +91,38 @@ public interface GameCommentsMapper { AND is_delete IS NOT TRUE """) int updateGameComment(GameCommentData gameComment); + + class CommentSqlProvider { + + // SELECT/WHERE 는 컴파일타임 리터럴. ORDER BY 만 sort enum 으로 고정 문자열 분기(${} 미사용). + public String listComments(Map 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}"; + } + } } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewAxesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewAxesMapper.java new file mode 100644 index 0000000..a287fdb --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewAxesMapper.java @@ -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 axes); + + @Delete("DELETE FROM game_review_axes WHERE review_id = #{reviewId}") + int deleteReviewAxes(@Param("reviewId") long reviewId); + + @SelectProvider(type = AxesSqlProvider.class, method = "listByReviewIds") + List listAxesByReviewIds(@Param("reviewIds") List reviewIds); + + class AxesSqlProvider { + + // VALUES 다행. 각 값은 #{axes[i].axisKey}/#{axes[i].score} 바인딩(${} 미사용). + public String insertAxes(Map params) { + @SuppressWarnings("unchecked") + List axes = (List) 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 params) { + @SuppressWarnings("unchecked") + List ids = (List) 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(); + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewStatsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewStatsMapper.java new file mode 100644 index 0000000..0c02fb6 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewStatsMapper.java @@ -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 getStats(long gameId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java index 6b6d2b3..8fd05e0 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java @@ -6,9 +6,11 @@ import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.SelectProvider; import org.apache.ibatis.annotations.Update; import java.util.List; +import java.util.Map; @Mapper public interface GameReviewsMapper { @@ -20,6 +22,7 @@ public interface GameReviewsMapper { 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, @@ -33,25 +36,11 @@ public interface GameReviewsMapper { """) GameReviewData getGameReview(long id); - @Select(""" - SELECT - r.id, - r.game_id AS gameId, - r.user_id AS userId, - 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 listGameReviews(@Param("gameId") long gameId); + @SelectProvider(type = ReviewSqlProvider.class, method = "listReviews") + List listGameReviews(@Param("gameId") long gameId, + @Param("sort") String sort, + @Param("offset") int offset, + @Param("limit") int limit); @Select(""" SELECT @@ -60,6 +49,7 @@ public interface GameReviewsMapper { r.user_id AS userId, r.rating, r.body, + r.is_rating_manual AS ratingManual, r.created_at AS createdAt, r.updated_at AS updatedAt FROM game_reviews r @@ -71,15 +61,9 @@ public interface GameReviewsMapper { @Insert(""" INSERT INTO game_reviews ( - game_id, - user_id, - rating, - body + game_id, user_id, rating, body, is_rating_manual ) VALUES ( - #{gameId}, - #{userId}, - #{rating}, - #{body} + #{gameId}, #{userId}, #{rating}, #{body}, #{ratingManual} ) """) @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") @@ -87,12 +71,9 @@ public interface GameReviewsMapper { @Update(""" UPDATE game_reviews - SET - rating = #{rating}, - body = #{body}, - updated_at = now() - WHERE id = #{id} - AND is_delete IS NOT TRUE + SET rating = #{rating}, body = #{body}, + is_rating_manual = #{ratingManual}, updated_at = now() + WHERE id = #{id} AND is_delete IS NOT TRUE """) int editGameReview(GameReviewData review); @@ -105,4 +86,37 @@ public interface GameReviewsMapper { AND is_delete IS NOT TRUE """) int softDeleteGameReview(long id); + + class ReviewSqlProvider { + + // SELECT/JOIN/WHERE 는 컴파일타임 리터럴. ORDER BY 만 sort enum 으로 고정 문자열 분기(${} 미사용). + public String listReviews(Map 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}"; + } + } } diff --git a/src/main/java/com/pandoli365/bibimbap/util/TextNormalizer.java b/src/main/java/com/pandoli365/bibimbap/util/TextNormalizer.java new file mode 100644 index 0000000..b8e22bd --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/util/TextNormalizer.java @@ -0,0 +1,27 @@ +package com.pandoli365.bibimbap.util; + +public final class TextNormalizer { + + private TextNormalizer() { + } + + // 저장 위생: \t/\n/\r 외의 C0/C1 제어문자 제거 + 외곽 strip. 내부 공백/줄바꿈 보존. null→null. + 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(); + } +} diff --git a/src/main/webapp/WEB-INF/views/game-detail.jsp b/src/main/webapp/WEB-INF/views/game-detail.jsp index 47781d7..f7246ea 100644 --- a/src/main/webapp/WEB-INF/views/game-detail.jsp +++ b/src/main/webapp/WEB-INF/views/game-detail.jsp @@ -733,6 +733,10 @@ } .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--input:focus-within .game-stars__btn:focus-visible { outline: 2px solid var(--accent); @@ -758,7 +762,9 @@ .game-reviews__composer { margin-bottom: 1.25rem; padding: 1.1rem 1.15rem 1.2rem; - background: var(--accent-soft); + 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; } @@ -925,6 +931,249 @@ border-color: var(--accent); box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.18); } + + /* ── 6축 평가 입력 그리드 ─────────────────────────────── */ + .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--axis { + gap: 0.1rem; + } + .game-stars--axis .game-stars__btn { + font-size: 1.25rem; + } + + /* ── 육각형 레이더 ───────────────────────────────────── */ + .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; + } + /* drop-shadow 가 viewBox 밖으로 번질 수 있어 클립 방지 */ + .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; + } + /* fill 은 JS 가 url(#gradient) 프레젠테이션 속성으로 지정 — 여기서 fill 지정 금지(우선순위 역전) */ + .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); } + } + @media (prefers-reduced-motion: reduce) { + .game-reviews__radar .game-axis-poly, + .game-reviews__radar .game-axis-dot, + .game-reviews__axis-meter span { animation: none; } + } + .game-reviews__card-radar { + margin-top: 0.6rem; + display: inline-flex; + } + .game-reviews__card-radar svg { + display: block; + } + + /* ── 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-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); + } + html[data-theme="dark"] .game-comments__sort-btn.is-active { + color: #1a1a1a; + } + .game-comments__more { + margin-top: 1rem; + display: flex; + justify-content: center; + } + .game-comments__more-btn { + padding: 0.5rem 1.4rem; + 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; + } + .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); + } + @media (prefers-reduced-motion: reduce) { + .game-comments__more-btn:hover { transform: none; } + } + + /* ── 제출 잠금/로딩 ──────────────────────────────────── */ + .game-comments__hint.is-warn { + color: #b33; + font-weight: 700; + } + button[aria-busy="true"] { + opacity: 0.65; + cursor: progress; + } + .game-comments__edited { + margin-left: 0.35rem; + font-size: 0.6875rem; + font-weight: 500; + color: var(--text-muted); + } @@ -1036,22 +1285,20 @@ + @@ -1198,10 +1464,50 @@ syncLike(); // ===== 공통 헬퍼 (서버 연동 덧글/리뷰) ===== - function fmtDate(iso) { + 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; + + function fmtAbsolute(iso) { try { return iso ? new Date(iso).toLocaleString('ko-KR') : ''; } catch (e) { return ''; } } + // 상대시각: 7일 이내는 "n분/시간/일 전", 초과는 절대 날짜 + function fmtRelative(iso) { + if (!iso) return ''; + var d = new Date(iso); + if (isNaN(d.getTime())) return ''; + var diff = Date.now() - d.getTime(); + if (diff < 0) diff = 0; + var sec = Math.floor(diff / 1000); + if (sec < 60) return '방금 전'; + var min = Math.floor(sec / 60); + if (min < 60) return min + '분 전'; + var hr = Math.floor(min / 60); + if (hr < 24) return hr + '시간 전'; + var day = Math.floor(hr / 24); + if (day <= 7) return day + '일 전'; + return d.toLocaleDateString('ko-KR'); + } + // edited 면 "(수정됨)" 뱃지 동반.