From 305cc73860ccf1f90673f4e3cfd26624400d3d7a Mon Sep 17 00:00:00 2001 From: art Date: Mon, 29 Jun 2026 12:34:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(badge):=20W4=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80/=ED=8F=89=ED=8C=90=20=E2=80=94=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EC=96=B4/=ED=85=8C=ED=81=AC=EB=8B=88=EC=85=98=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=C2=B7=EC=88=98=EB=8F=99=20=ED=95=98=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=A6=AC=EB=93=9C=20=EB=B6=80=EC=97=AC=20+=20?= =?UTF-8?q?=ED=8F=89=ED=8C=90=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - badges/user_badges/reputation_events 3테이블: docs/badge-ddl.sql 권위 + db/schema.sql 동기, 멱등. badge_type/event_type CHECK + 부분유니크(ux_user_badges_active WHERE revoked_at IS NULL · ux_reputation_events_source WHERE source_ref IS NOT NULL) + FK→users - BadgeKeys enum 단일정의처(REVIEWER 임계10/REVIEW_WRITTEN, TECHNICIAN 임계3/GAME_UPLOADED). BadgeCatalogSeeder ApplicationRunner ON CONFLICT DO NOTHING(신규 키만 시드) - 자동부여: ReputationService.record(best-effort 훅, 리뷰작성/게임업로드 후단 try/catch) → BadgeService.evaluateAndSync(countActive>=임계 → insertIgnore 멱등). 임계초과 추가활동·동시성 1회만(ON CONFLICT 부분유니크) - 수동 부여/회수: BadgeManageController /manage/users/{userId}/badges/{key}/(grant|revoke) — /admin/** 밖 경로 + permissionGate.has(BADGE_MANAGE) 게이트헬퍼(ADMIN 암묵+SUBADMIN 키). CSRF→401→403. revoke=revoked_at+reason, 회수후 재획득 가능 - 신규 권한키 BADGE_MANAGE(PermissionKeys 4번째) — PermissionCatalogVerifier values() 자동 시드(W1 카탈로그 3→4, 회귀 0) - 표시: 리뷰 작성자 배지 배치 부착(listReviews userId 집합 IN 1쿼리, N+1 차단) + game-detail.jsp 칩(textContent) - 신규 5매퍼 #{} only(${} 0). @MockBean 6 검증: 컨테이너 ./mvnw -o test 347/347 GREEN(신규 29: BadgeServiceTest 12·BadgeManageControllerTest 14·GameReviewControllerTest 회귀 3), 회귀 0. L2 격리 throwaway DB contract PASS(자동부여 멱등·회수후 재획득 부분유니크·reputation dedupe·countActive·배치 alias·CHECK/FK). W1 카탈로그 회귀 0. 실 dev DB 무접촉. 알려진 잔여(표시 deferral): 프로필 myBadges 컨트롤러 주입 + 게임카드 creator 배지 미구현(JSP 방어적 준비됨, 무렌더 — 설계 graceful-empty 정합). 배지 도메인 코어 + 리뷰작성자 표시는 완성. 두 표시면은 WebMvcController/SearchController(W3-1/3-4) 수정 필요라 별도 후속. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD --- db/schema.sql | 90 ++++++ docs/badge-ddl.sql | 90 ++++++ .../pandoli365/bibimbap/badge/BadgeKeys.java | 40 +++ .../bibimbap/badge/BadgeService.java | 74 +++++ .../bibimbap/badge/ReputationService.java | 30 ++ .../bibimbap/config/BadgeCatalogSeeder.java | 38 +++ .../controller/BadgeManageController.java | 140 +++++++++ .../controller/api/GameController.java | 14 +- .../controller/api/GameReviewController.java | 43 ++- .../pandoli365/bibimbap/data/BadgeData.java | 50 +++ .../bibimbap/data/UserBadgeData.java | 70 +++++ .../bibimbap/data/UserBadgeKeyRow.java | 23 ++ .../bibimbap/mapper/BadgesMapper.java | 36 +++ .../mapper/ReputationEventsMapper.java | 27 ++ .../bibimbap/mapper/UserBadgesMapper.java | 48 +++ .../mapper/UserBadgesQueryMapper.java | 25 ++ .../bibimbap/security/PermissionKeys.java | 3 +- src/main/webapp/WEB-INF/views/game-detail.jsp | 43 +++ src/main/webapp/WEB-INF/views/profile.jsp | 37 +++ .../bibimbap/BibimbapApplicationTests.java | 25 ++ .../controller/BadgeManageControllerTest.java | 289 ++++++++++++++++++ .../api/GameReviewControllerTest.java | 101 +++++- .../bibimbap/service/BadgeServiceTest.java | 170 +++++++++++ 23 files changed, 1501 insertions(+), 5 deletions(-) create mode 100644 docs/badge-ddl.sql create mode 100644 src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java create mode 100644 src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java create mode 100644 src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java create mode 100644 src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/BadgeData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/UserBadgeKeyRow.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/BadgeManageControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/service/BadgeServiceTest.java diff --git a/db/schema.sql b/db/schema.sql index fd3108a..0cf887f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1067,3 +1067,93 @@ CREATE INDEX IF NOT EXISTS "idx_game_upload_audit_outcome" COMMENT ON TABLE "game_upload_audit_log" IS 'WebGL 업로드 감사 로그(성공/거부 추적, W3-5)'; COMMENT ON COLUMN "game_upload_audit_log"."reject_reason" IS 'REJECTED 사유코드: NOT_ZIP/MAGIC_FAIL/TOO_LARGE/TOO_MANY_ENTRIES/ENTRY_TOO_LARGE/EXTRACTED_TOO_LARGE/ZIP_SLIP/SYMLINK/BAD_ENTRY_NAME/NO_INDEX/INCOMPLETE_BUILD'; + +-- W4 유저 배지/평판 (권위: docs/badge-ddl.sql 동기 사본) + +-- 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, + "display_name" character varying(100) NOT NULL, + "description" character varying(500), + "badge_type" character varying(30) NOT NULL, + "criteria_json" text, + "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, + "awarded_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + "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 +$$; +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, + "source_ref" character varying(100), + "weight" integer DEFAULT 1 NOT NULL, + "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 +$$; +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"); diff --git a/docs/badge-ddl.sql b/docs/badge-ddl.sql new file mode 100644 index 0000000..2721fc1 --- /dev/null +++ b/docs/badge-ddl.sql @@ -0,0 +1,90 @@ +-- 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, + "display_name" character varying(100) NOT NULL, + "description" character varying(500), + "badge_type" character varying(30) NOT NULL, + "criteria_json" text, + "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, + "awarded_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + "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 +$$; +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, + "source_ref" character varying(100), + "weight" integer DEFAULT 1 NOT NULL, + "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 +$$; +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"); diff --git a/src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java b/src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java new file mode 100644 index 0000000..599f2b3 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/badge/BadgeKeys.java @@ -0,0 +1,40 @@ +package com.pandoli365.bibimbap.badge; + +public enum BadgeKeys { + REVIEWER("리뷰어", "REVIEW_WRITTEN", 10), + TECHNICIAN("테크니션", "GAME_UPLOADED", 3); + + private final String displayName; + private final String eventType; + private final int threshold; + + BadgeKeys(String displayName, String eventType, int threshold) { + this.displayName = displayName; + this.eventType = eventType; + this.threshold = threshold; + } + + public String displayName() { + return displayName; + } + + public String eventType() { + return eventType; + } + + public int threshold() { + return threshold; + } + + public static boolean isValid(String key) { + if (key == null) { + return false; + } + try { + valueOf(key); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java b/src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java new file mode 100644 index 0000000..77283b5 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/badge/BadgeService.java @@ -0,0 +1,74 @@ +package com.pandoli365.bibimbap.badge; + +import org.springframework.stereotype.Service; + +import com.pandoli365.bibimbap.mapper.ReputationEventsMapper; +import com.pandoli365.bibimbap.mapper.UserBadgesMapper; +import com.pandoli365.bibimbap.mapper.UsersMapper; + +@Service +public class BadgeService { + + private final ReputationEventsMapper reputationEventsMapper; + private final UserBadgesMapper userBadgesMapper; + private final UsersMapper usersMapper; + + public BadgeService(ReputationEventsMapper reputationEventsMapper, + UserBadgesMapper userBadgesMapper, + UsersMapper usersMapper) { + this.reputationEventsMapper = reputationEventsMapper; + this.userBadgesMapper = userBadgesMapper; + this.usersMapper = usersMapper; + } + + // 자동부여: 해당 배지의 평판 누적이 임계 도달 시 멱등 부여. + // 자동 경로의 badgeKey 는 항상 코드 상수(ReputationService)라 정상엔 무효 키가 도달하지 않으나, 방어적으로 무시한다. + public void evaluateAndSync(long userId, String badgeKey) { + if (!BadgeKeys.isValid(badgeKey)) { + return; + } + BadgeKeys badge = BadgeKeys.valueOf(badgeKey); + long count = reputationEventsMapper.countActive(userId, badge.eventType()); + if (count >= badge.threshold()) { + // ux_user_badges_active ON CONFLICT DO NOTHING (멱등) + userBadgesMapper.insertIgnore(userId, badgeKey); + } + } + + // 수동 부여: 컨트롤러가 CSRF+BADGE_MANAGE 게이트 통과 후 호출. 도메인 검증(키유효/대상존재/중복)만 수행. + public GrantResult grantManual(long userId, String badgeKey, long awardedBy) { + if (!BadgeKeys.isValid(badgeKey)) { + return GrantResult.INVALID_BADGE; + } + if (usersMapper.getUser(userId) == null) { + return GrantResult.USER_NOT_FOUND; + } + if (userBadgesMapper.existsActive(userId, badgeKey)) { + return GrantResult.ALREADY_ACTIVE; + } + userBadgesMapper.insert(userId, badgeKey, awardedBy); + return GrantResult.GRANTED; + } + + // 수동 회수: revoked_at+reason 설정. 활성 미보유면 NOT_ACTIVE. 키 유효성 선검사. + public RevokeResult revokeManual(long userId, String badgeKey, String reason) { + if (!BadgeKeys.isValid(badgeKey)) { + return RevokeResult.INVALID_BADGE; + } + int affected = userBadgesMapper.revoke(userId, badgeKey, reason); + return affected > 0 ? RevokeResult.REVOKED : RevokeResult.NOT_ACTIVE; + } + + public enum GrantResult { + GRANTED, + ALREADY_ACTIVE, + INVALID_BADGE, + USER_NOT_FOUND + } + + public enum RevokeResult { + REVOKED, + NOT_ACTIVE, + INVALID_BADGE + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java b/src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java new file mode 100644 index 0000000..a1964f5 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/badge/ReputationService.java @@ -0,0 +1,30 @@ +package com.pandoli365.bibimbap.badge; + +import org.springframework.stereotype.Service; + +import com.pandoli365.bibimbap.mapper.ReputationEventsMapper; + +@Service +public class ReputationService { + + private final ReputationEventsMapper reputationEventsMapper; + private final BadgeService badgeService; + + public ReputationService(ReputationEventsMapper reputationEventsMapper, BadgeService badgeService) { + this.reputationEventsMapper = reputationEventsMapper; + this.badgeService = badgeService; + } + + // 평판 이벤트 기록 + 해당 event_type 에 매핑된 배지 임계 재평가. + // best-effort try/catch 는 호출자(컨트롤러 훅, 설계 S1)가 담당하므로 여기선 감싸지 않는다. + // 단 evaluateAndSync 실패가 insertIgnore 를 무효화하지 않도록 순서: insert 먼저 → evaluate. + public void record(long userId, String eventType, String sourceRef) { + reputationEventsMapper.insertIgnore(userId, eventType, sourceRef); + // event_type 1:1 매핑(REVIEW_WRITTEN→REVIEWER, GAME_UPLOADED→TECHNICIAN)에 해당하는 배지 재평가. + for (BadgeKeys badge : BadgeKeys.values()) { + if (badge.eventType().equals(eventType)) { + badgeService.evaluateAndSync(userId, badge.name()); + } + } + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java b/src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java new file mode 100644 index 0000000..f0427e9 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/config/BadgeCatalogSeeder.java @@ -0,0 +1,38 @@ +package com.pandoli365.bibimbap.config; + +import com.pandoli365.bibimbap.badge.BadgeKeys; +import com.pandoli365.bibimbap.mapper.BadgesMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class BadgeCatalogSeeder implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(BadgeCatalogSeeder.class); + + private final BadgesMapper badgesMapper; + + public BadgeCatalogSeeder(BadgesMapper badgesMapper) { + this.badgesMapper = badgesMapper; + } + + @Override + public void run(ApplicationArguments args) { + // 시드(멱등): enum 전체 순회 — insertIgnore 로 신규 키만 삽입, 기존 행 미덮어쓰기 + for (BadgeKeys badge : BadgeKeys.values()) { + badgesMapper.insertIgnore(badge.name(), badge.displayName(), descriptionFor(badge), badge.name()); + } + + log.info("[BADGE] 배지 카탈로그 시드 완료: {} 종", BadgeKeys.values().length); + } + + private String descriptionFor(BadgeKeys badge) { + return switch (badge) { + case REVIEWER -> "리뷰 " + badge.threshold() + "건 이상 작성 시 자동 부여"; + case TECHNICIAN -> "게임 " + badge.threshold() + "개 이상 업로드 시 자동 부여"; + }; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java b/src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java new file mode 100644 index 0000000..248794b --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/BadgeManageController.java @@ -0,0 +1,140 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.badge.BadgeService; +import com.pandoli365.bibimbap.badge.BadgeService.GrantResult; +import com.pandoli365.bibimbap.badge.BadgeService.RevokeResult; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +/** + * /manage/** 는 인터셉터 미등록 경로이므로 진입부에서 CSRF·PermissionGate 게이트를 직접 호출한다. + */ +@Controller +public class BadgeManageController { + + private final BadgeService badgeService; + private final PermissionGate permissionGate; + + public BadgeManageController(BadgeService badgeService, PermissionGate permissionGate) { + this.badgeService = badgeService; + this.permissionGate = permissionGate; + } + + @PostMapping("/manage/users/{userId}/badges/{badgeKey}/grant") + @ResponseBody + public ResponseEntity> grant( + @PathVariable("userId") long userId, + @PathVariable("badgeKey") String badgeKey, + HttpServletRequest request, + HttpSession session) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + if (!permissionGate.isAuthenticated(session)) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + if (!permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name())) { + return response(HttpStatus.FORBIDDEN, "권한이 없습니다."); + } + Long actorId = sessionUserId(session); + if (actorId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + + GrantResult r = badgeService.grantManual(userId, badgeKey, actorId); + switch (r) { + case INVALID_BADGE: + return response(HttpStatus.NOT_FOUND, "존재하지 않는 배지입니다."); + case USER_NOT_FOUND: + return response(HttpStatus.NOT_FOUND, "대상 사용자를 찾을 수 없습니다."); + case ALREADY_ACTIVE: + return response(HttpStatus.CONFLICT, "이미 보유한 배지입니다."); + case GRANTED: + default: + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("message", "배지를 부여했습니다."); + body.put("userId", userId); + body.put("badgeKey", badgeKey); + body.put("awarded", true); + return ResponseEntity.ok(body); + } + } + + @PostMapping("/manage/users/{userId}/badges/{badgeKey}/revoke") + @ResponseBody + public ResponseEntity> revoke( + @PathVariable("userId") long userId, + @PathVariable("badgeKey") String badgeKey, + @RequestParam(name = "reason", required = false) String reason, + HttpServletRequest request, + HttpSession session) { + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + if (!permissionGate.isAuthenticated(session)) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + if (!permissionGate.has(session, PermissionKeys.BADGE_MANAGE.name())) { + return response(HttpStatus.FORBIDDEN, "권한이 없습니다."); + } + String trimmedReason = (reason == null) ? null : reason.trim(); + if (trimmedReason == null || trimmedReason.isBlank()) { + return response(HttpStatus.BAD_REQUEST, "회수 사유를 입력해 주세요."); + } + + RevokeResult r = badgeService.revokeManual(userId, badgeKey, trimmedReason); + switch (r) { + case INVALID_BADGE: + return response(HttpStatus.NOT_FOUND, "존재하지 않는 배지입니다."); + case NOT_ACTIVE: + return response(HttpStatus.NOT_FOUND, "회수할 활성 배지가 없습니다."); + case REVOKED: + default: + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("message", "배지를 회수했습니다."); + body.put("userId", userId); + body.put("badgeKey", badgeKey); + body.put("revoked", true); + return ResponseEntity.ok(body); + } + } + + private ResponseEntity> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } + + 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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index e56a2ef..cff2788 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -30,11 +30,14 @@ import java.util.Map; @Controller public class GameController { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GameController.class); + private final GamesMapper gamesMapper; private final GameCommentsMapper gameCommentsMapper; private final GameReviewsMapper gameReviewsMapper; private final GameViewsMapper gameViewsMapper; private final GameAssetCleanupService assetCleanupService; + private final com.pandoli365.bibimbap.badge.ReputationService reputationService; @Value("${app.webgl.asset-origin:}") private String webglAssetOrigin; @@ -43,12 +46,14 @@ public class GameController { GameCommentsMapper gameCommentsMapper, GameReviewsMapper gameReviewsMapper, GameViewsMapper gameViewsMapper, - GameAssetCleanupService assetCleanupService) { + GameAssetCleanupService assetCleanupService, + com.pandoli365.bibimbap.badge.ReputationService reputationService) { this.gamesMapper = gamesMapper; this.gameCommentsMapper = gameCommentsMapper; this.gameReviewsMapper = gameReviewsMapper; this.gameViewsMapper = gameViewsMapper; this.assetCleanupService = assetCleanupService; + this.reputationService = reputationService; } public static String webglUrlForGame(int gameId) { @@ -109,6 +114,13 @@ public class GameController { return response(HttpStatus.INTERNAL_SERVER_ERROR, "게임 등록 결과를 확인하지 못했습니다."); } + // W4 평판 훅(best-effort): 게임 업로드 신호 기록 + TECHNICIAN 임계 평가. 실패해도 게임 등록 본흐름 롤백 금지. + try { + reputationService.record(userId, "GAME_UPLOADED", "game:" + game.getId()); + } catch (RuntimeException e) { + log.warn("[BADGE] 게임 업로드 평판 훅 실패 userId={} gameId={}", userId, game.getId(), e); + } + Map body = new LinkedHashMap<>(); body.put("status", 200); body.put("message", "게임 등록이 완료되었습니다."); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java index be6ba28..01961bb 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameReviewController.java @@ -1,16 +1,21 @@ package com.pandoli365.bibimbap.controller.api; +import com.pandoli365.bibimbap.badge.ReputationService; import com.pandoli365.bibimbap.data.GameReviewData; import com.pandoli365.bibimbap.data.ReviewAxisRow; +import com.pandoli365.bibimbap.data.UserBadgeKeyRow; import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper; import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.PermissionGate; import com.pandoli365.bibimbap.util.TextNormalizer; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -26,10 +31,14 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; @Controller public class GameReviewController { + private static final Logger log = LoggerFactory.getLogger(GameReviewController.class); + private static final int RATING_MIN = 1; private static final int RATING_MAX = 5; private static final int BODY_MIN = 10; @@ -42,17 +51,23 @@ public class GameReviewController { private final GameReviewStatsMapper gameReviewStatsMapper; private final GamesMapper gamesMapper; private final PermissionGate permissionGate; + private final ReputationService reputationService; + private final UserBadgesQueryMapper userBadgesQueryMapper; public GameReviewController(GameReviewsMapper gameReviewsMapper, GameReviewAxesMapper gameReviewAxesMapper, GameReviewStatsMapper gameReviewStatsMapper, GamesMapper gamesMapper, - PermissionGate permissionGate) { + PermissionGate permissionGate, + ReputationService reputationService, + UserBadgesQueryMapper userBadgesQueryMapper) { this.gameReviewsMapper = gameReviewsMapper; this.gameReviewAxesMapper = gameReviewAxesMapper; this.gameReviewStatsMapper = gameReviewStatsMapper; this.gamesMapper = gamesMapper; this.permissionGate = permissionGate; + this.reputationService = reputationService; + this.userBadgesQueryMapper = userBadgesQueryMapper; } @GetMapping("/game/{id}/reviews") @@ -88,9 +103,26 @@ public class GameReviewController { } } + // W4 작성자 배지 배치 부착(N+1 차단): 작성자 userId distinct → 1쿼리(IN) 조회 후 view 에 부착. + Map> badgesByUser = new LinkedHashMap<>(); + if (!pageRows.isEmpty()) { + List userIds = pageRows.stream() + .map(GameReviewData::getUserId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (!userIds.isEmpty()) { + for (UserBadgeKeyRow row : userBadgesQueryMapper.listActiveBadgeKeysByUserIds(userIds)) { + badgesByUser.computeIfAbsent(row.getUserId(), k -> new ArrayList<>()).add(row.getBadgeKey()); + } + } + } + List> reviews = new ArrayList<>(); for (GameReviewData r : pageRows) { - reviews.add(reviewView(r)); + Map view = reviewView(r); + view.put("authorBadges", badgesByUser.getOrDefault(r.getUserId(), List.of())); + reviews.add(view); } Map body = new LinkedHashMap<>(); @@ -185,6 +217,13 @@ public class GameReviewController { } gameReviewAxesMapper.addReviewAxes(review.getId(), toAxisRows(review.getId(), axes)); + // W4 평판 훅(best-effort): 리뷰 작성 신호 기록 + REVIEWER 임계 평가. 실패해도 리뷰 본흐름 롤백 금지. + try { + reputationService.record(userId, "REVIEW_WRITTEN", "review:" + review.getId()); + } catch (RuntimeException e) { + log.warn("[BADGE] 리뷰 평판 훅 실패 userId={} reviewId={}", userId, review.getId(), e); + } + GameReviewData created = gameReviewsMapper.getGameReview(review.getId()); if (created != null) { created.setAxes(loadAxes(created.getId())); diff --git a/src/main/java/com/pandoli365/bibimbap/data/BadgeData.java b/src/main/java/com/pandoli365/bibimbap/data/BadgeData.java new file mode 100644 index 0000000..db39fc2 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/BadgeData.java @@ -0,0 +1,50 @@ +package com.pandoli365.bibimbap.data; + +public class BadgeData { + + private String badgeKey; + private String displayName; + private String description; + private String badgeType; + private Boolean isActive; + + public String getBadgeKey() { + return badgeKey; + } + + public void setBadgeKey(String badgeKey) { + this.badgeKey = badgeKey; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getBadgeType() { + return badgeType; + } + + public void setBadgeType(String badgeType) { + this.badgeType = badgeType; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java b/src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java new file mode 100644 index 0000000..1a6bc4d --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/UserBadgeData.java @@ -0,0 +1,70 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +public class UserBadgeData { + + private Long id; + private Long userId; + private String badgeKey; + private Long awardedBy; + private OffsetDateTime awardedAt; + private OffsetDateTime revokedAt; + private String revokeReason; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getBadgeKey() { + return badgeKey; + } + + public void setBadgeKey(String badgeKey) { + this.badgeKey = badgeKey; + } + + public Long getAwardedBy() { + return awardedBy; + } + + public void setAwardedBy(Long awardedBy) { + this.awardedBy = awardedBy; + } + + public OffsetDateTime getAwardedAt() { + return awardedAt; + } + + public void setAwardedAt(OffsetDateTime awardedAt) { + this.awardedAt = awardedAt; + } + + public OffsetDateTime getRevokedAt() { + return revokedAt; + } + + public void setRevokedAt(OffsetDateTime revokedAt) { + this.revokedAt = revokedAt; + } + + public String getRevokeReason() { + return revokeReason; + } + + public void setRevokeReason(String revokeReason) { + this.revokeReason = revokeReason; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/UserBadgeKeyRow.java b/src/main/java/com/pandoli365/bibimbap/data/UserBadgeKeyRow.java new file mode 100644 index 0000000..34249ff --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/UserBadgeKeyRow.java @@ -0,0 +1,23 @@ +package com.pandoli365.bibimbap.data; + +public class UserBadgeKeyRow { + + private Long userId; + private String badgeKey; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getBadgeKey() { + return badgeKey; + } + + public void setBadgeKey(String badgeKey) { + this.badgeKey = badgeKey; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java new file mode 100644 index 0000000..16d16f4 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/BadgesMapper.java @@ -0,0 +1,36 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.BadgeData; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface BadgesMapper { + + @Insert(""" + INSERT INTO badges (badge_key, display_name, description, badge_type, is_active) + VALUES (#{badgeKey}, #{displayName}, #{description}, #{badgeType}, true) + ON CONFLICT (badge_key) DO NOTHING + """) + int insertIgnore(@Param("badgeKey") String badgeKey, + @Param("displayName") String displayName, + @Param("description") String description, + @Param("badgeType") String badgeType); + + @Select(""" + SELECT + badge_key AS "badgeKey", + display_name AS "displayName", + description AS "description", + badge_type AS "badgeType", + is_active AS "isActive" + FROM badges + WHERE is_active = true + ORDER BY id + """) + List listActive(); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java new file mode 100644 index 0000000..a8a99f9 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/ReputationEventsMapper.java @@ -0,0 +1,27 @@ +package com.pandoli365.bibimbap.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface ReputationEventsMapper { + + @Insert(""" + INSERT INTO reputation_events (user_id, event_type, source_ref) + VALUES (#{userId}, #{eventType}, #{sourceRef}) + ON CONFLICT (user_id, event_type, source_ref) WHERE source_ref IS NOT NULL DO NOTHING + """) + int insertIgnore(@Param("userId") long userId, + @Param("eventType") String eventType, + @Param("sourceRef") String sourceRef); + + @Select(""" + SELECT COUNT(*) + FROM reputation_events + WHERE user_id = #{userId} + AND event_type = #{eventType} + """) + long countActive(@Param("userId") long userId, @Param("eventType") String eventType); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java new file mode 100644 index 0000000..48e534c --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesMapper.java @@ -0,0 +1,48 @@ +package com.pandoli365.bibimbap.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +@Mapper +public interface UserBadgesMapper { + + @Insert(""" + INSERT INTO user_badges (user_id, badge_key, awarded_by) + VALUES (#{userId}, #{badgeKey}, NULL) + ON CONFLICT (user_id, badge_key) WHERE revoked_at IS NULL DO NOTHING + """) + int insertIgnore(@Param("userId") long userId, @Param("badgeKey") String badgeKey); + + @Insert(""" + INSERT INTO user_badges (user_id, badge_key, awarded_by) + VALUES (#{userId}, #{badgeKey}, #{awardedBy}) + """) + int insert(@Param("userId") long userId, + @Param("badgeKey") String badgeKey, + @Param("awardedBy") long awardedBy); + + @Select(""" + SELECT EXISTS( + SELECT 1 FROM user_badges + WHERE user_id = #{userId} + AND badge_key = #{badgeKey} + AND revoked_at IS NULL + ) + """) + boolean existsActive(@Param("userId") long userId, @Param("badgeKey") String badgeKey); + + @Update(""" + UPDATE user_badges + SET revoked_at = now(), + revoke_reason = #{reason} + WHERE user_id = #{userId} + AND badge_key = #{badgeKey} + AND revoked_at IS NULL + """) + int revoke(@Param("userId") long userId, + @Param("badgeKey") String badgeKey, + @Param("reason") String reason); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java new file mode 100644 index 0000000..ad60cf7 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/UserBadgesQueryMapper.java @@ -0,0 +1,25 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.UserBadgeKeyRow; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface UserBadgesQueryMapper { + + @Select(""" + + """) + List listActiveBadgeKeysByUserIds(@Param("userIds") List userIds); +} diff --git a/src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java b/src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java index d5e0885..a17a47e 100644 --- a/src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java +++ b/src/main/java/com/pandoli365/bibimbap/security/PermissionKeys.java @@ -3,7 +3,8 @@ package com.pandoli365.bibimbap.security; public enum PermissionKeys { GAME_JAM_MANAGE("게임잼 관리"), POST_WRITE("포스팅 작성"), - CONTENT_MODERATE("콘텐츠 모더레이션"); + CONTENT_MODERATE("콘텐츠 모더레이션"), + BADGE_MANAGE("배지 관리"); private final String displayName; diff --git a/src/main/webapp/WEB-INF/views/game-detail.jsp b/src/main/webapp/WEB-INF/views/game-detail.jsp index f7246ea..c4dd758 100644 --- a/src/main/webapp/WEB-INF/views/game-detail.jsp +++ b/src/main/webapp/WEB-INF/views/game-detail.jsp @@ -860,6 +860,27 @@ background: rgba(232, 165, 75, 0.16); border-radius: 999px; } + .game-reviews__badges { + display: inline-flex; + flex-wrap: wrap; + gap: 0.3rem; + } + .game-reviews__badge { + padding: 0.05rem 0.45rem; + font-size: 0.625rem; + font-weight: 700; + color: var(--text-muted); + background: var(--accent-soft); + border-radius: 999px; + } + .game-reviews__badge--reviewer { + color: #2563eb; + background: rgba(37, 99, 235, 0.14); + } + .game-reviews__badge--technician { + color: #16a34a; + background: rgba(22, 163, 74, 0.14); + } .game-reviews__edited { font-size: 0.6875rem; font-weight: 500; @@ -2095,6 +2116,15 @@ if (rComposer) rComposer.hidden = false; } + // W4 배지 키→표시명. 서버 카탈로그(display_name)와 동일 한글명. + function badgeLabel(key) { + switch (String(key)) { + case 'REVIEWER': return '리뷰어'; + case 'TECHNICIAN': return '테크니션'; + default: return String(key); + } + } + function buildReviewItem(r) { var mine = viewerId != null && r.userId != null && Number(r.userId) === Number(viewerId); var li = document.createElement('li'); @@ -2110,6 +2140,19 @@ nickEl.textContent = nick; head.appendChild(nickEl); + // W4 유저 배지 표시: 작성자 활성 배지(REVIEWER/TECHNICIAN) 칩. textContent 로 XSS 안전. + if (Array.isArray(r.authorBadges) && r.authorBadges.length) { + var badgeWrap = document.createElement('span'); + badgeWrap.className = 'game-reviews__badges'; + r.authorBadges.forEach(function (key) { + var chip = document.createElement('span'); + chip.className = 'game-reviews__badge game-reviews__badge--' + String(key).toLowerCase(); + chip.textContent = badgeLabel(key); + badgeWrap.appendChild(chip); + }); + head.appendChild(badgeWrap); + } + if (mine) { var mb = document.createElement('span'); mb.className = 'game-reviews__mine-badge'; diff --git a/src/main/webapp/WEB-INF/views/profile.jsp b/src/main/webapp/WEB-INF/views/profile.jsp index ac3d9da..8e5ca60 100644 --- a/src/main/webapp/WEB-INF/views/profile.jsp +++ b/src/main/webapp/WEB-INF/views/profile.jsp @@ -4,6 +4,13 @@ <%@ page import="java.util.Collections" %> <%@ page import="java.util.List" %> <%@ page import="java.util.Locale" %> +<%! + private String badgeLabel(String key) { + if ("REVIEWER".equals(key)) return "리뷰어"; + if ("TECHNICIAN".equals(key)) return "테크니션"; + return key; + } +%> <% String ctx = request.getContextPath(); @@ -19,6 +26,14 @@ String avatarInitial = HtmlUtils.htmlEscape(initial); Object rawMyGames = request.getAttribute("myGames"); List myGames = rawMyGames instanceof List ? (List) rawMyGames : Collections.emptyList(); + + java.util.List badgeKeys = new java.util.ArrayList<>(); + Object badgesAttr = request.getAttribute("myBadges"); + if (badgesAttr instanceof java.util.List) { + for (Object b : (java.util.List) badgesAttr) { + if (b != null) badgeKeys.add(String.valueOf(b)); + } + } %> @@ -139,6 +154,21 @@ font-size: 0.9375rem; word-break: break-word; } + .profile__badges { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.55rem; + } + .profile__badge { + border-radius: 999px; + padding: 0.18rem 0.55rem; + background: rgba(232, 165, 75, 0.18); + color: var(--text); + font-size: 0.75rem; + font-weight: 800; + line-height: 1.4; + } .profile-avatar-form { display: flex; flex-wrap: wrap; @@ -417,6 +447,13 @@

<%= displayName %>

+ <% if (!badgeKeys.isEmpty()) { %> +
+ <% for (String bk : badgeKeys) { %> + <%= HtmlUtils.htmlEscape(badgeLabel(bk)) %> + <% } %> +
+ <% } %>
diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index a6e6f9c..5517c5d 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -1,5 +1,8 @@ package com.pandoli365.bibimbap; +import com.pandoli365.bibimbap.badge.BadgeService; +import com.pandoli365.bibimbap.badge.ReputationService; +import com.pandoli365.bibimbap.mapper.BadgesMapper; import com.pandoli365.bibimbap.mapper.GameCommentsMapper; import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; @@ -26,10 +29,13 @@ import com.pandoli365.bibimbap.mapper.PostCategoriesMapper; import com.pandoli365.bibimbap.mapper.PostsMapper; import com.pandoli365.bibimbap.mapper.RbacAuditMapper; import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; +import com.pandoli365.bibimbap.mapper.ReputationEventsMapper; import com.pandoli365.bibimbap.mapper.TagsMapper; import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper; import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper; import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper; +import com.pandoli365.bibimbap.mapper.UserBadgesMapper; +import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper; import com.pandoli365.bibimbap.mapper.UserPermissionsMapper; import com.pandoli365.bibimbap.mapper.UsersMapper; import com.pandoli365.bibimbap.security.JamRoleGate; @@ -171,6 +177,25 @@ class BibimbapApplicationTests { @MockBean private GameAssetCleanupService gameAssetCleanupService; + // W4 유저 배지/평판 신규 매퍼 4종 + 서비스 2종(MyBatis autoconfigure excluded → @MockBean 필수, contextLoads 안정화) + @MockBean + private BadgesMapper badgesMapper; + + @MockBean + private UserBadgesMapper userBadgesMapper; + + @MockBean + private UserBadgesQueryMapper userBadgesQueryMapper; + + @MockBean + private ReputationEventsMapper reputationEventsMapper; + + @MockBean + private BadgeService badgeService; + + @MockBean + private ReputationService reputationService; + @Test void contextLoads() { } diff --git a/src/test/java/com/pandoli365/bibimbap/controller/BadgeManageControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/BadgeManageControllerTest.java new file mode 100644 index 0000000..78716f0 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/BadgeManageControllerTest.java @@ -0,0 +1,289 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.badge.BadgeService; +import com.pandoli365.bibimbap.badge.BadgeService.GrantResult; +import com.pandoli365.bibimbap.badge.BadgeService.RevokeResult; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +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.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BadgeManageControllerTest { + + private static final long ACTOR_ID = 7L; + private static final long TARGET_ID = 42L; + private static final String BADGE_KEY = "FIRST_POST"; + private static final String BADGE_MANAGE = PermissionKeys.BADGE_MANAGE.name(); + + @Mock + private BadgeService badgeService; + + @Mock + private PermissionGate permissionGate; + + // ---- grant ---- + + // AC-5: CSRF 누락이면 게이트/서비스 진입 전 403. 컨트롤러 순서상 CSRF 가 가장 먼저라 service 미호출. + @Test + void grantRejectsMissingCsrf() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + verifyNoInteractions(badgeService); + } + + // AC-11: CSRF 유효하나 미인증이면 401. + @Test + void grantReturns401WhenUnauthenticated() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(false); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + verify(badgeService, never()).grantManual(anyLong(), anyString(), anyLong()); + } + + // AC-4/AC-11: 인증되었으나 BADGE_MANAGE 미보유(SUBADMIN 무권한)면 403. + @Test + void grantReturns403WhenMissingBadgeManage() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(false); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + verify(badgeService, never()).grantManual(anyLong(), anyString(), anyLong()); + } + + // ADMIN 통과(게이트가 has=true) → GRANTED → 200, awarded=true. + @Test + void grantSucceedsForAdmin() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + when(badgeService.grantManual(TARGET_ID, BADGE_KEY, ACTOR_ID)).thenReturn(GrantResult.GRANTED); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).containsEntry("awarded", true); + verify(badgeService).grantManual(TARGET_ID, BADGE_KEY, ACTOR_ID); + } + + // BADGE_MANAGE 키 보유 SUBADMIN 통과(게이트 has=true 동형) → GRANTED → 200. 권한키 경로 명시. + @Test + void grantSucceedsForSubadminWithBadgeManageKey() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + when(badgeService.grantManual(TARGET_ID, BADGE_KEY, ACTOR_ID)).thenReturn(GrantResult.GRANTED); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).containsEntry("awarded", true); + verify(badgeService).grantManual(TARGET_ID, BADGE_KEY, ACTOR_ID); + } + + // ALREADY_ACTIVE → 409. + @Test + void grantReturns409WhenAlreadyActive() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + when(badgeService.grantManual(TARGET_ID, BADGE_KEY, ACTOR_ID)).thenReturn(GrantResult.ALREADY_ACTIVE); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(409); + } + + // INVALID_BADGE → 404. + @Test + void grantReturns404WhenBadgeKeyInvalid() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + when(badgeService.grantManual(TARGET_ID, BADGE_KEY, ACTOR_ID)).thenReturn(GrantResult.INVALID_BADGE); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + // USER_NOT_FOUND → 404. + @Test + void grantReturns404WhenUserMissing() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + when(badgeService.grantManual(TARGET_ID, BADGE_KEY, ACTOR_ID)).thenReturn(GrantResult.USER_NOT_FOUND); + + ResponseEntity> response = controller.grant(TARGET_ID, BADGE_KEY, request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + // ---- revoke ---- + + // AC-5: CSRF 누락 → 403, service 미호출. + @Test + void revokeRejectsMissingCsrf() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.revoke(TARGET_ID, BADGE_KEY, "스팸 정정", request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + verifyNoInteractions(badgeService); + } + + // AC-11: 미인증 → 401. + @Test + void revokeReturns401WhenUnauthenticated() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(false); + + ResponseEntity> response = + controller.revoke(TARGET_ID, BADGE_KEY, "스팸 정정", request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + verify(badgeService, never()).revokeManual(anyLong(), anyString(), anyString()); + } + + // AC-4/AC-11: BADGE_MANAGE 미보유 → 403. + @Test + void revokeReturns403WhenMissingBadgeManage() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(false); + + ResponseEntity> response = + controller.revoke(TARGET_ID, BADGE_KEY, "스팸 정정", request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + verify(badgeService, never()).revokeManual(anyLong(), anyString(), anyString()); + } + + // 게이트 통과했으나 reason 공백 → 400, service 미호출. + @Test + void revokeReturns400WhenReasonBlank() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + + ResponseEntity> response = + controller.revoke(TARGET_ID, BADGE_KEY, " ", request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + verify(badgeService, never()).revokeManual(anyLong(), anyString(), anyString()); + } + + // 정상 회수: reason 유효 + REVOKED → 200, revoked=true. 컨트롤러가 reason 을 trim 해 전달. + @Test + void revokeSucceeds() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + when(badgeService.revokeManual(TARGET_ID, BADGE_KEY, "스팸 정정")).thenReturn(RevokeResult.REVOKED); + + ResponseEntity> response = + controller.revoke(TARGET_ID, BADGE_KEY, " 스팸 정정 ", request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).containsEntry("revoked", true); + verify(badgeService).revokeManual(eq(TARGET_ID), eq(BADGE_KEY), eq("스팸 정정")); + } + + // NOT_ACTIVE → 404. + @Test + void revokeReturns404WhenNotActive() { + BadgeManageController controller = controller(); + MockHttpSession session = grantSession(); + MockHttpServletRequest request = csrfPost(session); + when(permissionGate.isAuthenticated(session)).thenReturn(true); + when(permissionGate.has(session, BADGE_MANAGE)).thenReturn(true); + when(badgeService.revokeManual(TARGET_ID, BADGE_KEY, "스팸 정정")).thenReturn(RevokeResult.NOT_ACTIVE); + + ResponseEntity> response = + controller.revoke(TARGET_ID, BADGE_KEY, "스팸 정정", request, session); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + // ---- helpers ---- + + private BadgeManageController controller() { + return new BadgeManageController(badgeService, permissionGate); + } + + // actorId 추출용 userId + CSRF 토큰을 세팅한 세션. + private MockHttpSession grantSession() { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userId", ACTOR_ID); + CsrfTokens.getOrCreate(session); + return session; + } + + private MockHttpServletRequest csrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE)); + return request; + } + + private MockHttpServletRequest noCsrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java index dcb2ee0..d3a7050 100644 --- a/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/GameReviewControllerTest.java @@ -1,12 +1,15 @@ package com.pandoli365.bibimbap.controller.api; +import com.pandoli365.bibimbap.badge.ReputationService; import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.data.GameReviewData; import com.pandoli365.bibimbap.data.ReviewAxisRow; +import com.pandoli365.bibimbap.data.UserBadgeKeyRow; import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper; import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.PermissionGate; import org.junit.jupiter.api.Test; @@ -30,8 +33,10 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -54,6 +59,12 @@ class GameReviewControllerTest { @Mock private PermissionGate permissionGate; + @Mock + private ReputationService reputationService; + + @Mock + private UserBadgesQueryMapper userBadgesQueryMapper; + // 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual). private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"}; @@ -385,6 +396,8 @@ class GameReviewControllerTest { .thenReturn(List.of(review(50L, 1L, 7L, 4, false))); lenient().when(gameReviewAxesMapper.listAxesByReviewIds(anyList())).thenReturn(List.of()); lenient().when(gameReviewStatsMapper.getStats(1L)).thenReturn(null); + lenient().when(userBadgesQueryMapper.listActiveBadgeKeysByUserIds(any())) + .thenReturn(List.of()); ResponseEntity> response = controller.listReviews(1L, 0, "garbage"); @@ -392,6 +405,84 @@ class GameReviewControllerTest { assertThat(response.getBody()).containsKey("hasMore"); } + // ---- W4 VP-4 / AC-1: 리뷰 작성 성공 시 평판 훅 호출 ---- + + @Test + void createReviewRecordsReputationOnSuccess() { + GameReviewController controller = controller(); + MockHttpSession session = loginSession(7L, "USER"); + MockHttpServletRequest request = csrfPost(session); + when(gamesMapper.getGame(1L)).thenReturn(game(1L)); + when(gameReviewsMapper.getActiveReviewByGameAndUser(1L, 7L)).thenReturn(null); + when(gameReviewsMapper.addGameReview(any(GameReviewData.class))).thenAnswer(inv -> { + inv.getArgument(0, GameReviewData.class).setId(50L); + return 1; + }); + when(gameReviewsMapper.getGameReview(50L)).thenReturn(review(50L, 1L, 7L, 4, false)); + + ResponseEntity> response = + createReview(controller, 1L, null, "평판 훅 호출 검증 본문입니다", AXES_OK, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(reputationService).record(eq(7L), eq("REVIEW_WRITTEN"), eq("review:50")); + } + + // 평판 훅이 던져도 리뷰 작성 본흐름은 불변(best-effort). 단위테스트는 트랜잭션 미적용이라 200 반환. + @Test + void createReviewSucceedsEvenWhenReputationHookThrows() { + 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)); + doThrow(new RuntimeException("boom")) + .when(reputationService).record(anyLong(), anyString(), anyString()); + + ResponseEntity> response = + createReview(controller, 1L, null, "평판 훅 예외 흡수 검증 본문입니다", AXES_OK, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("message", "리뷰가 등록되었습니다."); + verify(gameReviewsMapper).addGameReview(any(GameReviewData.class)); + } + + // ---- W4 VP-5 / AC-9: listReviews 작성자 배지 배치 부착(N+1 차단) ---- + + @Test + void listReviewsAttachesAuthorBadgesInSingleBatchQuery() { + GameReviewController controller = controller(); + when(gamesMapper.getGame(1L)).thenReturn(game(1L)); + // 3건, 서로 다른 userId 2명(7,7,8) → distinct userIds {7,8} 한 번에 조회되어야 함. + when(gameReviewsMapper.listGameReviews(eq(1L), anyString(), anyInt(), anyInt())) + .thenReturn(List.of( + review(50L, 1L, 7L, 4, false), + review(51L, 1L, 7L, 3, false), + review(52L, 1L, 8L, 5, false))); + lenient().when(gameReviewAxesMapper.listAxesByReviewIds(anyList())).thenReturn(List.of()); + lenient().when(gameReviewStatsMapper.getStats(1L)).thenReturn(null); + when(userBadgesQueryMapper.listActiveBadgeKeysByUserIds(any())) + .thenReturn(List.of(badgeKeyRow(7L, "REVIEWER"))); + + ResponseEntity> response = controller.listReviews(1L, 0, "newest"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // N+1 차단: 리뷰 3건이어도 배지 배치 쿼리는 정확히 1회. + verify(userBadgesQueryMapper, times(1)).listActiveBadgeKeysByUserIds(any()); + + @SuppressWarnings("unchecked") + List> reviews = (List>) response.getBody().get("reviews"); + assertThat(reviews).hasSize(3); + assertThat(reviews.get(0).get("authorBadges")).isEqualTo(List.of("REVIEWER")); + assertThat(reviews.get(1).get("authorBadges")).isEqualTo(List.of("REVIEWER")); + // userId 8 은 배지 없음 → 빈 리스트. + assertThat(reviews.get(2).get("authorBadges")).isEqualTo(List.of()); + } + // ---- helpers ---- private void assertCreateRatingRejected(String rating) { @@ -444,7 +535,8 @@ class GameReviewControllerTest { private GameReviewController controller() { return new GameReviewController( - gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper, permissionGate); + gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper, permissionGate, + reputationService, userBadgesQueryMapper); } private GameData game(long id) { @@ -475,6 +567,13 @@ class GameReviewControllerTest { return r; } + private UserBadgeKeyRow badgeKeyRow(long userId, String badgeKey) { + UserBadgeKeyRow row = new UserBadgeKeyRow(); + row.setUserId(userId); + row.setBadgeKey(badgeKey); + return row; + } + private MockHttpSession loginSession(long userId, String role) { MockHttpSession session = new MockHttpSession(); session.setAttribute("userId", userId); diff --git a/src/test/java/com/pandoli365/bibimbap/service/BadgeServiceTest.java b/src/test/java/com/pandoli365/bibimbap/service/BadgeServiceTest.java new file mode 100644 index 0000000..f07d94c --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/service/BadgeServiceTest.java @@ -0,0 +1,170 @@ +package com.pandoli365.bibimbap.service; + +import com.pandoli365.bibimbap.badge.BadgeService; +import com.pandoli365.bibimbap.data.UserData; +import com.pandoli365.bibimbap.mapper.ReputationEventsMapper; +import com.pandoli365.bibimbap.mapper.UserBadgesMapper; +import com.pandoli365.bibimbap.mapper.UsersMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BadgeServiceTest { + + private static final long USER_ID = 7L; + private static final long AWARDED_BY = 99L; + + @Mock + private ReputationEventsMapper reputationEventsMapper; + @Mock + private UserBadgesMapper userBadgesMapper; + @Mock + private UsersMapper usersMapper; + + private BadgeService service() { + return new BadgeService(reputationEventsMapper, userBadgesMapper, usersMapper); + } + + // ---- VP-1 / AC-3: 자동부여(임계 도달 시 멱등 부여) ---- + + @Test + void evaluateAndSyncGrantsWhenThresholdReached() { + // REVIEWER threshold=10, eventType=REVIEW_WRITTEN. 10 >= 10 → 부여. + when(reputationEventsMapper.countActive(USER_ID, "REVIEW_WRITTEN")).thenReturn(10L); + + service().evaluateAndSync(USER_ID, "REVIEWER"); + + verify(userBadgesMapper).insertIgnore(USER_ID, "REVIEWER"); + } + + @Test + void evaluateAndSyncSkipsWhenBelowThreshold() { + // 9 < 10 → 미부여. + when(reputationEventsMapper.countActive(USER_ID, "REVIEW_WRITTEN")).thenReturn(9L); + + service().evaluateAndSync(USER_ID, "REVIEWER"); + + verify(userBadgesMapper, never()).insertIgnore(USER_ID, "REVIEWER"); + } + + @Test + void evaluateAndSyncIsIdempotentAcrossRepeatedCalls() { + // ux_user_badges_active 부분유니크(WHERE revoked_at IS NULL)가 DB 차원에서 활성 1행을 보장한다. + // L1 단위 테스트는 호출 위임만 검증한다: 2회 평가 시 insertIgnore 가 2회 호출되더라도 + // insertIgnore 자체가 ON CONFLICT DO NOTHING(0행 반환=이미 보유) 이라 부수효과는 누적되지 않는다. + when(reputationEventsMapper.countActive(USER_ID, "REVIEW_WRITTEN")).thenReturn(12L); + when(userBadgesMapper.insertIgnore(USER_ID, "REVIEWER")).thenReturn(1).thenReturn(0); + + BadgeService service = service(); + service.evaluateAndSync(USER_ID, "REVIEWER"); + service.evaluateAndSync(USER_ID, "REVIEWER"); + + verify(userBadgesMapper, times(2)).insertIgnore(USER_ID, "REVIEWER"); + } + + @Test + void evaluateAndSyncGrantsTechnicianWhenThresholdReached() { + // TECHNICIAN threshold=3, eventType=GAME_UPLOADED. 3 >= 3 → 부여. + when(reputationEventsMapper.countActive(USER_ID, "GAME_UPLOADED")).thenReturn(3L); + + service().evaluateAndSync(USER_ID, "TECHNICIAN"); + + verify(userBadgesMapper).insertIgnore(USER_ID, "TECHNICIAN"); + } + + // ---- grantManual 분기 ---- + + @Test + void grantManualGrantsForValidNewBadge() { + when(usersMapper.getUser(USER_ID)).thenReturn(new UserData()); + when(userBadgesMapper.existsActive(USER_ID, "REVIEWER")).thenReturn(false); + + BadgeService.GrantResult result = service().grantManual(USER_ID, "REVIEWER", AWARDED_BY); + + assertThat(result).isEqualTo(BadgeService.GrantResult.GRANTED); + verify(userBadgesMapper).insert(USER_ID, "REVIEWER", AWARDED_BY); + } + + @Test + void grantManualReturnsAlreadyActiveWhenHeld() { + when(usersMapper.getUser(USER_ID)).thenReturn(new UserData()); + when(userBadgesMapper.existsActive(USER_ID, "REVIEWER")).thenReturn(true); + + BadgeService.GrantResult result = service().grantManual(USER_ID, "REVIEWER", AWARDED_BY); + + assertThat(result).isEqualTo(BadgeService.GrantResult.ALREADY_ACTIVE); + verify(userBadgesMapper, never()).insert(USER_ID, "REVIEWER", AWARDED_BY); + } + + @Test + void grantManualReturnsUserNotFoundWhenUserMissing() { + when(usersMapper.getUser(USER_ID)).thenReturn(null); + + BadgeService.GrantResult result = service().grantManual(USER_ID, "REVIEWER", AWARDED_BY); + + assertThat(result).isEqualTo(BadgeService.GrantResult.USER_NOT_FOUND); + verify(userBadgesMapper, never()).insert(USER_ID, "REVIEWER", AWARDED_BY); + } + + @Test + void grantManualReturnsInvalidBadgeForUnknownKey() { + // 무효 키는 유저 조회 전에 즉시 차단 → usersMapper/userBadgesMapper 미접촉. + BadgeService.GrantResult result = service().grantManual(USER_ID, "FOO", AWARDED_BY); + + assertThat(result).isEqualTo(BadgeService.GrantResult.INVALID_BADGE); + verify(userBadgesMapper, never()).insert(USER_ID, "FOO", AWARDED_BY); + } + + // ---- revokeManual 분기(AC-6/AC-7) ---- + + @Test + void revokeManualReturnsRevokedWhenRowAffected() { + when(userBadgesMapper.revoke(USER_ID, "REVIEWER", "정책 위반")).thenReturn(1); + + BadgeService.RevokeResult result = service().revokeManual(USER_ID, "REVIEWER", "정책 위반"); + + assertThat(result).isEqualTo(BadgeService.RevokeResult.REVOKED); + } + + @Test + void revokeManualReturnsNotActiveWhenNoRowAffected() { + // 활성 미보유 → UPDATE ... WHERE revoked_at IS NULL 가 0행. + when(userBadgesMapper.revoke(USER_ID, "REVIEWER", "사유")).thenReturn(0); + + BadgeService.RevokeResult result = service().revokeManual(USER_ID, "REVIEWER", "사유"); + + assertThat(result).isEqualTo(BadgeService.RevokeResult.NOT_ACTIVE); + } + + @Test + void revokeManualReturnsInvalidBadgeForUnknownKey() { + // 무효 키는 revoke 호출 전에 차단. + BadgeService.RevokeResult result = service().revokeManual(USER_ID, "FOO", "사유"); + + assertThat(result).isEqualTo(BadgeService.RevokeResult.INVALID_BADGE); + verify(userBadgesMapper, never()).revoke(USER_ID, "FOO", "사유"); + } + + // ---- AC-7: 회수 후 재부여 ---- + + @Test + void grantManualRegrantsAfterRevoke() { + // 회수된 행은 ux_user_badges_active 부분유니크(WHERE revoked_at IS NULL)에서 제외되므로 + // existsActive=false → 동일 키 재부여가 가능하다. + when(usersMapper.getUser(USER_ID)).thenReturn(new UserData()); + when(userBadgesMapper.existsActive(USER_ID, "REVIEWER")).thenReturn(false); + + BadgeService.GrantResult result = service().grantManual(USER_ID, "REVIEWER", AWARDED_BY); + + assertThat(result).isEqualTo(BadgeService.GrantResult.GRANTED); + verify(userBadgesMapper).insert(USER_ID, "REVIEWER", AWARDED_BY); + } +}