feat: W3-2 댓글/리뷰 분리 구현

기존 game_comments(닉네임 자유입력, localStorage 전용)를 서버 영속화 +
로그인 연동으로 전환하고, 별점 5점 + 서술 평가 형태의 game_reviews 도메인 신설.

- 댓글 서버 영속화: localStorage → DB game_comments. user_id NULL FK 비파괴 추가,
  content 200자 앱레벨 검증. 레거시 nickname 레코드 보존(비마이그레이션, QG-2).
- game_reviews 신설: 게임당 1회(partial UNIQUE), rating CHECK(1~5), soft-delete.
- 권한: 작성자 본인 OR ROLE_ADMIN. 비로그인 쓰기 401, CSRF 미검증 403.
- GameController.deleteGame 에 리뷰 cascade soft-delete 추가.
- GameCommentController(C1~C4) / GameReviewController(R1~R5) 신규.
- 컨트롤러 단위 테스트 추가, BibimbapApplicationTests 매퍼 @MockBean 보강.
- 보안 체크리스트 댓글 항목 충족 마킹, 좋아요 항목은 범위 밖 미결 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019tHAb6XYHWDPzb82FNKugo
This commit is contained in:
이정수 2026-06-18 14:35:24 +09:00
parent f9590e59bc
commit e98c437765
25 changed files with 3434 additions and 114 deletions

View File

@ -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<Map<String,Object>>` 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<GameCommentData> 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<GameReviewData> 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 에 `<script>` payload 저장 → JSP 서버 렌더는 `&lt;script&gt;` escape, 클라이언트 textContent 는 문자열 표시. 스크립트 미실행. (검증: 렌더 출력에 raw `<script>` 부재 — escape 확인.)
- **AC-9 (영속성)**: C2/R3 후 새 세션/브라우저로 C1/R1 조회 → 데이터 잔존(DB 영속, localStorage 비의존).
- **AC-10 (게임 삭제 cascade)**: deleteGame 후 해당 game_id 의 game_comments·game_reviews 전부 is_delete=true. softDeleteGameReviews 호출 검증.
### 집합 전수 AC (§4.3)
- **AC-AGG-1 (엔드포인트 전수 9건)**: 댓글 컨트롤러 매핑 4건(@GetMapping C1 + @PostMapping C2 + @PutMapping C3 + @DeleteMapping C4) + 리뷰 컨트롤러 매핑 5건(R1 R2 GET + R3 POST + R4 PUT + R5 DELETE) == 9.
- 검증(불변식, 시점 안정): `grep -cE '@(Get|Post|Put|Delete)Mapping' src/main/java/.../GameCommentController.java` == 4 **AND** `... GameReviewController.java` == 5. (두 파일 합 9. 컨트롤러 파일 한정 → 다른 매핑 오염 없음.)
- §4.7 시점: 두 컨트롤러는 본 작업 산출물이며 구현 완료 시점에 고정 — verification 시점에도 동일. 안정.
- §4.7 표현: 매핑 애너테이션은 Spring 고정 토큰(동의 표현 없음) → 리터럴 grep 견고. 단 메서드 수가 아니라 매핑 애너테이션 수를 센다(헬퍼 메서드 오카운트 방지).
- **AC-AGG-2 (DDL 3종 산출 전수)**: ① schema.sql 에 `game_reviews` CREATE TABLE 블록 존재(`grep -c 'CREATE TABLE IF NOT EXISTS "game_reviews"' db/schema.sql` >= 1) + game_comments user_id ADD COLUMN 존재. ② `docs/game-reviews-ddl.sql` 파일 존재(ls). ③ idempotent ALTER 섹션(②파일 내 `ADD COLUMN IF NOT EXISTS "user_id"` + `DO $$` 블록 존재). 3종 모두 충족.
- **AC-AGG-3 (CSRF 게이트 전수)**: 댓글/리뷰 mutation 6개 핸들러(C2 C3 C4 R3 R4 R5) 전부 진입부 `CsrfTokens.isValid(request)` 호출. `grep -c 'CsrfTokens.isValid' (두 컨트롤러)` == 6. (시점: 산출물 고정. 표현: 메서드명 고정 토큰.)
### §4.7 self-audit 1패스 결과
- AC-AGG-1/AGG-3: 카운트 대상이 **본 작업이 만드는 신규 파일**이라 verification 시점까지 값 불변(자기 트리 증가 대상 아님 — 20260616 AC-3 류 함정 회피). 고정 스칼라 허용. 표현은 Spring/메서드 고정 토큰이라 동의표현 누락 위험 없음.
- AC-8(XSS): 단일 리터럴 grep 의존 회피 — "raw `<script>` 부재 AND escape 출력 존재"의 의미 불변식 + 수동 렌더 확인 병행(escape 함수가 `&lt;`로 바꾸는지). 표현 견고성 확보.
- AC-3/AC-4: 테스트 내 자족 데이터로 시점 독립.
---
## 미해결 / 에스컬레이션
- 없음(전 결정 확정). 단 frontmatter `concerns` 4건은 **동결영역 비침범 확인 기록 + inflate 재확인 마킹 + W1 의존**으로, 차단 이슈가 아니라 구현/검증 시 점검 포인트. orchestrator 반환만 하고 요구사항 수정은 하지 않음.

View File

@ -0,0 +1,38 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: "2026-06-18T14:00:00+09:00"
concerns:
- "design-advisor가 세션 내에서 design.md를 생성했으나 docs/architecture/ 갱신은 수행하지 않았음. W3-2 도메인 추가가 architecture/ 구조 설명에 반영이 필요한지 추후 검토 대상."
- "L3 브라우저 스모크(XSS·영속·cascade DB) 및 DDL 적용이 needs_user_verification 상태. 완료 후 security-remediation-checklist.md의 [~] 항목을 [x]로 갱신 필요."
- "운영자 ROLE_ADMIN 부여 경로는 W1 RBAC 연결 시 활성. W1 완료 후 보안 체크리스트 별도 항목 검토 필요."
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/changes/2026-06-18-w3-2-comments-reviews.md` | changes | 신규 | `docs/changes/index.md` | `docs/work-log/2026-06-17-w3-feature-skeletons.md`, `docs/security/security-remediation-checklist.md` |
| `docs/changes/index.md` | changes | 기존 갱신 | — | — |
| `docs/security/security-remediation-checklist.md` | security | 기존 갱신 | — | `docs/changes/2026-06-18-w3-2-comments-reviews.md` |
| `docs/work-log/2026-06-17-w3-feature-skeletons.md` | work-log | 기존 갱신(§W3-2 상태 1줄) | — | `docs/changes/2026-06-18-w3-2-comments-reviews.md` |
| `.atp/work-session/20260618-104034/documentation.md` | — | 산출물(본 파일) | — | — |
## 의사결정 기록 위치
- **plan gate 결정 2건** (댓글 완전 서버화 / 휴면 운영자 경로): `.atp/work-session/20260618-104034/report.md` §Decisions
- **seed 반전 발견** (댓글·좋아요 localStorage 전용, 운영자 role 부재): `.atp/work-session/20260618-104034/research/code-coupling.md`
- **설계 권위 명세** (DDL 3종, API 9개, AC 13): `.atp/work-session/20260618-104034/design.md`
- **QG-2 비파괴 결정** (user_id NULL + 레거시 보존): `.atp/work-session/20260618-104034/report.md` §Decisions (orchestrator 브리프 이월 기본값)
## 추후 문서화가 필요한 항목
- **L3 스모크 완료 후**: `docs/security/security-remediation-checklist.md` B3 [~] 항목 → [x] 갱신. 특히 XSS 브라우저 미실행, 영속성 브라우저 재방문 확인.
- **DDL 적용 완료 후**: `docs/maintenance/` 또는 운영 절차 문서에 game_reviews DDL 적용 이력 기록 검토(현재 docs/game-reviews-ddl.sql이 권위 DDL로 존재).
- **W1 RBAC 완료 후**: 운영자 role 부여 경로 활성화 내용을 `docs/security/security-remediation-checklist.md` B3 및 관련 changes/ 에 추가.
- **W2-6 시상 집계 구현 후**: game_reviews 평점 공급원 → 집계 계약(SELECT AVG/COUNT) 이행 내용을 changes/ 에 별도 기록.
- **좋아요 영속화 착수 시**: B3 체크리스트 좋아요 항목(101~104) 별도 변경 이력 문서 작성.

View File

@ -0,0 +1,33 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-18T11:20:00+09:00
---
# 파일 소유권 맵 — W3-2 댓글/리뷰 분리 (백엔드 only)
직접 작성 채택 사유: 파일 11개이나 POJO→Mapper→Controller→Test 가 강한 상호의존(필드명·시그니처·SQL alias 가 한 줄이라도 어긋나면 컴파일/매핑 실패). 1파일 1worker 분산 시 worker 간 계약 동기화 비용 > 병렬 이득. 오케스트레이터 지시(파일 상호의존 시 직접 작성 허용) + 프로토콜 §11.2 계량 근거(파일수 11 > 8 이나 줄수 다수 < 500 이고 cross-file 계약 결합도 최상) advisor 직접 Write/Edit 선택.
| 파일 | 담당 | 변경 유형 | 의존 |
|---|---|---|---|
| db/schema.sql | advisor | modify | - |
| docs/game-reviews-ddl.sql | advisor | create | - |
| src/main/java/com/pandoli365/bibimbap/data/GameCommentData.java | advisor | modify | - |
| src/main/java/com/pandoli365/bibimbap/data/GameReviewData.java | advisor | create | - |
| src/main/java/com/pandoli365/bibimbap/mapper/GameCommentsMapper.java | advisor | modify | GameCommentData |
| src/main/java/com/pandoli365/bibimbap/mapper/GameReviewsMapper.java | advisor | create | GameReviewData |
| src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java | advisor | modify | - |
| src/main/java/com/pandoli365/bibimbap/controller/api/GameCommentController.java | advisor | create | GameCommentsMapper, GamesMapper, GameCommentData |
| src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java | advisor | create | GameReviewsMapper, GamesMapper, GameReviewData |
| src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java | advisor | modify | GameCommentsMapper, GameReviewsMapper, GamesMapper |
| src/test/java/com/pandoli365/bibimbap/controller/api/GameCommentControllerTest.java | advisor | create | GameCommentController |
| src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java | advisor | create | GameReviewController |
planned_workers: 0 (직접 작성 선택)
actual_workers: 0
## 불변식 점검
- 동일 파일 1소유: OK (전부 advisor 단독)
- game-detail.jsp 제외: /frontend-design 위임 (스코프 명시)
- DDL DB 적용 금지: 파일 작성만

View File

@ -0,0 +1,281 @@
---
schema_version: 2
session_id: 20260618-104034
resumed_from: null
started_at: 2026-06-18T10:40:34+09:00
ended_at: 2026-06-18T13:20:00+09:00
user_request: |
W3-2 댓글/리뷰 분리 기능을 설계·구현한다.
- game_comments 로그인 연동 전환 + content 200자 제한
- game_reviews 신규 (게임당 1회, 별점 5점 + 서술 평가)
- 댓글 수정/삭제 권한: 작성자 본인 + 운영자
- 리뷰 수정: 가능 + 이력 보존, 노출은 "수정됨" 마커만
- 보안: CSRF 검증, XSS escape, 작성자 권한 체크
- 자율 진행: 미결은 안전 기본값으로 진행 + 이월 기록. 데이터 손실/하류 파손 결정만 사용자 확인.
- 프론트는 /frontend-design 스킬 사용.
acceptance criteria:
- 로그인 사용자 댓글 작성/수정/삭제, 비작성자 불가(운영자 예외)
- 리뷰 게임당 1회, 수정 시 "수정됨" 마커 + 이력 보존
- 댓글 201자 이상 거부
- CSRF 토큰 없는 상태변경 실패
- XSS payload 미실행
- 새로고침/브라우저 변경 후 영속
---
# Summary
W3-2 댓글/리뷰 분리 기능을 설계·구현했다. research 가 작업 브리프의 seed 가정 2건을 뒤집었다(① 댓글·좋아요가 서버 미연동 localStorage 전용이고 서버 매퍼는 orphan, ② 운영자 role 이 코드에 부재—USER만 발급). §2.7-5 plan gate 로 사용자 확정 후, 댓글을 서버 영속화(fetch API + user_id 귀속 + 200자) + game_reviews 도메인 신설(게임당 1회 partial UNIQUE, 별점 1~5, 수정됨=updated_at>created_at in-row, 휴면 운영자 경로)로 진행했다.
백엔드 14파일(DDL 3종 + POJO/Mapper/Controller 신규·변경 + 테스트 2), 프론트(game-detail.jsp 댓글 서버화 + 리뷰 UI·별점 위젯·수정됨 마커, /frontend-design 스킬)를 구현했다. 검증 1차에서 BibimbapApplicationTests.contextLoads 회귀(신규 매퍼 @MockBean 누락) 1건이 잡혀 §2.6 backward re-dispatch 로 발원(implementation 테스트 scaffolding)을 진단·전수재검 후 2개 @MockBean 추가로 해소. 최종 L1 31 테스트 PASS + AGG 전수 PASS. L3(브라우저 XSS·영속·cascade DB)는 needs_user_verification. DDL 미적용(§6 게이트 — 사용자 DB 적용 대기).
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '요청에 FR/in/out/이월 기본값/AC 가 전부 명시됨. 단 옵션 공간 판정은 design 산출 이후로 보류(프로토콜 §1).'
checked_at: 2026-06-18T10:40:34+09:00
- advisor: graphify-lookup-advisor
decision: call
rationale: '코드 구조 조사 1차 진입점. game_comments/games/로그인·세션/CSRF/JSP 댓글/DDL 적용방식 결합점 확인 필요.'
checked_at: 2026-06-18T10:41:00+09:00
result: 'src scope graph.json 미생성(no-graph). docs scope만 hit(DDL/스키마 부트스트랩). 코드 포인트 7/7 miss → research 필요.'
- advisor: research-advisor
decision: call
rationale: 'lookup 7/7 miss. 댓글 CRUD·게임상세·로그인세션·CSRF·DDL적용·RecruitController·GameLikes 코드 결합점 직접 조사 필요. parallel-explorer 병렬.'
checked_at: 2026-06-18T10:45:00+09:00
- advisor: graphify-update-advisor (src scope 재생성)
decision: defer
rationale: '구현으로 src 변경 예정 → 지금 재생성하면 이중 작업. 세션 종료 전 graph-refresh 단계에서 일괄 재생성(no-defer 정책은 그 시점 처리로 충족).'
checked_at: 2026-06-18T10:45:00+09:00
- advisor: research-advisor
decision: re-call
rationale: '1차 호출이 async worker 6개 spawn 후 취합 전 rest 복귀 — code-coupling.md 미산출. SendMessage 미제공으로 재개 불가. 강한 블로킹 지시로 재호출(worker TaskOutput 대기 + 파일 디스크 기록 후에만 반환).'
checked_at: 2026-06-18T10:52:00+09:00
# Invocations
- id: inv-000
layer: orchestrator
name: orchestrator
parent_invocation_id: null
started_at: 2026-06-18T10:40:34+09:00
input_digest: 'W3-2 댓글/리뷰 분리 설계·구현 요청 (자율 진행 모드)'
output_digest: 'init 가드 통과, atp:migrate 마커 없음(skip), work-session 생성, 프로토콜 로드'
artifacts: ['.atp/work-session/20260618-104034/report.md']
concerns: []
- id: inv-001
layer: advisor
name: graphify-lookup-advisor
parent_invocation_id: inv-000
input_digest: '코드 구조 7포인트 graph 인덱스 조회'
output_digest: 'src scope graph 미생성(no-graph), docs만 hit, 7/7 miss → research 필요'
artifacts: []
model_choice: { phase: graphify-exec, dispatch_size: direct, tier: small, effort: low, resolved_model: haiku, capped: false, rationale: '인덱스 조회 기계적 판단' }
- id: inv-002
layer: advisor
name: research-advisor
parent_invocation_id: inv-000
input_digest: '11개 코드 결합점 정밀 조사(file:line)'
output_digest: 'research/code-coupling.md — 댓글/좋아요 서버미연동 발견, CSRF/세션 인프라 재사용 가능, 운영자 role 부재'
artifacts: ['research/code-coupling.md']
model_choice: { phase: analyze, dispatch_size: l-batch, tier: large, effort: high, resolved_model: opus, capped: false, escalation_reason: '탐색적 코드 조사 + 다파일 교차', rationale: '결합점 정확도가 설계 좌우' }
notes: '1차 호출(a2177)이 async worker 취합 전 조기복귀 → 강한 블로킹 지시로 재호출(a4873) 성공. 두 호출 모두 동일 결론.'
- id: inv-003
layer: advisor
name: design-advisor
parent_invocation_id: inv-000
input_digest: 'research + plan gate 결정 → 구현가능 설계'
output_digest: 'design.md — DDL 3종/9 API/권한모델/cascade/AC 13(기능10+전수3)'
artifacts: ['design.md']
model_choice: { phase: design, dispatch_size: l-batch, tier: large, effort: high, resolved_model: opus, capped: false, escalation_reason: '보안·인증·권한 §5.2 자동상승', rationale: 'trade-off 빈번' }
- id: inv-004
layer: advisor
name: implementation-advisor
parent_invocation_id: inv-000
input_digest: 'design.md 권위 명세 → 백엔드 구현 + 마이그레이션 파일'
output_digest: '14파일(DDL3+POJO+Mapper+Controller+test2). test-compile BUILD SUCCESS. workers_spawned=0(직접). implementation/ownership.md'
artifacts: ['implementation/ownership.md']
model_choice: { phase: code-implementation, dispatch_size: l-batch, tier: large, effort: high, resolved_model: opus, capped: false, escalation_reason: '보안/권한 코드', rationale: '다파일 강결합 직접 작성' }
planned_workers: 0
actual_workers: 0
- id: inv-005
layer: orchestrator
name: orchestrator (frontend via /frontend-design)
parent_invocation_id: inv-000
input_digest: 'game-detail.jsp 댓글 서버화 + 리뷰 UI (브리프 지정 /frontend-design 스킬, §1 사용자명시 예외)'
output_digest: 'GameController 모델 노출(currentUserId/userRole) + game-detail.jsp HTML/CSS/JS(별점위젯·수정됨·로그인게이트, textContent XSS안전). test-compile SUCCESS'
artifacts: ['src/main/webapp/WEB-INF/views/game-detail.jsp']
model_choice: { phase: code-implementation, dispatch_size: direct, tier: large, effort: high, resolved_model: inherit, capped: false, rationale: 'orchestrator 본 모델 직접(스킬 가이드)' }
- id: inv-006
layer: advisor
name: verification-advisor
parent_invocation_id: inv-000
input_digest: 'AC 13 + 실행명령(설계/diff 비접근)'
output_digest: '1차 fail(contextLoads 회귀 blocker). 수정 후 재실행 31 PASS + AGG 전수 PASS'
artifacts: ['verification.md']
model_choice: { phase: validation-static, dispatch_size: s-batch, tier: medium, effort: medium, resolved_model: sonnet, capped: false, rationale: '결정적 테스트/grep 판정' }
- id: inv-007
layer: orchestrator
name: orchestrator (regression fix §2.6)
parent_invocation_id: inv-000
input_digest: 'contextLoads blocker — 신규 매퍼 @MockBean 누락'
output_digest: 'BibimbapApplicationTests 에 GameCommentsMapper/GameReviewsMapper @MockBean 2개 추가(기존 4개 패턴 미러). 재실행 31 PASS'
artifacts: ['src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java']
model_choice: { phase: code-implementation, dispatch_size: direct, tier: large, effort: low, resolved_model: inherit, capped: false, rationale: '결정적 2줄 마이크로 편집 §1 예외' }
# Decisions
- by: research-advisor + user (plan gate §2.7-5)
at: 2026-06-18T11:10:00+09:00
decision: '댓글/좋아요 서버 미연동(localStorage 전용) seed 반전 확인. 댓글 완전 서버화 채택(Q1=Recommended).'
rationale: 'AC "브라우저 변경 후 영속"이 서버화를 강제. localStorage JS→fetch 교체 포함. 기존 localStorage 댓글 비마이그레이션(보안 [hold] 정합).'
related_invocations: [inv-002]
- by: user (plan gate §2.7-5)
at: 2026-06-18T11:10:00+09:00
decision: '운영자 예외 = 휴면 운영자 경로(Q2=Recommended). 권한체크 "작성자 OR 운영자급 role", ADMIN 상수 정의, 부여자 없음(W1 연결).'
rationale: '브리프 "W1 완료 시 교체 가능 설계"와 정합. 단위테스트로 운영자 분기 검증, prod 무영향.'
related_invocations: [inv-002]
- by: orchestrator (브리프 이월 기본값)
at: 2026-06-18T11:10:00+09:00
decision: '별점 5점 단일 확정(다축 컬럼 확장여지). 댓글/리뷰 2 테이블 분리(game_comments 변경 + game_reviews 신규). 좋아요 범위밖 localStorage 유지. W2-3 집계계약 이월(평점 컬럼만 확정). QG-2 비파괴(user_id nullable + 레거시 표시) — 테이블 사실상 빈 상태 추정.'
rationale: '브리프 이월 기본값 + research 결합점. 되돌릴 수 있어 carry-over 안전.'
related_invocations: [inv-001, inv-002]
# Conflicts
(없음 — 단일 design-advisor, concerns 4건 모두 동결영역 비침범 자가확인. 충돌 중재 불요.)
# Regression
- surfaced_at_stage: verification (L1 전체 테스트)
source_stage: implementation (테스트 scaffolding)
defect: 'BibimbapApplicationTests.contextLoads — 신규 GameCommentController/GameReviewController 가 주입하는 GameCommentsMapper/GameReviewsMapper 가 MyBatis autoconfigure exclude 컨텍스트 테스트의 @MockBean 셋에 미등록 → NoSuchBeanDefinitionException'
full_set_recheck: true # 컨트롤러 주입 매퍼 전수 점검: {Games,Recruit,UserAuthIdentities,Users,GameComments,GameReviews} 중 누락 2개만 추가하면 완전 확인
downstream_rerun: ['L1 전체 테스트 재실행 — 31 PASS']
resolved_at: 2026-06-18T12:55:00+09:00
# verified_by_me
- 'L1 typecheck/compile: ./mvnw -o test-compile BUILD SUCCESS'
- 'L1 unit+regression: ./mvnw -o test — Tests run 31, Failures 0, Errors 0 (GameCommentControllerTest 12 + GameReviewControllerTest 13 + UserControllerCsrfTest 5 + BibimbapApplicationTests 1). 회귀 0.'
- 'AGG-1 엔드포인트: GameCommentController @Mapping==4, GameReviewController==5 (PASS)'
- 'AGG-3 CSRF 게이트: CsrfTokens.isValid 합 6 (mutation 6개 전수, PASS)'
- 'AGG-2 DDL 3종: schema.sql game_reviews CREATE + game_comments user_id, docs/game-reviews-ddl.sql 존재 + 멱등 ALTER (PASS)'
- 'L2: skipped — no-external-dependency (커스텀 세션/CSRF, mock 기반 단위)'
# needs_user_verification
- 'L3 실 톰캣+DB 스모크: 게임 상세 페이지에서 (a) 로그인 후 댓글 작성/수정/삭제, (b) 리뷰 작성→게임당 1회(2번째 409)→수정 시 "수정됨" 표시, (c) <script> payload (textContent escape), (d) / , (e) · .'
- 'DDL 적용: [완료 — 로컬 dev] 사용자 승인 후 orchestrator 가 docs/game-reviews-ddl.sql 을 로컬 dev DB(bibimbap-db postgres:16, localhost:5433, schema=dev)에 --single-transaction 적용. game_reviews(컬럼9/제약4/인덱스2) + game_comments.user_id 생성 검증, 멱등 재실행 error 0. **잔여: live 스키마는 배포 시 동일 적용 필요**(현 적용은 dev 한정).'
# Open Items
- 'git 미커밋 잔여: 본 작업 단위(W3-2)의 변경 5 modified + 9 untracked(src) + docs/game-reviews-ddl.sql. 프로젝트 커밋 정책(사용자 요청 시 커밋)에 따라 커밋 대기 — 사용자 확인 후 진행.'
- '이월: W2-3 평점 집계 계약(game_reviews 는 평점 공급원까지만, 집계 컬럼/뷰 미생성). W2-6 시상 집계는 후속 SELECT AVG/COUNT 로 소비.'
- '이월: 운영자 role 부여 경로 — ROLE_ADMIN="ADMIN" 상수만 정의, 실제 ADMIN 부여자 없음(W1 RBAC/Interceptor 연결 시 활성).'
- '이월: 다축(육각형) 평점 — 현재 단일 rating, 향후 game_review_axes 분리 여지(주석).'
- '이월: 리뷰 이력 열람 — 현재 in-row(수정됨 마커만), history 테이블은 열람권한 확정 시 후속.'
- '범위밖: 좋아요 영속화(여전히 localStorage) — 보안 체크리스트 별도 항목.'
- '범위밖: GameCatalog 정적 폴백 게임(DB 미존재)은 댓글/리뷰 mutation 시 404(게임존재 검증). 실 게임만 기능 동작.'
# graph_refresh
- judgment: 'src=no-graph(산출물 부재 — 세션 시작 시점부터 pre-existing, W3-2 무관) + working-tree fully-stale 수준 변경 / docs=partial-stale(신규 문서4 + game-reviews-ddl.sql 미반영)'
handling: '커밋 시점 처리(no-defer 정책 — 미래 세션 이월 아님). checker 권고대로 미커밋 tree 에서 graphify 시 source_commit 부정확 → 커밋 직후 /graphify src + /graphify docs 실행 + docs/graph/index.md frontmatter·Scopes 표 갱신. 커밋이 사용자 요청 대기 중이므로 graph 재생성도 커밋 단계에 동반.'
# Applied Changes (orchestrator — §12 회고 반영)
- 'MEMORY task_completion 갱신: 신규 컨트롤러/매퍼 의존 추가 시 BibimbapApplicationTests @MockBean 등록 누락 → contextLoads 실패 함정 + full test 의무 (candidate 2, negative).'
- 'docs_sync: docs/development/verification-strategies.md 에 "신규 컨트롤러/매퍼 의존 변경 시 full test 의무" 규칙 추가 (candidate 2 docs_sync_target).'
- 'candidate 1(worker-spawn advisor 블로킹+write-before-return): 본 세션에서 선제 적용으로 재발 0. protocol_feedback 로 surface(아래).'
- 'candidate 3(plan-gate 마일스톤): changes/2026-06-18 문서가 이미 기록 — 별도 MEMORY 미작성.'
- 'protocol_feedback 2건(structural)은 atp 플러그인 `agent-team-protocol.md`(= ~/.claude/ 전역설정) 수정 제안 → §6 전역설정 수정 금지로 orchestrator 가 직접 적용하지 않음. 사용자에게 surface(플러그인 유지자 결정 영역). (1) worker-spawn advisor 블로킹 규약 명문화 (2) implementation 단계 컨트롤러 추가 시 full test 의무.'
# User Signals
user_signals:
positive:
- quote_or_paraphrase: 'plan gate 2개 질문에 즉시 Recommended 선택(완전 서버화 + 휴면 운영자 경로)'
about: 'seed 반전 후 제시한 기본값 방향이 사용자 의도와 정합 — 1회 수락'
negative: []
# Retrospective
Retrospective:
signals:
positive:
- quote_or_paraphrase: 'plan gate 2개 질문에 즉시 Recommended 선택(완전 서버화 + 휴면 운영자 경로)'
about: 'research가 seed 가정 2건 반전 후 plan gate에서 제시한 기본값 방향(Q1=완전 서버화, Q2=휴면 운영자 경로)이 사용자 의도와 1회 수락으로 정합됨. 비자명한 판단(localStorage 전용 댓글을 완전 서버화로 전환하는 방향, 운영자 role 미정의 상태에서 휴면 경로 채택)이 재확인됨.'
negative: []
what_went_well:
- 'research-advisor가 seed 가정 2건(댓글 서버 미연동, 운영자 role 부재)을 file:line 근거로 반전시키고 plan gate로 사용자 위임한 흐름이 1회 수락으로 완결. 기존 memory [[research-seed-reversal-plan-gate-delegation]]의 패턴이 bibimbap 세션에서도 재현됨.'
- 'implementation-advisor가 worker 0(advisor 직접 작성)을 선택한 판단이 옳았음. 파일 12개이나 POJO→Mapper→Controller→Test 강결합으로 분산 시 계약 동기화 비용 > 병렬 이득. 기존 memory [[implementation-advisor-direct-execution-threshold]] 패턴과 일치.'
- 'verification-advisor가 §2.6 backward re-dispatch를 발동해 contextLoads 회귀를 blocker 판정 후 발원 단계(implementation 테스트 scaffolding)까지 소급 진단. 컨트롤러 주입 매퍼 전수 재검 후 누락 2개만 정확히 식별.'
- 'research-advisor 2차 호출 시 강한 블로킹 지시("worker TaskOutput 대기 + 파일 디스크 기록 후에만 반환")를 넣자 code-coupling.md 정상 산출. 이후 implementation/verification 호출에 같은 지시를 선제 적용해 재발 0.'
what_to_improve:
- '[패턴 1] research-advisor(tier-3, worker spawn) 가 worker 6개 spawn 후 취합 전 조기복귀. code-coupling.md 미산출 → orchestrator가 SendMessage 없이 재호출. 호출 비용 이중 발생 + 세션 지연. 블로킹 지시가 없으면 advisor가 async spawn 후 결과 대기 없이 반환하는 것이 기본 동작인 것으로 보임. → 프로토콜 수준 대응 필요(protocol_feedback 참조).'
- '[패턴 2] implementation-advisor가 test-compile(./mvnw -o test-compile)만 실행하고 full context test(./mvnw -o test)를 생략. BibimbabApplicationTests.contextLoads 회귀 미탐지 → verification에서 뒤늦게 발견. contextLoads는 MyBatis autoconfigure exclude + 신규 컨트롤러 @MockBean 수동 제공 패턴이므로, 컨트롤러 추가 시마다 @MockBean 갱신이 필요한데 이것이 implementation 단계에서 체크되지 않음.'
memory_candidates:
- name: worker-spawn-advisor-blocking-write-before-return
type: feedback
description: worker를 spawn하는 advisor는 모든 worker의 TaskOutput 완료 대기 + 산출 파일 디스크 기록 후에만 반환해야 한다. 블로킹 지시 없이 호출하면 async spawn 후 조기복귀 → 산출물 미생성 재호출 비용 발생.
body_draft: |
advisor가 병렬 worker(parallel-explorer 등)를 spawn할 때, 호출 측이 명시적 블로킹 지시를 넣지 않으면 advisor가 worker TaskOutput을 취합하지 않고 조기복귀하는 사례가 관측됨(20260618-104034 research-advisor 1차 호출: worker 6개 spawn 후 code-coupling.md 미산출 복귀).
**Why:** SendMessage 툴이 호스트 컨텍스트에 미제공인 경우 advisor가 subagent 재개 수단이 없어 결과를 기다리지 않고 반환할 수 있다. 강한 블로킹 지시를 명시하면 advisor가 tool 반환값(TaskOutput)을 동기적으로 기다리게 된다.
**How to apply:**
- worker spawn이 예상되는 advisor(research-advisor, analysis-advisor 등) 호출 시 orchestrator는 다음 문구를 프롬프트 마지막에 명시한다:
"worker를 spawn한 경우 모든 worker의 TaskOutput을 완료까지 대기하고, 산출 파일을 디스크에 기록한 후에만 반환하라. 결과 취합 전 반환 금지."
- 재호출 1회 이내에 성공한 경우라도, 이후 동일 advisor 호출(implementation, verification 포함)에 선제 적용해 재발 차단.
- 산출 파일 존재 여부는 orchestrator가 advisor 반환 직후 Glob/Read로 확인 가능.
rationale_for_saving: worker spawn 패턴은 research/analysis advisor에서 반복 재현 가능. SendMessage 미제공 환경에서는 구조적으로 재발한다. 코드/커밋으로 유도 불가한 호출 규약 지식.
signal_source: negative
docs_sync_target: null
- name: spring-context-test-mockbean-exhaustive-on-new-controller
type: feedback
description: Spring Boot 컨텍스트 테스트(MyBatis autoconfigure exclude + @MockBean 패턴)에서 신규 컨트롤러 추가 시 해당 컨트롤러가 주입하는 모든 매퍼의 @MockBean을 반드시 등록해야 한다. implementation 단계에서 full context test(./mvnw -o test)를 실행해야 누락 탐지 가능.
body_draft: |
BibimbapApplicationTests는 DataSource/MyBatis autoconfigure를 exclude하고 컨트롤러가 주입하는 매퍼를 @MockBean으로 수동 제공하는 패턴이다. 이 패턴에서 신규 컨트롤러(GameCommentController, GameReviewController)를 추가할 때 해당 컨트롤러가 주입하는 매퍼(GameCommentsMapper, GameReviewsMapper)의 @MockBean을 BibimbabApplicationTests에 추가하지 않으면 contextLoads가 NoSuchBeanDefinitionException으로 실패한다.
**Why:** implementation-advisor가 test-compile(./mvnw -o test-compile)만 실행하면 컨트롤러 클래스는 컴파일되지만, full Spring context 로드는 ./mvnw -o test를 실행해야만 검증된다. 컴파일 성공 ≠ contextLoads 통과. 세션 20260618-104034에서 이 미탐지로 verification 단계에서 blocker가 발생했다.
**How to apply:**
- implementation-advisor는 컨트롤러 파일을 추가/변경할 때 반드시 ./mvnw -o test를 실행해 contextLoads 회귀를 탐지해야 한다. test-compile만으로는 불충분.
- 신규 컨트롤러 추가 체크리스트: (1) 컨트롤러가 @Autowired/@RequiredArgsConstructor로 주입하는 모든 @Mapper 인터페이스를 나열, (2) BibimbabApplicationTests의 기존 @MockBean 선언(@MockBean GameCommentsMapper 등) 목록과 대조, (3) 누락 항목에 @MockBean 추가.
- 이 프로젝트의 현행 @MockBean 목록: GamesMapper, RecruitPostsMapper, UserAuthIdentitiesMapper, UsersMapper, GameCommentsMapper, GameReviewsMapper (W3-2 추가 후 기준).
rationale_for_saving: 이 프로젝트 특유의 MyBatis autoconfigure exclude + @MockBean 수동 패턴은 컨트롤러를 추가할 때마다 재발 가능한 구조적 함정. 코드/커밋에서 유도 불가(테스트 파일을 직접 읽어야만 파악 가능). 후속 W3-x, W4-x 세션에서 컨트롤러 추가가 예상되므로 재현성 높음.
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
- name: bibimbap-plan-gate-seed-reversal-two-q-accepted
type: project
description: W3-2 세션에서 research가 seed 가정 2건 반전 후 plan gate 2질문 즉시 수락. 기본값 방향(완전 서버화 + 휴면 운영자 경로)이 사용자 의도와 정합 확인됨.
body_draft: |
W3-2(댓글/리뷰 분리) 세션(20260618-104034)에서 research-advisor가 seed 가정 2건을 반전:
- GAP-1: 댓글/좋아요가 서버 미연동 localStorage 전용(서버화 가정 깨짐)
- GAP-3: 운영자 role이 코드 전체에 부재("USER"만 발급)
Plan gate 2질문 모두 Recommended(완전 서버화 / 휴면 운영자 경로) 즉시 수락.
**확인된 패턴:** [[research-seed-reversal-plan-gate-delegation]]이 bibimbap 프로젝트에서도 동작함. seed 반전 + Recommended 기본값 방향 제시 조합이 사용자 의사결정 비용을 최소화한 케이스로 기록.
rationale_for_saving: bibimbap 프로젝트 히스토리 마일스톤. W3 진행 중 댓글/좋아요가 localStorage 전용이었다는 사실은 후속 W3-x/W4-x 세션에서 참고 필요. 또한 운영자 경로(ROLE_ADMIN="ADMIN" 상수 정의, 부여자 미구현)가 W1 RBAC 연결 전 휴면 상태라는 결정 이력.
signal_source: positive
docs_sync_target: null
protocol_feedback:
- issue: 'worker를 spawn하는 advisor(tier-3)의 조기복귀가 프로토콜 허점임. SendMessage 미제공 환경에서 advisor가 async worker spawn 후 TaskOutput 취합 없이 반환하는 동작을 방지하는 명시적 규약이 없다.'
structural: true
detail: |
현상: research-advisor 1차 호출이 worker 6개 spawn 후 취합 전 반환 → code-coupling.md 미산출 → 재호출 비용.
근본 원인: 프로토콜에 "worker spawn 후 반환 조건"이 없음. 개별 호출 시 orchestrator가 블로킹 지시를 매번 수동으로 포함해야 해결됨 — 즉 규약이 아니라 ad-hoc 대응.
proposed_fix: |
agent-team-protocol.md (bibimbap 번들 또는 ATP 원본) 에 다음 규약 추가:
§ [worker spawn 완료 조건] advisor가 worker를 spawn하는 경우:
1. 모든 worker의 TaskOutput이 도달할 때까지 반환하지 않는다 (blocking-wait).
2. 약속된 산출 파일을 디스크에 기록 완료한 것을 확인 후 반환한다.
3. orchestrator는 advisor 반환 직후 산출 파일 존재 여부를 Glob/Read로 확인한다. 미존재 시 즉시 블로킹 지시 추가 후 재호출한다.
[선택] orchestrator dispatch 시 worker spawn이 예상되는 advisor에 대해 표준 블로킹 문구를 항상 append하는 것을 orchestrator 디스패치 규약으로 명문화.
docs_target: docs/development/agent-team-protocol.md (bibimbap 번들) 또는 ATP 원본 리포
- issue: 'implementation-advisor의 테스트 실행 범위가 test-compile에 한정되어 full context 회귀(contextLoads)를 미탐지. verification 단계에서 blocker로 발견되어 §2.6 backward re-dispatch 비용 발생.'
structural: true
detail: |
현상: implementation-advisor가 test-compile만 실행 → contextLoads 회귀 미탐지 → verification에서 blocker 판정 → §2.6 backward re-dispatch.
근본 원인: 프로토콜/verification-strategies에 "implementation 단계에서 컨트롤러 추가 시 full test 실행 의무"가 없음. 컴파일 성공을 단위 검증 완료로 간주하는 암묵적 관행.
proposed_fix: |
verification-strategies.md 또는 implementation-advisor 호출 규약에 다음 추가:
"컨트롤러 파일을 신규 추가하거나 기존 컨트롤러의 매퍼 의존을 변경하는 경우, implementation 단계에서도 ./mvnw -o test(full context 포함)를 실행한다. test-compile만으로는 Spring ApplicationContext 로드 실패를 탐지할 수 없다."
단, 프로젝트별 test 실행 시간이 긴 경우 -Dtest=BibimbabApplicationTests만 별도 실행하는 것도 허용.
docs_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md (신규 섹션 "컨트롤러 추가 시 검증 의무")
applied_changes: []

View File

@ -0,0 +1,180 @@
---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-18T11:05:00+09:00
concerns:
- "동결/권위 영역: db/schema.sql 의 game_comments/game_likes/games/users 는 '비권위 복원본'(타입 추론). game_reviews 신규 DDL 작성 시 인접 타입을 권위로 신뢰 금지 — recruit_posts(권위) 스타일을 따를 것."
- "현존 게임 상세의 댓글/좋아요는 100% 클라이언트 localStorage 다(서버 미연동). W3-2 가 '분리'가 아니라 사실상 '서버 영속화 신설'을 포함함 — 요구사항 advisor 전제(이미 서버 댓글이 있다는 가정)가 있다면 깨짐."
- "JSP 두 종 header 혼재: 활성 header 는 views/header.jsp(커스텀 세션·BibimbapCsrf). jsp/fragments/header.jspf 는 Spring Security taglib(sec:authorize, _csrf.parameterName) 기반 미사용 잔재 — 신규 작업에서 절대 참조 금지."
concerns_checked: true
source_confidence: high
workers_spawned: 0
---
# 조사 결과 — W3-2 댓글/리뷰 분리 코드 결합점
## 주제
W3-2 (댓글/리뷰 분리) 설계·구현을 위한 11개 결합점 정밀 조사. 모든 발견은 file:line 근거. 본 산출물은 전부 프로젝트 내부 코드 1차 확인(확인됨). 외부 자료 미사용 → source_confidence: high.
> 조사 방식: parallel-explorer worker 미사용. 11개 포인트가 동일 소규모 코드베이스(~25 Java + 17 JSP)에 밀결합되어 있어 advisor 가 직접 Read/Grep 으로 전수 확인. 모든 항목 1차 출처 직접 확인됨.
---
## 포인트별 발견
### 포인트 1: 댓글 도메인 CRUD 전체 [확인됨]
- **Mapper**: `mapper/GameCommentsMapper.java`
- `GameCommentData getGameComment(long id)``:13-25` (SELECT, `WHERE id=#{id} AND is_delete IS NOT TRUE`)
- `int addGameComment(GameCommentData)``:27-39` (INSERT game_id/nickname/content, `@Options(useGeneratedKeys=true, keyProperty="id")`)
- `int updateGameComment(GameCommentData)``:41-51` (nickname/content/deleted_at 갱신 + `is_delete = CASE WHEN deletedAt IS NULL THEN false ELSE true END`)
- **DELETE/list 메서드 없음**. game_id 별 목록 조회 메서드도 없음(soft-delete 일괄은 GamesMapper 에 있음 — 포인트 8).
- **POJO**: `data/GameCommentData.java:5-61` — 필드: `Long id, Long gameId, String nickname, String content, OffsetDateTime createdAt, OffsetDateTime deletedAt`. (userId 없음 — 작성자 식별 컬럼 부재, nickname 만 있음)
- **컬럼**(db/schema.sql:99-108, 비권위): `id bigint`, `game_id bigint NOT NULL REFERENCES games(id)`, `nickname varchar(100)`, `content text`, `created_at timestamptz DEFAULT now()`, `deleted_at timestamptz`, `is_delete boolean DEFAULT false`. (요청서가 언급한 6컬럼 + is_delete 7개 전부 일치)
- **컨트롤러 엔드포인트**: **존재하지 않음**. `GameCommentsMapper` 를 주입/호출하는 컨트롤러 없음(rg 확인). 즉 서버측 댓글 작성/조회/삭제 HTTP API 가 전무.
- **서비스 계층**: 없음(프로젝트 전체가 controller→mapper 직결 구조).
- **작성/조회/삭제 흐름**: 현재 댓글은 서버 미연동. game-detail.jsp 가 localStorage 로만 처리(포인트 2). GameCommentsMapper 는 사실상 orphan(유일 사용처: 게임 삭제 cascade 의 `GamesMapper.softDeleteGameComments`, 포인트 8).
### 포인트 2: 게임 상세 페이지 [확인됨]
- **핸들러**: `controller/api/GameController.java:103-129` `@GetMapping("/game/{id}") String gameDetail(long id, Model, HttpSession)`.
- DB 게임 존재 시 `addGameModel(model, game, sessionUserId(session))` → view `"game-detail"` (`:104-109`).
- DB miss 시 `GameCatalog` 정적 폴백(`:111-128`).
- **model attribute**(`addGameModel` `:254-268`): `gameId, gameName, creator, likeCount, likeCountFormatted, creatorNote, gitUrl, webglUrl, webglFrameSrc, webglDeployPath, owner`. `owner = currentUserId != null && currentUserId.equals(game.getUserId())` (`:267`).
- **game-detail.jsp 댓글/좋아요 실제 코드**(localStorage):
- 좋아요 버튼 `#game-like-btn` HTML `:730-735`; 좋아요 JS 전부 localStorage(`LIKE_KEY='bibimbap-game-liked'`) — `getLikedMap :815-823`, `setLiked :825-832`, `localStorage.setItem :830`, 클릭 핸들러 `:904-908` (서버 POST/DELETE 없음).
- 댓글 폼 `#game-comment-form` HTML `:790-801` (textarea name="comment" maxlength=1000, 작성자 입력란 없음).
- 댓글 JS 전부 localStorage(`COMMENT_KEY='bibimbap-game-comments'`) — `getComments :911-920`, `saveComments :922-930`(`localStorage.setItem :928`), 렌더 `:938-992`, submit 핸들러 `:994-1005`(닉네임 하드코딩 `DEFAULT_NICK='익명' :936`, id=crypto.randomUUID).
- 라인 근거: 요청서가 지목한 812(LIKE_KEY)/830(setItem)/913(getComments raw)/928(saveComments setItem) 전부 위치 확정.
### 포인트 3: 로그인/세션 인증 패턴 [확인됨]
- **인증 방식**: Spring Security 아님. **커스텀 HttpSession attribute** 기반.
- **로그인 컨트롤러**: `controller/api/UserController.java``@PostMapping("/login") :122-168`, `/signup :65-120`, `/logout :170-179`. 로그인 페이지(GET)는 `WebMvcController.mainView` switch `case "login" :75-80`.
- **세션 저장**(`UserController.saveLoginSession :502-525`): `session.setAttribute("userId", user.getId())`(Long), 그 외 `id, displayName, email, avatarUrl, role, status, authProvider, authIdentityId, lastLoginAt`, 그리고 `account`(LinkedHashMap 복제본). 로그인 시 `request.changeSessionId() :160`(세션 고정 방어).
- **현재 로그인 사용자 얻는 코드**(컨트롤러 공통 헬퍼, 3곳에 동일 복붙): `sessionUserId(HttpSession)``GameController:291-307`, `RecruitController:155-171`, `UserController:333-349`. `session.getAttribute("userId")` 를 Number/String→Long 변환, 없으면 null. (WebMvcController 는 `:118-127` 변형 — null 대신 `IllegalStateException` throw.)
- **UserData 필드 전체**(`data/UserData.java:5-15`): `Long id, String displayName, String canonicalEmail, String avatarUrl, String role, String status, OffsetDateTime lastLoginAt, createdAt, updatedAt`. **id 타입 Long, role 타입 String**(기본값 "USER", UserController:43 `ROLE_USER="USER"`). 운영자 role 명칭은 코드상 미정의(USER 만 발급됨) — 포인트 6 참조.
- **비로그인 처리**: 페이지는 `redirect:/login`(RecruitController:43-46, GameController:133-135, WebMvcController:82-83). AJAX/상태변경 API 는 `401 UNAUTHORIZED + {status,message}` JSON(GameController:57-59 등).
### 포인트 4: CSRF 현황 (설계 핵심 제약) [확인됨]
- **spring-security 의존**: **없음**. pom.xml 의존성 = web, mybatis-spring-boot-starter, postgresql, tomcat-embed-jasper, lombok, starter-tomcat(provided), starter-test(test) (`pom.xml:53-87`). starter-security 부재.
- **SecurityConfig / SecurityFilterChain / @EnableWebSecurity**: **클래스 없음**(rg 확인 0건).
- **CSRF 인프라**: **커스텀 자체 구현 존재**`security/CsrfTokens.java`:
- `SESSION_ATTRIBUTE="csrfToken" :12`, `HEADER_NAME="X-CSRF-Token" :13`.
- `getOrCreate(HttpSession) :20-33`(세션에 토큰 발급/재사용, Base64 32바이트).
- `isValid(HttpServletRequest) :35-49` — 헤더 `X-CSRF-Token` 우선, 없으면 파라미터 `_csrf`(`:46`). 세션 토큰과 `.equals` 비교.
- `errorBody() :51-56``{status:403, message:"요청 보안 토큰이 유효하지 않습니다."}`.
- **기존 POST/AJAX 의 토큰 전달 방식**:
- **뷰 노출**: `theme-init.jsp:5-7``<meta name="csrf-token" content="<%= HtmlUtils.htmlEscape(CsrfTokens.getOrCreate(session)) %>">` 출력. 모든 페이지가 theme-init.jsp 를 include 하므로 메타 토큰이 전역 제공됨.
- **JS 헬퍼**: theme-init.jsp:19-31 `window.BibimbapCsrf` = `{token():meta 읽기, headers(extra):extra+{'X-CSRF-Token':token}}`.
- **form hidden**: login.jsp:221 `<input type="hidden" name="_csrf" value="<%= csrfToken %>">`(signup.jsp 동일).
- **AJAX 사용례**: login.jsp:283 `BibimbapCsrf.headers(...)`, game-detail.jsp:850(삭제) 동일 패턴.
- **서버 검증례**: 모든 상태변경 핸들러 진입부 `if(!CsrfTokens.isValid(request)) return 403`(GameController:53/171/227, RecruitController:65, UserController:76/131/172/187/223). 테스트: `test/.../UserControllerCsrfTest.java`.
- **설계 함의**: 신규 댓글/리뷰 POST/DELETE 는 **반드시 `CsrfTokens.isValid(request)` 게이트 + 클라이언트 `BibimbapCsrf.headers()` 사용**. 새 CSRF 인프라 신설 불필요(재사용).
### 포인트 5: DDL/스키마 적용 방식 [확인됨]
- **부트스트랩**: `db/schema.sql` 가 전체 스키마. flyway/liquibase **없음**(docs/usage/local-setup.md:139 "flyway/liquibase 가 없다").
- **적용법**:
- Docker: db 컨테이너 최초 기동 시 `db/schema.sql``docker-entrypoint-initdb.d` 로 자동 1회 실행(dev 스키마 채움, live 는 빈 스키마). 재적용은 `docker compose down -v` 후 재기동(local-setup.md:149).
- 호스트 로컬 PG: `psql -f db/schema.sql` 수동(local-setup.md:150).
- **권위 패턴**: `docs/recruit-posts-ddl.sql`(권위) 가 신규 테이블 표준 스타일. `docs/security-hardening-ddl.sql` 는 기존 테이블에 인덱스/제약 추가용(중복 점검 SELECT → CREATE UNIQUE INDEX → DO $$ idempotent ALTER 패턴).
- **DbUpdateQueryGenerator**: 테스트 `test/.../DbUpdateQueryGeneratorTest.java` + `test/db/dev-to-live-update.sql` 존재(surefire 에서 제외됨, pom.xml:130-133). dev→live 스키마 동기화 SQL 생성 용도로 보임 — **마이그레이션 자동화 도구 아님**(테스트성).
- **신규 테이블(game_reviews) 추가 절차**: ① `db/schema.sql``SET search_path TO dev` 블록 내 CREATE TABLE 추가, ② 권위 DDL 파일을 docs/ 에 별도 작성(recruit-posts-ddl.sql 선례), ③ 기존 DB 적용용 idempotent ALTER 스크립트(security-hardening-ddl.sql 선례). 파일 위치: `db/schema.sql` + `docs/*-ddl.sql`.
### 포인트 6: 유사 게시판 패턴 — RecruitController (핵심 레퍼런스) [확인됨]
- `controller/RecruitController.java` + `mapper/RecruitPostsMapper.java`.
- **구현된 것**: list(`/recruit` GET → JSP recruit-list `:35-39`), form(`/recruit/new` GET, 비로그인 redirect `:41-47`), create(`/recruit/new` POST `:49-143`), detail(`/recruit/{id}` GET → JSP recruit-detail `:145-153`).
- **create 패턴(댓글/리뷰가 그대로 따를 표준)**:
1. `@Transactional` (`:50`)
2. CSRF 우선 검증 `if(!CsrfTokens.isValid(request)) 403` (`:65-67`)
3. 로그인 검증 `userId = sessionUserId(session); if null → 401` (`:68-71`)
4. trimToNull/trimToEmpty 정규화 + 길이/허용값(Set.contains) 검증, 위반 시 `400 BAD_REQUEST` (`:73-114`)
5. data POJO 세팅 후 mapper.add, 생성 id null 체크 → `500` (`:132-135`)
6. 성공 응답 = **JSON** `{status:200, message, recruitPostId, location:"/recruit/{id}"}` (`:137-142`)
- **응답 형식**: 상태변경(POST/DELETE)은 `ResponseEntity<Map<String,Object>>` JSON. 조회(GET)는 view name String(JSP). redirect 는 비로그인 폼 접근시만.
- **작성자 권한 체크 / 운영자 예외**: **RecruitController 에는 update/delete 가 아예 없음** → 작성자 권한 체크·운영자 예외 선례는 RecruitController 에 없다. **권한 체크 표준 선례는 GameController** 다: `if(!userId.equals(existing.getUserId())) return 403 "작성자만 수정/삭제할 수 있습니다"` (GameController:183-185 수정, :239-241 삭제). **운영자(admin) 예외 분기는 코드 전체에 없음**(role 비교 분기 부재). → W3-2 가 운영자 삭제를 요구하면 신설 영역(role="ADMIN" 등 명칭도 미정의, 포인트 3).
- **RecruitPostsMapper 메서드**: `getRecruitPost :15-42`(users JOIN, is_delete/visible 필터), `getVisibleRecruitPosts :44-72`, `addRecruitPost :74-108`(generatedKeys), `nextSortOrder :110-115`. update/delete 매퍼 없음.
### 포인트 7: 좋아요 패턴 (참고) [확인됨]
- `mapper/GameLikesMapper.java`: `getGameLike :13-22`, `addGameLike :24-34`(generatedKeys), `updateGameLike :36-43`. **삭제/중복방지/카운트 매퍼 없음**.
- `data/GameLikeData.java:5-43`: `Long id, Long gameId, String userKey, OffsetDateTime createdAt`. (좋아요 주체 식별이 `userKey` String — userId FK 아님)
- **컬럼**(db/schema.sql:114-122, 비권위): `id, game_id NOT NULL FK, user_key varchar(200) NOT NULL, created_at`. **is_delete 없음**(hard delete 설계, schema.sql:112 주석 명시).
- **영속화/중복방지 현황**: GameLikesMapper 를 호출하는 컨트롤러 **없음**(rg 0건). 좋아요도 댓글과 동일하게 서버 미연동 → game-detail.jsp localStorage(`LIKE_KEY`)만. 중복방지는 DB UNIQUE 제약 없음(schema 상). 즉 likes 도 orphan mapper.
- **카운트 출처**: `games.like_count`(GamesMapper.getGame:27 `like_count AS likeCount`) 정적 컬럼 — 실시간 game_likes COUNT 아님.
### 포인트 8: 게임 삭제 cascade [확인됨]
- `GameController.deleteGame :222-252` (`@DeleteMapping("/game/{id}")`, `@Transactional`).
- 순서(`:243-245`): `gamesMapper.softDeleteGameComments(id)``gamesMapper.deleteGameLikes(id)``gamesMapper.softDeleteGame(id)`.
- **cascade 매퍼**(GamesMapper):
- `softDeleteGameComments(long gameId) :165-173``UPDATE game_comments SET is_delete=true, deleted_at=COALESCE(deleted_at,now()) WHERE game_id=#{gameId} AND is_delete IS NOT TRUE` (**soft delete**)
- `deleteGameLikes(long gameId) :175-179``DELETE FROM game_likes WHERE game_id=#{gameId}` (**hard delete**)
- `softDeleteGame(long id) :181-189``UPDATE games SET is_delete=true, updated_at=now()`
- **game_reviews 동일 정리 패턴**: review 가 soft-delete(is_delete 컬럼) 채택 시 → `softDeleteGameReviews(gameId)` (comments 미러). hard-delete 채택 시 → `deleteGameReviews(gameId)` (likes 미러). 그리고 `deleteGame :243-245` 에 호출 한 줄 추가가 정확한 결합점.
### 포인트 9: JSP escape 패턴 [확인됨]
- **서버 렌더 출력**: `org.springframework.web.util.HtmlUtils.htmlEscape(...)` 를 scriptlet `<%= %>` 안에서 사용이 지배적 패턴.
- recruit-detail.jsp: 전 사용자 데이터 `<%= HtmlUtils.htmlEscape(role/projectName/summary/contact/description...) %>` (`:219-288`).
- game-detail.jsp: `creatorNoteValue/gameNameValue/creatorValue` 등은 핸들러 model 값을 `HtmlUtils.htmlEscape` 로 미리 감싼 변수로 출력(`:9` likeCountFormattedValue 등, 본문 `:703/721/771`).
- header.jsp:11 avatarUrl `HtmlUtils.htmlEscape`.
- **JSTL escape**: `<c:out value='${q}'/>`(header.jspf:7 — 단 header.jspf 는 미사용 잔재). 활성 뷰는 scriptlet 방식.
- **클라이언트 렌더**: game-detail.jsp 댓글 렌더는 **`textContent` 사용**(`:951 av.textContent`, `:958 nickEl.textContent`, `:969 p.textContent`) — innerHTML 은 초기화용 `listEl.innerHTML=''`(`:942`)만. → 신규 서버연동 댓글/리뷰 클라이언트 렌더도 textContent 규약 유지 필수(CLAUDE.md 보안원칙 일치).
- **신규 review JSP 출력 규약**: 사용자 입력(평점 코멘트 등) 서버 렌더 시 `HtmlUtils.htmlEscape`, 클라이언트 동적 삽입 시 `textContent`.
### 포인트 10: MyBatis annotation 규약 [확인됨]
- annotation-only(XML 매퍼 없음). `@Mapper` 인터페이스 + `@Select/@Insert/@Update/@Delete` 인라인 SQL(Java text block `"""`).
- **generatedKey**: `@Options(useGeneratedKeys=true, keyProperty="id", keyColumn="id")` (GameCommentsMapper:38, RecruitPostsMapper:107, GamesMapper:138, GameLikesMapper:33).
- **created_at**: INSERT 문에 미포함 → **DB DEFAULT now() 의존**(GameCommentsMapper:27-37 은 game_id/nickname/content 만 INSERT). updated_at 갱신은 UPDATE 문에서 `updated_at=now()` 명시(GamesMapper:159).
- **바인딩**: 전부 `#{}` (예: `#{id}`, `#{gameId}`). `${}` 동적 치환 사용처 없음(검색 0건). 다중 인자는 `@Param("name")`(GamesMapper:91/115, RecruitPostsMapper:42). 컬럼 alias 는 `snake_case AS camelCase` 로 POJO 매핑.
- **soft-delete 조회 규약**: 모든 SELECT 가 `is_delete IS NOT TRUE` 필터 + users JOIN 시 `u.is_delete IS NOT TRUE`.
### 포인트 11: DB 종류·문법 [확인됨]
- **DB**: PostgreSQL. driver `org.postgresql.Driver`, url `jdbc:postgresql://localhost:5433/bibimbap?currentSchema=dev` (src/main/resources/dev/db.properties). Docker: `jdbc:postgresql://db:5432/...?currentSchema=${APP_SCHEMA:-dev}` (docker-compose.yml:40). pom.xml:64-67 postgresql runtime.
- **스키마 분리**: dev / live 두 PostgreSQL schema(search_path). schema.sql:21-24 `CREATE SCHEMA dev/live; SET search_path TO dev`.
- **DDL 문법 관례**(db/schema.sql, recruit-posts-ddl.sql): SERIAL/BIGSERIAL **미사용** → 명시적 `CREATE SEQUENCE` + `bigint DEFAULT nextval('..._id_seq'::regclass)` + `ALTER SEQUENCE ... OWNED BY`. 타임스탬프 `timestamp with time zone`(=timestamptz, OffsetDateTime 매핑). boolean `is_delete DEFAULT false NOT NULL`. FK `bigint REFERENCES "table"("id")`. **ON DELETE CASCADE 미사용**(앱레벨 cascade, 포인트 8). CHECK 제약 사용례 recruit_posts(schema.sql:156-161). 인덱스 `CREATE INDEX IF NOT EXISTS ... WHERE`(partial index).
- **id 타입**: 전 테이블 `bigint`(POJO Long). like_count/sort_order 만 `integer`.
---
## 종합 판단
### 핵심 상위 패턴
- 프로젝트는 **controller→mapper 직결**(서비스 계층 없음), **커스텀 세션 인증 + 커스텀 CsrfTokens**(Spring Security 전무), **MyBatis annotation + 명시 시퀀스 PostgreSQL**, **soft-delete(is_delete) 규약**.
- 상태변경 API 표준 시퀀스: `@Transactional` → CSRF 검증 → 로그인 검증 → 정규화/검증 → mapper → JSON 응답.
### 충돌·갭 (요구사항 전제 점검 — 프로토콜 충돌 시 조항)
- **GAP-1 (중대)**: "댓글/리뷰 **분리**"라는 표현은 기존에 서버 댓글이 존재함을 함의하나, **실제 댓글·좋아요 모두 서버 미연동 localStorage** 다. GameCommentsMapper/GameLikesMapper 는 orphan(삭제 cascade 외 미사용). 따라서 W3-2 는 분리 이전에 **댓글 서버 영속화 + 리뷰 신설** 두 가지를 포함. 요구사항 advisor 가 '댓글은 이미 서버에 있고 리뷰만 떼낸다'를 전제했다면 깨짐.
- **GAP-2**: game_comments 에 작성자 식별 컬럼 없음(nickname 만, userId FK 부재). 로그인 사용자 귀속·작성자 권한 삭제를 원하면 user_id 컬럼 신설 필요.
- **GAP-3**: 운영자(admin) 예외 로직이 코드 전체에 부재. role 은 "USER"만 발급되며 ADMIN 명칭·분기 미정의. 운영자 댓글/리뷰 삭제 요구 시 전부 신설.
- **GAP-4**: game_reviews 테이블·POJO·매퍼·DDL **전무**(docs work-log 도 "review 테이블 없음" 명시). 평점 축(단일/다축) 미정 — work-log:C6 에서 W3-2 설계 종속으로 남겨둠.
### 권위 격상 전 검증 필요 항목
- 없음(전 항목 1차 코드 확인). 단, **db/schema.sql 의 game_comments/game_likes/games/users 타입·길이·기본값은 schema.sql 자체가 '비권위 추론값'으로 선언**(주석 :4-11). 신규 game_reviews DDL 의 인접 타입을 이들에서 복사할 때는 recruit_posts(권위) 스타일을 기준으로 삼을 것. (source_confidence 는 '코드가 이렇게 되어있다'는 사실에 대해 high. '운영 DB 실제 타입'은 schema.sql 스스로 미확인 선언.)
---
## 설계 입력 요약 (a 재사용 / b 신설)
### (a) 재사용할 현존 패턴
- **CSRF**: `CsrfTokens.isValid(request)` 게이트 + 뷰 `BibimbapCsrf.headers()`/메타 `csrf-token` + hidden `_csrf`. (신규 인프라 불필요)
- **인증**: `sessionUserId(HttpSession)` 헬퍼(session attr "userId"→Long), 비로그인 페이지 redirect:/login, API 401 JSON.
- **컨트롤러 골격**: RecruitController.createRecruitPost(:49-143) 시퀀스 + GameController 의 작성자 권한 체크(:183-185/239-241)를 합성.
- **응답 형식**: `ResponseEntity<Map<String,Object>>` `{status,message,...,location}` (조회는 JSP view name).
- **MyBatis**: `@Mapper` + 인라인 SQL + `@Options(useGeneratedKeys)` + `#{}` + `is_delete IS NOT TRUE` 필터 + `snake AS camel`.
- **삭제 cascade**: GameController.deleteGame(:243-245) 에 review 정리 한 줄 추가.
- **DDL 스타일**: recruit_posts(권위) — CREATE SEQUENCE + bigint nextval + timestamptz + partial index, ON DELETE CASCADE 미사용.
- **JSP escape**: 서버 `HtmlUtils.htmlEscape`, 클라이언트 `textContent`.
- **스키마 적용**: db/schema.sql + docs/*-ddl.sql(권위) + idempotent ALTER(security-hardening 선례).
### (b) 신설해야 할 것
- **게임 댓글 서버 API**: GameComment(s) 컨트롤러 신설(list GET + create POST + delete) — 현재 전무. localStorage JS(game-detail.jsp:807-1009) → fetch 기반으로 교체.
- **GameCommentsMapper 확장**: game_id 별 목록 SELECT, (soft)delete 메서드 — 현재 없음.
- **game_reviews 도메인 전체**: 테이블 DDL(db/schema.sql + docs/game-reviews-ddl.sql), GameReviewData POJO, GameReviewsMapper, 리뷰 컨트롤러, JSP 영역, 삭제 cascade 정리 메서드.
- **작성자 식별**: 댓글/리뷰에 user_id 컬럼(로그인 귀속·작성자 삭제 원할 경우) — game_comments 현재 nickname 만.
- **(요구 시) 운영자 예외**: role 명칭 정의 + 권한 분기 — 현재 전무.
- **(설계 종속) 평점 모델**: 단일 평점 vs 다축 — W3-2 설계에서 확정 필요(work-log C6).
---
## 미해결
- **운영 DB 실제 스키마**: game_comments/game_likes/games 타입·제약은 schema.sql 가 비권위 선언. pg_dump 대조 전까지 game_reviews 인접 타입을 운영값으로 확신 불가(설계는 recruit_posts 권위 스타일로 진행 권장).
- **평점 데이터 모델**: 단일 점수/다축/리뷰수 정렬 의미 — W3-2 요구·설계 결정 사항(조사 범위 밖).
- **운영자 role 명칭**: ADMIN 등 명칭과 부여 경로 미정(코드상 USER 만 존재).
- **댓글↔리뷰 '분리'의 정확한 의미**: 별도 테이블 2개인지, 단일 테이블 type 컬럼인지 — 요구사항 확정 필요(본 조사는 결합점만 제공, 설계 판단은 design-advisor 몫).

View File

@ -0,0 +1,164 @@
---
phase: verification
agent: verification-advisor
agent_version: 1
generated_at: "2026-06-18T11:51:35+09:00"
concerns:
- "BibimbapApplicationTests.contextLoads ERROR: GameCommentsMapper bean not found in full Spring context (DataSource/MyBatis autoconfigured but MyBatis mapper scan 미적용 상태). 이 테스트는 신규 도입된 mapper 가 MyBatis scan 대상에서 누락된 경우 발생하는 회귀. blocker 판정."
- "L3 skipped: no-external-dependency (외부 서비스 불필요, 런타임 톰캣+DB 미기동 단위 범위 밖)"
concerns_checked: true
---
# 검증 결과
## Acceptance Criteria (입력 받은 그대로 인용)
1. **댓글 CRUD**: 로그인 사용자 작성/수정/삭제 가능 — GameCommentControllerTest 의 작성·수정·삭제 케이스 통과 여부.
2. **비작성자 불가 + 운영자 예외**: 타 사용자(USER) 수정/삭제 403, ADMIN role 200 — 테스트 케이스 통과 여부.
3. **리뷰 게임당 1회**: 동일 user+game 두 번째 작성 409 — 테스트.
4. **리뷰 수정 "수정됨" + 이력 보존**: 수정 후 edited=true(updated_at>created_at), created_at 불변 — 테스트.
5. **댓글 201자 거부 / 200자 경계 허용**: 201자 400, 200자 통과 — 테스트.
6. **rating 범위**: 0/6 거부 400, 1/5 허용 — 테스트.
7. **CSRF 없는 상태변경 실패**: mutation 6개 CSRF 토큰 부재 시 403 — 테스트 + AGG-3 게이트 수.
8. **XSS payload 미실행**: 컨트롤러는 content/body raw 반환(이중escape 안 함), 클라이언트는 textContent 렌더 → 실제 미실행은 L3 런타임. 단위 가능 범위(컨트롤러 raw 저장/반환)만 판정하고 브라우저 미실행은 L3-defer.
9. **영속성(새로고침/브라우저변경)**: DB 영속 — 서버 저장 자체는 단위(addGameComment/addGameReview) 통과로 부분 확인, 실제 브라우저 재방문 영속은 L3-defer.
10. **게임 삭제 cascade**: deleteGame 후 game_reviews soft-delete — 테스트(GamesMapper softDeleteGameReviews mock verify) 또는 grep 으로 deleteGame 에 호출 존재 확인.
---
## 실행된 전략
| id | cmd | exit | severity | 결과 |
|---|---|---|---|---|
| L1-a (mvn test) | `./mvnw -o test` | 1 (BUILD FAILURE) | blocker | FAIL |
| L1-b AGG-1 GameCommentController | `grep -cE '@(Get|Post|Put|Delete)Mapping' ...GameCommentController.java` | 0 | blocker | PASS (실제 4, 기대 4) |
| L1-b AGG-1 GameReviewController | `grep -cE '@(Get|Post|Put|Delete)Mapping' ...GameReviewController.java` | 0 | blocker | PASS (실제 5, 기대 5) |
| L1-b AGG-3 CSRF GameComment | `grep -c 'CsrfTokens.isValid' ...GameCommentController.java` | 0 | blocker | PASS (실제 3, 기대 3) |
| L1-b AGG-3 CSRF GameReview | `grep -c 'CsrfTokens.isValid' ...GameReviewController.java` | 0 | blocker | PASS (실제 3, 기대 3) |
| L1-b AGG-3 합산 | CSRF 게이트 총합 | — | blocker | PASS (합계 6, 기대 6) |
| L1-b AGG-2 game_reviews DDL | `grep -c 'CREATE TABLE IF NOT EXISTS "game_reviews"' db/schema.sql` | 0 | blocker | PASS (실제 1, 기대 >=1) |
| L1-b AGG-2 user_id 존재 | `grep -c 'user_id' db/schema.sql` | 0 | blocker | PASS (실제 12, 기대 >=1) |
| L1-b AGG-2 docs/game-reviews-ddl.sql 존재 | `ls docs/game-reviews-ddl.sql` | 0 | blocker | PASS (파일 존재) |
| L1-b AGG-2 멱등 ALTER | `grep -c 'IF NOT EXISTS' docs/game-reviews-ddl.sql` | 0 | blocker | PASS (실제 10, 기대 >=1) |
| L2 | 외부 서비스 의존 없음 | — | — | skipped: no-external-dependency |
| L3 | 런타임 톰캣+DB 미기동 | — | — | needs_user_verification |
### L1-a 분해 결과
| 단계 | 결과 |
|---|---|
| L1 컴파일 | pass (컴파일 성공, 30개 신규+기존 테스트 실행 도달) |
| GameCommentControllerTest (12건) | pass (Failures: 0, Errors: 0) |
| GameReviewControllerTest (13건) | pass (Failures: 0, Errors: 0) |
| UserControllerCsrfTest (5건) | pass (Failures: 0, Errors: 0) |
| BibimbapApplicationTests (1건) | **FAIL** (Errors: 1 — contextLoads) |
| L2 contract | skipped: no-external-dependency |
| L3 런타임 | needs_user_verification |
| 로그 스캔 | warn: BibimbapApplicationTests ERROR 1건 (APPLICATION FAILED TO START) |
---
## 실패 상세
### BibimbapApplicationTests.contextLoads — blocker
- **테스트 클래스**: `com.pandoli365.bibimbap.BibimbapApplicationTests`
- **실패 지점**: `BibimbapApplicationTests.java` contextLoads() (ApplicationContext 로드 실패)
- **원인 체인**:
1. `UnsatisfiedDependencyException`: bean `gameCommentController` 생성 실패
2. `NoSuchBeanDefinitionException`: `com.pandoli365.bibimbap.mapper.GameCommentsMapper` — 등록 bean 없음
3. `BibimbapApplicationTests`는 DataSource/MyBatis autoconfigure 를 exclude 하지만 MyBatis mapper scan 이 동작하지 않아 `GameCommentsMapper` bean 이 ApplicationContext 에 등록되지 않음
- **재현 명령**:
```
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
cd /Users/wemadeplay/workspace/stz/bibimbap
./mvnw -o test -Dtest=BibimbapApplicationTests 2>&1
```
- **분리 실행 (신규 테스트만 통과 확인용)**:
```
./mvnw -o test -Dtest="GameCommentControllerTest,GameReviewControllerTest,UserControllerCsrfTest" 2>&1 | grep -E '(Tests run|BUILD)'
```
- **회귀 여부**: 이 테스트는 기존부터 존재. `GameCommentsMapper` 신규 추가 후 이 테스트가 전체 컨텍스트를 로드하면서 MyBatis scan 범위에서 누락된 mapper 를 찾지 못하는 것. 기존 테스트가 신규 구현으로 인해 실패하므로 **회귀 blocker**.
---
## AGG 집합 전수 결과 상세
### AGG-1 엔드포인트 매핑 수
| 컨트롤러 | 실제 count | 기대 | 판정 |
|---|---|---|---|
| GameCommentController | 4 | 4 | PASS |
| GameReviewController | 5 | 5 | PASS |
### AGG-3 CSRF 게이트 수
| 컨트롤러 | 실제 count | 기대 | 판정 |
|---|---|---|---|
| GameCommentController | 3 | 3 | PASS |
| GameReviewController | 3 | 3 | PASS |
| **합산** | **6** | **6** | **PASS** |
### AGG-2 DDL 3종
| 항목 | 실제 | 기대 | 판정 |
|---|---|---|---|
| `CREATE TABLE IF NOT EXISTS "game_reviews"` in db/schema.sql | 1 | >=1 | PASS |
| `user_id` in db/schema.sql | 12 | >=1 | PASS |
| docs/game-reviews-ddl.sql 파일 존재 | 존재 | 존재 | PASS |
| `IF NOT EXISTS` in docs/game-reviews-ddl.sql | 10 | >=1 | PASS |
---
## Acceptance 매칭
| criterion | 매칭 전략 | 판정 |
|---|---|---|
| 1. 댓글 CRUD | GameCommentControllerTest 12건 all pass | PASS |
| 2. 비작성자 403 / ADMIN 200 | GameCommentControllerTest 포함 (12건 all pass) | PASS |
| 3. 리뷰 게임당 1회 409 | GameReviewControllerTest 13건 all pass | PASS |
| 4. 리뷰 수정 edited=true + created_at 불변 | GameReviewControllerTest 13건 all pass | PASS |
| 5. 댓글 201자 400 / 200자 허용 | GameCommentControllerTest 12건 all pass | PASS |
| 6. rating 0/6→400 / 1/5→200 | GameReviewControllerTest 13건 all pass | PASS |
| 7. CSRF 없는 상태변경 403 (mutation 6개) | AGG-3 게이트 6개 PASS + UserControllerCsrfTest 5건 all pass | PASS |
| 8. XSS payload — 컨트롤러 raw 반환(단위 범위) | 단위 테스트 통과로 부분 확인 / 브라우저 미실행 | PASS(단위) + L3-defer(브라우저 렌더) |
| 9. 영속성 — 서버 저장 단위 범위 | 단위 테스트 addGameComment/addGameReview 통과 | PASS(단위) + L3-defer(브라우저 재방문) |
| 10. 게임 삭제 cascade | GameReviewControllerTest 포함 확인 (13건 all pass) | PASS |
| **회귀 (BibimbapApplicationTests.contextLoads)** | L1-a 전체 테스트 실행 | **FAIL — blocker** |
---
## 종합 판정
```
overall: fail
rollback_signal: partial
```
**rollback_signal: partial** 근거: `GameCommentsMapper` bean 이 ApplicationContext scan 에서 누락된 것. 컨트롤러 구현·mapper 인터페이스 자체는 존재하지만 MyBatis mapper 등록 설정(annotation 또는 `@MapperScan` 범위)이 누락 또는 불완전. 신규 파일 추가 범위의 설정 보완으로 수정 가능하며 전체 revert 보다는 설정 파일 부분 수정이 적합하다.
---
## verified_by_me (L1 + AGG 통과 항목)
- GameCommentControllerTest 12건 all pass (Failures: 0, Errors: 0)
- GameReviewControllerTest 13건 all pass (Failures: 0, Errors: 0)
- UserControllerCsrfTest 5건 all pass (Failures: 0, Errors: 0)
- AGG-1: 엔드포인트 매핑 수 정확 (Comment 4, Review 5)
- AGG-3: CSRF 게이트 합산 6개 정확 (Comment 3 + Review 3)
- AGG-2: DDL 3종 (schema.sql game_reviews 테이블 존재, user_id 존재, docs/game-reviews-ddl.sql 존재, 멱등 ALTER 존재)
- AC 1~10 단위 범위 모두 PASS (회귀 1건 제외)
## needs_user_verification (L3 스모크)
수동으로 확인이 필요한 항목:
1. **XSS 브라우저 렌더**: 톰캣+DB 기동 후 `<script>alert(1)</script>` 를 댓글/리뷰 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 추가 이전부터 존재하던 기존 테스트. 신규 구현 이후 실패 → 회귀.

View File

@ -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 컬럼 없음)
-- ---------------------------------------------------------------------------

View File

@ -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).

View File

@ -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. 좋아요는 범위밖.

View File

@ -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 → 로그 스캔 순차 실행을 담당.

100
docs/game-reviews-ddl.sql Normal file
View File

@ -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 — 레거시 닉네임 덧글 보존)';

View File

@ -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. 의존성/세션/운영 하드닝

View File

@ -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자 제한 신규.
- **결합/의존**:

View File

@ -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<Map<String, Object>> listComments(@PathVariable("id") long id) {
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
List<Map<String, Object>> comments = new ArrayList<>();
for (GameCommentData comment : gameCommentsMapper.listGameComments(id)) {
comments.add(commentView(comment));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("comments", comments);
return ResponseEntity.ok(body);
}
@PostMapping("/game/{id}/comments")
@Transactional
public ResponseEntity<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "덧글이 등록되었습니다.");
body.put("commentId", comment.getId());
body.put("gameId", id);
body.put("authorName", authorName);
body.put("userId", userId);
body.put("content", normalizedContent);
return ResponseEntity.ok(body);
}
@PutMapping("/game/{id}/comments/{commentId}")
@Transactional
public ResponseEntity<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("message", "덧글이 삭제되었습니다.");
return ResponseEntity.ok(body);
}
private Map<String, Object> commentView(GameCommentData comment) {
Map<String, Object> 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<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -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) {

View File

@ -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<Map<String, Object>> listReviews(@PathVariable("id") long id) {
if (gamesMapper.getGame(id) == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
List<Map<String, Object>> reviews = new ArrayList<>();
for (GameReviewData review : gameReviewsMapper.listGameReviews(id)) {
reviews.add(reviewView(review));
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("reviews", reviews);
return ResponseEntity.ok(body);
}
@GetMapping("/game/{id}/reviews/{reviewId}")
public ResponseEntity<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("review", reviewView(review));
return ResponseEntity.ok(body);
}
@PostMapping("/game/{id}/reviews")
@Transactional
public ResponseEntity<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> result = new LinkedHashMap<>();
result.put("status", 200);
result.put("message", "리뷰가 삭제되었습니다.");
return ResponseEntity.ok(result);
}
private Map<String, Object> reviewView(GameReviewData review) {
Map<String, Object> 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<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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<GameCommentData> 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

View File

@ -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<GameReviewData> 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);
}

View File

@ -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}

View File

@ -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);
}
</style>
</head>
@ -777,6 +1021,49 @@
</div>
</section>
<section class="game-panel game-panel--reviews" aria-labelledby="reviews-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l2.9 6.2 6.8.9-5 4.7 1.3 6.7L12 17.8 5.9 21.2 7.2 14.5l-5-4.7 6.8-.9z"/></svg>
</span>
<div>
<h2 id="reviews-heading" class="game-panel__title">리뷰</h2>
<p class="game-panel__subtitle">별점과 함께 플레이 평가를 남겨 주세요. 게임당 한 번 작성할 수 있어요.</p>
</div>
<div class="game-reviews__summary" id="game-reviews-summary" hidden>
<span class="game-reviews__avg" id="game-reviews-avg"></span>
<div class="game-stars game-stars--avg" id="game-reviews-avg-stars" aria-hidden="true"></div>
<span class="game-reviews__count" id="game-reviews-count"></span>
</div>
</div>
<div class="game-reviews__composer" id="game-review-composer" hidden>
<form id="game-review-form" class="game-comments__form" novalidate>
<div class="game-reviews__rating-row">
<span class="game-comments__label" id="game-review-stars-label">별점</span>
<div class="game-stars game-stars--input" id="game-review-stars" role="radiogroup" aria-labelledby="game-review-stars-label">
<button type="button" class="game-stars__btn" data-value="1" role="radio" aria-checked="false" aria-label="별 1점">★</button>
<button type="button" class="game-stars__btn" data-value="2" role="radio" aria-checked="false" aria-label="별 2점">★</button>
<button type="button" class="game-stars__btn" data-value="3" role="radio" aria-checked="false" aria-label="별 3점">★</button>
<button type="button" class="game-stars__btn" data-value="4" role="radio" aria-checked="false" aria-label="별 4점">★</button>
<button type="button" class="game-stars__btn" data-value="5" role="radio" aria-checked="false" aria-label="별 5점">★</button>
</div>
</div>
<label class="game-comments__label" for="game-review-input">평가</label>
<textarea id="game-review-input" name="body" rows="4" maxlength="1000" placeholder="이 게임을 어떻게 즐기셨나요?"></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint" id="game-review-hint">별점은 필수예요 · 최대 1,000자</span>
<div class="game-reviews__form-buttons">
<button type="button" class="game-reviews__cancel" id="game-review-cancel" hidden>취소</button>
<button type="submit" id="game-review-submit">리뷰 등록</button>
</div>
</div>
</form>
</div>
<p class="game-comments__login-gate" id="game-review-login-gate" hidden>리뷰를 남기려면 <a href="<%= ctx %>/login">로그인</a>이 필요해요.</p>
<p id="game-reviews-empty" class="game-comments__empty" hidden>아직 리뷰가 없습니다.<br>첫 번째 리뷰를 남겨 보세요.</p>
<ul id="game-review-list" class="game-reviews__list" aria-labelledby="reviews-heading"></ul>
</section>
<section class="game-panel game-panel--comments" aria-labelledby="comments-heading">
<div class="game-panel__head">
<span class="game-panel__icon" aria-hidden="true">
@ -787,16 +1074,17 @@
<p class="game-panel__subtitle">플레이 소감이나 버그 제보를 남겨 주세요.</p>
</div>
</div>
<div class="game-comments__composer">
<div class="game-comments__composer" id="game-comment-composer" hidden>
<form id="game-comment-form" class="game-comments__form" novalidate>
<label class="game-comments__label" for="game-comment-input">내용</label>
<textarea id="game-comment-input" name="comment" rows="4" maxlength="1000" placeholder="여기에 덧글을 작성해 주세요." required></textarea>
<textarea id="game-comment-input" name="comment" rows="4" maxlength="200" placeholder="여기에 덧글을 작성해 주세요." required></textarea>
<div class="game-comments__actions">
<span class="game-comments__hint">최대 1,000자</span>
<span class="game-comments__hint">최대 200자</span>
<button type="submit">덧글 등록</button>
</div>
</form>
</div>
<p class="game-comments__login-gate" id="game-comment-login-gate" hidden>덧글을 남기려면 <a href="<%= ctx %>/login">로그인</a>이 필요해요.</p>
<p id="game-comments-empty" class="game-comments__empty" hidden>아직 덧글이 없습니다.<br>첫 번째 덧글을 남겨 보세요.</p>
<ul id="game-comment-list" class="game-comments__list" aria-labelledby="comments-heading"></ul>
</section>
@ -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();
})();
</script>
</body>

View File

@ -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;

View File

@ -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<Map<String, Object>> 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<Map<String, Object>> response = controller.listComments(1L);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
List<Map<String, Object>> comments = (List<Map<String, Object>>) 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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;
}
}

View File

@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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;
}
}