462 lines
42 KiB
Markdown
462 lines
42 KiB
Markdown
---
|
|
phase: design
|
|
agent: design-advisor
|
|
agent_version: 1
|
|
generated_at: 2026-06-23T12:00:00+09:00
|
|
workstream: W4-유저 배지/평판
|
|
concerns:
|
|
- "신규 함수 시그니처(BadgeService.evaluateAndSync / ReputationService.record / BadgeQueryMapper.* )는 최소 인자로 명세했다. 구현 단계에서 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2). 특히 evaluateAndSync 의 reason/actorId 후보 인자."
|
|
- "신규 매퍼(BadgesMapper / UserBadgesMapper / ReputationEventsMapper / BadgeQueryMapper) + BadgeService / ReputationService 의 컨트롤러/JSP 신규 의존은 verification-strategies §30 에 따라 implementation 에서 test-compile 로 끝내지 말고 full ./mvnw -o test + BibimbapApplicationTests 에 신규 빈 @MockBean 수동 등록 의무. 누락 시 contextLoads NoSuchBeanDefinitionException."
|
|
- "배지 표시 조인(리뷰 작성자·게임 카드 옆 배지)은 N+1 위험. 본 설계는 배치 조회(listActiveBadgeKeysByUserIds(List)) 로 차단하도록 명세했으나, 구현이 리뷰 1건당 1쿼리로 풀면 N+1 회귀. listGameReviews/getVisibleGames 호출지점에서 userId 집합을 1회 배치 조회하는지 verification 에서 확인 필요."
|
|
- "신규 매퍼 SQL 의 camelCase alias 는 일반 매퍼 표준(snake→camel 직접 alias, r.created_at AS createdAt)을 따른다. 단 배지 집계/통계성 조회(reputation 누적 합산 등 VIEW 성)는 케이스 폴딩 함정 대상 — 그 경우만 큰따옴표 alias(verification-strategies §33). DB-방언 계약(L2) 대상이며 dev DB contract 미구축은 기존 open item."
|
|
- "PermissionKeys 에 BADGE_MANAGE 추가는 enum 멤버 1개 추가 — PermissionCatalogVerifier 가 values() 순회 시드라 자동 동기되나, W1 의 AC-T1(권한 카탈로그 전수 N건) 카운트가 3→4 로 변한다. W1 의 기존 전수 AC 가 하드코딩 3이면 그 AC 가 본 변경으로 FAIL 할 수 있음 — 본 설계 §롤아웃에서 명시, verification 에서 W1 AC-T1 재측정 필요."
|
|
concerns_checked: true
|
|
self_verification:
|
|
checklist_passed: true
|
|
references:
|
|
requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
|
|
research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
|
|
adrs:
|
|
- .atp/work-session/20260622-180054/implementation/W1-design.md
|
|
- docs/work-log/2026-06-17-jam-platform-roadmap.md
|
|
- docs/development/verification-strategies.md
|
|
---
|
|
|
|
# 설계: W4 — 유저 배지 / 평판 (리뷰어/테크니션 배지 + 평판 이벤트 감사로그 + 자동/수동 하이브리드 부여)
|
|
|
|
## 목표 / 비목표
|
|
|
|
### 목표 (FR/NFR 추적 — 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음)
|
|
- **B1 배지 카탈로그** (Q1, Q4): 정의 가능·확장 가능한 배지 카탈로그(`badges`). 1차 배지 2종 = 리뷰어(`REVIEWER`)·테크니션(`TECHNICIAN`). badge_type CHECK 로 확장 가능. **"기술자" 명칭 = TECHNICIAN(영문 키) / display_name "테크니션"(한글 표시)** 확정(Q4 닫음).
|
|
- **B2 유저 배지 보유** (Q1, Q6): `user_badges` 가 유저↔배지 보유를 표현. 활성(미회수) UNIQUE(user_id, badge_key) 로 중복 부여 방지(자동부여 멱등성).
|
|
- **B3 평판 이벤트 감사로그** (Q3): `reputation_events` 가 평판 신호(리뷰 작성·업로드·역할·좋아요 등)를 누적 감사 기록. 배지가 1차 노출(이산), reputation_events 가 임계 판정·감사 소스(연속 점수 보조).
|
|
- **B4 자동 부여(임계)** (Q2 → QG-W4-A 닫음): 평판 신호 누적이 배지 기준 임계 도달 시 자동 부여. 이벤트 훅(리뷰 작성/게임 업로드 직후) + 멱등(UNIQUE 방어).
|
|
- **B5 수동 부여/회수** (Q2, Q5): 운영자가 배지 수동 부여·회수. 신규 권한 키 `BADGE_MANAGE` 게이트(W1 PermissionGate 위). 회수 = `revoked_at` + 사유.
|
|
- **B6 배지 표시** (Q6): 프로필 + 리뷰 작성자(닉네임 옆) + 게임 카드. 표시명 = `users.display_name` 단일 출처에 배지 부착(스냅샷 닉네임 아닌 유저 단위 배지).
|
|
- **NFR**: 상태변경(수동 부여/회수) CSRF 전수, `#{}` 바인딩(`${}` 금지), 권한 게이트는 W1 인프라(PermissionGate/PermissionKeys) 위에 얹고 임시 role 직접 체크 금지, 비파괴 멱등 DDL, 배지 표시 조회 N+1 차단(배치 조회).
|
|
- **착수 독립성** (골자 C5): 평판 소스(리뷰 R-B / 좋아요 R-D / role R-A / 업로드 R-C) 전부 실재 → **W2 무관 독립 착수**.
|
|
|
|
### 비목표 (스코프 밖)
|
|
- 연속 평판 "점수" 산식 노출 UI — reputation_events 는 감사·임계 판정 소스로만 사용, 점수 가시화는 후속.
|
|
- 배지 기준의 정교한 "품질" 가중(추천수·신고수 반영) — 1차는 활동량 임계 + 회수(부정 다수). 추천 인프라는 현재 부재(code-fact: game_reviews 에 추천 컬럼 0).
|
|
- 잼(W2) 연동 배지(수상자 배지 등) — W2 시상(W2-6) 종속이라 W4 1차 스코프 밖. badge_type 확장으로 후속 흡수 가능.
|
|
- 좋아요(`game_likes`) 기반 배지 — R-D 가 1인1표 UNIQUE 미보장(추정)·user_key varchar 라 신뢰 신호로 부적합. reputation_events 스키마는 source 확장 열어두되 1차 자동부여 소스에서 제외.
|
|
- 자동부여 스케줄러(주기 배치) — 1차는 **이벤트 훅 동기 평가**(리뷰/업로드 트랜잭션 직후). 스케줄 백필은 후속(아래 §대안 비교).
|
|
|
|
---
|
|
|
|
## 개요
|
|
|
|
bibimbap 에 배지/평판 도메인은 전무하다(grounding R-E: badge/reputation 0 hit). 본 설계는 신규 3테이블(`badges`/`user_badges`/`reputation_events`) + 신규 권한 키 `BADGE_MANAGE` 를 도입한다. 평판 신호 원천은 전부 실재한다 — 리뷰(`game_reviews`, R-B), 업로드(`games`, R-C), 역할(`users.role`, R-A).
|
|
|
|
핵심 구조:
|
|
- **이산 배지(1차 노출)**: `badges` 카탈로그 + `user_badges` 보유. 리뷰어/테크니션 2종으로 시작, badge_type CHECK 확장.
|
|
- **연속 신호(보조·감사)**: `reputation_events` 가 평판 신호를 append-only 누적. 자동부여는 "이벤트 기록 → 해당 유저의 누적 집계 → 임계 도달 시 배지 부여" 흐름. reputation_events 가 임계 판정 단일 소스 + 감사 추적.
|
|
- **하이브리드 부여**: 자동(임계, 이벤트 훅 동기) + 수동(운영자, BADGE_MANAGE 게이트). 둘 다 같은 `user_badges` 에 기록되며 `awarded_by`(자동=NULL, 수동=actor) 로 출처 구분.
|
|
- **표시**: 유저 단위 배지를 `users.display_name` 단일 출처에 부착. 리뷰 작성자(authorName)·게임 카드(creator)·프로필 표면에서 해당 userId 의 활성 배지를 배치 조회로 붙인다.
|
|
|
|
확정한 골자 미결(W4 Q1~Q6, QG-W4-A):
|
|
- **Q1/Q4 (배지 종류·명칭)**: 1차 2종 REVIEWER/TECHNICIAN. "기술자" 표시명=테크니션. badge_type CHECK('REVIEWER','TECHNICIAN') + 확장 시 CHECK 멱등 확장.
|
|
- **Q2/QG-W4-A (부여 방식)**: 자동 임계 + 수동 하이브리드. 정석 = 신규 BADGE_MANAGE 권한 키(권한 분리, CONTENT_MODERATE 재사용 기각 — 근거 §대안 비교).
|
|
- **Q3 (평판 점수)**: 이산 배지 1차 노출 + reputation_events 누적(연속 보조·감사). 점수 UI 비목표.
|
|
- **Q5 (회수)**: `user_badges.revoked_at` + `revoke_reason`. 부정 리뷰/삭제 다수 등 운영 판단 → 수동 회수. 자동 회수는 1차 비목표(거짓 회수 위험).
|
|
- **Q6 (표시 위치)**: 프로필 + 리뷰 작성자 + 게임 카드. display_name 단일 출처.
|
|
|
|
---
|
|
|
|
## 핵심 결정 요약 (전제 — 재논의 금지)
|
|
|
|
| 결정 | 확정값 | 본 설계의 구체화 |
|
|
|---|---|---|
|
|
| 배지 스토리지 | badges + user_badges + reputation_events 3테이블 | `docs/badge-ddl.sql` 권위 + schema.sql 동기(멱등) |
|
|
| 배지 1차 종류 | REVIEWER / TECHNICIAN | badge_type CHECK 확장 가능. 카탈로그 부팅 시드(BadgeCatalogSeeder) |
|
|
| "기술자" 명칭 | 키 TECHNICIAN / 표시 "테크니션" | badge_key=TECHNICIAN, display_name="테크니션" |
|
|
| 부여 방식 | 자동 임계 + 수동 하이브리드 | 자동=reputation_events 누적 집계 임계, 수동=BADGE_MANAGE 게이트. 둘 다 user_badges, awarded_by 로 출처 구분 |
|
|
| 권한 키 | **신규 BADGE_MANAGE** (CONTENT_MODERATE 재사용 기각) | PermissionKeys enum +1, PermissionCatalogVerifier values() 자동 시드 |
|
|
| 평판 점수 | 이산 배지 1차 + reputation_events 누적(보조) | reputation_events = 임계 판정 + 감사 단일 소스 |
|
|
| 회수 | revoked_at + revoke_reason(수동) | UNIQUE active 부분인덱스로 재부여 허용(회수 후 재획득) |
|
|
| 자동부여 트리거 | 이벤트 훅 동기(리뷰/업로드 직후) | 스케줄 배치 비목표(1차) |
|
|
| 자동부여 멱등 | UNIQUE(user_id, badge_key) active 부분인덱스 | 중복부여 INSERT ... ON CONFLICT DO NOTHING |
|
|
| 표시 | display_name 단일 출처 + 배치 조회 | listActiveBadgeKeysByUserIds(List) N+1 차단 |
|
|
|
|
---
|
|
|
|
## 데이터 모델 (DDL)
|
|
|
|
> 권위 워크플로(grounding R-F): **권위 원본 = `docs/badge-ddl.sql`** (신규). `db/apply-local-ddl.sh` 가 `docs/*-ddl.sql` 글롭 알파벳순 멱등 적용(ON_ERROR_STOP, search_path=dev). `db/schema.sql` 은 동기 사본(컨테이너 최초1회 부트스트랩). 선례: W1 `docs/rbac-ddl.sql`, W3-2 `docs/game-reviews-ddl.sql`. 알파벳순상 `badge-ddl.sql` 은 `game-reviews-ddl.sql` 보다 먼저 적용되나, badges 는 users 외 FK 가 game_reviews/games 를 직접 참조하지 않으므로(소비는 런타임 조회) 적용 순서 의존 없음(아래 FK 설계 참조).
|
|
|
|
### 신규 파일: `docs/badge-ddl.sql` (권위 DDL)
|
|
|
|
```sql
|
|
-- W4 유저 배지/평판. 멱등. db/apply-local-ddl.sh 로 실행 DB 비파괴 적용.
|
|
-- 신규 도메인(스토리지 0). 추가만, 파괴 없음. search_path=dev.
|
|
|
|
-- 1) badges (배지 카탈로그 — 정의 가능·확장 가능)
|
|
CREATE SEQUENCE IF NOT EXISTS "badges_id_seq";
|
|
CREATE TABLE IF NOT EXISTS "badges" (
|
|
"id" bigint DEFAULT nextval('badges_id_seq'::regclass) NOT NULL,
|
|
"badge_key" character varying(50) NOT NULL, -- 코드 상수 단일 정의처와 정합(REVIEWER/TECHNICIAN)
|
|
"display_name" character varying(100) NOT NULL, -- 표시명("리뷰어"/"테크니션")
|
|
"description" character varying(500), -- 획득 기준 설명(표시용)
|
|
"badge_type" character varying(30) NOT NULL, -- 분류(REVIEWER/TECHNICIAN, 확장 가능)
|
|
"criteria_json" text, -- 임계 기준 직렬화(예: {"minReviews":10}). 표시·문서화용. 판정은 코드 상수
|
|
"is_active" boolean DEFAULT true NOT NULL,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
PRIMARY KEY ("id")
|
|
);
|
|
ALTER SEQUENCE "badges_id_seq" OWNED BY "badges"."id";
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_badges_key" ON "badges" ("badge_key");
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_type_check') THEN
|
|
ALTER TABLE "badges"
|
|
ADD CONSTRAINT "badges_type_check"
|
|
CHECK ("badge_type" IN ('REVIEWER', 'TECHNICIAN'));
|
|
END IF;
|
|
END
|
|
$$;
|
|
|
|
-- 2) user_badges (유저↔배지 보유. 자동/수동 부여 공통 기록)
|
|
CREATE SEQUENCE IF NOT EXISTS "user_badges_id_seq";
|
|
CREATE TABLE IF NOT EXISTS "user_badges" (
|
|
"id" bigint DEFAULT nextval('user_badges_id_seq'::regclass) NOT NULL,
|
|
"user_id" bigint NOT NULL,
|
|
"badge_key" character varying(50) NOT NULL,
|
|
"awarded_by" bigint, -- 수동=운영자 user_id, 자동=NULL
|
|
"awarded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"revoked_at" timestamp with time zone, -- 회수 시각(NULL=활성)
|
|
"revoke_reason" character varying(500), -- 회수 사유(수동)
|
|
PRIMARY KEY ("id")
|
|
);
|
|
ALTER SEQUENCE "user_badges_id_seq" OWNED BY "user_badges"."id";
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'user_badges_user_id_fkey') THEN
|
|
ALTER TABLE "user_badges"
|
|
ADD CONSTRAINT "user_badges_user_id_fkey"
|
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
|
END IF;
|
|
END
|
|
$$;
|
|
-- 활성(미회수) 중복 부여 방지 — 부분 유니크. 회수 후 재부여 허용(revoked_at IS NOT NULL 행은 제외).
|
|
-- 자동부여 멱등성의 DB 차원 보증(INSERT ... ON CONFLICT DO NOTHING 대상 인덱스).
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_user_badges_active"
|
|
ON "user_badges" ("user_id", "badge_key") WHERE "revoked_at" IS NULL;
|
|
CREATE INDEX IF NOT EXISTS "idx_user_badges_user_active"
|
|
ON "user_badges" ("user_id") WHERE "revoked_at" IS NULL;
|
|
|
|
-- 3) reputation_events (평판 신호 감사로그 — append-only, 임계 판정 단일 소스)
|
|
CREATE SEQUENCE IF NOT EXISTS "reputation_events_id_seq";
|
|
CREATE TABLE IF NOT EXISTS "reputation_events" (
|
|
"id" bigint DEFAULT nextval('reputation_events_id_seq'::regclass) NOT NULL,
|
|
"user_id" bigint NOT NULL,
|
|
"event_type" character varying(40) NOT NULL, -- REVIEW_WRITTEN/GAME_UPLOADED(1차). 확장 가능
|
|
"source_ref" character varying(100), -- 원천 식별(review:123, game:45 등). 중복 이벤트 방지 키
|
|
"weight" integer DEFAULT 1 NOT NULL, -- 신호 가중(1차 전부 1, 향후 품질 가중)
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
PRIMARY KEY ("id")
|
|
);
|
|
ALTER SEQUENCE "reputation_events_id_seq" OWNED BY "reputation_events"."id";
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_type_check') THEN
|
|
ALTER TABLE "reputation_events"
|
|
ADD CONSTRAINT "reputation_events_type_check"
|
|
CHECK ("event_type" IN ('REVIEW_WRITTEN', 'GAME_UPLOADED'));
|
|
END IF;
|
|
END
|
|
$$;
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reputation_events_user_id_fkey') THEN
|
|
ALTER TABLE "reputation_events"
|
|
ADD CONSTRAINT "reputation_events_user_id_fkey"
|
|
FOREIGN KEY ("user_id") REFERENCES "users" ("id");
|
|
END IF;
|
|
END
|
|
$$;
|
|
-- 같은 원천 신호 중복 기록 방지(예: 같은 리뷰 재집계). source_ref NULL 은 제외(부분 유니크).
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "ux_reputation_events_source"
|
|
ON "reputation_events" ("user_id", "event_type", "source_ref")
|
|
WHERE "source_ref" IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS "idx_reputation_events_user_type"
|
|
ON "reputation_events" ("user_id", "event_type");
|
|
```
|
|
|
|
### FK 설계 결정 (적용 순서·소비 결합)
|
|
- `user_badges`/`reputation_events` 는 **`users` 만 FK** 참조. game_reviews/games 를 FK 로 묶지 않는다 — 이유: (a) docs/*-ddl.sql 알파벳 적용 순서 의존 회피, (b) 소비는 런타임 집계 조회(리뷰 수·업로드 수)이지 참조 무결성 대상 아님, (c) 리뷰/게임 soft-delete 시 평판 이벤트는 감사 기록으로 잔존해야 함(append-only). `source_ref` 는 varchar 약결합(`review:123`)으로 추적성만 확보.
|
|
|
|
### `db/schema.sql` 반영 (동기 사본)
|
|
- `recruit_posts` 블록 뒤(파일 말미)에 위 1·2·3번(badges/user_badges/reputation_events)을 신설 블록으로 추가. 반영 방식은 `game_reviews`(권위 DDL → schema.sql 동기, schema.sql:128~) 선례와 동일 — **docs/badge-ddl.sql 이 권위, schema.sql 은 사본**.
|
|
|
|
### 카탈로그 시드 (badges 행 — 부팅 멱등)
|
|
- W1 `PermissionCatalogVerifier`(enum→DB 멱등 upsert) 선례와 동일 패턴으로 `BadgeCatalogSeeder`(`ApplicationRunner`) 가 코드 배지 상수(`BadgeKeys` enum)를 `badges` 에 멱등 upsert. 단일 정의처 = 코드 enum, DB 는 시드. **권한과 달리 배지는 운영자가 표시명/설명을 DB 에서 조정할 수 있어야 하므로, upsert 는 `badge_key` 부재 시 INSERT 만 하고 기존 행 display_name/description 은 덮어쓰지 않는다**(INSERT ... ON CONFLICT DO NOTHING). 신규 키만 시드.
|
|
|
|
---
|
|
|
|
## 외부 계약 (API)
|
|
|
|
> 공통: 모든 상태변경(수동 부여/회수)은 `CsrfTokens.isValid(request)` 검증(없으면 403 + `CsrfTokens.errorBody()`). 수동 부여/회수 API 는 `BADGE_MANAGE` 게이트 통과 후 도달. 응답은 기존 컨트롤러 패턴(`Map<String,Object>` + `status`/`message`). 읽기(배지 표시)는 기존 리뷰/게임 조회에 배치 부착(신규 페이지 API 최소).
|
|
|
|
### 401 vs 403 정책 (W1 정책 계승)
|
|
- **미인증**(세션 `userId` 없음): API → **401** JSON, 페이지 → `redirect:/login`.
|
|
- **인증·미인가**(BADGE_MANAGE 없음): **403** JSON `{status:403, message:"권한이 없습니다."}`.
|
|
- **CSRF 실패**: 403 + `CsrfTokens.errorBody()`.
|
|
|
|
### 수동 부여/회수 API (상태변경 — 전부 CSRF + BADGE_MANAGE 게이트)
|
|
| 액션 | method | path | 요청 | 응답(200) | 에러 |
|
|
|---|---|---|---|---|---|
|
|
| 수동 부여(B5) | POST | `/admin/users/{userId}/badges/{badgeKey}/grant` | (path) | `{status:200, message, userId, badgeKey, awarded:true}` | 404(대상/배지키 없음), 409(이미 활성 보유), 403(CSRF/권한) |
|
|
| 수동 회수(B5/Q5) | POST | `/admin/users/{userId}/badges/{badgeKey}/revoke` | `{reason}`(form) | `{status:200, message, userId, badgeKey, revoked:true}` | 404(대상/활성보유 없음), 403 |
|
|
|
|
- **grant/revoke 분리 채택 근거**: W1 권한 토글은 부여=회수 대칭 역연산이라 단일 toggle 이었으나, 배지 회수는 **사유(revoke_reason) 필수 + 부정 판단 동반**이라 의미가 비대칭이다. 회수를 토글로 묶으면 사유 누락·오회수 위험. 따라서 명시적 2엔드포인트 채택(grant 는 사유 불요, revoke 는 사유 요구).
|
|
- **경로 선택**: `/admin/**` 하위 — RbacInterceptor 가 `/admin/**` 를 ADMIN 게이트로 1차 보호하나, 배지 관리는 ADMIN 뿐 아니라 BADGE_MANAGE 보유 SUBADMIN 도 허용해야 함. 따라서 **인터셉터의 `/admin/**` ADMIN-only 와 충돌**(아래 §인터셉터 연동에서 해소).
|
|
|
|
### 배지 표시 조회 (읽기 — 기존 조회에 배치 부착, 신규 페이지 API 없음)
|
|
- 리뷰 목록(`GameReviewController` listReviews) / 게임 목록(`GameController` getVisibleGames) / 프로필 응답에 해당 userId 집합의 활성 배지 키를 **1회 배치 조회**로 부착. 응답 형태: 각 리뷰/게임 항목에 `authorBadges: ["REVIEWER", ...]`(키 배열) 추가. 표시명 매핑은 클라이언트가 카탈로그(부트 시 모델 주입 또는 정적 상수)로 변환.
|
|
|
|
---
|
|
|
|
## 인터셉터 / 게이트 연동
|
|
|
|
### `/admin/**` ADMIN-only 와 BADGE_MANAGE(SUBADMIN 허용)의 충돌 해소 (확정)
|
|
- 현 `RbacInterceptor` 는 `/admin/**` 전체를 `isAdmin` 게이트로 보호(grounding R-A: InterceptorConfig.java:18-20, `addPathPatterns("/admin/**")` + isAdmin). SUBADMIN 은 어떤 권한 키를 가져도 `/admin/**` 진입 불가.
|
|
- **결정: 배지 관리 API 를 `/admin/**` 밖 경로로 둔다 → `/manage/badges/...`**. 이유: `/admin/**` 의 ADMIN-only 인터셉터 의미를 훼손하지 않으면서 BADGE_MANAGE 보유 SUBADMIN 진입을 허용. 인터셉터 URL 패턴 변경(SUBADMIN 분기 추가)은 W1 enforcement 계약을 흔들어 회귀 위험 — 신규 경로가 정석.
|
|
- 수정 경로 계약: 수동 부여 `POST /manage/users/{userId}/badges/{badgeKey}/grant`, 회수 `POST /manage/users/{userId}/badges/{badgeKey}/revoke`.
|
|
- 이 경로는 인터셉터에 등록하지 **않고**, 컨트롤러 진입부에서 **게이트 헬퍼** `permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name())` 를 직접 호출(W1-design §인터셉터 "소비 액션=게이트 헬퍼" 선례와 동일 — 경로별 권한이 콘솔 단일 ADMIN 과 다를 때의 표준 패턴). 미인증→401, 미인가→403 은 컨트롤러가 작성.
|
|
- **PermissionGate 재사용**: 신규 게이트 메서드 불요. 기존 `has(HttpSession, String)` 가 ADMIN 암묵전권 + SUBADMIN 키 보유를 모두 커버(PermissionGate.java:22-38 확인). BADGE_MANAGE 는 enum 값일 뿐 게이트 코어 변경 0.
|
|
|
|
### PermissionKeys 확장 (코드 — 설계 명세, 본 문서는 enum 확장 명세만, 코드 미수정)
|
|
- `PermissionKeys` enum 에 멤버 1개 추가:
|
|
```java
|
|
BADGE_MANAGE("배지 관리")
|
|
```
|
|
- `PermissionCatalogVerifier`(PermissionCatalogVerifier.java:30, `values()` 순회 시드) 가 자동으로 DB `permissions` 에 멱등 upsert → **코드 수정 0(verifier 불변), enum 멤버 추가만으로 카탈로그 동기**. 이것이 W1 의 "enum 단일 정의처" 불변식의 확장 시연.
|
|
|
|
---
|
|
|
|
## 시퀀스
|
|
|
|
### S1. 자동 부여 (리뷰 작성 → 평판 이벤트 → 임계 판정 → 배지 부여)
|
|
```
|
|
[유저 42] POST /game/7/reviews (CSRF) — 리뷰 작성(기존 W3-2 경로)
|
|
→ GameReviewController.createReview (기존)
|
|
→ CSRF + 리뷰 INSERT (기존 트랜잭션)
|
|
→ [신규 훅, 같은 트랜잭션 후단] reputationService.record(
|
|
userId=42, eventType=REVIEW_WRITTEN, sourceRef="review:" + newReviewId)
|
|
→ reputationEventsMapper.insertIgnore(...) # ux_reputation_events_source ON CONFLICT DO NOTHING(중복 신호 방어)
|
|
→ badgeService.evaluateAndSync(userId=42, badgeKey=REVIEWER)
|
|
→ countActiveReputation(42, REVIEW_WRITTEN) # reputation_events 누적 집계(단일 소스)
|
|
→ if count >= REVIEWER_THRESHOLD(=10, 코드 상수):
|
|
userBadgesMapper.insertIgnore(userId=42, badgeKey=REVIEWER, awardedBy=NULL)
|
|
# ux_user_badges_active ON CONFLICT DO NOTHING(자동부여 멱등 — 이미 보유면 no-op)
|
|
→ 200 (리뷰 응답, 배지 부여는 부작용)
|
|
```
|
|
- **자동부여 멱등 논증(crossRefs 보안)**: 동일 유저가 임계 초과 후 리뷰를 더 써도 `ux_user_badges_active` 부분 유니크가 중복 INSERT 를 DO NOTHING 으로 흡수 → 1회만 부여. 동시성(같은 유저 동시 2리뷰)도 DB 유니크가 차단.
|
|
- **트랜잭션 경계**: 평판 훅은 리뷰 INSERT 와 같은 트랜잭션. 훅 실패가 리뷰 작성을 롤백하면 UX 손상 — 따라서 **훅은 best-effort(try/catch + 로그)**로 감싸 리뷰 본 흐름을 막지 않는다(평판은 보조 도메인). 누락된 이벤트는 후속 스케줄 백필로 복구 가능(비목표지만 구조 보존).
|
|
|
|
### S2. 자동 부여 (게임 업로드 → TECHNICIAN)
|
|
```
|
|
[유저 42] POST /game/new (CSRF) — 게임 메타 생성(기존 GameController.createGame)
|
|
→ [신규 훅, 트랜잭션 후단] reputationService.record(42, GAME_UPLOADED, "game:" + newGameId)
|
|
→ badgeService.evaluateAndSync(42, TECHNICIAN)
|
|
→ countActiveReputation(42, GAME_UPLOADED) >= TECHNICIAN_THRESHOLD(=3) → insertIgnore TECHNICIAN
|
|
```
|
|
|
|
### S3. 수동 부여/회수 (운영자, BADGE_MANAGE 게이트)
|
|
```
|
|
[SUBADMIN(BADGE_MANAGE 보유) 세션] POST /manage/users/42/badges/REVIEWER/grant (CSRF)
|
|
→ BadgeManageController.grant(42, "REVIEWER")
|
|
→ CSRF 검증 (없으면 403 + errorBody)
|
|
→ permissionGate.has(session, BADGE_MANAGE) (미인증 401 / 미인가 403)
|
|
→ BadgeKeys.isValid("REVIEWER") (아니면 404)
|
|
→ usersMapper.getUser(42) 존재 확인 (아니면 404)
|
|
→ 이미 활성 보유? userBadgesMapper.existsActive(42, REVIEWER) → 있으면 409
|
|
→ userBadgesMapper.insert(42, REVIEWER, awardedBy=actorId) # 수동: awarded_by=actor
|
|
→ 200 {awarded:true}
|
|
|
|
[운영자] POST /manage/users/42/badges/REVIEWER/revoke (CSRF, reason="부정 리뷰 다수")
|
|
→ BadgeManageController.revoke(42, "REVIEWER", reason)
|
|
→ CSRF + BADGE_MANAGE 게이트
|
|
→ 활성 보유? (아니면 404)
|
|
→ userBadgesMapper.revoke(42, REVIEWER, reason, now()) # revoked_at + revoke_reason set
|
|
# 부분 유니크가 revoked_at IS NULL 만 보므로, 회수 후 재획득(자동/수동) 가능
|
|
→ 200 {revoked:true}
|
|
```
|
|
|
|
### S4. 배지 표시 (리뷰 목록에 작성자 배지 배치 부착 — N+1 차단)
|
|
```
|
|
[누구나] GET /game/7/reviews (기존 listReviews)
|
|
→ GameReviewController.listReviews → List<GameReviewData>(각 userId/authorName 보유)
|
|
→ [신규] userIds = reviews.stream().map(userId).distinct()
|
|
→ Map<Long,List<String>> badges = userBadgesQueryMapper.listActiveBadgeKeysByUserIds(userIds) # 1쿼리(IN)
|
|
→ 각 리뷰 DTO 에 authorBadges = badges.getOrDefault(userId, []) 부착
|
|
→ JSP/JSON: 작성자 닉네임 옆 배지 표시(textContent/HtmlUtils 이스케이프)
|
|
```
|
|
- **N+1 차단 논증(concern 3)**: userId 집합을 1회 `IN (...)` 배치 조회. 리뷰 1건당 1쿼리 금지. 게임 카드(getVisibleGames)·프로필 동일 패턴.
|
|
|
|
---
|
|
|
|
## 파일 영향 맵
|
|
|
|
> 소유권 분할(implementation-advisor worker 단위 후보):
|
|
> **B-SCHEMA**(DDL/schema 동기) · **B-DOMAIN**(BadgeKeys enum/data POJO/매퍼/카탈로그 시더) · **B-SERVICE**(ReputationService/BadgeService 임계 판정·멱등 부여) · **B-HOOK**(리뷰/게임 작성 경로 평판 훅 연결) · **B-MANAGE**(수동 부여/회수 컨트롤러 + 게이트) · **B-DISPLAY**(리뷰/게임/프로필 배지 배치 부착 + JSP) · **B-PERM**(PermissionKeys BADGE_MANAGE 추가).
|
|
> 의존: B-SCHEMA → B-DOMAIN → {B-SERVICE → B-HOOK, B-MANAGE, B-DISPLAY}. B-PERM 은 B-MANAGE 선행.
|
|
|
|
| 변경 유형 | 경로 | 역할 | 소유 |
|
|
|---|---|---|---|
|
|
| 신규 | `docs/badge-ddl.sql` | 권위 DDL(badges/user_badges/reputation_events + CHECK/유니크). apply-local-ddl.sh 자동 적용 | B-SCHEMA |
|
|
| 수정 | `db/schema.sql` | 말미에 3테이블 블록 추가(badge-ddl 동기 사본) | B-SCHEMA |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java` | 배지 키 enum 단일 정의처(REVIEWER/TECHNICIAN + displayName + isValid). PermissionKeys 선례 | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java` | `ApplicationRunner` — BadgeKeys → badges 멱등 INSERT(ON CONFLICT DO NOTHING). PermissionCatalogVerifier 선례 | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/BadgeData.java` | badges 행 POJO(badgeKey/displayName/description/badgeType/isActive) | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java` | user_badges 행 POJO(userId/badgeKey/awardedBy/awardedAt/revokedAt/revokeReason) | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java` | `@Mapper` 카탈로그 시드/조회(`#{}`) | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java` | `@Mapper` user_badges CRUD(insertIgnore/insert/existsActive/revoke, `#{}`) | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java` | `@Mapper` 표시 배치 조회(listActiveBadgeKeysByUserIds — IN, N+1 차단) | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java` | `@Mapper` 평판 이벤트 insertIgnore + 누적 집계(countActive, `#{}`) | B-DOMAIN |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java` | 평판 이벤트 기록 + 임계 평가 위임(record). best-effort 훅 | B-SERVICE |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java` | 임계 판정 + 멱등 부여(evaluateAndSync) + 수동 부여/회수 도메인 로직 | B-SERVICE |
|
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java` | createReview 후단 reputationService.record(REVIEW_WRITTEN) 훅. listReviews 응답에 배지 배치 부착 | B-HOOK / B-DISPLAY |
|
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/GameController.java` | createGame 후단 reputationService.record(GAME_UPLOADED) 훅 | B-HOOK |
|
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/GameController.java` (또는 GameRestController) | getVisibleGames 응답에 creator 배지 배치 부착 | B-DISPLAY |
|
|
| 신규 | `src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java` | 수동 부여/회수 `/manage/users/{userId}/badges/{badgeKey}/(grant|revoke)` + BADGE_MANAGE 게이트 헬퍼 | B-MANAGE |
|
|
| 수정 | `src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java` | `BADGE_MANAGE("배지 관리")` enum 멤버 1개 추가(verifier 자동 시드) | B-PERM |
|
|
| 수정 | `src/main/webapp/WEB-INF/views/profile.jsp` | 프로필에 본인 활성 배지 표시(HtmlUtils 이스케이프/textContent) | B-DISPLAY |
|
|
| 수정 | (리뷰/게임 카드 JSP — 리뷰 목록·게임 목록 렌더 지점) | 작성자/creator 닉네임 옆 배지 표시 | B-DISPLAY |
|
|
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | 신규 매퍼·BadgeService·ReputationService `@MockBean` 등록(contextLoads, verification §30) | (검증) |
|
|
| 신규 | `src/test/.../BadgeServiceTest.java` | 임계 판정·멱등 부여(중복 no-op)·회수 후 재부여 단위 | (검증) |
|
|
| 신규 | `src/test/.../BadgeManageControllerTest.java` | grant/revoke + 401/403/CSRF/404/409 + BADGE_MANAGE 게이트 | (검증) |
|
|
| 수정 | `src/test/.../GameReviewControllerTest.java` | 리뷰 작성 후 평판 훅 호출 회귀(REVIEW_WRITTEN 기록) + 작성 본흐름 불변 | (검증) |
|
|
|
|
> SSR 호출지점 확인(verification §영향맵): 배지 배치 부착은 기존 리뷰/게임 DTO 에 **신규 필드(authorBadges) 추가**라 기존 호출지점 깨짐 0. PermissionKeys 멤버 추가는 enum 확장(values() 순회 코드 불변)이라 컴파일 회귀 0.
|
|
|
|
### 신규 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
|
|
```java
|
|
// ReputationService — 평판 이벤트 기록 + 임계 평가 위임. best-effort 훅.
|
|
void record(long userId, // 신호 주체
|
|
String eventType, // REVIEW_WRITTEN/GAME_UPLOADED(ReputationEventTypes 상수)
|
|
String sourceRef) // 중복 신호 방지 키(review:N/game:N). NULL 허용은 안 함(훅은 항상 원천 보유)
|
|
|
|
// BadgeService — 임계 판정 + 멱등 부여.
|
|
void evaluateAndSync(long userId, // 평가 대상
|
|
String badgeKey) // 평가할 배지(REVIEWER/TECHNICIAN). 임계·소스 매핑은 BadgeKeys 내부
|
|
// (수동) — 컨트롤러가 BADGE_MANAGE 게이트·CSRF 통과 후 호출. 도메인 검증(존재/중복/회수)만.
|
|
GrantResult grantManual(long userId, // 대상
|
|
String badgeKey,// 부여 배지
|
|
long awardedBy) // 운영자 user_id(awarded_by 기록)
|
|
void revokeManual(long userId, // 대상
|
|
String badgeKey, // 회수 배지
|
|
String reason) // 회수 사유(revoke_reason). NULL/blank 거부는 컨트롤러 검증
|
|
|
|
// UserBadgesMapper (@Mapper, #{} only)
|
|
int insertIgnore(long userId, String badgeKey) // 자동부여 멱등(ON CONFLICT DO NOTHING, awarded_by NULL)
|
|
int insert(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("awardedBy") long awardedBy) // 수동부여
|
|
boolean existsActive(@Param("userId") long userId, @Param("badgeKey") String badgeKey) // 409/404 판정
|
|
int revoke(@Param("userId") long userId, @Param("badgeKey") String badgeKey, @Param("reason") String reason) // revoked_at+reason set
|
|
|
|
// UserBadgesQueryMapper (@Mapper, #{} only) — 표시 배치(N+1 차단)
|
|
List<UserBadgeKeyRow> listActiveBadgeKeysByUserIds(@Param("userIds") List<Long> userIds) // IN 1쿼리. (userId, badgeKey) 행
|
|
|
|
// ReputationEventsMapper (@Mapper, #{} only)
|
|
int insertIgnore(@Param("userId") long userId, @Param("eventType") String eventType, @Param("sourceRef") String sourceRef) // 중복신호 ON CONFLICT DO NOTHING
|
|
long countActive(@Param("userId") long userId, @Param("eventType") String eventType) // 누적 집계(임계 좌변)
|
|
|
|
// BadgesMapper (@Mapper, #{} only)
|
|
int insertIgnore(@Param("badgeKey") String k, @Param("displayName") String d, @Param("description") String desc, @Param("badgeType") String t) // 시더(ON CONFLICT DO NOTHING)
|
|
List<BadgeData> listActive() // 카탈로그(표시명 매핑 모델 주입)
|
|
```
|
|
> inflate 마킹(concern 1): `evaluateAndSync` 는 의도적으로 `(userId, badgeKey)` 2인자로 최소화했다 — 임계값·평판 소스 event_type 은 `BadgeKeys` enum 내부 매핑(REVIEWER→REVIEW_WRITTEN/임계10, TECHNICIAN→GAME_UPLOADED/임계3)으로 외부 인자 부풀림을 막는다. `record` 의 `weight` 는 시그니처에서 제외(1차 전부 weight=1, DB DEFAULT 1) — 품질 가중 도입 시 그때 추가. `grantManual` 의 `awardedBy` 는 audit 출처라 실사용 확정. 구현에서 각 인자 실제 사용 재확인 필요(concern 1).
|
|
|
|
---
|
|
|
|
## 대안 비교
|
|
|
|
| 주제 | 안 | 장점 | 단점 | 채택 |
|
|
|---|---|---|---|---|
|
|
| 권한 키 | (A) 신규 `BADGE_MANAGE` | 권한 분리(콘텐츠 모더 ≠ 배지 관리), 최소권한 정석 | enum +1, 카탈로그 +1 | **채택** |
|
|
| | (B) `CONTENT_MODERATE` 재사용 | 신규 키 0 | 모더레이터=배지관리자 강제 결합, 권한 분리 위배 | 기각 |
|
|
| 자동부여 트리거 | (A) 이벤트 훅 동기(작성 직후 best-effort) | 즉시 반영, 인프라 0, 트랜잭션 결합 약 | 누락 신호 백필 별도 | **채택** |
|
|
| | (B) 주기 스케줄 배치 | 누락 0 일괄 | 신규 스케줄러 인프라, 즉시성 손실, 1차 과도 | 기각(후속) |
|
|
| | (C) DB 트리거/계산컬럼 | 앱 무관 | DB 로직 산재, 임계 변경 어려움, 테스트 난 | 기각 |
|
|
| 배지 회수 | (A) revoked_at + reason, 수동만(부분유니크 재획득 허용) | 감사·재획득·정석 | 회수 후 재부여 가능(의도) | **채택** |
|
|
| | (B) hard delete | 단순 | 감사 소실, 회수 이력 0 | 기각 |
|
|
| | (C) 자동 회수(임계 하락) | 일관 | 거짓 회수(리뷰 일시 삭제 등) 위험 | 기각(1차) |
|
|
| 평판 점수 | (A) 이산 배지 1차 + reputation_events 누적(보조) | 노출 단순 + 감사·임계 소스 | 이벤트 테이블 1개 | **채택** |
|
|
| | (B) users 에 점수 컬럼 누적 | 조회 빠름 | 감사 소실, 재계산 불가, 단조증가 결함 | 기각 |
|
|
| 배지 관리 경로 | (A) `/manage/**` 신규(게이트 헬퍼) | `/admin/**` ADMIN-only 의미 보존 + SUBADMIN+BADGE_MANAGE 허용 | 경로 1개 신설 | **채택** |
|
|
| | (B) `/admin/**` 하위 + 인터셉터 SUBADMIN 분기 | 경로 통일 | W1 인터셉터 ADMIN-only 계약 훼손, 회귀 위험 | 기각 |
|
|
| 표시 조회 | (A) userId 집합 배치 IN 1쿼리 | N+1 0 | 매퍼 IN 1개 | **채택** |
|
|
| | (B) 리뷰/게임 1건당 배지 조회 | 단순 | N+1(목록 성능) | 기각 |
|
|
| 좋아요 평판 소스 | (A) 1차 제외 | game_likes 신뢰 불가(1인1표 미보장 추정·user_key varchar) | 신호 1종 감소 | **채택** |
|
|
| | (B) game_likes 포함 | 신호 풍부 | 어뷰징·중복(R-D concern) | 기각(소스 확장 여지만 보존) |
|
|
|
|
---
|
|
|
|
## 롤아웃 / 마이그레이션
|
|
|
|
### 순서
|
|
1. **스키마 적용**: `docs/badge-ddl.sql` → `db/apply-local-ddl.sh`(로컬, 멱등) / 운영 동일 멱등 DDL. 신규 도메인이라 기존 데이터 호환(추가만, 파괴 0). `db/schema.sql` 동기.
|
|
2. **권한 키**: `PermissionKeys.BADGE_MANAGE` 추가 → 배포 시 `PermissionCatalogVerifier` 가 permissions 카탈로그 자동 시드(코드 verifier 불변).
|
|
3. **배지 카탈로그**: 부팅 시 `BadgeCatalogSeeder` 가 REVIEWER/TECHNICIAN 멱등 INSERT.
|
|
4. **코드 배포**: 도메인/서비스/훅/매니지/표시. 평판 훅이 리뷰/게임 작성 경로에 발효.
|
|
5. **BADGE_MANAGE 부여**: ADMIN 이 W1 콘솔(`/admin/console` 권한 토글)로 대상 SUBADMIN 에게 BADGE_MANAGE 부여 → `/manage/badges` 진입 가능. (W1 토글이 PermissionKeys 전체를 노출하므로 신규 키 자동 토글 가능 — W1 콘솔 코드 변경 불요, 단 §concerns AC-T1 카운트 주의.)
|
|
|
|
### 역호환
|
|
- 기존 유저/리뷰/게임: 배지 0 상태로 시작. 평판 이벤트는 배포 이후 신규 작성분만 기록(과거 백필 안 함 — 비목표). 표시 배치 조회는 배지 없으면 빈 배열 → 표시 변화 0.
|
|
- W1 콘솔: PermissionKeys 멤버 추가만 — 콘솔이 enum 순회로 키를 노출하면 BADGE_MANAGE 가 자동 토글 대상에 추가됨(동작 확장, 회귀 0).
|
|
- 리뷰/게임 DTO: 신규 필드 `authorBadges` 추가만 → 기존 직렬화/JSP 깨짐 0.
|
|
|
|
### 롤백
|
|
- 코드 롤백: 평판 훅·표시 부착·매니지 컨트롤러 제거 시 배지 도메인 비활성. 신규 테이블/컬럼은 추가 전용이라 잔존해도 무해(비파괴). PermissionKeys 에서 BADGE_MANAGE 제거 시 DB permissions 의 BADGE_MANAGE 행은 `PermissionCatalogVerifier` 가 "DB-only 키 경고 로그"로 감지(W1 PermissionCatalogVerifier.java:40-44) — 삭제 안 하므로 안전.
|
|
- 스키마 롤백: 명시적 DROP 은 별도 maintenance(append-only 감사 데이터 보존 권장).
|
|
|
|
---
|
|
|
|
## AC 매핑
|
|
|
|
| AC | 요구 | 만족 설계 요소 | 비고 |
|
|
|---|---|---|---|
|
|
| AC-1 | 리뷰 N개 작성 시 REVIEWER 자동 부여 | S1 평판 훅 → countActive >= 임계 → insertIgnore | 임계=BadgeKeys 상수 |
|
|
| AC-2 | 게임 M개 업로드 시 TECHNICIAN 자동 부여 | S2 평판 훅 → GAME_UPLOADED 집계 임계 | |
|
|
| AC-3 | **자동부여 멱등(중복 부여 0)** | ux_user_badges_active 부분유니크 + insertIgnore(ON CONFLICT DO NOTHING). 임계 초과 추가 활동·동시성 모두 1회만 | S1 논증 |
|
|
| AC-4 | 운영자 수동 부여 — BADGE_MANAGE 게이트 | grant 컨트롤러 permissionGate.has(BADGE_MANAGE), 미인가 403 | S3 |
|
|
| AC-5 | 수동 부여/회수 CSRF 없으면 403 | grant/revoke CsrfTokens.isValid 선검증 → 403+errorBody | §외부계약 공통 |
|
|
| AC-6 | 회수 — revoked_at + 사유 기록 | revoke: revoked_at=now()+revoke_reason. 활성 보유 아니면 404 | S3 |
|
|
| AC-7 | 회수 후 재획득 가능 | 부분유니크 WHERE revoked_at IS NULL → 회수행 제외, 재부여 INSERT 가능 | DDL 2 |
|
|
| AC-8 | 배지 표시(프로필/리뷰작성자/게임카드) display_name 단일출처 | listActiveBadgeKeysByUserIds 배치 부착, userId 기준(스냅샷 닉네임 아님) | S4 |
|
|
| AC-9 | 표시 조회 N+1 0 | userId 집합 IN 1쿼리 | concern 3 |
|
|
| AC-10 | 배지 SQL `${}` 0 | 신규 매퍼 전부 `#{}` | §파일영향맵 |
|
|
| AC-11 | 비-BADGE_MANAGE 의 /manage/badges 접근 차단 | 게이트 헬퍼 미인가 403, 미인증 401 | §인터셉터연동 |
|
|
| AC-12 | reputation_events 중복 신호 방어 | ux_reputation_events_source + insertIgnore | DDL 3 |
|
|
|
|
---
|
|
|
|
## 검증 포인트 (verification-advisor 점검 대상)
|
|
|
|
> L레벨 매핑(verification-strategies.md): 권한 게이트/부여 플로우 = **L1+L2+L3**. 신규 매퍼 SQL/alias = **L1+L2(dev DB contract)**. 신규 매퍼·서비스 빈 의존 = full `./mvnw -o test` 의무(§30). 자동부여 멱등·회수 = L1+L3.
|
|
|
|
### 시나리오 검증
|
|
- **VP-1 (AC-3 멱등, L1)**: BadgeServiceTest — 임계 도달 후 evaluateAndSync 반복 호출 시 user_badges 활성 1행 유지(insertIgnore no-op). 회수 후 재호출 시 재부여(AC-7).
|
|
- **VP-2 (AC-4/AC-11 게이트, L1+L3)**: BadgeManageControllerTest — BADGE_MANAGE 미보유 SUBADMIN→403, 미인증→401, ADMIN→통과, BADGE_MANAGE 보유 SUBADMIN→통과.
|
|
- **VP-3 (AC-5 CSRF, L1)**: grant/revoke CSRF 누락 → 403 + mapper 미호출(W1 `deleteCommentRejectsMissingCsrfBeforeMapperAccess` 패턴 준용).
|
|
- **VP-4 (AC-1/AC-2 자동부여, L1+L3)**: 리뷰/게임 작성 훅이 reputation_events insert + 임계 도달 시 배지 부여. 본 흐름(리뷰/게임 작성) 불변 회귀(훅 best-effort).
|
|
- **VP-5 (AC-9 N+1, L1)**: 리뷰 목록 N건 응답 시 배지 조회 쿼리 1회(IN). 리뷰 1건당 1쿼리면 FAIL.
|
|
- **VP-6 (DB-방언 계약, L2)**: 신규 매퍼 반환 키가 소비 키와 정합. 집계성 조회(countActive)·alias 케이스 폴딩 확인.
|
|
- **VP-7 (contextLoads, L1)**: BibimbapApplicationTests 에 신규 매퍼·BadgeService·ReputationService @MockBean 등록 후 PASS(§30).
|
|
|
|
### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
|
|
> self-audit: 아래 카운트는 본 설계가 신규 생성하는 정적 산출물 + 기존 docs/*-ddl.sql 집합이며, 시점·표현 견고성을 2축 점검했다.
|
|
|
|
- **AC-T1 (시점 self-audit 적용) DDL 권위 파일 전수** — `docs/*-ddl.sql` 은 verification 시점까지 **W2 등 타 워크스트림이 동시 추가할 수 있는 집합**(현재 4: recruit-posts/security-hardening/game-reviews/rbac, 본 설계가 badge 추가 → 5). 고정 스칼라 5 는 시점 불안정 → **불변식으로 전환**: "badge-ddl.sql 이 존재하고 그 안의 CREATE TABLE 3종(badges/user_badges/reputation_events)이 schema.sql 에도 동일 존재" — `grep -c 'CREATE TABLE IF NOT EXISTS "\(badges\|user_badges\|reputation_events\)"' docs/badge-ddl.sql` == 3 AND db/schema.sql 동일 == 3 (권위↔사본 동등성 불변식). 타 W 파일 추가와 무관.
|
|
- **AC-T2 신규 테이블 멱등 가드 전수 3건** — badge-ddl.sql 의 3테이블 전부 `CREATE TABLE IF NOT EXISTS`: `grep -c 'CREATE TABLE IF NOT EXISTS' docs/badge-ddl.sql` == 3. 멱등 누락(IF NOT EXISTS 빠진 CREATE) 동시 검출.
|
|
- **AC-T3 배지 SQL `${}` 0건** — 신규 매퍼 5개(BadgesMapper/UserBadgesMapper/UserBadgesQueryMapper/ReputationEventsMapper) + 수정 컨트롤러에 `${` 매치 0: `grep -rc '\${' <매퍼 파일들>` == 0 (AC-10).
|
|
- **AC-T4 (표현 self-audit 적용) 자동부여 멱등 가드 전수** — 자동부여 INSERT 경로 전부(user_badges/reputation_events insertIgnore)가 `ON CONFLICT DO NOTHING` 동반. 단일 리터럴 grep 은 SQL 표현 변형(`ON CONFLICT (...) DO NOTHING` 컬럼 명시)에 취약 → **의미 불변식 + 수동 판정**: insertIgnore 메서드 2개(UserBadgesMapper.insertIgnore / ReputationEventsMapper.insertIgnore)가 각각 부분유니크 인덱스(ux_user_badges_active / ux_reputation_events_source)에 대응하는 ON CONFLICT 절을 갖는지 코드 리뷰 확인. **이 AC 가 AC-3/AC-12 멱등의 핵심 가드** — 누락 시 중복 부여 보안결함.
|
|
- **AC-T5 (시점 self-audit 적용) PermissionKeys 카탈로그 동기 불변식** — BADGE_MANAGE 추가로 enum 멤버 수가 변한다(고정 카운트 금지). 불변식: `PermissionCatalogVerifier` 가 `values()` 순회 시드이므로 enum 멤버 수 == DB 시드 키 수(자동 동기). 검증: PermissionKeys enum 멤버에 BADGE_MANAGE 존재 AND verifier 코드가 여전히 `values()` 순회(하드코딩 리스트 아님). **W1 의 기존 AC-T1(권한 전수 3건)이 하드코딩 3이면 본 변경으로 FAIL — verification 에서 W1 AC 를 4로 재측정 필요**(concern 5).
|
|
- **AC-T6 평판 신호 event_type CHECK 정합** — reputation_events CHECK IN 목록(REVIEW_WRITTEN/GAME_UPLOADED) == 코드 record 호출이 사용하는 event_type 집합. 검증: DDL CHECK IN 2종 == ReputationEventTypes 상수 2종 == 훅 호출지점 2곳(리뷰/게임). 신호 종류 추가 시 CHECK 누락 동시 검출.
|
|
|
|
---
|
|
|
|
## 잔여 오픈 질문
|
|
없음(0). 골자 W4 Q1~Q6 + QG-W4-A 를 확정값으로 닫음(§핵심 결정 요약). 시그니처 inflate·신규 빈 full-test·표시 N+1·DB-방언 L2·W1 AC-T1 카운트 변화는 오픈 질문이 아니라 **구현/검증 단계 점검 항목**으로 `concerns` 에 이관.
|