feat: W3-2 댓글/리뷰 고도화 — 다축 평점·육각형 레이더·페이지네이션·집계뷰
W3-2 코어 위에 FINAL SPEC 전체 구현 (resumed_from 20260618-145152).
- 다축 평점: game_review_axes(6축·리뷰당6행) + overall 자동평균/수동(is_rating_manual)
- 육각형 SVG 레이더(요약+카드, frontend-design 폴리시), 6축 radiogroup(roving tabindex)
- A1 commentView 통일 / A2 하이브리드 작성자명+탈퇴자 마스킹(QG-2) / A3 edited·updated_at
- B1 페이지네이션(limit+1 hasMore) + sort 화이트리스트(@SelectProvider, ${} 미사용)
- B2 본문 최소10자 / B3 TextNormalizer 정규화
- C3 game_review_stats 집계뷰(W3-2 일반 DDL, 잼평가 동결 무관). 클라 평균계산 폐기
- DDL 멱등 ALTER/CREATE (game-reviews-ddl.sql + db/schema.sql 동기화)
검증: ./mvnw test 43/43 GREEN(회귀0). DDL 적용·L3 스모크 = 사용자 환경.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a06b39d76
commit
b9d836d5f4
|
|
@ -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 컬럼 없음)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
---
|
||||
kind: change
|
||||
title: "W3-2 댓글/리뷰 고도화 — 다축 평점·일관성·목록규모·UX"
|
||||
session_id: 20260622-092800
|
||||
resumed_from: 20260618-145152
|
||||
created_at: 2026-06-22
|
||||
status: implemented
|
||||
related_prev_change: "./2026-06-18-w3-2-comments-reviews.md"
|
||||
related_security_checklist: "../security/security-remediation-checklist.md"
|
||||
related_work_log: "../work-log/2026-06-17-jam-platform-roadmap.md"
|
||||
related_design: "../../.atp/work-session/20260622-092800/implementation/design.md"
|
||||
related_report: "../../.atp/work-session/20260622-092800/report.md"
|
||||
---
|
||||
|
||||
# W3-2 댓글/리뷰 고도화 변경 이력
|
||||
|
||||
상위 코어 이력: [2026-06-18 W3-2 댓글/리뷰 분리 구현](./2026-06-18-w3-2-comments-reviews.md)
|
||||
|
||||
## 개요
|
||||
|
||||
2026-06-18 코어 구현(댓글/리뷰 서버 영속화·CRUD) 위에 4개 축의 고도화를 얹었다.
|
||||
|
||||
| 고도화 축 | 주요 내용 |
|
||||
|---|---|
|
||||
| **A 일관성** | commentView 단일 통일(A1) + 작성자명 하이브리드 마스킹 QG-2 해결(A2) + edited/updatedAt(A3) |
|
||||
| **B 목록규모** | 페이지네이션 더보기 20건(B1) + 리뷰 본문 최소 10자(B2) + TextNormalizer 제어문자 정규화(B3) |
|
||||
| **다축 평점** | game_review_axes 6축(리뷰당 6행) + overall 자동평균/수동덮어쓰기(is_rating_manual) + game_review_stats 집계뷰 |
|
||||
| **C UX** | submit 잠금(C1) + 상대시각(C2) + 글자수 카운터(C4) + 완전소멸 유지(C5) + radiogroup 완전 패턴 roving tabindex(C6) + 육각형 SVG 레이더 |
|
||||
|
||||
프론트엔드 구현(game-detail.jsp 시각 컴포넌트)은 frontend-design 스킬 패스로 처리됐다.
|
||||
|
||||
---
|
||||
|
||||
## C3 재분류 — 직전 코어 이월 항목 해소 (중요)
|
||||
|
||||
### 배경
|
||||
|
||||
직전 코어 이력(`2026-06-18-w3-2-comments-reviews.md`) 이월 항목 1번:
|
||||
|
||||
> "W2-3 평점 집계 계약 (SELECT AVG/COUNT) — **W2-6 시상 후속**."
|
||||
|
||||
이 이월 표기는 game_review_stats 집계뷰가 W2-3 동결 묶음에 속한다는 **오분류**에 근거했다. 본 세션에서 정정 확인 후 해소했다.
|
||||
|
||||
### 정정 근거
|
||||
|
||||
`docs/work-log/2026-06-17-jam-platform-roadmap.md` 3개 지점:
|
||||
|
||||
- `:63` — "W2-3 범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님)."
|
||||
- `:201` — "잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계."
|
||||
- `:202` — "댓글/리뷰 분리 → W3-2 일반기능으로 재분류: 잼 평가 동결묶음에서 분리."
|
||||
|
||||
### 결론
|
||||
|
||||
`game_review_stats` = `game_reviews`(W3-2 테이블) 위 **읽기전용 집계뷰** 신설 = **W3-2 일반 DDL**. W2-3 잼 평가(심사/투표/시상) 스키마 동결과 완전히 무관하다. `§6 파괴적 게이트 / 사용자 재확인 경로` 불필요 — 본 세션에서 일반 CREATE VIEW 로 처리 완료.
|
||||
|
||||
### 부수 무효화
|
||||
|
||||
아래 두 보수 주석이 본 변경으로 무효화됐다(구현 완료 시 갱신 대상):
|
||||
|
||||
| 위치 | 기존 보수 주석 | 상태 |
|
||||
|---|---|---|
|
||||
| `docs/game-reviews-ddl.sql:63` | "집계 컬럼/뷰는 신설하지 않음 (W2-3 동결 보호)" | 본 DDL 블록 추가로 **무효화** |
|
||||
| `db/schema.sql:136-137` | "집계 컬럼/뷰는 W2-3 동결 — 신설 금지" | 동기화 DDL 블록으로 **무효화** |
|
||||
|
||||
> **ADR 권고**: W2-3 동결 경계 재확인(잼 평가 한정, 댓글/리뷰 무관)은 미래 세션에서 `adr/ADR-0001` 로 정식화할 가치가 있다. 현재는 직전 오분류를 정정하는 수준이므로 `changes/` 내 섹션으로 기록하되, W2-3 관련 설계 분기가 재발하면 ADR 발행을 권고한다.
|
||||
|
||||
---
|
||||
|
||||
## DDL 변경
|
||||
|
||||
### 신규 테이블: game_review_axes
|
||||
|
||||
리뷰당 정확히 6행. `UNIQUE(review_id, axis_key)`로 중복 방지.
|
||||
|
||||
| 컬럼 | 타입 | 비고 |
|
||||
|---|---|---|
|
||||
| `id` | bigint PK | `game_review_axes_id_seq` |
|
||||
| `review_id` | bigint NOT NULL | FK → `game_reviews(id)` |
|
||||
| `axis_key` | varchar(20) NOT NULL | CHECK 6종 (아래 참조) |
|
||||
| `score` | smallint NOT NULL | CHECK(1~5) |
|
||||
|
||||
`axis_key` 6종 (육각형 축 인덱스 0~5 순서 고정):
|
||||
`immersion`(몰입성) / `creativity`(창의성) / `controls`(조작성) / `completeness`(완성도) / `sound`(사운드) / `visual`(비주얼)
|
||||
|
||||
### game_comments.updated_at (멱등 ALTER)
|
||||
|
||||
`timestamptz DEFAULT now() NOT NULL` 추가. `edited = updated_at > created_at` (리뷰 대칭). ALTER 직후 기존 댓글 `updated_at = created_at` 정렬 UPDATE 1회 적용(오표시 방지, 멱등).
|
||||
|
||||
### game_reviews.is_rating_manual (멱등 ALTER)
|
||||
|
||||
`boolean DEFAULT false NOT NULL` 추가. `true` = 유저 직접 선택 overall, `false` = 6축 자동평균.
|
||||
|
||||
### game_review_stats (VIEW 신규)
|
||||
|
||||
`game_reviews(is_delete IS NOT TRUE)` LEFT JOIN `game_review_axes` 집계.
|
||||
|
||||
컬럼: `game_id, avg_rating numeric, review_count bigint, avg_immersion numeric, avg_creativity numeric, avg_controls numeric, avg_completeness numeric, avg_sound numeric, avg_visual numeric`
|
||||
|
||||
클라이언트 평균계산(`updateSummary`, JSP:1513-1522) 폐기 공급원.
|
||||
|
||||
### 적용 대상 파일
|
||||
|
||||
- `docs/game-reviews-ddl.sql` — 멱등 블록 append(+ :63 보수주석 갱신)
|
||||
- `db/schema.sql` — 동일 정의 동기화(+ :136-137 주석 갱신)
|
||||
|
||||
**적용 상태**: needs_user_verification (dev: schema.sql 재부트 or ddl 수동 실행)
|
||||
|
||||
---
|
||||
|
||||
## 런타임 동작 변화
|
||||
|
||||
### A1 — commentView 단일 통일
|
||||
|
||||
| 항목 | 이전 | 이후 |
|
||||
|---|---|---|
|
||||
| POST /comments 응답 | flat(commentId/gameId/authorName/userId/content) | commentView 전체(+createdAt/edited/updatedAt) |
|
||||
| PUT /comments/{id} 응답 | 부분(commentId/content) | commentView 전체 |
|
||||
| commentView 키 수 | 가변 | 고정 8키 |
|
||||
|
||||
### A2 — 하이브리드 작성자명 + 탈퇴자 마스킹 (QG-2 해결)
|
||||
|
||||
`GameCommentsMapper` LEFT JOIN `users` + CASE 3분기:
|
||||
|
||||
1. `u.id IS NULL` → 스냅샷 닉네임(레거시 user_id NULL 레코드 역호환)
|
||||
2. `u.is_delete` → `"(탈퇴한 사용자)"` 마스킹
|
||||
3. else → `u.display_name`
|
||||
|
||||
QG-2(레거시 user_id NULL 레코드 `authorName` 빈값) 자동 해결.
|
||||
|
||||
### A3 — 댓글 edited/updatedAt
|
||||
|
||||
댓글 수정 시 `updated_at = now()` 갱신. `edited = (updated_at > created_at)`. nickname 덮어쓰기 없음.
|
||||
|
||||
### B1 — 페이지네이션 (limit+1 hasMore 방식)
|
||||
|
||||
| 항목 | 이전 | 이후 |
|
||||
|---|---|---|
|
||||
| 목록 반환 형태 | `{ status, items[] }` | `{ status, items[], hasMore }` |
|
||||
| 요청 파라미터 | 없음 | `?page=<int≥0>&sort=<enum>` |
|
||||
| 더보기 방식 | 전체 반환 | 20건 고정, `limit+1` 조회 후 hasMore 판정 |
|
||||
|
||||
sort enum:
|
||||
|
||||
| 대상 | 허용값 | 기본값 |
|
||||
|---|---|---|
|
||||
| 댓글 | `oldest`, `newest` | `oldest` |
|
||||
| 리뷰 | `newest`, `rating_desc`, `rating_asc` | `newest` |
|
||||
|
||||
미허용 값 → 기본값 fallback (400 미반환). ORDER BY는 `@SelectProvider` 컴파일타임 상수 분기 — `${}` 동적치환 0건. `offset`/`limit`은 `#{}` 바인딩.
|
||||
|
||||
### B2 — 리뷰 본문 최소 10자
|
||||
|
||||
`TextNormalizer.normalize()` 후 trim 10자 미만 → 400. 댓글은 현행 유지(상한 200자만).
|
||||
|
||||
### B3 — TextNormalizer 제어문자 정규화
|
||||
|
||||
신규 `com.pandoli365.bibimbap.util.TextNormalizer.normalize(String raw)`:
|
||||
|
||||
- C0/C1 제어문자(U+0000~U+001F, U+007F~U+009F) 중 `\t`(U+0009)·`\n`·`\r` 제외 후 제거
|
||||
- 외곽 `strip()`. 내부 공백/줄바꿈 보존.
|
||||
- 댓글 content / 리뷰 body 검증 **전** 적용.
|
||||
|
||||
### 다축 평점 — overall 자동/수동
|
||||
|
||||
| 입력 | overall 결정 | is_rating_manual |
|
||||
|---|---|---|
|
||||
| overall 미전송/빈값 | `rating = Math.round(6축 평균)` (서버 계산, HALF_UP) | `false` |
|
||||
| overall 직접 전송 | `rating = 전송값(1~5 검증)` | `true` |
|
||||
|
||||
axes 재저장 전략(editReview): `deleteReviewAxes(reviewId)` → `addReviewAxes(reviewId, 6행)` (TX 원자성).
|
||||
|
||||
---
|
||||
|
||||
## 프론트엔드 (frontend-design 스킬 패스)
|
||||
|
||||
frontend-design 스킬로 처리된 `game-detail.jsp` 변경 요약. 기존 앰버/크림 디자인 언어 확장, 외부 의존성 추가 없음.
|
||||
|
||||
| 컴포넌트 | 내용 |
|
||||
|---|---|
|
||||
| 육각형 SVG 레이더 | 인라인 SVG 자체 구현. 요약(viewBox 200×200, R=80) + 개별 리뷰 카드 컴팩트(viewBox 120×120, R=44). 방사 그라디언트·글로우·등장 모션. `role="img" aria-label` 6축 점수 텍스트 대체(a11y 필수). |
|
||||
| 6축 radiogroup (C6) | `buildStarRadioGroup(axisKey, labelText)` 공용 함수. roving tabindex(선택 radio `tabindex="0"`, 나머지 `-1`). 키보드: ←→↑↓ 이동+즉시선택, Home=1/End=5, Space/Enter 확정, `preventDefault()` 스크롤 방지. |
|
||||
| C1 submit 잠금 | 제출 중 버튼 disabled + 로딩 표시, 응답 후 해제. 연타 차단. |
|
||||
| C2 상대시각 | "n분 전" 표시 + title 절대시각. |
|
||||
| C4 글자수 카운터 | 댓글 n/200, 리뷰 n/1000 + 최소 10자 안내 실시간 표시. |
|
||||
| C5 완전소멸 | 삭제 항목 DOM 제거 유지. |
|
||||
| 서버집계 표시 | game_review_stats 평균 소수 1자리, review_count=0 → "아직 평가 없음". 클라 평균계산(updateSummary 구버전) 제거. |
|
||||
|
||||
---
|
||||
|
||||
## 신규/변경 파일
|
||||
|
||||
### 신규 (4개)
|
||||
|
||||
| 파일 | 설명 |
|
||||
|---|---|
|
||||
| `src/.../data/ReviewAxisRow.java` | 축 평점 행 POJO (reviewId/axisKey/score) |
|
||||
| `src/.../mapper/GameReviewAxesMapper.java` | axes add/deleteAll/listByReviewIds |
|
||||
| `src/.../mapper/GameReviewStatsMapper.java` | game_review_stats 1행 조회 |
|
||||
| `src/.../util/TextNormalizer.java` | B3 제어문자 정규화 static util |
|
||||
|
||||
### 변경 (13개)
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|---|---|
|
||||
| `docs/game-reviews-ddl.sql` | 고도화 DDL 블록 append(:63 보수주석 갱신) |
|
||||
| `db/schema.sql` | 동기화(:136-137 동결주석 C3 갱신) |
|
||||
| `src/.../data/GameCommentData.java` | updatedAt + edited 필드 추가 |
|
||||
| `src/.../data/GameReviewData.java` | ratingManual + axes(Map) 필드 추가 |
|
||||
| `src/.../mapper/GameCommentsMapper.java` | A2 LEFT JOIN+CASE, A3 updated_at, B1 offset/limit/sort |
|
||||
| `src/.../mapper/GameReviewsMapper.java` | B1 offset/limit/sort, is_rating_manual, @SelectProvider |
|
||||
| `src/.../controller/api/GameCommentController.java` | A1 재조회·commentView, B1 page/sort, B3 normalize |
|
||||
| `src/.../controller/api/GameReviewController.java` | 다축 검증/저장, overall 자동·수동, B2 10자, B3, B1, summary |
|
||||
| `src/.../controller/api/GameController.java` | gameDetail SSR 신규 매퍼 주입 (영향맵 누락→보정 완료) |
|
||||
| `src/main/webapp/WEB-INF/views/game-detail.jsp` | 육각형 SVG, C1~C6, 페이지네이션·sort UI, 서버집계, 6축 입력 |
|
||||
| `src/test/.../GameCommentControllerTest.java` | A1·A2·B3 신규 + 기존 12건 유지 |
|
||||
| `src/test/.../GameReviewControllerTest.java` | 다축·B2·sort 신규 + 기존 13건 유지 |
|
||||
| `src/test/.../BibimbapApplicationTests.java` | 신규 매퍼(GameReviewAxesMapper·GameReviewStatsMapper) @MockBean 추가 |
|
||||
|
||||
---
|
||||
|
||||
## API 계약 변화
|
||||
|
||||
### commentView 스키마 (A1 통일 — POST·PUT·list 동일)
|
||||
|
||||
```json
|
||||
{
|
||||
"commentId": 100,
|
||||
"gameId": 1,
|
||||
"authorName": "표시명 또는 (탈퇴한 사용자) 또는 스냅샷닉",
|
||||
"userId": 7,
|
||||
"content": "...",
|
||||
"createdAt": "ISO-8601",
|
||||
"edited": false,
|
||||
"updatedAt": "ISO-8601"
|
||||
}
|
||||
```
|
||||
|
||||
### reviewView 스키마 (axes 추가)
|
||||
|
||||
기존 reviewView(reviewId/gameId/authorName/userId/rating/body/edited/createdAt/updatedAt) + 신규:
|
||||
|
||||
```json
|
||||
{
|
||||
"ratingManual": false,
|
||||
"axes": { "immersion":4,"creativity":5,"controls":3,"completeness":4,"sound":2,"visual":5 }
|
||||
}
|
||||
```
|
||||
|
||||
### GET list 응답 형태
|
||||
|
||||
- 댓글: `{ status, comments:[commentView...], hasMore }`
|
||||
- 리뷰: `{ status, reviews:[reviewView...], hasMore, summary }`
|
||||
- `summary` = `{ avgRating, reviewCount, axes:{immersion..visual} }` (review_count=0 이면 `null`)
|
||||
|
||||
### 요청 파라미터 신규
|
||||
|
||||
`?page=<int≥0, default 0>&sort=<enum, default 토글기본값>`
|
||||
|
||||
---
|
||||
|
||||
## 검증 결과 요약
|
||||
|
||||
| 레이어 | 결과 | 비고 |
|
||||
|---|---|---|
|
||||
| L1 단위 테스트 (43건) | GREEN — 43/43 Failures 0 | 기존 31건 무회귀. FIX LOOP 1회(테스트 계약 미갱신 8건, 컨트롤러 무버그 — body<10자 정당거부). verification-advisor 2차 독립 재판정 확정. |
|
||||
| AC-4 sort 5분기 | PASS | `@SelectProvider` 분기 5건 전수, ORDER BY 상수 |
|
||||
| AC-5 `${}` 0건 | PASS | 신규·변경 매퍼 전수(주석 3건 제외) |
|
||||
| AC-6 axis_key 6키 정합 | PASS | DDL CHECK·뷰 FILTER·앱 enum·JSP 라벨 4곳 일치 |
|
||||
| L2 contract-DB | skip | 원격 DB 미기동 |
|
||||
| L3 브라우저 스모크 | **needs_user_verification** | DDL 적용 후 dev 실게임 대상 |
|
||||
| DDL 적용 | **needs_user_verification** | game_review_axes/stats/updated_at/is_rating_manual |
|
||||
|
||||
FIX LOOP 상세: `report.md#Invocations` implementation-advisor(FIX LOOP §2.6) — 근본원인=테스트 계약 미갱신(컨트롤러 무버그). body<10자 7곳→10자+ 교체, 행위 assertion 보존, 계약 무약화. main 코드 무수정.
|
||||
|
||||
---
|
||||
|
||||
## 범위 밖 / 이월
|
||||
|
||||
| 항목 | 사유 |
|
||||
|---|---|
|
||||
| 신고·숨김 | W1 운영자 role 선행 필요 |
|
||||
| 리뷰 이력 테이블 | in-row 마커(updated_at)만. 열람 권한 정책 확정 시 별도 |
|
||||
| 좋아요 서버화 | 별도 관리 (여전히 localStorage) |
|
||||
| GET /reviews/mine | applyReviewGate 정밀화 — 본인 리뷰 첫 페이지 밖 폼 노출 가능. 서버 409 최종 차단(비차단). 차기 세션 후보 |
|
||||
| 댓글 더보기 정렬 정합 | 낙관 삽입 2페이지+ 정렬 어긋남 가능. 리뷰는 page0 재조회로 정합, 비차단. 차기 세션 후보 |
|
||||
| 기존 리뷰 axes 백필 | 기존 game_reviews 행은 axes 0행 — stats 뷰 6축 평균 NULL. 백필 필요 시 별도 |
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정
|
||||
|
||||
|
|
|
|||
|
|
@ -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 인접 = 고위험 추론 금지" 조항 추가 권고.
|
||||
|
|
|
|||
|
|
@ -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축평균';
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> listComments(@PathVariable("id") long id) {
|
||||
public ResponseEntity<Map<String, Object>> listComments(
|
||||
@PathVariable("id") long id,
|
||||
@RequestParam(name = "page", defaultValue = "0") int page,
|
||||
@RequestParam(name = "sort", required = false) String sort
|
||||
) {
|
||||
if (gamesMapper.getGame(id) == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String sortEnum = normalizeCommentSort(sort);
|
||||
int offset = Math.max(page, 0) * PAGE_SIZE;
|
||||
List<GameCommentData> rows = gameCommentsMapper.listGameComments(id, sortEnum, offset, PAGE_SIZE + 1);
|
||||
boolean hasMore = rows.size() > PAGE_SIZE;
|
||||
int count = Math.min(rows.size(), PAGE_SIZE);
|
||||
|
||||
List<Map<String, Object>> comments = new ArrayList<>();
|
||||
for (GameCommentData comment : gameCommentsMapper.listGameComments(id)) {
|
||||
comments.add(commentView(comment));
|
||||
for (int i = 0; i < count; i++) {
|
||||
comments.add(commentView(rows.get(i)));
|
||||
}
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> listReviews(@PathVariable("id") long id) {
|
||||
public ResponseEntity<Map<String, Object>> listReviews(
|
||||
@PathVariable("id") long id,
|
||||
@RequestParam(name = "page", defaultValue = "0") int page,
|
||||
@RequestParam(name = "sort", required = false) String sort
|
||||
) {
|
||||
if (gamesMapper.getGame(id) == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String sortEnum = normalizeReviewSort(sort);
|
||||
int offset = Math.max(page, 0) * PAGE_SIZE;
|
||||
List<GameReviewData> rows = gameReviewsMapper.listGameReviews(id, sortEnum, offset, PAGE_SIZE + 1);
|
||||
boolean hasMore = rows.size() > PAGE_SIZE;
|
||||
int count = Math.min(rows.size(), PAGE_SIZE);
|
||||
List<GameReviewData> pageRows = rows.subList(0, count);
|
||||
|
||||
// axes batch 조회(N+1 회피). 빈 페이지 가드.
|
||||
if (!pageRows.isEmpty()) {
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (GameReviewData r : pageRows) {
|
||||
ids.add(r.getId());
|
||||
}
|
||||
Map<Long, Map<String, Integer>> axesByReview = new LinkedHashMap<>();
|
||||
for (ReviewAxisRow ax : gameReviewAxesMapper.listAxesByReviewIds(ids)) {
|
||||
axesByReview.computeIfAbsent(ax.getReviewId(), k -> new LinkedHashMap<>())
|
||||
.put(ax.getAxisKey(), ax.getScore());
|
||||
}
|
||||
for (GameReviewData r : pageRows) {
|
||||
r.setAxes(axesByReview.get(r.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> reviews = new ArrayList<>();
|
||||
for (GameReviewData review : gameReviewsMapper.listGameReviews(id)) {
|
||||
reviews.add(reviewView(review));
|
||||
for (GameReviewData r : pageRows) {
|
||||
reviews.add(reviewView(r));
|
||||
}
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Integer> 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<String, Object> 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<String, Integer> 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<String, Object> 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<String, Integer> parseAxes(String immersion, String creativity, String controls,
|
||||
String completeness, String sound, String visual) {
|
||||
Map<String, String> raw = new LinkedHashMap<>();
|
||||
raw.put("immersion", immersion);
|
||||
raw.put("creativity", creativity);
|
||||
raw.put("controls", controls);
|
||||
raw.put("completeness", completeness);
|
||||
raw.put("sound", sound);
|
||||
raw.put("visual", visual);
|
||||
|
||||
Map<String, Integer> axes = new LinkedHashMap<>();
|
||||
for (String key : AXIS_KEYS) {
|
||||
String v = trimToNull(raw.get(key));
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
int s;
|
||||
try {
|
||||
s = Integer.parseInt(v);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
if (s < RATING_MIN || s > RATING_MAX) {
|
||||
return null;
|
||||
}
|
||||
axes.put(key, s);
|
||||
}
|
||||
return axes;
|
||||
}
|
||||
|
||||
private int averageOf(Map<String, Integer> axes) {
|
||||
int sum = 0;
|
||||
for (String key : AXIS_KEYS) {
|
||||
sum += axes.get(key);
|
||||
}
|
||||
return (int) Math.round(sum / (double) AXIS_KEYS.length);
|
||||
}
|
||||
|
||||
private List<ReviewAxisRow> toAxisRows(long reviewId, Map<String, Integer> axes) {
|
||||
List<ReviewAxisRow> rows = new ArrayList<>();
|
||||
for (String key : AXIS_KEYS) {
|
||||
rows.add(new ReviewAxisRow(reviewId, key, axes.get(key)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private Map<String, Integer> loadAxes(long reviewId) {
|
||||
Map<String, Integer> axes = new LinkedHashMap<>();
|
||||
for (ReviewAxisRow ax : gameReviewAxesMapper.listAxesByReviewIds(List.of(reviewId))) {
|
||||
axes.put(ax.getAxisKey(), ax.getScore());
|
||||
}
|
||||
return axes.isEmpty() ? null : axes;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildSummary(long gameId) {
|
||||
Map<String, Object> stats = gameReviewStatsMapper.getStats(gameId);
|
||||
if (stats == null) {
|
||||
return null;
|
||||
}
|
||||
Object countValue = stats.get("reviewCount");
|
||||
long reviewCount = countValue instanceof Number number ? number.longValue() : 0L;
|
||||
if (reviewCount == 0) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> axes = new LinkedHashMap<>();
|
||||
for (String key : AXIS_KEYS) {
|
||||
axes.put(key, stats.get(key));
|
||||
}
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("avgRating", stats.get("avgRating"));
|
||||
summary.put("reviewCount", reviewCount);
|
||||
summary.put("axes", axes);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private String normalizeReviewSort(String sort) {
|
||||
return switch (sort == null ? "" : sort) {
|
||||
case "rating_desc" -> "rating_desc";
|
||||
case "rating_asc" -> "rating_asc";
|
||||
default -> "newest";
|
||||
};
|
||||
}
|
||||
|
||||
private Integer parseRating(String rating) {
|
||||
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<Map<String, Object>> response(HttpStatus status, String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", status.value());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Integer> 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<String, Integer> getAxes() {
|
||||
return axes;
|
||||
}
|
||||
|
||||
public void setAxes(Map<String, Integer> axes) {
|
||||
this.axes = axes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GameCommentData> listGameComments(@Param("gameId") long gameId);
|
||||
@SelectProvider(type = CommentSqlProvider.class, method = "listComments")
|
||||
List<GameCommentData> 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<String, Object> params) {
|
||||
String sort = (String) params.get("sort");
|
||||
String orderBy = "newest".equals(sort)
|
||||
? "ORDER BY c.created_at DESC, c.id DESC"
|
||||
: "ORDER BY c.created_at ASC, c.id ASC";
|
||||
return """
|
||||
SELECT
|
||||
c.id,
|
||||
c.game_id AS gameId,
|
||||
c.user_id AS userId,
|
||||
CASE
|
||||
WHEN c.user_id IS NULL THEN c.nickname
|
||||
WHEN u.is_delete IS TRUE THEN '(탈퇴한 사용자)'
|
||||
ELSE u.display_name
|
||||
END AS authorName,
|
||||
c.nickname,
|
||||
c.content,
|
||||
c.created_at AS createdAt,
|
||||
c.updated_at AS updatedAt,
|
||||
(c.updated_at > c.created_at) AS edited,
|
||||
c.deleted_at AS deletedAt
|
||||
FROM game_comments c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.game_id = #{gameId}
|
||||
AND c.is_delete IS NOT TRUE
|
||||
"""
|
||||
+ orderBy
|
||||
+ " LIMIT #{limit} OFFSET #{offset}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.pandoli365.bibimbap.mapper;
|
||||
|
||||
import com.pandoli365.bibimbap.data.ReviewAxisRow;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.InsertProvider;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.SelectProvider;
|
||||
import org.apache.ibatis.jdbc.SQL;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface GameReviewAxesMapper {
|
||||
|
||||
@InsertProvider(type = AxesSqlProvider.class, method = "insertAxes")
|
||||
int addReviewAxes(@Param("reviewId") long reviewId, @Param("axes") List<ReviewAxisRow> axes);
|
||||
|
||||
@Delete("DELETE FROM game_review_axes WHERE review_id = #{reviewId}")
|
||||
int deleteReviewAxes(@Param("reviewId") long reviewId);
|
||||
|
||||
@SelectProvider(type = AxesSqlProvider.class, method = "listByReviewIds")
|
||||
List<ReviewAxisRow> listAxesByReviewIds(@Param("reviewIds") List<Long> reviewIds);
|
||||
|
||||
class AxesSqlProvider {
|
||||
|
||||
// VALUES 다행. 각 값은 #{axes[i].axisKey}/#{axes[i].score} 바인딩(${} 미사용).
|
||||
public String insertAxes(Map<String, Object> params) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ReviewAxisRow> axes = (List<ReviewAxisRow>) params.get("axes");
|
||||
StringBuilder sql = new StringBuilder("INSERT INTO game_review_axes (review_id, axis_key, score) VALUES ");
|
||||
for (int i = 0; i < axes.size(); i++) {
|
||||
if (i > 0) {
|
||||
sql.append(", ");
|
||||
}
|
||||
sql.append("(#{reviewId}, #{axes[").append(i).append("].axisKey}, #{axes[").append(i).append("].score})");
|
||||
}
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
// IN (...) batch. reviewIds 각 원소 #{reviewIds[i]} 바인딩.
|
||||
public String listByReviewIds(Map<String, Object> params) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Long> ids = (List<Long>) params.get("reviewIds");
|
||||
SQL sql = new SQL();
|
||||
sql.SELECT("review_id AS reviewId, axis_key AS axisKey, score").FROM("game_review_axes");
|
||||
StringBuilder in = new StringBuilder();
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
if (i > 0) {
|
||||
in.append(", ");
|
||||
}
|
||||
in.append("#{reviewIds[").append(i).append("]}");
|
||||
}
|
||||
sql.WHERE("review_id IN (" + in + ")");
|
||||
return sql.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.pandoli365.bibimbap.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface GameReviewStatsMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT
|
||||
game_id AS gameId,
|
||||
avg_rating AS avgRating,
|
||||
review_count AS reviewCount,
|
||||
avg_immersion AS immersion,
|
||||
avg_creativity AS creativity,
|
||||
avg_controls AS controls,
|
||||
avg_completeness AS completeness,
|
||||
avg_sound AS sound,
|
||||
avg_visual AS visual
|
||||
FROM game_review_stats
|
||||
WHERE game_id = #{gameId}
|
||||
""")
|
||||
Map<String, Object> getStats(long gameId);
|
||||
}
|
||||
|
|
@ -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<GameReviewData> listGameReviews(@Param("gameId") long gameId);
|
||||
@SelectProvider(type = ReviewSqlProvider.class, method = "listReviews")
|
||||
List<GameReviewData> 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<String, Object> params) {
|
||||
String sort = (String) params.get("sort");
|
||||
String orderBy = switch (sort == null ? "" : sort) {
|
||||
case "rating_desc" -> "ORDER BY r.rating DESC, r.created_at DESC, r.id DESC";
|
||||
case "rating_asc" -> "ORDER BY r.rating ASC, r.created_at DESC, r.id DESC";
|
||||
default -> "ORDER BY r.created_at DESC, r.id DESC";
|
||||
};
|
||||
return """
|
||||
SELECT
|
||||
r.id,
|
||||
r.game_id AS gameId,
|
||||
r.user_id AS userId,
|
||||
r.rating,
|
||||
r.body,
|
||||
r.is_rating_manual AS ratingManual,
|
||||
u.display_name AS authorName,
|
||||
(r.updated_at > r.created_at) AS edited,
|
||||
r.created_at AS createdAt,
|
||||
r.updated_at AS updatedAt
|
||||
FROM game_reviews r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
WHERE r.game_id = #{gameId}
|
||||
AND r.is_delete IS NOT TRUE
|
||||
AND u.is_delete IS NOT TRUE
|
||||
"""
|
||||
+ orderBy
|
||||
+ " LIMIT #{limit} OFFSET #{offset}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,8 @@
|
|||
package com.pandoli365.bibimbap;
|
||||
|
||||
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
|
||||
|
|
@ -27,6 +29,12 @@ class BibimbapApplicationTests {
|
|||
@MockBean
|
||||
private GameReviewsMapper gameReviewsMapper;
|
||||
|
||||
@MockBean
|
||||
private GameReviewAxesMapper gameReviewAxesMapper;
|
||||
|
||||
@MockBean
|
||||
private GameReviewStatsMapper gameReviewStatsMapper;
|
||||
|
||||
@MockBean
|
||||
private RecruitPostsMapper recruitPostsMapper;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.pandoli365.bibimbap.mapper.GamesMapper;
|
|||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -14,12 +15,16 @@ import org.springframework.http.ResponseEntity;
|
|||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
|
@ -46,6 +51,7 @@ class GameCommentControllerTest {
|
|||
inv.getArgument(0, GameCommentData.class).setId(100L);
|
||||
return 1;
|
||||
});
|
||||
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persistedComment(100L, 1L, 7L, "작성자", false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createComment(1L, "좋은 게임", request, session);
|
||||
|
|
@ -61,17 +67,14 @@ class GameCommentControllerTest {
|
|||
void listCommentsReturnsViewArray() {
|
||||
GameCommentController controller = controller();
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
GameCommentData c = new GameCommentData();
|
||||
c.setId(5L);
|
||||
c.setGameId(1L);
|
||||
c.setUserId(7L);
|
||||
c.setNickname("작성자");
|
||||
c.setContent("내용");
|
||||
when(gameCommentsMapper.listGameComments(1L)).thenReturn(List.of(c));
|
||||
GameCommentData c = persistedComment(5L, 1L, 7L, "작성자", false);
|
||||
when(gameCommentsMapper.listGameComments(eq(1L), anyString(), anyInt(), anyInt()))
|
||||
.thenReturn(List.of(c));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = controller.listComments(1L);
|
||||
ResponseEntity<Map<String, Object>> response = controller.listComments(1L, 0, null);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsKey("hasMore");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> comments = (List<Map<String, Object>>) response.getBody().get("comments");
|
||||
assertThat(comments).hasSize(1);
|
||||
|
|
@ -84,7 +87,9 @@ class GameCommentControllerTest {
|
|||
GameCommentController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "작성자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L));
|
||||
when(gameCommentsMapper.getGameComment(5L))
|
||||
.thenReturn(comment(5L, 1L, 7L))
|
||||
.thenReturn(persistedComment(5L, 1L, 7L, "작성자", true));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.updateComment(1L, 5L, "수정된 내용", request, session);
|
||||
|
|
@ -163,6 +168,7 @@ class GameCommentControllerTest {
|
|||
inv.getArgument(0, GameCommentData.class).setId(101L);
|
||||
return 1;
|
||||
});
|
||||
when(gameCommentsMapper.getGameComment(101L)).thenReturn(persistedComment(101L, 1L, 7L, "작성자", false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createComment(1L, "가".repeat(200), request, session);
|
||||
|
|
@ -229,6 +235,138 @@ class GameCommentControllerTest {
|
|||
verifyNoInteractions(gameCommentsMapper);
|
||||
}
|
||||
|
||||
// ---- AC-11 (A1): POST/PUT 응답 = commentView 전체 8키 ----
|
||||
|
||||
@Test
|
||||
void createCommentReturnsFullCommentView() {
|
||||
GameCommentController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "작성자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
|
||||
inv.getArgument(0, GameCommentData.class).setId(100L);
|
||||
return 1;
|
||||
});
|
||||
OffsetDateTime created = OffsetDateTime.now();
|
||||
GameCommentData persisted = persistedComment(100L, 1L, 7L, "작성자", false);
|
||||
persisted.setCreatedAt(created);
|
||||
persisted.setUpdatedAt(created);
|
||||
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persisted);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createComment(1L, "좋은 게임", request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
Map<String, Object> body = response.getBody();
|
||||
assertThat(body).containsKeys(
|
||||
"commentId", "gameId", "authorName", "userId",
|
||||
"content", "createdAt", "edited", "updatedAt");
|
||||
assertThat(body).containsEntry("commentId", 100L);
|
||||
assertThat(body).containsEntry("edited", false);
|
||||
assertThat(body.get("createdAt")).isEqualTo(created);
|
||||
assertThat(body.get("updatedAt")).isEqualTo(created);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateCommentReturnsFullCommentView() {
|
||||
GameCommentController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "작성자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
OffsetDateTime created = OffsetDateTime.now().minusMinutes(10);
|
||||
OffsetDateTime updated = OffsetDateTime.now();
|
||||
GameCommentData persisted = persistedComment(5L, 1L, 7L, "작성자", true);
|
||||
persisted.setCreatedAt(created);
|
||||
persisted.setUpdatedAt(updated);
|
||||
when(gameCommentsMapper.getGameComment(5L))
|
||||
.thenReturn(comment(5L, 1L, 7L))
|
||||
.thenReturn(persisted);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.updateComment(1L, 5L, "수정된 내용", request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
Map<String, Object> body = response.getBody();
|
||||
assertThat(body).containsKeys(
|
||||
"commentId", "gameId", "authorName", "userId",
|
||||
"content", "createdAt", "edited", "updatedAt");
|
||||
assertThat(body).containsEntry("edited", true);
|
||||
assertThat(body.get("updatedAt")).isEqualTo(updated);
|
||||
}
|
||||
|
||||
// ---- AC-12 (A2): 탈퇴 사용자 마스킹 (authorName 우선 노출) ----
|
||||
|
||||
@Test
|
||||
void createCommentExposesMaskedAuthorNameFromReFetch() {
|
||||
GameCommentController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "작성자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
|
||||
inv.getArgument(0, GameCommentData.class).setId(100L);
|
||||
return 1;
|
||||
});
|
||||
// 재조회 SQL 의 CASE 결과 authorName 이 마스킹 값이면 컨트롤러는 getAuthorName 우선 노출
|
||||
when(gameCommentsMapper.getGameComment(100L))
|
||||
.thenReturn(persistedComment(100L, 1L, 7L, "(탈퇴한 사용자)", false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createComment(1L, "좋은 게임", request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("authorName", "(탈퇴한 사용자)");
|
||||
}
|
||||
|
||||
// ---- AC-13 (A3): 수정 시 editGameComment 호출 + updatedAt 노출 ----
|
||||
|
||||
@Test
|
||||
void updateCommentInvokesEditAndExposesUpdatedAt() {
|
||||
GameCommentController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "작성자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
OffsetDateTime updated = OffsetDateTime.now();
|
||||
GameCommentData persisted = persistedComment(5L, 1L, 7L, "작성자", true);
|
||||
persisted.setUpdatedAt(updated);
|
||||
when(gameCommentsMapper.getGameComment(5L))
|
||||
.thenReturn(comment(5L, 1L, 7L))
|
||||
.thenReturn(persisted);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.updateComment(1L, 5L, "수정", request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(gameCommentsMapper).editGameComment(any(GameCommentData.class));
|
||||
assertThat(response.getBody()).containsEntry("edited", true);
|
||||
assertThat(response.getBody().get("updatedAt")).isEqualTo(updated);
|
||||
}
|
||||
|
||||
// ---- B3: 제어문자(NUL) 정규화 — TextNormalizer 가 NUL 제거, 일반 공백은 보존 ----
|
||||
|
||||
@Test
|
||||
void createCommentStripsControlCharsBeforePersist() {
|
||||
GameCommentController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "작성자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameCommentsMapper.addGameComment(any(GameCommentData.class))).thenAnswer(inv -> {
|
||||
inv.getArgument(0, GameCommentData.class).setId(100L);
|
||||
return 1;
|
||||
});
|
||||
when(gameCommentsMapper.getGameComment(100L)).thenReturn(persistedComment(100L, 1L, 7L, "작성자", false));
|
||||
|
||||
String nul = "\u0000";
|
||||
String withControlChars = "좋은" + nul + " " + nul + "게임";
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createComment(1L, withControlChars, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
ArgumentCaptor<GameCommentData> captor = ArgumentCaptor.forClass(GameCommentData.class);
|
||||
verify(gameCommentsMapper).addGameComment(captor.capture());
|
||||
String stored = captor.getValue().getContent();
|
||||
assertThat(stored).doesNotContain(nul);
|
||||
assertThat(stored).isEqualTo("좋은 게임");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private GameCommentController controller() {
|
||||
|
|
@ -252,6 +390,21 @@ class GameCommentControllerTest {
|
|||
return c;
|
||||
}
|
||||
|
||||
// 재조회분: SQL alias 로 authorName/edited/createdAt/updatedAt 가 채워진 행
|
||||
private GameCommentData persistedComment(long id, long gameId, long userId, String authorName, boolean edited) {
|
||||
GameCommentData c = new GameCommentData();
|
||||
c.setId(id);
|
||||
c.setGameId(gameId);
|
||||
c.setUserId(userId);
|
||||
c.setNickname("작성자");
|
||||
c.setAuthorName(authorName);
|
||||
c.setContent("내용");
|
||||
c.setEdited(edited);
|
||||
c.setCreatedAt(OffsetDateTime.now());
|
||||
c.setUpdatedAt(OffsetDateTime.now());
|
||||
return c;
|
||||
}
|
||||
|
||||
private MockHttpSession loginSession(long userId, String role, String displayName) {
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
session.setAttribute("userId", userId);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ package com.pandoli365.bibimbap.controller.api;
|
|||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -14,11 +18,18 @@ import org.springframework.http.ResponseEntity;
|
|||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpSession;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
|
@ -30,9 +41,18 @@ class GameReviewControllerTest {
|
|||
@Mock
|
||||
private GameReviewsMapper gameReviewsMapper;
|
||||
|
||||
@Mock
|
||||
private GameReviewAxesMapper gameReviewAxesMapper;
|
||||
|
||||
@Mock
|
||||
private GameReviewStatsMapper gameReviewStatsMapper;
|
||||
|
||||
@Mock
|
||||
private GamesMapper gamesMapper;
|
||||
|
||||
// 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual).
|
||||
private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"};
|
||||
|
||||
// ---- AC-4: 수정 시 edited 토글 (작성 직후 false → 수정 후 true) ----
|
||||
|
||||
@Test
|
||||
|
|
@ -49,7 +69,7 @@ class GameReviewControllerTest {
|
|||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createReview(1L, "4", "재미있음", request, session);
|
||||
createReview(controller, 1L, null, "정말 재미있는 게임입니다", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("reviewId", 50L);
|
||||
|
|
@ -67,7 +87,7 @@ class GameReviewControllerTest {
|
|||
.thenReturn(review(50L, 1L, 7L, 5, true));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.updateReview(1L, 50L, "5", "더 좋아짐", request, session);
|
||||
updateReview(controller, 1L, 50L, "5", "수정 후 훨씬 더 좋아졌습니다", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("edited", true);
|
||||
|
|
@ -86,13 +106,13 @@ class GameReviewControllerTest {
|
|||
.thenReturn(review(50L, 1L, 7L, 4, false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createReview(1L, "5", "또작성", request, session);
|
||||
createReview(controller, 1L, "5", "같은 게임에 또 작성 시도합니다", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
verify(gameReviewsMapper, never()).addGameReview(any());
|
||||
}
|
||||
|
||||
// ---- AC-6: rating 0/6 거부, 1/5 경계 ----
|
||||
// ---- AC-6: overall rating 0/6 거부, 1/5 경계 ----
|
||||
|
||||
@Test
|
||||
void createReviewRejectsRatingZero() {
|
||||
|
|
@ -122,7 +142,7 @@ class GameReviewControllerTest {
|
|||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.updateReview(1L, 50L, "6", "범위초과", request, session);
|
||||
updateReview(controller, 1L, 50L, "6", "범위초과", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verify(gameReviewsMapper, never()).editGameReview(any());
|
||||
|
|
@ -138,7 +158,7 @@ class GameReviewControllerTest {
|
|||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.updateReview(1L, 50L, "3", "강제수정", request, session);
|
||||
updateReview(controller, 1L, 50L, "3", "강제수정", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verify(gameReviewsMapper, never()).editGameReview(any());
|
||||
|
|
@ -167,7 +187,7 @@ class GameReviewControllerTest {
|
|||
MockHttpServletRequest request = noCsrfPost(session);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createReview(1L, "4", "내용", request, session);
|
||||
createReview(controller, 1L, "4", "내용입니다열자", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verifyNoInteractions(gameReviewsMapper);
|
||||
|
|
@ -181,7 +201,7 @@ class GameReviewControllerTest {
|
|||
MockHttpServletRequest request = noCsrfPost(session);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.updateReview(1L, 50L, "4", "내용", request, session);
|
||||
updateReview(controller, 1L, 50L, "4", "내용입니다열자", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verifyNoInteractions(gameReviewsMapper);
|
||||
|
|
@ -200,6 +220,156 @@ class GameReviewControllerTest {
|
|||
verifyNoInteractions(gameReviewsMapper);
|
||||
}
|
||||
|
||||
// ---- AC-7(다축): 6축 누락 시 400 ----
|
||||
|
||||
@Test
|
||||
void createReviewRejectsMissingAxis() {
|
||||
GameReviewController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
|
||||
// immersion 빈값 → 6축 미충족 → 400.
|
||||
String[] missing = {"", "5", "3", "4", "2", "5"};
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
createReview(controller, 1L, "4", "내용입니다열자", missing, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verify(gameReviewsMapper, never()).addGameReview(any());
|
||||
}
|
||||
|
||||
// ---- AC-8: overall 자동평균 / 수동 ----
|
||||
|
||||
@Test
|
||||
void createReviewAutoAveragesOverallWhenRatingOmitted() {
|
||||
GameReviewController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
|
||||
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
|
||||
inv.getArgument(0, GameReviewData.class).setId(50L);
|
||||
return 1;
|
||||
});
|
||||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
|
||||
|
||||
// 6축 [4,5,3,4,2,5] 합 23 / 6 = 3.83 → round 4, ratingManual=false.
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
createReview(controller, 1L, null, "별점 자동평균 계산 검증 본문", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
ArgumentCaptor<GameReviewData> captor = ArgumentCaptor.forClass(GameReviewData.class);
|
||||
verify(gameReviewsMapper).addGameReview(captor.capture());
|
||||
assertThat(captor.getValue().getRating()).isEqualTo(4);
|
||||
assertThat(captor.getValue().getRatingManual()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReviewUsesManualOverallWhenRatingProvided() {
|
||||
GameReviewController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
|
||||
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
|
||||
inv.getArgument(0, GameReviewData.class).setId(50L);
|
||||
return 1;
|
||||
});
|
||||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 2, false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
createReview(controller, 1L, "2", "수동 별점 사용 검증 본문입니다", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
ArgumentCaptor<GameReviewData> captor = ArgumentCaptor.forClass(GameReviewData.class);
|
||||
verify(gameReviewsMapper).addGameReview(captor.capture());
|
||||
assertThat(captor.getValue().getRating()).isEqualTo(2);
|
||||
assertThat(captor.getValue().getRatingManual()).isTrue();
|
||||
}
|
||||
|
||||
// ---- 다축 저장: addReviewAxes 6행 ----
|
||||
|
||||
@Test
|
||||
void createReviewPersistsSixAxes() {
|
||||
GameReviewController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
|
||||
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
|
||||
inv.getArgument(0, GameReviewData.class).setId(50L);
|
||||
return 1;
|
||||
});
|
||||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
createReview(controller, 1L, null, "여섯 축 저장 검증용 본문입니다", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<ReviewAxisRow>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(gameReviewAxesMapper).addReviewAxes(eq(50L), captor.capture());
|
||||
List<ReviewAxisRow> axes = captor.getValue();
|
||||
assertThat(axes).hasSize(6);
|
||||
assertThat(axes).extracting(ReviewAxisRow::getAxisKey)
|
||||
.containsExactlyInAnyOrder(
|
||||
"immersion", "creativity", "controls", "completeness", "sound", "visual");
|
||||
}
|
||||
|
||||
// ---- AC-9: B2 본문 최소 10자 ----
|
||||
|
||||
@Test
|
||||
void createReviewRejectsBodyUnderTenChars() {
|
||||
GameReviewController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
|
||||
// trim 후 9자 → 400.
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
createReview(controller, 1L, "4", "가".repeat(9), AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verify(gameReviewsMapper, never()).addGameReview(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createReviewAcceptsBodyTenCharsBoundary() {
|
||||
GameReviewController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null);
|
||||
when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> {
|
||||
inv.getArgument(0, GameReviewData.class).setId(50L);
|
||||
return 1;
|
||||
});
|
||||
when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
createReview(controller, 1L, "4", "가".repeat(10), AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
|
||||
}
|
||||
|
||||
// ---- B1: listReviews 미허용 sort 관대 처리(200) ----
|
||||
|
||||
@Test
|
||||
void listReviewsToleratesUnknownSort() {
|
||||
GameReviewController controller = controller();
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameReviewsMapper.listGameReviews(eq(1L), anyString(), anyInt(), anyInt()))
|
||||
.thenReturn(List.of(review(50L, 1L, 7L, 4, false)));
|
||||
lenient().when(gameReviewAxesMapper.listAxesByReviewIds(anyList())).thenReturn(List.of());
|
||||
lenient().when(gameReviewStatsMapper.getStats(1L)).thenReturn(null);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response = controller.listReviews(1L, 0, "garbage");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsKey("hasMore");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void assertCreateRatingRejected(String rating) {
|
||||
|
|
@ -209,7 +379,7 @@ class GameReviewControllerTest {
|
|||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createReview(1L, rating, "내용", request, session);
|
||||
createReview(controller, 1L, rating, "별점 범위 검증용 본문입니다", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verify(gameReviewsMapper, never()).addGameReview(any());
|
||||
|
|
@ -229,14 +399,30 @@ class GameReviewControllerTest {
|
|||
.thenReturn(review(50L, 1L, 7L, Integer.parseInt(rating), false));
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.createReview(1L, rating, "내용", request, session);
|
||||
createReview(controller, 1L, rating, "별점 경계값 통과 검증 본문입니다", AXES_OK, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(gameReviewsMapper).addGameReview(any(GameReviewData.class));
|
||||
}
|
||||
|
||||
// createReview positional 호출 래퍼. axes = [immersion,creativity,controls,completeness,sound,visual].
|
||||
private ResponseEntity<Map<String, Object>> createReview(
|
||||
GameReviewController controller, long id, String rating, String body,
|
||||
String[] axes, MockHttpServletRequest request, MockHttpSession session) {
|
||||
return controller.createReview(id, rating, body,
|
||||
axes[0], axes[1], axes[2], axes[3], axes[4], axes[5], request, session);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> updateReview(
|
||||
GameReviewController controller, long id, long reviewId, String rating, String body,
|
||||
String[] axes, MockHttpServletRequest request, MockHttpSession session) {
|
||||
return controller.updateReview(id, reviewId, rating, body,
|
||||
axes[0], axes[1], axes[2], axes[3], axes[4], axes[5], request, session);
|
||||
}
|
||||
|
||||
private GameReviewController controller() {
|
||||
return new GameReviewController(gameReviewsMapper, gamesMapper);
|
||||
return new GameReviewController(
|
||||
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper);
|
||||
}
|
||||
|
||||
private GameData game(long id) {
|
||||
|
|
@ -255,6 +441,15 @@ class GameReviewControllerTest {
|
|||
r.setBody("내용");
|
||||
r.setAuthorName("작성자");
|
||||
r.setEdited(edited);
|
||||
r.setRatingManual(false);
|
||||
Map<String, Integer> axes = new LinkedHashMap<>();
|
||||
axes.put("immersion", 4);
|
||||
axes.put("creativity", 5);
|
||||
axes.put("controls", 3);
|
||||
axes.put("completeness", 4);
|
||||
axes.put("sound", 2);
|
||||
axes.put("visual", 5);
|
||||
r.setAxes(axes);
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue