feat(badge): W4 유저 배지/평판 — 리뷰어/테크니션 자동·수동 하이브리드 부여 + 평판 이벤트 감사

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
이정수 2026-06-29 12:34:23 +09:00
parent 08d191ff2e
commit 305cc73860
23 changed files with 1501 additions and 5 deletions

View File

@ -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 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'; 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");

90
docs/badge-ddl.sql Normal file
View File

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

View File

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

View File

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

View File

@ -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_WRITTENREVIEWER, GAME_UPLOADEDTECHNICIAN) 해당하는 배지 재평가.
for (BadgeKeys badge : BadgeKeys.values()) {
if (badge.eventType().equals(eventType)) {
badgeService.evaluateAndSync(userId, badge.name());
}
}
}
}

View File

@ -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() + "개 이상 업로드 시 자동 부여";
};
}
}

View File

@ -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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
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;
}
}

View File

@ -30,11 +30,14 @@ import java.util.Map;
@Controller @Controller
public class GameController { public class GameController {
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(GameController.class);
private final GamesMapper gamesMapper; private final GamesMapper gamesMapper;
private final GameCommentsMapper gameCommentsMapper; private final GameCommentsMapper gameCommentsMapper;
private final GameReviewsMapper gameReviewsMapper; private final GameReviewsMapper gameReviewsMapper;
private final GameViewsMapper gameViewsMapper; private final GameViewsMapper gameViewsMapper;
private final GameAssetCleanupService assetCleanupService; private final GameAssetCleanupService assetCleanupService;
private final com.pandoli365.bibimbap.badge.ReputationService reputationService;
@Value("${app.webgl.asset-origin:}") @Value("${app.webgl.asset-origin:}")
private String webglAssetOrigin; private String webglAssetOrigin;
@ -43,12 +46,14 @@ public class GameController {
GameCommentsMapper gameCommentsMapper, GameCommentsMapper gameCommentsMapper,
GameReviewsMapper gameReviewsMapper, GameReviewsMapper gameReviewsMapper,
GameViewsMapper gameViewsMapper, GameViewsMapper gameViewsMapper,
GameAssetCleanupService assetCleanupService) { GameAssetCleanupService assetCleanupService,
com.pandoli365.bibimbap.badge.ReputationService reputationService) {
this.gamesMapper = gamesMapper; this.gamesMapper = gamesMapper;
this.gameCommentsMapper = gameCommentsMapper; this.gameCommentsMapper = gameCommentsMapper;
this.gameReviewsMapper = gameReviewsMapper; this.gameReviewsMapper = gameReviewsMapper;
this.gameViewsMapper = gameViewsMapper; this.gameViewsMapper = gameViewsMapper;
this.assetCleanupService = assetCleanupService; this.assetCleanupService = assetCleanupService;
this.reputationService = reputationService;
} }
public static String webglUrlForGame(int gameId) { public static String webglUrlForGame(int gameId) {
@ -109,6 +114,13 @@ public class GameController {
return response(HttpStatus.INTERNAL_SERVER_ERROR, "게임 등록 결과를 확인하지 못했습니다."); 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<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200); body.put("status", 200);
body.put("message", "게임 등록이 완료되었습니다."); body.put("message", "게임 등록이 완료되었습니다.");

View File

@ -1,16 +1,21 @@
package com.pandoli365.bibimbap.controller.api; package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.badge.ReputationService;
import com.pandoli365.bibimbap.data.GameReviewData; import com.pandoli365.bibimbap.data.GameReviewData;
import com.pandoli365.bibimbap.data.ReviewAxisRow; import com.pandoli365.bibimbap.data.ReviewAxisRow;
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate; import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.util.TextNormalizer; import com.pandoli365.bibimbap.util.TextNormalizer;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
@ -26,10 +31,14 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Controller @Controller
public class GameReviewController { public class GameReviewController {
private static final Logger log = LoggerFactory.getLogger(GameReviewController.class);
private static final int RATING_MIN = 1; private static final int RATING_MIN = 1;
private static final int RATING_MAX = 5; private static final int RATING_MAX = 5;
private static final int BODY_MIN = 10; private static final int BODY_MIN = 10;
@ -42,17 +51,23 @@ public class GameReviewController {
private final GameReviewStatsMapper gameReviewStatsMapper; private final GameReviewStatsMapper gameReviewStatsMapper;
private final GamesMapper gamesMapper; private final GamesMapper gamesMapper;
private final PermissionGate permissionGate; private final PermissionGate permissionGate;
private final ReputationService reputationService;
private final UserBadgesQueryMapper userBadgesQueryMapper;
public GameReviewController(GameReviewsMapper gameReviewsMapper, public GameReviewController(GameReviewsMapper gameReviewsMapper,
GameReviewAxesMapper gameReviewAxesMapper, GameReviewAxesMapper gameReviewAxesMapper,
GameReviewStatsMapper gameReviewStatsMapper, GameReviewStatsMapper gameReviewStatsMapper,
GamesMapper gamesMapper, GamesMapper gamesMapper,
PermissionGate permissionGate) { PermissionGate permissionGate,
ReputationService reputationService,
UserBadgesQueryMapper userBadgesQueryMapper) {
this.gameReviewsMapper = gameReviewsMapper; this.gameReviewsMapper = gameReviewsMapper;
this.gameReviewAxesMapper = gameReviewAxesMapper; this.gameReviewAxesMapper = gameReviewAxesMapper;
this.gameReviewStatsMapper = gameReviewStatsMapper; this.gameReviewStatsMapper = gameReviewStatsMapper;
this.gamesMapper = gamesMapper; this.gamesMapper = gamesMapper;
this.permissionGate = permissionGate; this.permissionGate = permissionGate;
this.reputationService = reputationService;
this.userBadgesQueryMapper = userBadgesQueryMapper;
} }
@GetMapping("/game/{id}/reviews") @GetMapping("/game/{id}/reviews")
@ -88,9 +103,26 @@ public class GameReviewController {
} }
} }
// W4 작성자 배지 배치 부착(N+1 차단): 작성자 userId distinct 1쿼리(IN) 조회 view 부착.
Map<Long, List<String>> badgesByUser = new LinkedHashMap<>();
if (!pageRows.isEmpty()) {
List<Long> 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<Map<String, Object>> reviews = new ArrayList<>(); List<Map<String, Object>> reviews = new ArrayList<>();
for (GameReviewData r : pageRows) { for (GameReviewData r : pageRows) {
reviews.add(reviewView(r)); Map<String, Object> view = reviewView(r);
view.put("authorBadges", badgesByUser.getOrDefault(r.getUserId(), List.of()));
reviews.add(view);
} }
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
@ -185,6 +217,13 @@ public class GameReviewController {
} }
gameReviewAxesMapper.addReviewAxes(review.getId(), toAxisRows(review.getId(), axes)); 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()); GameReviewData created = gameReviewsMapper.getGameReview(review.getId());
if (created != null) { if (created != null) {
created.setAxes(loadAxes(created.getId())); created.setAxes(loadAxes(created.getId()));

View File

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

View File

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

View File

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

View File

@ -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<BadgeData> listActive();
}

View File

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

View File

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

View File

@ -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("""
<script>
SELECT
user_id AS "userId",
badge_key AS "badgeKey"
FROM user_badges
WHERE revoked_at IS NULL
AND user_id IN
<foreach item="uid" collection="userIds" open="(" separator="," close=")">#{uid}</foreach>
</script>
""")
List<UserBadgeKeyRow> listActiveBadgeKeysByUserIds(@Param("userIds") List<Long> userIds);
}

View File

@ -3,7 +3,8 @@ package com.pandoli365.bibimbap.security;
public enum PermissionKeys { public enum PermissionKeys {
GAME_JAM_MANAGE("게임잼 관리"), GAME_JAM_MANAGE("게임잼 관리"),
POST_WRITE("포스팅 작성"), POST_WRITE("포스팅 작성"),
CONTENT_MODERATE("콘텐츠 모더레이션"); CONTENT_MODERATE("콘텐츠 모더레이션"),
BADGE_MANAGE("배지 관리");
private final String displayName; private final String displayName;

View File

@ -860,6 +860,27 @@
background: rgba(232, 165, 75, 0.16); background: rgba(232, 165, 75, 0.16);
border-radius: 999px; 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 { .game-reviews__edited {
font-size: 0.6875rem; font-size: 0.6875rem;
font-weight: 500; font-weight: 500;
@ -2095,6 +2116,15 @@
if (rComposer) rComposer.hidden = false; 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) { function buildReviewItem(r) {
var mine = viewerId != null && r.userId != null && Number(r.userId) === Number(viewerId); var mine = viewerId != null && r.userId != null && Number(r.userId) === Number(viewerId);
var li = document.createElement('li'); var li = document.createElement('li');
@ -2110,6 +2140,19 @@
nickEl.textContent = nick; nickEl.textContent = nick;
head.appendChild(nickEl); 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) { if (mine) {
var mb = document.createElement('span'); var mb = document.createElement('span');
mb.className = 'game-reviews__mine-badge'; mb.className = 'game-reviews__mine-badge';

View File

@ -4,6 +4,13 @@
<%@ page import="java.util.Collections" %> <%@ page import="java.util.Collections" %>
<%@ page import="java.util.List" %> <%@ page import="java.util.List" %>
<%@ page import="java.util.Locale" %> <%@ page import="java.util.Locale" %>
<%!
private String badgeLabel(String key) {
if ("REVIEWER".equals(key)) return "리뷰어";
if ("TECHNICIAN".equals(key)) return "테크니션";
return key;
}
%>
<!DOCTYPE html> <!DOCTYPE html>
<% <%
String ctx = request.getContextPath(); String ctx = request.getContextPath();
@ -19,6 +26,14 @@
String avatarInitial = HtmlUtils.htmlEscape(initial); String avatarInitial = HtmlUtils.htmlEscape(initial);
Object rawMyGames = request.getAttribute("myGames"); Object rawMyGames = request.getAttribute("myGames");
List<GameData> myGames = rawMyGames instanceof List<?> ? (List<GameData>) rawMyGames : Collections.emptyList(); List<GameData> myGames = rawMyGames instanceof List<?> ? (List<GameData>) rawMyGames : Collections.emptyList();
java.util.List<String> 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));
}
}
%> %>
<html lang="ko"> <html lang="ko">
<head> <head>
@ -139,6 +154,21 @@
font-size: 0.9375rem; font-size: 0.9375rem;
word-break: break-word; 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 { .profile-avatar-form {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -417,6 +447,13 @@
<div class="profile-summary__body"> <div class="profile-summary__body">
<h2 class="profile-summary__name" id="profile-display-name"><%= displayName %></h2> <h2 class="profile-summary__name" id="profile-display-name"><%= displayName %></h2>
<p class="profile-summary__email"><%= email %></p> <p class="profile-summary__email"><%= email %></p>
<% if (!badgeKeys.isEmpty()) { %>
<div class="profile__badges">
<% for (String bk : badgeKeys) { %>
<span class="profile__badge"><%= HtmlUtils.htmlEscape(badgeLabel(bk)) %></span>
<% } %>
</div>
<% } %>
<form class="profile-avatar-form" action="<%= ctx %>/profile/avatar" method="post" enctype="multipart/form-data" id="profile-avatar-form"> <form class="profile-avatar-form" action="<%= ctx %>/profile/avatar" method="post" enctype="multipart/form-data" id="profile-avatar-form">
<label class="profile-button profile-button--small" for="profile-avatar-input" id="profile-avatar-label">프로필 이미지 변경</label> <label class="profile-button profile-button--small" for="profile-avatar-input" id="profile-avatar-label">프로필 이미지 변경</label>
<input class="profile-avatar-form__input" type="file" id="profile-avatar-input" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" /> <input class="profile-avatar-form__input" type="file" id="profile-avatar-input" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" />

View File

@ -1,5 +1,8 @@
package com.pandoli365.bibimbap; 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.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; 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.PostsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper; import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper; import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
import com.pandoli365.bibimbap.mapper.ReputationEventsMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper; import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper; import com.pandoli365.bibimbap.mapper.UnityFeedItemsMapper;
import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper; import com.pandoli365.bibimbap.mapper.UnityFeedSourcesMapper;
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper; 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.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper; import com.pandoli365.bibimbap.mapper.UsersMapper;
import com.pandoli365.bibimbap.security.JamRoleGate; import com.pandoli365.bibimbap.security.JamRoleGate;
@ -171,6 +177,25 @@ class BibimbapApplicationTests {
@MockBean @MockBean
private GameAssetCleanupService gameAssetCleanupService; 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 @Test
void contextLoads() { void contextLoads() {
} }

View File

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

View File

@ -1,12 +1,15 @@
package com.pandoli365.bibimbap.controller.api; package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.badge.ReputationService;
import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.GameReviewData; import com.pandoli365.bibimbap.data.GameReviewData;
import com.pandoli365.bibimbap.data.ReviewAxisRow; import com.pandoli365.bibimbap.data.ReviewAxisRow;
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
import com.pandoli365.bibimbap.security.CsrfTokens; import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate; import com.pandoli365.bibimbap.security.PermissionGate;
import org.junit.jupiter.api.Test; 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.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -54,6 +59,12 @@ class GameReviewControllerTest {
@Mock @Mock
private PermissionGate permissionGate; private PermissionGate permissionGate;
@Mock
private ReputationService reputationService;
@Mock
private UserBadgesQueryMapper userBadgesQueryMapper;
// 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual). // 6축 유효 입력(immersion,creativity,controls,completeness,sound,visual).
private static final String[] AXES_OK = {"4", "5", "3", "4", "2", "5"}; 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))); .thenReturn(List.of(review(50L, 1L, 7L, 4, false)));
lenient().when(gameReviewAxesMapper.listAxesByReviewIds(anyList())).thenReturn(List.of()); lenient().when(gameReviewAxesMapper.listAxesByReviewIds(anyList())).thenReturn(List.of());
lenient().when(gameReviewStatsMapper.getStats(1L)).thenReturn(null); lenient().when(gameReviewStatsMapper.getStats(1L)).thenReturn(null);
lenient().when(userBadgesQueryMapper.listActiveBadgeKeysByUserIds(any()))
.thenReturn(List.of());
ResponseEntity<Map<String, Object>> response = controller.listReviews(1L, 0, "garbage"); ResponseEntity<Map<String, Object>> response = controller.listReviews(1L, 0, "garbage");
@ -392,6 +405,84 @@ class GameReviewControllerTest {
assertThat(response.getBody()).containsKey("hasMore"); 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> reviews = (List<Map<String, Object>>) 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 ---- // ---- helpers ----
private void assertCreateRatingRejected(String rating) { private void assertCreateRatingRejected(String rating) {
@ -444,7 +535,8 @@ class GameReviewControllerTest {
private GameReviewController controller() { private GameReviewController controller() {
return new GameReviewController( return new GameReviewController(
gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper, permissionGate); gameReviewsMapper, gameReviewAxesMapper, gameReviewStatsMapper, gamesMapper, permissionGate,
reputationService, userBadgesQueryMapper);
} }
private GameData game(long id) { private GameData game(long id) {
@ -475,6 +567,13 @@ class GameReviewControllerTest {
return r; 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) { private MockHttpSession loginSession(long userId, String role) {
MockHttpSession session = new MockHttpSession(); MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", userId); session.setAttribute("userId", userId);

View File

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