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:
parent
08d191ff2e
commit
305cc73860
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() + "개 이상 업로드 시 자동 부여";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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", "게임 등록이 완료되었습니다.");
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue