diff --git a/.atp/work-session/20260618-104034/design.md b/.atp/work-session/20260618-104034/design.md new file mode 100644 index 0000000..b9474c5 --- /dev/null +++ b/.atp/work-session/20260618-104034/design.md @@ -0,0 +1,414 @@ +--- +phase: design +agent: design-advisor +agent_version: 1 +generated_at: 2026-06-18T12:10:00+09:00 +concerns: + - "동결영역(W2-3 평점 집계 계약) 비침범 확인: game_reviews 는 rating 컬럼(평점 공급원)까지만 정의. 집계 컬럼/뷰/트리거를 만들지 않음. 후속 집계가 SELECT AVG(rating)/COUNT 로 읽을 수 있도록 rating(NOT NULL, 1~5 CHECK)·is_delete·game_id 인덱스만 보장. 집계 컬럼 신설은 명시적으로 비목표." + - "동결영역(schema.sql 비권위 타입) 비신뢰 확인: game_reviews 신규 DDL 의 인접 타입(users.id, games.id)을 schema.sql 비권위 복원본에서 복사하지 않고 recruit_posts 권위 스타일(bigint nextval, timestamptz, partial index, ON DELETE CASCADE 미사용)을 기준으로 작성. game_comments user_id 추가도 동일 권위 스타일." + - "시그니처 inflate 재확인 필요: GameReviewsMapper.getGameReview / GameCommentsMapper.listGameComments / isOperator(role) 의 모든 인자가 구현에서 실제 사용되는지 구현 단계 unused 진단 게이트(프로토콜 §11.2)에서 재확인. 본 설계는 최소 인자 원칙으로 명세했으나 헬퍼(isOperator)는 inflate 유혹 영역." + - "운영자 role 부여 경로 부재(W1 연결): ROLE_ADMIN 상수는 정의하나 실제 ADMIN 부여자가 코드에 없음(USER 만 발급). isOperator 분기는 단위테스트로만 검증 가능. 운영 환경에서 ADMIN 사용자 생성 경로는 W1 작업 범위 — 본 설계 범위 밖." +concerns_checked: true +references: + requirements: null + research: /Users/wemadeplay/workspace/stz/bibimbap/.atp/work-session/20260618-104034/research/code-coupling.md + adrs: + - /Users/wemadeplay/workspace/stz/bibimbap/docs/recruit-posts-ddl.sql + - /Users/wemadeplay/workspace/stz/bibimbap/docs/security-hardening-ddl.sql + - /Users/wemadeplay/workspace/stz/bibimbap/docs/security/security-remediation-checklist.md +--- + +# 설계: W3-2 게임 댓글/리뷰 분리 (서버 영속화 + 리뷰 신설) + +## 목표 / 비목표 + +### 목표 +- (G1) 게임 상세 댓글을 **localStorage → 서버 영속화**로 전환. 작성자 = 로그인 사용자(user_id 귀속). content 200자 제한. +- (G2) 게임당 1회 **리뷰(별점 1~5 + 서술 평가)** 도메인 신설. 수정 시 "수정됨" 마커 + 이력 보존. +- (G3) 댓글/리뷰 수정·삭제 권한 = `작성자 본인 OR 운영자급 role`. 운영자 분기는 단위테스트로 검증 가능. +- (G4) 게임 soft-delete 시 game_reviews 동반 정리(cascade) 추가. +- (G5) 보안 체크리스트 line 101~117(댓글/리뷰 해당분) 동시 충족: CSRF 게이트 / 길이 제한 / 작성자·운영자 삭제 / 서버 escape·textContent / 서버 응답 기준 렌더 / 새로고침·브라우저변경 영속. +- (G6) DDL 3종 산출(schema.sql 블록, docs 권위 파일, idempotent ALTER). + +### 비목표 +- 좋아요(localStorage) 변경 — **범위 밖, 미변경**. +- 평점 집계 컬럼/뷰/트리거 신설 — **범위 밖**(W2-3 동결 계약 보호). rating 공급원까지만. +- 다축(육각형) 평점 — **후속**. DDL 은 단일 rating 컬럼으로 확정하되 향후 축 테이블 분리 여지를 주석으로만 남김. +- 기존 localStorage 댓글 마이그레이션 — **비마이그레이션 확정**. +- UI/JSP/CSS 비주얼 구현 — **별도 `/frontend-design` 위임**. 본 설계는 API 계약 + escape 규약 + textContent 규약 + data 형태만 명시. +- 운영자(ADMIN) 사용자 부여 경로 — W1 연결, 범위 밖(상수만 정의). + +--- + +## 개요 + +프로젝트는 **controller→mapper 직결**(서비스 계층 없음), **커스텀 HttpSession 인증 + 커스텀 CsrfTokens**(Spring Security 부재), **MyBatis annotation + 명시 시퀀스 PostgreSQL**, **soft-delete(is_delete) 규약**이다(research 종합). W3-2 는 이 패턴을 그대로 따라 ① 기존 orphan 인 `GameCommentsMapper`/`game_comments` 를 서버 API 로 연결(user_id 컬럼 nullable 추가)하고, ② 신규 `game_reviews` 도메인(테이블·POJO·Mapper·컨트롤러)을 recruit_posts 권위 스타일로 추가한다. + +표준 상태변경 시퀀스는 RecruitController.createRecruitPost(`RecruitController.java:49-143`)를 골격으로, 작성자/운영자 권한 분기는 GameController(`GameController.java:183-185`, `:239-241`)를 합성한다. 신규 운영자 분기(`isOperator`)는 session attr `role`(`UserController.java:508`)을 `ROLE_ADMIN` 상수와 비교한다. + +--- + +## 플로우 + +### F1. 댓글 작성 (POST /game/{id}/comments) +1. 진입: `@Transactional` +2. `CsrfTokens.isValid(request)` 실패 → 403 `CsrfTokens.errorBody()` +3. `sessionUserId(session)` null → 401 `{status:401,message:"로그인이 필요합니다."}` +4. 게임 존재 확인 `gamesMapper.getGame(id)` null → 404 +5. content `trimToNull` → null 또는 `length()>200` → 400 `"덧글은 200자 이내로 입력해 주세요."` +6. POJO 세팅(gameId, userId, content; nickname = session displayName 스냅샷) → `gameCommentsMapper.addGameComment` +7. 생성 id null → 500 +8. 종단: 200 JSON `{status:200, message, commentId, gameId, content, authorName, userId, createdAt}` (프론트가 textContent 로 즉시 append 가능한 data 형태) + +### F2. 댓글 목록 조회 +- **초기 진입은 model 주입**: `GameController.gameDetail(:103-129)` 의 DB 게임 분기(`:104-109`)에서 `addGameModel` 이후 `model.addAttribute("comments", gameCommentsMapper.listGameComments(id))` 추가. 정적 GameCatalog 폴백 분기(`:111-128`)는 DB 미존재 게임이므로 빈 리스트(`model.addAttribute("comments", List.of())`). +- **작성·수정·삭제 이후 갱신은 fetch GET API**: `GET /game/{id}/comments` → JSON 배열. (완전 서버화 = 초기 model 주입 + mutation 이후 fetch 재조회 혼합. 명시 확정.) + +### F3. 댓글 수정 (PUT /game/{id}/comments/{commentId}) +1~3. (F1 과 동일: Transactional → CSRF → 로그인) +4. `gameCommentsMapper.getGameComment(commentId)` null → 404. `gameId` 불일치 → 404(경로 위변조 방지). +5. **권한**: `userId.equals(comment.getUserId()) || isOperator(role)` 거짓 → 403 `"작성자만 수정할 수 있습니다."` (단, comment.userId == null 인 레거시 닉네임 댓글은 작성자 매칭 불가 → 운영자만 수정 가능) +6. content 200자 검증(F1-5 동일) +7. `gameCommentsMapper.editGameComment` (content 갱신, is_delete/deleted_at 비변경) +8. 200 JSON + +### F4. 댓글 삭제 (DELETE /game/{id}/comments/{commentId}) +1~5. (F3 과 동일 권한 분기) +6. `gameCommentsMapper.softDeleteGameComment(commentId)` (is_delete=true, deleted_at=now()) +7. 200 JSON `{status:200, message}` + +### F5. 리뷰 작성 (POST /game/{id}/reviews) +1~4. (Transactional → CSRF → 로그인 → 게임 존재) +5. rating: `Integer` 파싱 실패 또는 `<1 || >5` → 400 `"별점은 1~5 사이로 선택해 주세요."` +6. body(서술) `trimToEmpty` → `length()>1000` → 400 (recruit description 1200 대비 보수적 1000자) +7. **게임당 1회**: `gameReviewsMapper.getActiveReviewByGameAndUser(gameId, userId)` 존재 → 409 `"이미 이 게임에 리뷰를 작성하셨습니다."` (앱레벨 선검사 + DB partial UNIQUE 가 2차 방어) +8. `gameReviewsMapper.addGameReview` (DB UNIQUE 위반 시 DataIntegrityViolation → ApiExceptionControllerAdvice 가 처리하거나 사전 409 가 차단) +9. 생성 id null → 500 +10. 200 JSON `{status:200, message, reviewId, gameId, rating, body, authorName, userId, edited:false, createdAt, updatedAt}` + +### F6. 리뷰 수정 (PUT /game/{id}/reviews/{reviewId}) — "수정됨" + 이력 보존 +1~3. (Transactional → CSRF → 로그인) +4. `gameReviewsMapper.getGameReview(reviewId)` null → 404. gameId 불일치 → 404. +5. 권한: `userId.equals(review.getUserId()) || isOperator(role)` 거짓 → 403. +6. rating/body 검증(F5-5,6 동일) +7. **이력 보존(in-row 채택, 근거는 데이터 모델 절)**: 수정 전 `updated_at = now()` 갱신. `created_at` 불변. → `edited` 판별식 `updated_at > created_at`. (별도 history 테이블 미채택 — 노출은 "수정됨" 마커만, 이력 열람권한 이월.) +8. `gameReviewsMapper.editGameReview` (rating, body, updated_at=now() 갱신) +9. 200 JSON (`edited:true`) + +### F7. 리뷰 조회 +- **목록**: `GET /game/{id}/reviews` → JSON 배열 (또는 게임 상세 model 주입 `reviews`). 댓글과 동일하게 초기 model 주입 + mutation 후 fetch 혼합. +- **단건**: `GET /game/{id}/reviews/{reviewId}` → JSON 단건. + +### F8. 리뷰 삭제 (DELETE /game/{id}/reviews/{reviewId}) +1~5. (F6 권한 분기 동일) +6. `gameReviewsMapper.softDeleteGameReview(reviewId)` (is_delete=true, deleted_at=now()) +7. 200 JSON + +### F9. 게임 삭제 cascade (기존 deleteGame 확장) +`GameController.deleteGame(:243-245)` 의 cascade 블록에 한 줄 추가: +``` +gamesMapper.softDeleteGameComments(id); // 기존 +gamesMapper.softDeleteGameReviews(id); // 신규 추가 (review = soft-delete 채택, comments 미러) +gamesMapper.deleteGameLikes(id); // 기존 (좋아요 미변경) +gamesMapper.softDeleteGame(id); // 기존 +``` + +--- + +## 데이터 모델 + +### D1. game_comments 변경 (비파괴, QG-2) +- 추가 컬럼: `user_id bigint NULL REFERENCES "users"("id")` — 작성자 귀속. **NULL 허용**(기존 레코드/레거시 닉네임 댓글 보존, 파괴적 마이그레이션 없음). +- 기존 컬럼(id/game_id/nickname/content/created_at/deleted_at/is_delete) 전부 유지. +- **content 200자 제약 권위 = 앱레벨**(컨트롤러 검증). DB CHECK 미적용. 근거: ① 기존 컬럼 타입이 `text`(길이 무제한)이고 운영 DB 실제 타입이 비권위(schema.sql:8-11)라 DB CHECK 추가가 기존 레코드와 충돌 위험, ② recruit_posts 도 길이는 앱레벨 검증(`RecruitController.java:80-114`)으로만 처리하고 DB CHECK 는 enum 값(role/participation_type)에만 사용하는 선례. → 200자는 앱레벨 권위, DB 는 무제한 text 유지. +- FK 는 `ON DELETE CASCADE` 미사용(앱레벨 cascade 규약). user soft-delete 시 댓글 user_id 는 dangling 가능하나 조회는 user JOIN 없이 nickname 스냅샷으로 표시(아래). + +#### 댓글 작성자 표시명 전략 +- INSERT 시 `nickname` 에 **작성 시점 session displayName 스냅샷**을 저장(기존 nickname 컬럼 재사용). 동시에 `user_id` 저장. +- 목록 조회 SELECT 는 users JOIN 없이 `nickname` 을 그대로 표시명으로 사용 → 레거시 NULL user_id 댓글(기존 '익명' 닉네임)도 동일 경로로 표시. JOIN 부재로 user soft-delete dangling 무영향. + +### D2. game_reviews 신규 (recruit_posts 권위 스타일) + +| 컬럼 | 타입 | 제약 | 역할 | +|---|---|---|---| +| id | bigint | PK, DEFAULT nextval('game_reviews_id_seq') | 리뷰 고유 ID | +| game_id | bigint | NOT NULL, FK→games(id) | 대상 게임 | +| user_id | bigint | NOT NULL, FK→users(id) | 작성자 (리뷰는 댓글과 달리 로그인 필수 → NOT NULL) | +| rating | smallint | NOT NULL, CHECK(rating BETWEEN 1 AND 5) | 별점 5점 단일 (향후 다축은 별도 game_review_axes 테이블 분리 — 주석으로만) | +| body | text | NULL | 서술 평가 (앱레벨 1000자 제한, DB 무제한) | +| created_at | timestamptz | DEFAULT now() NOT NULL | 작성 시각 | +| updated_at | timestamptz | DEFAULT now() NOT NULL | 마지막 수정 시각 | +| deleted_at | timestamptz | NULL | soft-delete 시각 | +| is_delete | boolean | DEFAULT false NOT NULL | soft-delete 플래그 | + +- **게임당 1회 제약**: partial UNIQUE INDEX + `CREATE UNIQUE INDEX ux_game_reviews_game_user_active ON game_reviews (game_id, user_id) WHERE is_delete IS NOT TRUE;` + (security-hardening 의 active-unique 선례 `ux_user_auth_identities_..._active` 와 동형. soft-delete 된 리뷰는 제약 제외 → 삭제 후 재작성 허용.) +- **"수정됨" 판별 = in-row `updated_at > created_at`** (별도 boolean/edit_count 컬럼 불요). 채택 근거: ① 기존 테이블 전부 created_at/updated_at 쌍 보유(games/recruit_posts), ② 추가 컬럼 0개로 inflate 회피, ③ 노출 요구가 "수정됨" 마커 단일이라 횟수 불필요. +- **이력 보존 = in-row 채택(별도 history 테이블 미생성)**. 근거: 노출 요구는 "수정됨" 마커만이고 이력 **열람** 권한은 이월(범위 밖). history 테이블은 열람 UI·권한이 정해질 때(후속) 신설하는 편이 inflate 회피에 부합. 현재는 `updated_at` 으로 "수정 발생 사실"만 보존. (history 가 필요해지면 game_review_history 를 후속 추가 — 본 설계의 in-row 결정과 충돌하지 않음.) +- FK `ON DELETE CASCADE` 미사용(앱레벨 cascade). 게임 삭제 시 `softDeleteGameReviews(gameId)` 로 정리(F9). +- 인덱스: `CREATE INDEX idx_game_reviews_game ON game_reviews (game_id) WHERE is_delete = false;` (목록 조회 + 후속 집계 SELECT 의 game_id 필터용. **집계 컬럼/뷰는 만들지 않음** — W2-3 동결 보호.) + +### D3. POJO (data/GameReviewData.java 신규) +필드: `Long id, Long gameId, Long userId, Integer rating, String body, OffsetDateTime createdAt, OffsetDateTime updatedAt, OffsetDateTime deletedAt, String authorName`(목록 JOIN alias, 비영속), `Boolean edited`(`updated_at > created_at` SELECT 계산 alias, 비영속). +- GameCommentData 변경: `Long userId` 필드 추가(나머지 기존 유지). + +--- + +## 외부 계약 (HTTP API) + +응답은 전부 `ResponseEntity>` JSON `{status,message,...}`. 조회 GET 은 게임 상세 진입 시 model 주입 + 별도 fetch GET 은 JSON 배열. + +### 댓글 (5 엔드포인트) +| # | 메서드 | URL | 요청 | 성공 | 에러 | +|---|---|---|---|---|---| +| C1 | GET | /game/{id}/comments | — | 200 `{status,comments:[{commentId,gameId,authorName,userId,content,createdAt}]}` | 404(게임없음) | +| C2 | POST | /game/{id}/comments | body: `content`(form param) | 200 `{status,commentId,gameId,authorName,userId,content,createdAt}` | 403 CSRF / 401 / 404 / 400(공백·201자+) | +| C3 | PUT | /game/{id}/comments/{commentId} | `content` | 200 `{status,commentId,content}` | 403 CSRF / 401 / 404 / 403(권한) / 400 | +| C4 | DELETE | /game/{id}/comments/{commentId} | — | 200 `{status,message}` | 403 CSRF / 401 / 404 / 403(권한) | + +(C2 의 200자 거부: `content.length() > 200` → 400. 201자 입력은 거부.) + +### 리뷰 (6 엔드포인트) +| # | 메서드 | URL | 요청 | 성공 | 에러 | +|---|---|---|---|---|---| +| R1 | GET | /game/{id}/reviews | — | 200 `{status,reviews:[{reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt}]}` | 404 | +| R2 | GET | /game/{id}/reviews/{reviewId} | — | 200 `{status,review:{...}}` | 404 | +| R3 | POST | /game/{id}/reviews | `rating`(1~5), `body` | 200 `{status,reviewId,...,edited:false}` | 403 CSRF / 401 / 404 / 400(rating·body) / 409(중복) | +| R4 | PUT | /game/{id}/reviews/{reviewId} | `rating`, `body` | 200 `{status,reviewId,...,edited:true}` | 403 CSRF / 401 / 404 / 403(권한) / 400 | +| R5 | DELETE | /game/{id}/reviews/{reviewId} | — | 200 `{status,message}` | 403 CSRF / 401 / 404 / 403(권한) | + +> 엔드포인트 총합 = 댓글 4 + 리뷰 5 = **9 mutation/fetch 엔드포인트** (C1/R1/R2 GET 3, C2~C4 댓글 mutation 3, R3~R5 리뷰 mutation 3). AC-AGG-1 에서 전수 검증. + +### 컨트롤러 배치 +- 댓글: 신규 `controller/api/GameCommentController.java` (game_comments 도메인 전담; GameController 비대화 회피). +- 리뷰: 신규 `controller/api/GameReviewController.java`. +- 두 컨트롤러 모두 RecruitController/GameController 와 동일한 private 헬퍼(`sessionUserId`, `trimToNull`, `trimToEmpty`, `response`) 복붙 패턴 유지(프로젝트 기존 관행 — 3곳 복붙 선례). **새 추상화/서비스 계층 도입 금지**(범위 밖, inflate 회피). + +--- + +## 권한 모델 + +```java +// 운영자 role 상수 (신규). UserController.ROLE_USER="USER"(:43) 와 동일 위치 관행. +public static final String ROLE_ADMIN = "ADMIN"; // 운영자 role 값. 실제 부여자 없음(W1 연결). + +// isOperator: 인자는 role 문자열 1개만 — 최소 인자 원칙. (inflate 회피) +private boolean isOperator(String role) { // role: session attr "role" 스냅샷 (UserController.java:508) + return ROLE_ADMIN.equals(role); +} + +// 권한 합성: 작성자 본인 OR 운영자 +// authorUserId 는 댓글/리뷰의 user_id (댓글은 NULL 가능 → 레거시는 작성자 매칭 불가, 운영자만) +private boolean canModify(Long currentUserId, Long authorUserId, String role) { + // currentUserId: 현재 로그인 사용자 (sessionUserId) + // authorUserId: 대상 글 작성자 user_id + // role: 현재 사용자 role (운영자 분기용) + return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role); +} +``` + +- role 획득: `(String) session.getAttribute("role")` (로그인 시 `UserController.java:508` 저장). 비로그인은 4번 단계(sessionUserId null)에서 이미 401 차단되므로 role 은 로그인 사용자 한정. +- 비로그인 → 401. 권한없음 → 403 `"작성자만 수정/삭제할 수 있습니다."`. +- GameController:183-185(수정)/239-241(삭제) 작성자 패턴에 isOperator OR 분기를 합성한 것이 canModify. + +--- + +## 게임 삭제 cascade + +- **GamesMapper 신규 메서드** (comments soft-delete 미러): +```java +@Update(""" + UPDATE game_reviews + SET is_delete = true, deleted_at = COALESCE(deleted_at, now()) + WHERE game_id = #{gameId} AND is_delete IS NOT TRUE + """) +int softDeleteGameReviews(@Param("gameId") long gameId); +``` +- **GameController.deleteGame** cascade 블록(`:243-245`)에 `gamesMapper.softDeleteGameReviews(id);` 한 줄 추가(F9 순서). 좋아요는 미변경. + +--- + +## 파일 영향 맵 + +| 변경 유형 | 경로 | 역할 | +|---|---|---| +| 변경 | `db/schema.sql` | dev 블록에 game_comments user_id 컬럼 추가 + game_reviews CREATE 블록 추가 | +| 신규 | `docs/game-reviews-ddl.sql` | game_reviews 권위 DDL (recruit-posts-ddl.sql 선례) | +| 신규 | `docs/game-reviews-ddl.sql` 동봉 또는 별도 `docs/game-comments-user-id-ddl.sql` | 기존 DB 적용용 idempotent ALTER (game_comments user_id + game_reviews) — security-hardening-ddl.sql DO $$ 패턴 | +| 변경 | `src/main/java/.../data/GameCommentData.java` | `Long userId` 필드 + getter/setter 추가 | +| 신규 | `src/main/java/.../data/GameReviewData.java` | 리뷰 POJO | +| 변경 | `src/main/java/.../mapper/GameCommentsMapper.java` | listGameComments / editGameComment / softDeleteGameComment 추가, getGameComment/addGameComment SELECT·INSERT 에 user_id 반영 | +| 신규 | `src/main/java/.../mapper/GameReviewsMapper.java` | 리뷰 CRUD 매퍼 | +| 변경 | `src/main/java/.../mapper/GamesMapper.java` | softDeleteGameReviews 추가 | +| 신규 | `src/main/java/.../controller/api/GameCommentController.java` | 댓글 API (C1~C4) | +| 신규 | `src/main/java/.../controller/api/GameReviewController.java` | 리뷰 API (R1~R5), ROLE_ADMIN 상수·isOperator·canModify | +| 변경 | `src/main/java/.../controller/api/GameController.java` | gameDetail(:107) model 에 comments/reviews 주입 + deleteGame(:243) cascade 한 줄 | +| 변경(위임) | `src/main/webapp/WEB-INF/views/game-detail.jsp` | 댓글 localStorage JS(:807-1009 중 댓글부) → fetch 교체, 리뷰 위젯 추가 — **/frontend-design 위임** | +| 신규(test) | `src/test/.../GameCommentControllerTest.java` | CSRF/200자/권한/운영자 분기 | +| 신규(test) | `src/test/.../GameReviewControllerTest.java` | 게임당1회/rating/수정됨/운영자 분기 | + +### DDL 3종 골격 + +**① `db/schema.sql` (dev 블록 내 game_comments 직후 추가)** +```sql +-- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴) +ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint REFERENCES "users"("id"); + +-- --------------------------------------------------------------------------- +-- game_reviews (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규) +-- --------------------------------------------------------------------------- +CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq"; +CREATE TABLE IF NOT EXISTS "game_reviews" ( + "id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL, + "game_id" bigint NOT NULL REFERENCES "games"("id"), + "user_id" bigint NOT NULL REFERENCES "users"("id"), + "rating" smallint NOT NULL, + "body" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "is_delete" boolean DEFAULT false NOT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "game_reviews_rating_check" CHECK ("rating" BETWEEN 1 AND 5) +); +ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id"; +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 동결 — 신설 금지. +``` + +**② `docs/game-reviews-ddl.sql` (권위 파일)** — 위 game_reviews CREATE + 시퀀스 + UNIQUE/일반 인덱스 + FK/CHECK 를 DO $$ idempotent 블록(recruit-posts-ddl.sql 형식) + COMMENT ON COLUMN 전 컬럼. rating/body/edited 의미 주석. + +**③ `docs/game-reviews-ddl.sql` 하단 또는 동봉 ALTER 섹션 (기존 DB 적용용 idempotent)** +```sql +-- game_comments user_id (멱등) +ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='game_comments_user_id_fkey') THEN + ALTER TABLE "game_comments" ADD CONSTRAINT "game_comments_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id"); + END IF; +END $$; +-- game_reviews: CREATE SEQUENCE/TABLE IF NOT EXISTS + DO $$ FK/CHECK + CREATE UNIQUE INDEX IF NOT EXISTS (위 ① 와 동일, 멱등 보장) +``` + +--- + +## 신규 시그니처 (inflate 방지 — 각 인자 사용 목적 인라인) + +### GameCommentsMapper (변경) +```java +List listGameComments(long gameId); // gameId: 목록 필터 (is_delete IS NOT TRUE) +int editGameComment(GameCommentData c); // c: id+content (content 만 갱신) +int softDeleteGameComment(long id); // id: 대상 댓글 (is_delete=true, deleted_at=now()) +// getGameComment: SELECT 에 user_id AS userId 추가. addGameComment: INSERT 에 user_id 추가. +``` +- listGameComments SELECT: `id AS commentId? → id, game_id AS gameId, nickname AS authorName, user_id AS userId, 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`. (users JOIN 없음 — nickname 스냅샷 표시.) + +### GameReviewsMapper (신규) +```java +GameReviewData getGameReview(long id); // id: 단건/권한확인 +List listGameReviews(long gameId); // gameId: 목록 필터 +GameReviewData getActiveReviewByGameAndUser(@Param("gameId") long gameId, @Param("userId") long userId); // 게임당1회 선검사 +int addGameReview(GameReviewData r); // r: gameId/userId/rating/body +int editGameReview(GameReviewData r); // r: id/rating/body (updated_at=now() SET 문 명시) +int softDeleteGameReview(long id); // id: is_delete=true +``` +- list/get SELECT 의 edited alias: `(updated_at > created_at) AS edited`. authorName: users JOIN `u.display_name AS authorName`(리뷰는 user_id NOT NULL 이므로 JOIN 안전, `u.is_delete IS NOT TRUE` 필터 — soft-delete 사용자 리뷰는 목록서 누락 가능, 허용). + +### isOperator / canModify +- 위 권한 모델 절 참조. **inflate 재확인 대상**(concerns 마킹): isOperator(role) 단일 인자, canModify(currentUserId, authorUserId, role) 3인자 — 전부 본문에서 사용. 구현 시 unused 발생하면 즉시 제거. + +--- + +## 보안 (체크리스트 line 101~117 댓글/리뷰 해당분 충족 매핑) + +| 체크리스트 line | 충족 방식 | +|---|---| +| 105 `GET /game/{id}/comments` 또는 모델 주입 결정 | F2: 초기 model 주입 + mutation 후 fetch GET(C1) 혼합 — 확정 | +| 106 POST 댓글 CSRF/길이/작성자 | F1: CsrfTokens.isValid + 200자 앱레벨 + user_id 귀속 | +| 107 댓글 삭제 작성자/관리자 | F4 canModify(작성자 OR isOperator) | +| 108 서버 댓글도 escape/textContent | 서버 렌더 시 HtmlUtils.htmlEscape(JSP), 클라이언트 동적삽입 textContent (research 포인트9 규약 유지) | +| 110 localStorage UI 서버 응답 기준 교체 | F1~F4 fetch + 초기 model 주입으로 localStorage 댓글부 제거(위임: frontend-design) | +| 114 새로고침/브라우저변경 영속 | 서버 DB 영속(localStorage 비의존) | +| 115 토큰 없는 변경 실패 | 전 mutation 첫 게이트 CsrfTokens.isValid → 403 | +| 116 XSS payload 미실행 | escape/textContent (108) | +| 117 게임 삭제 시 정리 | F9 softDeleteGameComments(기존) + softDeleteGameReviews(신규) | + +- 좋아요 관련 line(101~104)은 범위 밖 — 본 설계 미해결(좋아요 미변경 명시). + +--- + +## 프론트엔드 경계 (/frontend-design 위임) + +본 설계가 제공하는 위임 입력: +- **소비 API 계약**: 위 댓글 C1~C4 / 리뷰 R1~R5 표(URL·메서드·요청 param·응답 JSON 형태). +- **CSRF 규약**: 모든 mutation 요청에 `BibimbapCsrf.headers()` (theme-init.jsp:19-31) 또는 `_csrf` 파라미터. 메타 `csrf-token` 전역 제공됨. +- **escape 규약**: 서버 렌더(JSP scriptlet `<%= HtmlUtils.htmlEscape(...) %>`); 클라이언트 동적 삽입은 `textContent`(innerHTML 금지, listEl 초기화 `innerHTML=''` 만 허용). +- **필요 data 형태**: 댓글 `{commentId,authorName,userId,content,createdAt}`; 리뷰 `{reviewId,authorName,userId,rating,body,edited,createdAt,updatedAt}`. `edited:true` 일 때 "수정됨" 마커 표시. 별점 위젯은 rating 1~5 정수 송수신. +- **소유자/권한 힌트**: 응답의 `userId` 와 로그인 사용자(`owner` 모델 또는 별도 노출) 비교로 수정·삭제 버튼 노출 결정(서버가 최종 권한 재검증). + +**위임 경계(본 설계 비포함)**: 실제 JSP 마크업, CSS, 별점 위젯 비주얼/인터랙션, "수정됨" 마커 위치·스타일. + +--- + +## 대안 비교 + +| 결정 | 채택 | 대안 | 채택 근거 | +|---|---|---|---| +| 댓글/리뷰 분리 | 2 테이블 (game_comments 변경 + game_reviews 신규) | 단일 테이블 type 컬럼 | rating/edited 등 리뷰 전용 컬럼이 댓글에 dead → 사용자 plan gate 확정 | +| content 200자 권위 | 앱레벨 검증 | DB CHECK | 기존 text 컬럼·비권위 운영타입·recruit 선례(길이는 앱레벨) | +| "수정됨" 판별 | in-row updated_at>created_at | boolean edited / edit_count | 추가 컬럼 0개, 기존 created/updated 쌍 활용, 노출요구 마커 단일 | +| 이력 보존 | in-row(updated_at) | 별도 game_review_history | 이력 열람 권한 이월(범위 밖) → 후속 신설이 inflate 회피 | +| 게임당 1회 | partial UNIQUE WHERE is_delete IS NOT TRUE | 전체 UNIQUE | soft-delete 후 재작성 허용 + security-hardening active-unique 선례 | +| 댓글 작성자 표시 | nickname 스냅샷(JOIN 없음) | users JOIN display_name | 레거시 NULL user_id·user soft-delete dangling 무영향 | +| 리뷰 cascade | soft-delete | hard-delete | comments 미러(soft) 일관성 | + +--- + +## 롤아웃 / 마이그레이션 + +1. **DDL 선적용**: 기존 DB → idempotent ALTER(③) 실행 (game_comments user_id 추가 + game_reviews 생성). 신규 환경은 schema.sql(①)로 자동. 둘 다 `IF NOT EXISTS`/`DO $$` 멱등. +2. **역호환**: game_comments user_id NULL 허용 → 기존 레코드 무손상. orphan 이던 GameCommentsMapper 가 컨트롤러 연결돼도 기존 SELECT(getGameComment) 컬럼 추가는 alias 만 늘어 호환. +3. **배포 순서**: DDL → 백엔드(매퍼/컨트롤러) → 프론트(/frontend-design). 프론트 미배포 상태에서도 백엔드 API 는 독립 동작(기존 localStorage UI 가 잠시 공존 가능, 비파괴). +4. **롤백**: 컨트롤러/매퍼 revert 시 game_reviews 테이블은 잔존(데이터 무손실). game_comments user_id 컬럼은 nullable 이라 revert 후에도 무해. 파괴적 DROP 불요. +5. **localStorage 댓글**: 비마이그레이션(확정). 사용자 브라우저 로컬 데이터는 방치(자연 소멸). + +--- + +## 검증 포인트 (AC) + +> §4.3 전수 체크 + §4.7 self-audit(시점 안정성·표현 견고성) 적용. + +### 기능 AC (원 요청 매핑) +- **AC-1 (댓글 CRUD)**: 로그인 사용자가 C2 작성 → C1 목록에 등장, C3 수정 반영, C4 삭제 후 C1 에서 누락. 단위/통합 테스트 4건. (시점: 테스트 내 자족 — 안정.) +- **AC-2 (비작성자 불가 + 운영자 예외)**: 사용자 A 작성 댓글을 사용자 B(role=USER)가 C3/C4 → 403. 사용자 C(role=ADMIN)는 C3/C4 → 200. 단위테스트로 isOperator 분기 검증. (운영자 부여자 없음 → 테스트에서 session role="ADMIN" 주입.) +- **AC-3 (리뷰 게임당 1회)**: 동일 user+game R3 두 번째 → 409. soft-delete(R5) 후 R3 재작성 → 200 (partial UNIQUE 검증). +- **AC-4 (수정 시 "수정됨" + 이력 보존)**: R3 직후 응답 `edited:false`(updated_at==created_at). R4 후 R1/R2 응답 `edited:true`(updated_at>created_at). created_at 불변 확인. +- **AC-5 (댓글 201자 거부)**: C2/C3 에 201자 content → 400. 200자 경계 → 200 (off-by-one 경계 테스트). +- **AC-6 (rating 범위)**: R3/R4 rating 0 또는 6 → 400. 1·5 경계 → 200. +- **AC-7 (CSRF 없는 상태변경 실패)**: C2/C3/C4/R3/R4/R5 를 X-CSRF-Token·_csrf 없이 호출 → 전부 403. (표현: 헤더/파라미터 둘 다 부재 케이스. UserControllerCsrfTest 선례 형식.) +- **AC-8 (XSS payload 미실행)**: content/body 에 `` 를 댓글/리뷰 body 에 입력 → 화면에 alert 팝업이 뜨지 않고 텍스트로 표시되는지 확인. 확인 경로: `textContent` 바인딩 JSP/JS 코드 동작. +2. **영속성 브라우저 재방문**: 댓글/리뷰 작성 후 브라우저 탭 닫고 재방문 → 데이터가 유지되는지 확인. +3. **실제 게임 삭제 cascade**: 게임 삭제 API 호출 후 DB에서 `game_reviews.deleted_at` 설정 여부 확인 (`SELECT * FROM game_reviews WHERE game_id = ?`). + +## blocker (수정 필요) + +**BibimbapApplicationTests.contextLoads — 회귀 FAIL** + +- 실패: `NoSuchBeanDefinitionException` for `com.pandoli365.bibimbap.mapper.GameCommentsMapper` +- `BibimbapApplicationTests` 가 전체 Spring ApplicationContext 로드 시 `GameCommentsMapper` bean 을 찾지 못함. +- 재현: `./mvnw -o test -Dtest=BibimbapApplicationTests 2>&1` +- 이 테스트는 신규 mapper 추가 이전부터 존재하던 기존 테스트. 신규 구현 이후 실패 → 회귀. diff --git a/db/schema.sql b/db/schema.sql index 70b9b22..5593ec4 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -108,6 +108,34 @@ CREATE TABLE IF NOT EXISTS "game_comments" ( ); ALTER SEQUENCE "game_comments_id_seq" OWNED BY "game_comments"."id"; +-- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴) +ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint REFERENCES "users" ("id"); + +-- --------------------------------------------------------------------------- +-- game_reviews (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규) +-- --------------------------------------------------------------------------- +CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq"; +CREATE TABLE IF NOT EXISTS "game_reviews" ( + "id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL, + "game_id" bigint NOT NULL REFERENCES "games" ("id"), + "user_id" bigint NOT NULL REFERENCES "users" ("id"), + "rating" smallint NOT NULL, + "body" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "is_delete" boolean DEFAULT false NOT NULL, + PRIMARY KEY ("id"), + CONSTRAINT "game_reviews_rating_check" CHECK ("rating" BETWEEN 1 AND 5) +); +ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id"; +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 동결 — 신설 금지. + -- --------------------------------------------------------------------------- -- 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 new file mode 100644 index 0000000..b06f3dc --- /dev/null +++ b/docs/changes/2026-06-18-w3-2-comments-reviews.md @@ -0,0 +1,122 @@ +--- +kind: change +title: "W3-2 댓글/리뷰 분리 구현" +session_id: 20260618-104034 +created_at: 2026-06-18 +status: implemented +related_security_checklist: "../security/security-remediation-checklist.md" +related_work_log: "../work-log/2026-06-17-w3-feature-skeletons.md" +related_design: "../../.atp/work-session/20260618-104034/design.md" +--- + +# W3-2 댓글/리뷰 분리 구현 변경 이력 + +상위 골자: [W3 사이트 플랫폼 기능 골자 카탈로그](../work-log/2026-06-17-w3-feature-skeletons.md) §W3-2. + +## 개요 + +기존 `game_comments`(닉네임 자유입력, localStorage 전용)를 서버 영속화 + 로그인 연동으로 전환하고, +별점 5점 + 서술 평가 형태의 `game_reviews` 도메인을 신설했다. + +## 변경된 런타임 동작 + +### 1. 댓글 서버 영속화 전환 + +- **이전**: 댓글이 `localStorage`에만 저장. `game_comments` 매퍼·테이블은 고아 상태. +- **이후**: 댓글이 서버 DB `game_comments`에 영속. 새로고침·브라우저 변경 후에도 유지. +- 기존 localStorage 댓글은 마이그레이션하지 않음(보안 [hold] 정합 — QG-2 결정). +- `game_comments.user_id` bigint NULL FK 추가(비파괴, 레거시 nickname 레코드 보존). +- content 200자 앱레벨 검증 신규 적용(이전: 길이 제한 없음). + +### 2. game_reviews 도메인 신설 + +- 신규 테이블 `game_reviews`: 게임당 1회 제한(partial UNIQUE WHERE is_delete IS NOT TRUE), rating smallint CHECK(1~5), body text. +- "수정됨" 상태: in-row(updated_at > created_at). 별도 이력 테이블 없음(열람 권한 확정 시 후속). +- 삭제: soft-delete(is_delete, deleted_at). + +### 3. 권한 모델 + +- 댓글/리뷰 수정·삭제: 작성자 본인(`sessionUserId == 리소스.userId`) OR 운영자(`ROLE_ADMIN="ADMIN"`). +- 운영자 role 부여 경로는 W1 RBAC 연결 시 활성(현재 휴면 — 부여자 없음). +- 로그인 없는 쓰기 시도: 401. +- CSRF 토큰 없는 상태 변경: 403. + +### 4. 게임 삭제 cascade 확장 + +- `GameController.deleteGame`에 `softDeleteGameReviews` 추가. +- 게임 삭제 시 관련 리뷰도 soft-delete. + +## 신규/변경 파일 + +### 신규 파일 (백엔드) + +| 파일 | 설명 | +|---|---| +| `src/.../data/GameReviewData.java` | 리뷰 도메인 POJO | +| `src/.../mapper/GameReviewsMapper.java` | 리뷰 MyBatis 매퍼 | +| `src/.../controller/api/GameCommentController.java` | 댓글 CRUD API (C1~C4) | +| `src/.../controller/api/GameReviewController.java` | 리뷰 CRUD API (R1~R5) | +| `docs/game-reviews-ddl.sql` | 권위 DDL (멱등 ALTER, 기존 DB 적용용) | +| `src/test/.../GameCommentControllerTest.java` | 댓글 컨트롤러 단위 테스트 12건 | +| `src/test/.../GameReviewControllerTest.java` | 리뷰 컨트롤러 단위 테스트 13건 | + +### 변경 파일 + +| 파일 | 변경 내용 | +|---|---| +| `src/.../data/GameCommentData.java` | userId 필드 추가 | +| `src/.../mapper/GameCommentsMapper.java` | user_id 기반 쿼리 확장 | +| `src/.../mapper/GamesMapper.java` | softDeleteGameReviews 추가 | +| `src/.../controller/api/GameController.java` | deleteGame cascade 확장, currentUserId/userRole 모델 노출 | +| `db/schema.sql` | game_reviews CREATE + game_comments user_id ALTER | +| `src/main/webapp/WEB-INF/views/game-detail.jsp` | 댓글 localStorage→서버 fetch 교체, 리뷰 UI 신규(별점 위젯·수정됨 마커·게임당1회·로그인 게이트) | +| `src/test/.../BibimbapApplicationTests.java` | GameCommentsMapper/GameReviewsMapper @MockBean 2개 추가 | + +## 신규 API 9개 + +### 댓글 (GameCommentController) + +| ID | 메서드 | 경로 | 설명 | +|---|---|---|---| +| C1 | GET | `/game/{id}/comments` | 댓글 목록 조회 | +| C2 | POST | `/game/{id}/comments` | 댓글 작성 (CSRF·로그인·200자 검증) | +| C3 | PUT | `/game/{id}/comments/{commentId}` | 댓글 수정 (작성자/운영자) | +| C4 | DELETE | `/game/{id}/comments/{commentId}` | 댓글 삭제 (작성자/운영자) | + +### 리뷰 (GameReviewController) + +| ID | 메서드 | 경로 | 설명 | +|---|---|---|---| +| R1 | GET | `/game/{id}/reviews` | 리뷰 목록 조회 | +| R2 | GET | `/game/{id}/reviews/{reviewId}` | 리뷰 단건 조회 | +| R3 | POST | `/game/{id}/reviews` | 리뷰 작성 (게임당 1회, CSRF·로그인·rating 검증) | +| R4 | PUT | `/game/{id}/reviews/{reviewId}` | 리뷰 수정 (수정됨 마커, 작성자/운영자) | +| R5 | DELETE | `/game/{id}/reviews/{reviewId}` | 리뷰 삭제 (soft-delete, 작성자/운영자) | + +모든 상태 변경 API 공통 시퀀스: `@Transactional → CsrfTokens.isValid (403) → sessionUserId (401) → 검증 (400) → mapper → JSON`. + +## 범위 밖 (미변경) + +- **좋아요**: 여전히 localStorage 전용. 보안 체크리스트 B3 좋아요 항목 별도 관리. +- **운영자 role 부여**: ROLE_ADMIN 상수 정의만. 실제 부여 경로는 W1 RBAC 연결 시. +- **W2-3 집계 계약**: game_reviews가 평점 공급원(rating 컬럼)까지만. AVG/COUNT 집계·뷰 미생성(W2-6 시상 후속). +- **리뷰 이력 열람**: in-row 수정됨 마커만. 이력 테이블은 열람 권한 확정 시 후속. + +## 검증 결과 요약 + +| 레이어 | 결과 | +|---|---| +| L1 단위 테스트 (31건) | PASS (GameCommentControllerTest 12 + GameReviewControllerTest 13 + UserControllerCsrfTest 5 + BibimbapApplicationTests 1) | +| AGG-1 엔드포인트 수 | PASS (Comment 4, Review 5) | +| AGG-3 CSRF 게이트 | PASS (합산 6개) | +| AGG-2 DDL 3종 | PASS | +| L3 브라우저 스모크 | needs_user_verification | +| DDL 적용 | needs_user_verification (docs/game-reviews-ddl.sql 참조) | + +## 이월 항목 + +1. W2-3 평점 집계 계약 (SELECT AVG/COUNT) — W2-6 시상 후속. +2. 운영자 role 부여 경로 — W1 RBAC/Interceptor 연결 시 활성. +3. 다축(육각형) 평점 — 현재 단일 rating, game_review_axes 분리 여지(주석). +4. 리뷰 이력 열람 권한 — 열람 정책 확정 시 별도 이력 테이블. +5. GameCatalog 정적 폴백 게임 대상 댓글/리뷰 — DB 실제 게임만 기능 동작(폴백은 404). diff --git a/docs/changes/index.md b/docs/changes/index.md index 609d2a0..a841e93 100644 --- a/docs/changes/index.md +++ b/docs/changes/index.md @@ -4,4 +4,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. 좋아요는 범위밖. diff --git a/docs/development/verification-strategies.md b/docs/development/verification-strategies.md index 0dca6f2..50926c3 100644 --- a/docs/development/verification-strategies.md +++ b/docs/development/verification-strategies.md @@ -26,6 +26,8 @@ **회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다. +**신규 컨트롤러/매퍼 의존 변경 시 full test 의무**: 신규 컨트롤러를 추가하거나 컨트롤러의 매퍼 의존을 늘리면 implementation 단계에서 `test-compile` 만으로 끝내지 말고 반드시 full `./mvnw -o test` 를 실행한다. `BibimbapApplicationTests` 는 MyBatis/DataSource autoconfigure 가 exclude 된 컨텍스트라 컨트롤러가 주입하는 매퍼마다 `@MockBean` 을 수동 등록해야 하며, 누락 시 `contextLoads` 가 `NoSuchBeanDefinitionException` 으로 실패한다. 이 회귀는 `test-compile` 로는 탐지되지 않는다(W3-2 세션 20260618 실증). + ### 실행 수단 프로젝트 루트에 통합 검증 스크립트를 둘 것을 권장한다 (예: `scripts/verify.sh`, `make verify`, `cargo xtask verify`). 스크립트는 L1 → L2 → 로그 스캔 순차 실행을 담당. diff --git a/docs/game-reviews-ddl.sql b/docs/game-reviews-ddl.sql new file mode 100644 index 0000000..939bbe5 --- /dev/null +++ b/docs/game-reviews-ddl.sql @@ -0,0 +1,100 @@ +-- Game reviews (single star rating 1~5 + free-text body, one per game per user). +-- PostgreSQL DDL aligned with the existing bibimbap table style (recruit-posts-ddl.sql). +-- W3-2: 댓글/리뷰 분리. game_reviews 신규 + game_comments user_id 컬럼 추가. + +CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq"; + +CREATE TABLE IF NOT EXISTS "game_reviews" ( + "id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL, + "game_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "rating" smallint NOT NULL, + "body" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "is_delete" boolean DEFAULT false NOT NULL, + PRIMARY KEY ("id") +); + +ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id"; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'game_reviews_game_id_fkey' + ) THEN + ALTER TABLE "game_reviews" + ADD CONSTRAINT "game_reviews_game_id_fkey" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'game_reviews_user_id_fkey' + ) THEN + ALTER TABLE "game_reviews" + ADD CONSTRAINT "game_reviews_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'game_reviews_rating_check' + ) THEN + ALTER TABLE "game_reviews" + ADD CONSTRAINT "game_reviews_rating_check" + CHECK ("rating" BETWEEN 1 AND 5); + END IF; +END +$$; + +-- 게임당 사용자 1회 (active 리뷰 한정 — soft-delete 후 재작성 허용). +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 동결 보호). +CREATE INDEX IF NOT EXISTS "idx_game_reviews_game" + ON "game_reviews" ("game_id") + WHERE "is_delete" = false; + +COMMENT ON TABLE "game_reviews" IS '게임 리뷰. 게임당 사용자 1회, 별점 1~5 + 서술 평가'; +COMMENT ON COLUMN "game_reviews"."id" IS '리뷰 고유 ID'; +COMMENT ON COLUMN "game_reviews"."game_id" IS '대상 게임 games.id'; +COMMENT ON COLUMN "game_reviews"."user_id" IS '리뷰 작성자 users.id (로그인 필수)'; +COMMENT ON COLUMN "game_reviews"."rating" IS '별점 5점 단일 (1~5). 향후 다축은 별도 game_review_axes 테이블로 분리'; +COMMENT ON COLUMN "game_reviews"."body" IS '서술 평가 (앱레벨 1,000자 제한, DB 무제한)'; +COMMENT ON COLUMN "game_reviews"."created_at" IS '리뷰 작성 시각'; +COMMENT ON COLUMN "game_reviews"."updated_at" IS '리뷰 마지막 수정 시각. updated_at > created_at 이면 수정됨'; +COMMENT ON COLUMN "game_reviews"."deleted_at" IS '리뷰 삭제 시각'; +COMMENT ON COLUMN "game_reviews"."is_delete" IS '소프트 삭제 여부'; + +-- =========================================================================== +-- 기존 DB 적용용 idempotent ALTER (security-hardening-ddl.sql DO $$ 패턴) +-- --------------------------------------------------------------------------- +-- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴). +-- 위 game_reviews CREATE/제약/인덱스는 전부 IF NOT EXISTS / DO $$ 멱등 → 재실행 안전. +-- =========================================================================== + +ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'game_comments_user_id_fkey' + ) THEN + ALTER TABLE "game_comments" + ADD CONSTRAINT "game_comments_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users" ("id"); + END IF; +END +$$; + +COMMENT ON COLUMN "game_comments"."user_id" IS '덧글 작성자 users.id (nullable — 레거시 닉네임 덧글 보존)'; diff --git a/docs/security/security-remediation-checklist.md b/docs/security/security-remediation-checklist.md index 30e23b6..5f0ace7 100644 --- a/docs/security/security-remediation-checklist.md +++ b/docs/security/security-remediation-checklist.md @@ -90,31 +90,36 @@ - 게임 삭제 시 댓글/좋아요 데이터 정리 로직도 있다. `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:243`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:244` - 현재 UI는 좋아요와 댓글을 `localStorage`에만 저장한다. `src/main/webapp/WEB-INF/views/game-detail.jsp:812`, `src/main/webapp/WEB-INF/views/game-detail.jsp:830`, `src/main/webapp/WEB-INF/views/game-detail.jsp:913`, `src/main/webapp/WEB-INF/views/game-detail.jsp:928` -의도 확인: +의도 확인 (댓글 결정 완료 / 좋아요 미결 유지): -- [hold] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. -- [hold] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. -- [hold] 기존 localStorage 댓글/좋아요를 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. +- [hold] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. ← **좋아요는 범위 밖, 미결 유지.** +- [x] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. → **로그인 사용자만(서버 영속화, session userId 귀속). 기존 닉네임 레코드는 user_id=NULL 보존(비파괴, QG-2).** W3-2 세션(20260618-104034) 확정. +- [x] 기존 localStorage 댓글을 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. → **비마이그레이션(신규 서버 데이터로만 전환).** 기존 localStorage 댓글 소멸. W3-2 세션 확정. +- [hold] 기존 localStorage 좋아요 처리 방침 — **좋아요는 범위 밖, 미결 유지.** 체크리스트: -- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. -- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다. -- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. -- [ ] `games.like_count` 증감은 race condition 없이 처리한다. -- [ ] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. -- [ ] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. -- [ ] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. -- [ ] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. -- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. -- [ ] localStorage UI는 서버 응답 기준으로 교체한다. +> **좋아요 항목(101~104)은 범위 밖 — 미충족 유지. 댓글 항목만 W3-2에서 충족.** + +- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. ← **좋아요: 미착수(범위 밖)** +- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다. ← **좋아요: 미착수(범위 밖)** +- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. ← **좋아요: 미착수(범위 밖)** +- [ ] `games.like_count` 증감은 race condition 없이 처리한다. ← **좋아요: 미착수(범위 밖)** +- [x] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. → **초기 모델 주입 가능 + 별도 GET C1(`GET /game/{id}/comments`) fetch 방식 채택.** GameCommentController C1 구현 완료(20260618-104034). +- [x] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. → **C2 `POST /game/{id}/comments`: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용.** L1 12건 PASS(20260618-104034). +- [x] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403.** L1 PASS(20260618-104034). +- [x] 서버에서 내려온 댓글도 JSP escape 또는 DOM `textContent`로 렌더링한다. → **game-detail.jsp 댓글 렌더링 전면 textContent 교체.** L3 브라우저 XSS 미실행 확인은 needs_user_verification(L3 스모크). +- [ ] 남용 방지를 위해 rate limit, 로그인 제한, 운영 신고/삭제 정책 중 최소 한 가지를 결정한다. ← **로그인 제한 적용됨(댓글 쓰기 로그인 필수). rate limit/신고 정책은 이월.** +- [x] localStorage UI는 서버 응답 기준으로 교체한다. → **game-detail.jsp 댓글 localStorage → fetch API 서버 응답 기준으로 전환.** L1 PASS(20260618-104034). 완료 조건: -- [ ] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. -- [ ] 토큰 없는 좋아요/댓글 변경 요청이 실패한다. -- [ ] XSS payload 댓글이 스크립트로 실행되지 않는다. -- [ ] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. +- [~] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L1 단위 서버 저장 확인 완료. 브라우저 재방문 영속은 L3 스모크 대기(needs_user_verification).** 좋아요: localStorage 유지(범위 밖, 미충족). +- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖. +- [~] XSS payload 댓글이 스크립트로 실행되지 않는다. → **textContent 렌더 적용(단위 컨트롤러 raw 반환 확인). 브라우저 렌더 미실행 확인은 L3 스모크 대기.** +- [x] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → **GameController.deleteGame에 softDeleteGameReviews 추가.** 댓글 soft-delete도 기존 로직 확인. L1 PASS. + +> 구현 이력 상세: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md) ## B4. 의존성/세션/운영 하드닝 diff --git a/docs/work-log/2026-06-17-w3-feature-skeletons.md b/docs/work-log/2026-06-17-w3-feature-skeletons.md index bfa775c..0f8f099 100644 --- a/docs/work-log/2026-06-17-w3-feature-skeletons.md +++ b/docs/work-log/2026-06-17-w3-feature-skeletons.md @@ -58,6 +58,8 @@ owner: art ### W3-2 — 댓글 / 리뷰 분리 +> **구현 완료** (2026-06-18, 세션 20260618-104034). 변경 이력: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md). L1 31테스트 PASS. L3 스모크·DDL 적용은 사용자 확인 필요. + - **목적**: 모든 게임 페이지 일반 기능. 잼 평가기간 게이트 없음, 잼 독립. - **핵심 동작**: 기존 `game_comments`(닉네임 자유입력)를 로그인 사용자 연동 댓글로 전환 + 별도 리뷰(게임당 1회) 신설. 리뷰 = **별점 5점 평점 + 서술 평가**, 다축(육각형 레이더)은 후속 확장 여지. W2-6 시상이 리뷰 평점을 **단방향 집계**. 댓글 content 200자 제한 신규. - **결합/의존**: diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java new file mode 100644 index 0000000..a254529 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java @@ -0,0 +1,247 @@ +package com.pandoli365.bibimbap.controller.api; + +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Controller +public class GameCommentController { + + private static final int CONTENT_MAX = 200; + private static final String ROLE_ADMIN = "ADMIN"; + + private final GameCommentsMapper gameCommentsMapper; + private final GamesMapper gamesMapper; + + public GameCommentController(GameCommentsMapper gameCommentsMapper, GamesMapper gamesMapper) { + this.gameCommentsMapper = gameCommentsMapper; + this.gamesMapper = gamesMapper; + } + + @GetMapping("/game/{id}/comments") + public ResponseEntity> listComments(@PathVariable("id") long id) { + if (gamesMapper.getGame(id) == null) { + return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); + } + + List> comments = new ArrayList<>(); + for (GameCommentData comment : gameCommentsMapper.listGameComments(id)) { + comments.add(commentView(comment)); + } + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("comments", comments); + return ResponseEntity.ok(body); + } + + @PostMapping("/game/{id}/comments") + @Transactional + public ResponseEntity> createComment( + @PathVariable("id") long id, + @RequestParam(name = "content", required = false) String content, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + if (gamesMapper.getGame(id) == null) { + return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); + } + + String normalizedContent = trimToNull(content); + if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) { + return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요."); + } + + String authorName = trimToEmpty(sessionDisplayName(session)); + GameCommentData comment = new GameCommentData(); + comment.setGameId(id); + comment.setUserId(userId); + comment.setNickname(authorName); + comment.setContent(normalizedContent); + gameCommentsMapper.addGameComment(comment); + if (comment.getId() == null) { + 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); + } + + @PutMapping("/game/{id}/comments/{commentId}") + @Transactional + public ResponseEntity> updateComment( + @PathVariable("id") long id, + @PathVariable("commentId") long commentId, + @RequestParam(name = "content", required = false) String content, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + GameCommentData comment = gameCommentsMapper.getGameComment(commentId); + if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) { + return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다."); + } + if (!canModify(userId, comment.getUserId(), sessionRole(session))) { + return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다."); + } + + String normalizedContent = trimToNull(content); + if (normalizedContent == null || normalizedContent.length() > CONTENT_MAX) { + return response(HttpStatus.BAD_REQUEST, "덧글은 200자 이내로 입력해 주세요."); + } + + 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); + } + + @DeleteMapping("/game/{id}/comments/{commentId}") + @Transactional + public ResponseEntity> deleteComment( + @PathVariable("id") long id, + @PathVariable("commentId") long commentId, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + GameCommentData comment = gameCommentsMapper.getGameComment(commentId); + if (comment == null || !Long.valueOf(id).equals(comment.getGameId())) { + return response(HttpStatus.NOT_FOUND, "덧글을 찾을 수 없습니다."); + } + if (!canModify(userId, comment.getUserId(), sessionRole(session))) { + return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다."); + } + + gameCommentsMapper.softDeleteGameComment(commentId); + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("message", "덧글이 삭제되었습니다."); + return ResponseEntity.ok(body); + } + + private Map commentView(GameCommentData comment) { + Map view = new LinkedHashMap<>(); + view.put("commentId", comment.getId()); + view.put("gameId", comment.getGameId()); + view.put("authorName", comment.getNickname()); + view.put("userId", comment.getUserId()); + view.put("content", comment.getContent()); + view.put("createdAt", comment.getCreatedAt()); + return view; + } + + private boolean isOperator(String role) { + return ROLE_ADMIN.equals(role); + } + + private boolean canModify(Long currentUserId, Long authorUserId, String role) { + return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role); + } + + private Long sessionUserId(HttpSession session) { + if (session == null) { + return null; + } + Object userId = session.getAttribute("userId"); + if (userId instanceof Number number) { + return number.longValue(); + } + if (userId instanceof String text) { + try { + return Long.parseLong(text); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private String sessionDisplayName(HttpSession session) { + if (session == null) { + return null; + } + Object displayName = session.getAttribute("displayName"); + return displayName instanceof String text ? text : null; + } + + private String sessionRole(HttpSession session) { + if (session == null) { + return null; + } + Object role = session.getAttribute("role"); + return role instanceof String text ? text : null; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + 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()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} 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 cfed348..09350f1 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -2,6 +2,8 @@ package com.pandoli365.bibimbap.controller.api; import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.game.GameCatalog; +import com.pandoli365.bibimbap.mapper.GameCommentsMapper; +import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.security.CsrfTokens; import jakarta.servlet.http.HttpServletRequest; @@ -19,6 +21,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -26,12 +29,18 @@ import java.util.Map; public class GameController { private final GamesMapper gamesMapper; + private final GameCommentsMapper gameCommentsMapper; + private final GameReviewsMapper gameReviewsMapper; @Value("${app.webgl.asset-origin:}") private String webglAssetOrigin; - public GameController(GamesMapper gamesMapper) { + public GameController(GamesMapper gamesMapper, + GameCommentsMapper gameCommentsMapper, + GameReviewsMapper gameReviewsMapper) { this.gamesMapper = gamesMapper; + this.gameCommentsMapper = gameCommentsMapper; + this.gameReviewsMapper = gameReviewsMapper; } public static String webglUrlForGame(int gameId) { @@ -105,6 +114,9 @@ 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("userRole", (String) session.getAttribute("role")); return "game-detail"; } @@ -125,6 +137,10 @@ public class GameController { model.addAttribute("webglFrameSrc", webglFrameSrc(webglUrlForGame(intId))); model.addAttribute("webglDeployPath", webglUrlForGame(intId)); model.addAttribute("owner", false); + model.addAttribute("comments", List.of()); + model.addAttribute("reviews", List.of()); + model.addAttribute("currentUserId", sessionUserId(session)); + model.addAttribute("userRole", (String) session.getAttribute("role")); return "game-detail"; } @@ -241,6 +257,7 @@ public class GameController { } gamesMapper.softDeleteGameComments(id); + gamesMapper.softDeleteGameReviews(id); gamesMapper.deleteGameLikes(id); gamesMapper.softDeleteGame(id); @@ -265,6 +282,7 @@ public class GameController { model.addAttribute("webglFrameSrc", webglFrameSrc(webglPath)); model.addAttribute("webglDeployPath", webglPath); model.addAttribute("owner", currentUserId != null && currentUserId.equals(game.getUserId())); + model.addAttribute("currentUserId", currentUserId); } private String webglFrameSrc(String path) { diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java new file mode 100644 index 0000000..3dbbc6e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java @@ -0,0 +1,286 @@ +package com.pandoli365.bibimbap.controller.api; + +import com.pandoli365.bibimbap.data.GameReviewData; +import com.pandoli365.bibimbap.mapper.GameReviewsMapper; +import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Controller +public class GameReviewController { + + private static final int RATING_MIN = 1; + private static final int RATING_MAX = 5; + private static final int BODY_MAX = 1000; + private static final String ROLE_ADMIN = "ADMIN"; + + private final GameReviewsMapper gameReviewsMapper; + private final GamesMapper gamesMapper; + + public GameReviewController(GameReviewsMapper gameReviewsMapper, GamesMapper gamesMapper) { + this.gameReviewsMapper = gameReviewsMapper; + this.gamesMapper = gamesMapper; + } + + @GetMapping("/game/{id}/reviews") + public ResponseEntity> listReviews(@PathVariable("id") long id) { + if (gamesMapper.getGame(id) == null) { + return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); + } + + List> reviews = new ArrayList<>(); + for (GameReviewData review : gameReviewsMapper.listGameReviews(id)) { + reviews.add(reviewView(review)); + } + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("reviews", reviews); + return ResponseEntity.ok(body); + } + + @GetMapping("/game/{id}/reviews/{reviewId}") + public ResponseEntity> getReview( + @PathVariable("id") long id, + @PathVariable("reviewId") long reviewId + ) { + GameReviewData review = gameReviewsMapper.getGameReview(reviewId); + if (review == null || !Long.valueOf(id).equals(review.getGameId())) { + return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."); + } + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("review", reviewView(review)); + return ResponseEntity.ok(body); + } + + @PostMapping("/game/{id}/reviews") + @Transactional + public ResponseEntity> createReview( + @PathVariable("id") long id, + @RequestParam(name = "rating", required = false) String rating, + @RequestParam(name = "body", required = false) String body, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + if (gamesMapper.getGame(id) == null) { + return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다."); + } + + Integer parsedRating = parseRating(rating); + if (parsedRating == null) { + return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요."); + } + String normalizedBody = trimToEmpty(body); + if (normalizedBody.length() > BODY_MAX) { + return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요."); + } + + if (gameReviewsMapper.getActiveReviewByGameAndUser(id, userId) != null) { + return response(HttpStatus.CONFLICT, "이미 이 게임에 리뷰를 작성하셨습니다."); + } + + GameReviewData review = new GameReviewData(); + review.setGameId(id); + review.setUserId(userId); + review.setRating(parsedRating); + review.setBody(normalizedBody); + gameReviewsMapper.addGameReview(review); + if (review.getId() == null) { + return response(HttpStatus.INTERNAL_SERVER_ERROR, "리뷰 등록 결과를 확인하지 못했습니다."); + } + + GameReviewData created = gameReviewsMapper.getGameReview(review.getId()); + Map result = created != null ? reviewView(created) : reviewView(review); + result.put("status", 200); + result.put("message", "리뷰가 등록되었습니다."); + return ResponseEntity.ok(result); + } + + @PutMapping("/game/{id}/reviews/{reviewId}") + @Transactional + public ResponseEntity> updateReview( + @PathVariable("id") long id, + @PathVariable("reviewId") long reviewId, + @RequestParam(name = "rating", required = false) String rating, + @RequestParam(name = "body", required = false) String body, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + GameReviewData review = gameReviewsMapper.getGameReview(reviewId); + if (review == null || !Long.valueOf(id).equals(review.getGameId())) { + return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."); + } + if (!canModify(userId, review.getUserId(), sessionRole(session))) { + return response(HttpStatus.FORBIDDEN, "작성자만 수정할 수 있습니다."); + } + + Integer parsedRating = parseRating(rating); + if (parsedRating == null) { + return response(HttpStatus.BAD_REQUEST, "별점은 1~5 사이로 선택해 주세요."); + } + String normalizedBody = trimToEmpty(body); + if (normalizedBody.length() > BODY_MAX) { + return response(HttpStatus.BAD_REQUEST, "평가는 1,000자 이내로 입력해 주세요."); + } + + review.setRating(parsedRating); + review.setBody(normalizedBody); + gameReviewsMapper.editGameReview(review); + + GameReviewData updated = gameReviewsMapper.getGameReview(reviewId); + Map result = updated != null ? reviewView(updated) : reviewView(review); + result.put("status", 200); + result.put("message", "리뷰가 수정되었습니다."); + return ResponseEntity.ok(result); + } + + @DeleteMapping("/game/{id}/reviews/{reviewId}") + @Transactional + public ResponseEntity> deleteReview( + @PathVariable("id") long id, + @PathVariable("reviewId") long reviewId, + HttpServletRequest request, + HttpSession session + ) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + GameReviewData review = gameReviewsMapper.getGameReview(reviewId); + if (review == null || !Long.valueOf(id).equals(review.getGameId())) { + return response(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다."); + } + if (!canModify(userId, review.getUserId(), sessionRole(session))) { + return response(HttpStatus.FORBIDDEN, "작성자만 삭제할 수 있습니다."); + } + + gameReviewsMapper.softDeleteGameReview(reviewId); + + Map result = new LinkedHashMap<>(); + result.put("status", 200); + result.put("message", "리뷰가 삭제되었습니다."); + return ResponseEntity.ok(result); + } + + private Map reviewView(GameReviewData review) { + Map view = new LinkedHashMap<>(); + view.put("reviewId", review.getId()); + view.put("gameId", review.getGameId()); + view.put("authorName", review.getAuthorName()); + view.put("userId", review.getUserId()); + view.put("rating", review.getRating()); + view.put("body", review.getBody()); + view.put("edited", review.getEdited() != null && review.getEdited()); + view.put("createdAt", review.getCreatedAt()); + view.put("updatedAt", review.getUpdatedAt()); + return view; + } + + private Integer parseRating(String rating) { + String text = trimToNull(rating); + if (text == null) { + return null; + } + int value; + try { + value = Integer.parseInt(text); + } catch (NumberFormatException e) { + return null; + } + if (value < RATING_MIN || value > RATING_MAX) { + return null; + } + return value; + } + + private boolean isOperator(String role) { + return ROLE_ADMIN.equals(role); + } + + private boolean canModify(Long currentUserId, Long authorUserId, String role) { + return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role); + } + + private Long sessionUserId(HttpSession session) { + if (session == null) { + return null; + } + Object userId = session.getAttribute("userId"); + if (userId instanceof Number number) { + return number.longValue(); + } + if (userId instanceof String text) { + try { + return Long.parseLong(text); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private String sessionRole(HttpSession session) { + if (session == null) { + return null; + } + Object role = session.getAttribute("role"); + return role instanceof String text ? text : null; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + 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()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java b/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java index 3850fef..ed844d2 100644 --- a/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java +++ b/src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java @@ -6,6 +6,7 @@ public class GameCommentData { private Long id; private Long gameId; + private Long userId; private String nickname; private String content; private OffsetDateTime createdAt; @@ -27,6 +28,14 @@ public class GameCommentData { this.gameId = gameId; } + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + public String getNickname() { return nickname; } diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java b/src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java new file mode 100644 index 0000000..4c198b1 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java @@ -0,0 +1,99 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class GameReviewData { + + private Long id; + private Long gameId; + private Long userId; + private Integer rating; + private String body; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private OffsetDateTime deletedAt; + + // 비영속 (목록 JOIN alias / SELECT 계산 alias) + private String authorName; + private Boolean edited; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getGameId() { + return gameId; + } + + public void setGameId(Long gameId) { + this.gameId = gameId; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public OffsetDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(OffsetDateTime deletedAt) { + this.deletedAt = deletedAt; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public Boolean getEdited() { + return edited; + } + + public void setEdited(Boolean edited) { + this.edited = edited; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java index 0cd1dc4..3122a98 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java @@ -4,9 +4,12 @@ import com.pandoli365.bibimbap.data.GameCommentData; import org.apache.ibatis.annotations.Insert; 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.Update; +import java.util.List; + @Mapper public interface GameCommentsMapper { @@ -14,6 +17,7 @@ public interface GameCommentsMapper { SELECT id, game_id AS gameId, + user_id AS userId, nickname, content, created_at AS createdAt, @@ -24,13 +28,30 @@ public interface GameCommentsMapper { """) 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); + @Insert(""" INSERT INTO game_comments ( game_id, + user_id, nickname, content ) VALUES ( #{gameId}, + #{userId}, #{nickname}, #{content} ) @@ -38,6 +59,25 @@ public interface GameCommentsMapper { @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") int addGameComment(GameCommentData gameComment); + @Update(""" + UPDATE game_comments + SET + content = #{content} + WHERE id = #{id} + AND is_delete IS NOT TRUE + """) + int editGameComment(GameCommentData gameComment); + + @Update(""" + UPDATE game_comments + SET + is_delete = true, + deleted_at = COALESCE(deleted_at, now()) + WHERE id = #{id} + AND is_delete IS NOT TRUE + """) + int softDeleteGameComment(long id); + @Update(""" UPDATE game_comments SET diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java new file mode 100644 index 0000000..6b6d2b3 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java @@ -0,0 +1,108 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.GameReviewData; +import org.apache.ibatis.annotations.Insert; +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.Update; + +import java.util.List; + +@Mapper +public interface GameReviewsMapper { + + @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, + r.deleted_at AS deletedAt + FROM game_reviews r + JOIN users u ON u.id = r.user_id + WHERE r.id = #{id} + AND r.is_delete IS NOT TRUE + AND u.is_delete IS NOT TRUE + """) + 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); + + @Select(""" + SELECT + r.id, + r.game_id AS gameId, + r.user_id AS userId, + r.rating, + r.body, + r.created_at AS createdAt, + r.updated_at AS updatedAt + FROM game_reviews r + WHERE r.game_id = #{gameId} + AND r.user_id = #{userId} + AND r.is_delete IS NOT TRUE + """) + GameReviewData getActiveReviewByGameAndUser(@Param("gameId") long gameId, @Param("userId") long userId); + + @Insert(""" + INSERT INTO game_reviews ( + game_id, + user_id, + rating, + body + ) VALUES ( + #{gameId}, + #{userId}, + #{rating}, + #{body} + ) + """) + @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id") + int addGameReview(GameReviewData review); + + @Update(""" + UPDATE game_reviews + SET + rating = #{rating}, + body = #{body}, + updated_at = now() + WHERE id = #{id} + AND is_delete IS NOT TRUE + """) + int editGameReview(GameReviewData review); + + @Update(""" + UPDATE game_reviews + SET + is_delete = true, + deleted_at = COALESCE(deleted_at, now()) + WHERE id = #{id} + AND is_delete IS NOT TRUE + """) + int softDeleteGameReview(long id); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java index 016fbee..6a1cbbf 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -172,6 +172,16 @@ public interface GamesMapper { """) int softDeleteGameComments(@Param("gameId") long gameId); + @Update(""" + UPDATE game_reviews + SET + is_delete = true, + deleted_at = COALESCE(deleted_at, now()) + WHERE game_id = #{gameId} + AND is_delete IS NOT TRUE + """) + int softDeleteGameReviews(@Param("gameId") long gameId); + @Delete(""" DELETE FROM game_likes WHERE game_id = #{gameId} diff --git a/src/main/webapp/WEB-INF/views/game-detail.jsp b/src/main/webapp/WEB-INF/views/game-detail.jsp index a2a003c..47781d7 100644 --- a/src/main/webapp/WEB-INF/views/game-detail.jsp +++ b/src/main/webapp/WEB-INF/views/game-detail.jsp @@ -680,6 +680,250 @@ .game-comments__hint { text-align: center; } + .game-reviews__form-buttons { + width: 100%; + flex-direction: column-reverse; + } + .game-reviews__cancel, + .game-reviews__form-buttons #game-review-submit { + width: 100%; + } + } + + /* ── 로그인 게이트 (덧글/리뷰 공통) ───────────────────── */ + .game-comments__login-gate { + margin: 0 0 0.25rem; + padding: 1.1rem 1rem; + font-size: 0.875rem; + text-align: center; + color: var(--text-muted); + background: var(--surface); + border: 1px dashed var(--border); + border-radius: 12px; + } + .game-comments__login-gate a { + font-weight: 700; + color: var(--accent); + text-decoration: none; + border-bottom: 1px solid rgba(232, 165, 75, 0.4); + } + .game-comments__login-gate a:hover { + border-bottom-color: var(--accent); + } + + /* ── 별점 위젯 ───────────────────────────────────────── */ + .game-stars { + display: inline-flex; + gap: 0.15rem; + line-height: 1; + } + .game-stars__btn { + padding: 0.1rem; + font-size: 1.6rem; + line-height: 1; + color: var(--border); + background: none; + border: none; + cursor: pointer; + transition: color 0.12s ease, transform 0.12s ease; + -webkit-tap-highlight-color: transparent; + } + .game-stars__btn:hover { + transform: scale(1.12); + } + .game-stars__btn.is-on { + color: var(--accent); + } + .game-stars--input:focus-within .game-stars__btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 4px; + } + /* 읽기 전용 별 표시 (리뷰 카드/요약) */ + .game-stars--display, + .game-stars--avg { + gap: 0.05rem; + font-size: 0.95rem; + } + .game-stars--display .game-star, + .game-stars--avg .game-star { + color: var(--border); + } + .game-stars--display .game-star.is-on, + .game-stars--avg .game-star.is-on { + color: var(--accent); + } + + /* ── 리뷰 작성/수정 폼 ───────────────────────────────── */ + .game-reviews__composer { + margin-bottom: 1.25rem; + padding: 1.1rem 1.15rem 1.2rem; + background: var(--accent-soft); + border: 1px solid rgba(232, 165, 75, 0.22); + border-radius: 14px; + } + .game-reviews__rating-row { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 0.85rem; + } + .game-reviews__rating-row .game-comments__label { + margin-bottom: 0; + } + .game-reviews__form-buttons { + display: flex; + gap: 0.5rem; + align-items: center; + } + .game-reviews__cancel { + padding: 0.55rem 1.1rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-muted); + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease; + } + .game-reviews__cancel:hover { + color: var(--text); + border-color: var(--accent); + } + + /* ── 리뷰 요약 (평균 별점) ───────────────────────────── */ + .game-reviews__summary { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; + } + .game-reviews__avg { + font-size: 1.35rem; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--accent); + } + .game-reviews__count { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + } + + /* ── 리뷰 목록 (덧글 카드 패턴 확장) ─────────────────── */ + .game-reviews__list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.85rem; + } + .game-reviews__item { + padding: 0.95rem 1.1rem; + font-size: 0.875rem; + line-height: 1.55; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + transition: border-color 0.2s ease; + } + .game-reviews__item.is-mine { + border-color: rgba(232, 165, 75, 0.4); + background: var(--accent-soft); + } + .game-reviews__item-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.65rem; + margin-bottom: 0.5rem; + } + .game-reviews__nick { + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); + } + .game-reviews__mine-badge { + padding: 0.05rem 0.45rem; + font-size: 0.625rem; + font-weight: 700; + color: var(--accent); + background: rgba(232, 165, 75, 0.16); + border-radius: 999px; + } + .game-reviews__edited { + font-size: 0.6875rem; + font-weight: 500; + color: var(--text-muted); + } + .game-reviews__time { + margin-left: auto; + font-size: 0.6875rem; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-muted); + } + .game-reviews__body { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); + } + .game-reviews__footer { + margin-top: 0.6rem; + display: flex; + justify-content: flex-end; + gap: 0.4rem; + } + .game-reviews__action { + padding: 0.25rem 0.6rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; + } + .game-reviews__action--edit:hover { + color: var(--accent); + border-color: rgba(232, 165, 75, 0.3); + background: var(--accent-soft); + } + .game-reviews__action--delete:hover { + color: #b33; + border-color: rgba(180, 50, 50, 0.25); + background: rgba(180, 50, 50, 0.06); + } + /* 덧글 수정 폼 (인라인) */ + .game-comments__edit-form { + margin-top: 0.6rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .game-comments__edit-form textarea { + width: 100%; + box-sizing: border-box; + min-height: 4.5rem; + padding: 0.65rem 0.85rem; + font-size: 0.875rem; + font-family: inherit; + line-height: 1.55; + color: var(--text); + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 10px; + resize: vertical; + } + .game-comments__edit-form textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.18); } @@ -777,6 +1021,49 @@ +
+
+ +
+

리뷰

+

별점과 함께 플레이 평가를 남겨 주세요. 게임당 한 번 작성할 수 있어요.

+
+ +
+ + + +
    +
    +
    -
    + +
      @@ -810,7 +1098,8 @@ var gameId = ${gameId}; var baseLikes = ${likeCount}; var LIKE_KEY = 'bibimbap-game-liked'; - var COMMENT_KEY = 'bibimbap-game-comments'; + var viewerId = ${empty currentUserId ? 'null' : currentUserId}; + var viewerRole = '${empty userRole ? "" : userRole}'; function getLikedMap() { try { @@ -908,103 +1197,382 @@ }); syncLike(); - function getComments() { - try { - var raw = localStorage.getItem(COMMENT_KEY); - var o = raw ? JSON.parse(raw) : {}; - var list = o[gid]; - return Array.isArray(list) ? list : []; - } catch (e) { - return []; + // ===== 공통 헬퍼 (서버 연동 덧글/리뷰) ===== + function fmtDate(iso) { + try { return iso ? new Date(iso).toLocaleString('ko-KR') : ''; } + catch (e) { return ''; } + } + function notifyError(title, message) { + if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') { + window.BibimbapModal.alert({ title: title, message: message, confirmText: '확인' }); + } else { alert(message); } + } + function confirmAction(title, message, okText, onOk) { + if (window.BibimbapModal && typeof window.BibimbapModal.confirm === 'function') { + window.BibimbapModal.confirm({ title: title, message: message, confirmText: okText, cancelText: '취소', onConfirm: onOk }); + } else if (confirm(message)) { onOk(); } + } + function baseHeaders(extra) { + var h = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }; + if (extra) { Object.keys(extra).forEach(function (k) { h[k] = extra[k]; }); } + return window.BibimbapCsrf ? window.BibimbapCsrf.headers(h) : h; + } + function formHeaders() { + return baseHeaders({ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }); + } + function encodeForm(obj) { + return Object.keys(obj).map(function (k) { + return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] == null ? '' : obj[k]); + }).join('&'); + } + function api(url, opts) { + return fetch(url, opts).then(function (res) { + return res.json().catch(function () { return {}; }).then(function (data) { + if (!res.ok) { throw new Error((data && data.message) || ('요청을 처리하지 못했습니다. (' + res.status + ')')); } + return data; + }); + }); + } + // 본인(작성자) 또는 운영자(ADMIN)만 수정/삭제 버튼 노출 — 서버가 최종 권한 재검증 + function canActOn(authorUserId) { + if (viewerId == null) return false; + if (authorUserId != null && Number(authorUserId) === Number(viewerId)) return true; + return viewerRole === 'ADMIN'; + } + + // ===== 덧글 (서버 연동) ===== + var cListEl = document.getElementById('game-comment-list'); + var cEmptyEl = document.getElementById('game-comments-empty'); + var cForm = document.getElementById('game-comment-form'); + var cInput = document.getElementById('game-comment-input'); + var cComposer = document.getElementById('game-comment-composer'); + var cLoginGate = document.getElementById('game-comment-login-gate'); + var commentsUrl = ctx + '/game/' + encodeURIComponent(gid) + '/comments'; + + function openCommentEdit(li, body, p, c) { + if (li.querySelector('.game-comments__edit-form')) return; + var ef = document.createElement('form'); + ef.className = 'game-comments__edit-form'; + var ta = document.createElement('textarea'); + ta.maxLength = 200; + ta.value = c.content || ''; + var actions = document.createElement('div'); + actions.className = 'game-comments__actions'; + var hint = document.createElement('span'); + hint.className = 'game-comments__hint'; + hint.textContent = '최대 200자'; + var btnWrap = document.createElement('div'); + btnWrap.className = 'game-reviews__form-buttons'; + var cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'game-reviews__cancel'; + cancel.textContent = '취소'; + var save = document.createElement('button'); + save.type = 'submit'; + save.textContent = '저장'; + btnWrap.appendChild(cancel); + btnWrap.appendChild(save); + actions.appendChild(hint); + actions.appendChild(btnWrap); + ef.appendChild(ta); + ef.appendChild(actions); + p.hidden = true; + body.appendChild(ef); + ta.focus(); + cancel.addEventListener('click', function () { ef.remove(); p.hidden = false; }); + ef.addEventListener('submit', function (ev) { + ev.preventDefault(); + var text = (ta.value || '').trim(); + if (!text) { notifyError('입력 필요', '덧글 내용을 입력해 주세요.'); return; } + if (text.length > 200) { notifyError('입력 초과', '덧글은 200자 이내로 입력해 주세요.'); return; } + api(commentsUrl + '/' + encodeURIComponent(c.commentId), { + method: 'PUT', headers: formHeaders(), body: encodeForm({ content: text }) + }).then(loadComments).catch(function (err) { notifyError('수정 실패', err.message); }); + }); + } + + function buildCommentItem(c) { + var li = document.createElement('li'); + li.className = 'game-comments__item'; + var nick = (c.authorName && String(c.authorName).trim()) ? String(c.authorName).trim() : '익명'; + + var av = document.createElement('div'); + av.className = 'game-comments__avatar'; + av.setAttribute('aria-hidden', 'true'); + av.textContent = nick.charAt(0).toUpperCase(); + + var body = document.createElement('div'); + body.className = 'game-comments__body'; + var meta = document.createElement('div'); + meta.className = 'game-comments__meta'; + var nickEl = document.createElement('span'); + nickEl.className = 'game-comments__nick'; + nickEl.textContent = nick; + var t = document.createElement('time'); + t.dateTime = c.createdAt || ''; + t.textContent = fmtDate(c.createdAt); + meta.appendChild(nickEl); + meta.appendChild(t); + + var p = document.createElement('p'); + p.textContent = c.content || ''; + + body.appendChild(meta); + body.appendChild(p); + + if (canActOn(c.userId)) { + var foot = document.createElement('div'); + foot.className = 'game-comments__footer'; + var editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'game-reviews__action game-reviews__action--edit'; + editBtn.textContent = '수정'; + editBtn.addEventListener('click', function () { openCommentEdit(li, body, p, c); }); + var del = document.createElement('button'); + del.type = 'button'; + del.className = 'game-reviews__action game-reviews__action--delete'; + del.textContent = '삭제'; + del.setAttribute('aria-label', '이 덧글 삭제'); + del.addEventListener('click', function () { + confirmAction('덧글 삭제', '이 덧글을 삭제할까요?', '삭제', function () { + api(commentsUrl + '/' + encodeURIComponent(c.commentId), { method: 'DELETE', headers: baseHeaders() }) + .then(loadComments).catch(function (err) { notifyError('삭제 실패', err.message); }); + }); + }); + foot.appendChild(editBtn); + foot.appendChild(del); + body.appendChild(foot); + } + + li.appendChild(av); + li.appendChild(body); + return li; + } + + function renderComments(list) { + cListEl.innerHTML = ''; + cEmptyEl.hidden = list.length > 0; + list.forEach(function (c) { cListEl.appendChild(buildCommentItem(c)); }); + } + function loadComments() { + return api(commentsUrl, { headers: baseHeaders() }) + .then(function (data) { renderComments(Array.isArray(data.comments) ? data.comments : []); }) + .catch(function () { cEmptyEl.hidden = false; }); + } + + if (cForm && cInput) { + if (viewerId == null) { + if (cComposer) cComposer.hidden = true; + if (cLoginGate) cLoginGate.hidden = false; + } else { + if (cComposer) cComposer.hidden = false; + if (cLoginGate) cLoginGate.hidden = true; + cForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + var text = (cInput.value || '').trim(); + if (!text) return; + if (text.length > 200) { notifyError('입력 초과', '덧글은 200자 이내로 입력해 주세요.'); return; } + api(commentsUrl, { method: 'POST', headers: formHeaders(), body: encodeForm({ content: text }) }) + .then(function () { cInput.value = ''; return loadComments(); }) + .catch(function (err) { notifyError('등록 실패', err.message); }); + }); + } + } + loadComments(); + + // ===== 리뷰 (서버 연동) ===== + var rListEl = document.getElementById('game-review-list'); + var rEmptyEl = document.getElementById('game-reviews-empty'); + var rComposer = document.getElementById('game-review-composer'); + var rLoginGate = document.getElementById('game-review-login-gate'); + var rForm = document.getElementById('game-review-form'); + var rInput = document.getElementById('game-review-input'); + var rStarsEl = document.getElementById('game-review-stars'); + var rSubmit = document.getElementById('game-review-submit'); + var rCancel = document.getElementById('game-review-cancel'); + var rSummary = document.getElementById('game-reviews-summary'); + var rAvg = document.getElementById('game-reviews-avg'); + var rAvgStars = document.getElementById('game-reviews-avg-stars'); + var rCount = document.getElementById('game-reviews-count'); + var reviewsUrl = ctx + '/game/' + encodeURIComponent(gid) + '/reviews'; + var selectedRating = 0; + var editingReviewId = null; // null = 작성 모드, 값 = 수정 모드 + + function fillStars(container, rating) { + container.innerHTML = ''; + for (var i = 1; i <= 5; i++) { + var s = document.createElement('span'); + s.className = 'game-star' + (i <= rating ? ' is-on' : ''); + s.textContent = '★'; + container.appendChild(s); + } + } + function buildStarDisplay(rating) { + var wrap = document.createElement('div'); + wrap.className = 'game-stars game-stars--display'; + wrap.setAttribute('aria-hidden', 'true'); + fillStars(wrap, rating); + return wrap; + } + function paintStarsInput() { + if (!rStarsEl) return; + Array.prototype.forEach.call(rStarsEl.querySelectorAll('.game-stars__btn'), function (b) { + var v = parseInt(b.getAttribute('data-value'), 10); + b.classList.toggle('is-on', v <= selectedRating); + b.setAttribute('aria-checked', v === selectedRating ? 'true' : 'false'); + }); + } + function setRating(v) { selectedRating = v; paintStarsInput(); } + + function startReviewEdit(r) { + editingReviewId = r.reviewId; + setRating(Number(r.rating) || 0); + if (rInput) rInput.value = r.body || ''; + if (rSubmit) rSubmit.textContent = '리뷰 수정'; + if (rCancel) rCancel.hidden = false; + if (rComposer) { + rComposer.hidden = false; + if (rComposer.scrollIntoView) rComposer.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + if (rInput) rInput.focus(); + } + function showComposerWrite() { + editingReviewId = null; + setRating(0); + if (rInput) rInput.value = ''; + if (rSubmit) rSubmit.textContent = '리뷰 등록'; + if (rCancel) rCancel.hidden = true; + if (rComposer) rComposer.hidden = false; + } + + function buildReviewItem(r) { + var mine = viewerId != null && r.userId != null && Number(r.userId) === Number(viewerId); + var li = document.createElement('li'); + li.className = 'game-reviews__item' + (mine ? ' is-mine' : ''); + + var head = document.createElement('div'); + head.className = 'game-reviews__item-head'; + head.appendChild(buildStarDisplay(Number(r.rating) || 0)); + + var nick = (r.authorName && String(r.authorName).trim()) ? String(r.authorName).trim() : '익명'; + var nickEl = document.createElement('span'); + nickEl.className = 'game-reviews__nick'; + nickEl.textContent = nick; + head.appendChild(nickEl); + + if (mine) { + var mb = document.createElement('span'); + mb.className = 'game-reviews__mine-badge'; + mb.textContent = '내 리뷰'; + head.appendChild(mb); + } + if (r.edited) { + var ed = document.createElement('span'); + ed.className = 'game-reviews__edited'; + ed.textContent = '· 수정됨'; + head.appendChild(ed); + } + var t = document.createElement('span'); + t.className = 'game-reviews__time'; + t.textContent = fmtDate(r.updatedAt || r.createdAt); + head.appendChild(t); + li.appendChild(head); + + if (r.body && String(r.body).trim()) { + var p = document.createElement('p'); + p.className = 'game-reviews__body'; + p.textContent = r.body; + li.appendChild(p); + } + + if (canActOn(r.userId)) { + var foot = document.createElement('div'); + foot.className = 'game-reviews__footer'; + var editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'game-reviews__action game-reviews__action--edit'; + editBtn.textContent = '수정'; + editBtn.addEventListener('click', function () { startReviewEdit(r); }); + var del = document.createElement('button'); + del.type = 'button'; + del.className = 'game-reviews__action game-reviews__action--delete'; + del.textContent = '삭제'; + del.addEventListener('click', function () { + confirmAction('리뷰 삭제', '이 리뷰를 삭제할까요?', '삭제', function () { + api(reviewsUrl + '/' + encodeURIComponent(r.reviewId), { method: 'DELETE', headers: baseHeaders() }) + .then(loadReviews).catch(function (err) { notifyError('삭제 실패', err.message); }); + }); + }); + foot.appendChild(editBtn); + foot.appendChild(del); + li.appendChild(foot); + } + return li; + } + + function updateSummary(list) { + if (!rSummary) return; + if (!list.length) { rSummary.hidden = true; return; } + var sum = list.reduce(function (acc, r) { return acc + (Number(r.rating) || 0); }, 0); + var avg = sum / list.length; + rSummary.hidden = false; + if (rAvg) rAvg.textContent = avg.toFixed(1); + if (rCount) rCount.textContent = '(' + list.length + ')'; + if (rAvgStars) fillStars(rAvgStars, Math.round(avg)); + } + + function applyReviewGate(list) { + if (viewerId == null) { + if (rComposer) rComposer.hidden = true; + if (rLoginGate) rLoginGate.hidden = false; + return; + } + if (rLoginGate) rLoginGate.hidden = true; + var mineExists = list.some(function (r) { return r.userId != null && Number(r.userId) === Number(viewerId); }); + if (mineExists) { + if (rComposer) rComposer.hidden = true; // 이미 작성 — 수정은 본인 카드의 '수정' 버튼으로 + } else { + showComposerWrite(); // 미작성 — 작성 폼 노출 } } - function saveComments(list) { - try { - var raw = localStorage.getItem(COMMENT_KEY); - var o = raw ? JSON.parse(raw) : {}; - if (typeof o !== 'object' || o === null) o = {}; - o[gid] = list; - localStorage.setItem(COMMENT_KEY, JSON.stringify(o)); - } catch (err) {} + function renderReviews(list) { + rListEl.innerHTML = ''; + rEmptyEl.hidden = list.length > 0; + list.forEach(function (r) { rListEl.appendChild(buildReviewItem(r)); }); + updateSummary(list); + applyReviewGate(list); + } + function loadReviews() { + editingReviewId = null; + return api(reviewsUrl, { headers: baseHeaders() }) + .then(function (data) { renderReviews(Array.isArray(data.reviews) ? data.reviews : []); }) + .catch(function () { rEmptyEl.hidden = false; }); } - var listEl = document.getElementById('game-comment-list'); - var emptyEl = document.getElementById('game-comments-empty'); - var form = document.getElementById('game-comment-form'); - var input = document.getElementById('game-comment-input'); - var DEFAULT_NICK = '익명'; - - function renderComments() { - var items = getComments().slice().sort(function (a, b) { - return (b.at || '').localeCompare(a.at || ''); - }); - listEl.innerHTML = ''; - emptyEl.hidden = items.length > 0; - items.forEach(function (c) { - var li = document.createElement('li'); - li.className = 'game-comments__item'; - var av = document.createElement('div'); - var nick = (c.nickname && String(c.nickname).trim()) ? String(c.nickname).trim() : DEFAULT_NICK; - av.className = 'game-comments__avatar'; - av.setAttribute('aria-hidden', 'true'); - av.textContent = nick.charAt(0).toUpperCase(); - var body = document.createElement('div'); - body.className = 'game-comments__body'; - var meta = document.createElement('div'); - meta.className = 'game-comments__meta'; - var nickEl = document.createElement('span'); - nickEl.className = 'game-comments__nick'; - nickEl.textContent = nick; - var t = document.createElement('time'); - t.dateTime = c.at || ''; - try { - t.textContent = c.at ? new Date(c.at).toLocaleString('ko-KR') : ''; - } catch (e) { - t.textContent = ''; - } - meta.appendChild(nickEl); - meta.appendChild(t); - var p = document.createElement('p'); - p.textContent = c.text || ''; - body.appendChild(meta); - body.appendChild(p); - if (c.id) { - var foot = document.createElement('div'); - foot.className = 'game-comments__footer'; - var del = document.createElement('button'); - del.type = 'button'; - del.className = 'game-comments__delete'; - del.textContent = '삭제'; - del.setAttribute('aria-label', '이 덧글 삭제'); - del.addEventListener('click', function () { - var next = getComments().filter(function (x) { return x.id !== c.id; }); - saveComments(next); - renderComments(); - }); - foot.appendChild(del); - body.appendChild(foot); - } - li.appendChild(av); - li.appendChild(body); - listEl.appendChild(li); + if (rStarsEl) { + Array.prototype.forEach.call(rStarsEl.querySelectorAll('.game-stars__btn'), function (b) { + b.addEventListener('click', function () { setRating(parseInt(b.getAttribute('data-value'), 10)); }); }); } - - form.addEventListener('submit', function (ev) { - ev.preventDefault(); - var text = (input.value || '').trim(); - if (!text) return; - var id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random(); - var entry = { id: id, text: text, at: new Date().toISOString(), nickname: DEFAULT_NICK }; - var list = getComments(); - list.push(entry); - saveComments(list); - input.value = ''; - renderComments(); - }); - - renderComments(); + if (rCancel) { + rCancel.addEventListener('click', function () { loadReviews(); }); + } + if (rForm) { + rForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + if (viewerId == null) { notifyError('로그인 필요', '리뷰를 남기려면 로그인이 필요합니다.'); return; } + if (!(selectedRating >= 1 && selectedRating <= 5)) { notifyError('별점 필요', '별점을 선택해 주세요.'); return; } + var bodyText = ((rInput && rInput.value) || '').trim(); + if (bodyText.length > 1000) { notifyError('입력 초과', '평가는 1,000자 이내로 입력해 주세요.'); return; } + var isEdit = editingReviewId != null; + var url = isEdit ? (reviewsUrl + '/' + encodeURIComponent(editingReviewId)) : reviewsUrl; + api(url, { method: isEdit ? 'PUT' : 'POST', headers: formHeaders(), body: encodeForm({ rating: selectedRating, body: bodyText }) }) + .then(loadReviews) + .catch(function (err) { notifyError(isEdit ? '수정 실패' : '등록 실패', err.message); }); + }); + } + loadReviews(); })(); diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index f090977..892a454 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -1,5 +1,7 @@ package com.pandoli365.bibimbap; +import com.pandoli365.bibimbap.mapper.GameCommentsMapper; +import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper; @@ -19,6 +21,12 @@ class BibimbapApplicationTests { @MockBean private GamesMapper gamesMapper; + @MockBean + private GameCommentsMapper gameCommentsMapper; + + @MockBean + private GameReviewsMapper gameReviewsMapper; + @MockBean private RecruitPostsMapper recruitPostsMapper; diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java new file mode 100644 index 0000000..67a2377 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java @@ -0,0 +1,276 @@ +package com.pandoli365.bibimbap.controller.api; + +import com.pandoli365.bibimbap.data.GameCommentData; +import com.pandoli365.bibimbap.data.GameData; +import com.pandoli365.bibimbap.mapper.GameCommentsMapper; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; + +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.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GameCommentControllerTest { + + @Mock + private GameCommentsMapper gameCommentsMapper; + + @Mock + private GamesMapper gamesMapper; + + // ---- AC-1: 댓글 CRUD ---- + + @Test + void createCommentPersistsAndReturnsView() { + 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; + }); + + ResponseEntity> response = + controller.createComment(1L, "좋은 게임", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("commentId", 100L); + assertThat(response.getBody()).containsEntry("authorName", "작성자"); + assertThat(response.getBody()).containsEntry("userId", 7L); + verify(gameCommentsMapper).addGameComment(any(GameCommentData.class)); + } + + @Test + 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)); + + ResponseEntity> response = controller.listComments(1L); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + List> comments = (List>) response.getBody().get("comments"); + assertThat(comments).hasSize(1); + assertThat(comments.get(0)).containsEntry("commentId", 5L); + assertThat(comments.get(0)).containsEntry("authorName", "작성자"); + } + + @Test + void updateCommentByAuthorSucceeds() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(7L, "USER", "작성자"); + MockHttpServletRequest request = csrfPost(session); + when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L)); + + ResponseEntity> response = + controller.updateComment(1L, 5L, "수정된 내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gameCommentsMapper).editGameComment(any(GameCommentData.class)); + } + + @Test + void deleteCommentByAuthorSucceeds() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(7L, "USER", "작성자"); + MockHttpServletRequest request = csrfPost(session); + when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L)); + + ResponseEntity> response = + controller.deleteComment(1L, 5L, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gameCommentsMapper).softDeleteGameComment(5L); + } + + // ---- AC-2: 비작성자 불가 + 운영자 예외 ---- + + @Test + void updateCommentByNonAuthorIsForbidden() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(8L, "USER", "다른사용자"); + MockHttpServletRequest request = csrfPost(session); + when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L)); + + ResponseEntity> response = + controller.updateComment(1L, 5L, "강제수정", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verify(gameCommentsMapper, never()).editGameComment(any()); + } + + @Test + void deleteCommentByOperatorSucceeds() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(99L, "ADMIN", "운영자"); + MockHttpServletRequest request = csrfPost(session); + when(gameCommentsMapper.getGameComment(5L)).thenReturn(comment(5L, 1L, 7L)); + + ResponseEntity> response = + controller.deleteComment(1L, 5L, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gameCommentsMapper).softDeleteGameComment(5L); + } + + // ---- AC-5: 댓글 201자 거부 / 200자 경계 ---- + + @Test + void createCommentRejects201Chars() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(7L, "USER", "작성자"); + MockHttpServletRequest request = csrfPost(session); + when(gamesMapper.getGame(1L)).thenReturn(game(1L)); + + ResponseEntity> response = + controller.createComment(1L, "가".repeat(201), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verify(gameCommentsMapper, never()).addGameComment(any()); + } + + @Test + void createCommentAccepts200CharsBoundary() { + 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(101L); + return 1; + }); + + ResponseEntity> response = + controller.createComment(1L, "가".repeat(200), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gameCommentsMapper).addGameComment(any(GameCommentData.class)); + } + + // ---- AC-7: CSRF 부재 403 (mutation 전) ---- + + @Test + void createCommentRejectsMissingCsrfBeforeMapperAccess() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(7L, "USER", "작성자"); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.createComment(1L, "내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(gameCommentsMapper); + verify(gamesMapper, never()).getGame(anyLong()); + } + + @Test + void updateCommentRejectsMissingCsrfBeforeMapperAccess() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(7L, "USER", "작성자"); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.updateComment(1L, 5L, "내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(gameCommentsMapper); + } + + @Test + void deleteCommentRejectsMissingCsrfBeforeMapperAccess() { + GameCommentController controller = controller(); + MockHttpSession session = loginSession(7L, "USER", "작성자"); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.deleteComment(1L, 5L, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(gameCommentsMapper); + } + + // ---- 401 (비로그인) ---- + + @Test + void createCommentRequiresLogin() { + GameCommentController controller = controller(); + MockHttpSession session = new MockHttpSession(); + CsrfTokens.getOrCreate(session); + MockHttpServletRequest request = csrfPost(session); + + ResponseEntity> response = + controller.createComment(1L, "내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(gameCommentsMapper); + } + + // ---- helpers ---- + + private GameCommentController controller() { + return new GameCommentController(gameCommentsMapper, gamesMapper); + } + + private GameData game(long id) { + GameData g = new GameData(); + g.setId(id); + g.setUserId(1L); + return g; + } + + private GameCommentData comment(long id, long gameId, long userId) { + GameCommentData c = new GameCommentData(); + c.setId(id); + c.setGameId(gameId); + c.setUserId(userId); + c.setNickname("작성자"); + c.setContent("기존 내용"); + return c; + } + + private MockHttpSession loginSession(long userId, String role, String displayName) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userId", userId); + session.setAttribute("role", role); + session.setAttribute("displayName", displayName); + CsrfTokens.getOrCreate(session); + return session; + } + + private MockHttpServletRequest csrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE)); + return request; + } + + private MockHttpServletRequest noCsrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java new file mode 100644 index 0000000..255cc0a --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java @@ -0,0 +1,282 @@ +package com.pandoli365.bibimbap.controller.api; + +import com.pandoli365.bibimbap.data.GameData; +import com.pandoli365.bibimbap.data.GameReviewData; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GameReviewControllerTest { + + @Mock + private GameReviewsMapper gameReviewsMapper; + + @Mock + private GamesMapper gamesMapper; + + // ---- AC-4: 수정 시 edited 토글 (작성 직후 false → 수정 후 true) ---- + + @Test + void createReviewReturnsEditedFalse() { + 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> response = + controller.createReview(1L, "4", "재미있음", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("reviewId", 50L); + assertThat(response.getBody()).containsEntry("edited", false); + verify(gameReviewsMapper).addGameReview(any(GameReviewData.class)); + } + + @Test + void updateReviewReturnsEditedTrue() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(7L, "USER"); + MockHttpServletRequest request = csrfPost(session); + when(gameReviewsMapper.getGameReview(50L)) + .thenReturn(review(50L, 1L, 7L, 4, false)) + .thenReturn(review(50L, 1L, 7L, 5, true)); + + ResponseEntity> response = + controller.updateReview(1L, 50L, "5", "더 좋아짐", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("edited", true); + verify(gameReviewsMapper).editGameReview(any(GameReviewData.class)); + } + + // ---- AC-3: 게임당 1회 409 ---- + + @Test + void createSecondReviewReturnsConflict() { + 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(review(50L, 1L, 7L, 4, false)); + + ResponseEntity> response = + controller.createReview(1L, "5", "또작성", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + verify(gameReviewsMapper, never()).addGameReview(any()); + } + + // ---- AC-6: rating 0/6 거부, 1/5 경계 ---- + + @Test + void createReviewRejectsRatingZero() { + assertCreateRatingRejected("0"); + } + + @Test + void createReviewRejectsRatingSix() { + assertCreateRatingRejected("6"); + } + + @Test + void createReviewAcceptsRatingOneBoundary() { + assertCreateRatingAccepted("1"); + } + + @Test + void createReviewAcceptsRatingFiveBoundary() { + assertCreateRatingAccepted("5"); + } + + @Test + void updateReviewRejectsRatingSix() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(7L, "USER"); + MockHttpServletRequest request = csrfPost(session); + when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false)); + + ResponseEntity> response = + controller.updateReview(1L, 50L, "6", "범위초과", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verify(gameReviewsMapper, never()).editGameReview(any()); + } + + // ---- AC-2: 비작성자 불가 + 운영자 예외 ---- + + @Test + void updateReviewByNonAuthorIsForbidden() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(8L, "USER"); + MockHttpServletRequest request = csrfPost(session); + when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false)); + + ResponseEntity> response = + controller.updateReview(1L, 50L, "3", "강제수정", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verify(gameReviewsMapper, never()).editGameReview(any()); + } + + @Test + void deleteReviewByOperatorSucceeds() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(99L, "ADMIN"); + MockHttpServletRequest request = csrfPost(session); + when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false)); + + ResponseEntity> response = + controller.deleteReview(1L, 50L, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gameReviewsMapper).softDeleteGameReview(50L); + } + + // ---- AC-7: CSRF 부재 403 ---- + + @Test + void createReviewRejectsMissingCsrfBeforeMapperAccess() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(7L, "USER"); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.createReview(1L, "4", "내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(gameReviewsMapper); + verify(gamesMapper, never()).getGame(anyLong()); + } + + @Test + void updateReviewRejectsMissingCsrfBeforeMapperAccess() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(7L, "USER"); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.updateReview(1L, 50L, "4", "내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(gameReviewsMapper); + } + + @Test + void deleteReviewRejectsMissingCsrfBeforeMapperAccess() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(7L, "USER"); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.deleteReview(1L, 50L, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(gameReviewsMapper); + } + + // ---- helpers ---- + + private void assertCreateRatingRejected(String rating) { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(7L, "USER"); + MockHttpServletRequest request = csrfPost(session); + when(gamesMapper.getGame(1L)).thenReturn(game(1L)); + + ResponseEntity> response = + controller.createReview(1L, rating, "내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verify(gameReviewsMapper, never()).addGameReview(any()); + } + + private void assertCreateRatingAccepted(String rating) { + 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, Integer.parseInt(rating), false)); + + ResponseEntity> response = + controller.createReview(1L, rating, "내용", request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gameReviewsMapper).addGameReview(any(GameReviewData.class)); + } + + private GameReviewController controller() { + return new GameReviewController(gameReviewsMapper, gamesMapper); + } + + private GameData game(long id) { + GameData g = new GameData(); + g.setId(id); + g.setUserId(1L); + return g; + } + + private GameReviewData review(long id, long gameId, long userId, int rating, boolean edited) { + GameReviewData r = new GameReviewData(); + r.setId(id); + r.setGameId(gameId); + r.setUserId(userId); + r.setRating(rating); + r.setBody("내용"); + r.setAuthorName("작성자"); + r.setEdited(edited); + return r; + } + + private MockHttpSession loginSession(long userId, String role) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userId", userId); + session.setAttribute("role", role); + session.setAttribute("displayName", "작성자"); + CsrfTokens.getOrCreate(session); + return session; + } + + private MockHttpServletRequest csrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE)); + return request; + } + + private MockHttpServletRequest noCsrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } +}