feat(search): W3-1 게임 태그 + 검색 확장 — tags/game_tags/jam_tags/game_views + 다중태그·정렬키·방문수·리뷰 정렬

- 신규 4테이블(tags/game_tags/jam_tags/game_views) + games.view_count ALTER: docs/tag-ddl.sql 권위 + db/schema.sql 동기 사본, 멱등(IF NOT EXISTS/DO $$ guard)
- 통합 태그 도메인: tag_type(GAME/JAM/COMMON) + 운영자 사전정의·사용자 pending→승인 하이브리드. slug/name UNIQUE
- 검색 확장 GamesMapper.searchGamesAdvanced(SearchCriteria): 태그 다중필터(AND HAVING==tagCount / OR IN) + 제작자 ILIKE + 정렬키 6(latest/likes/views/reviews/rating/relevance) + 잼 검색 라우트(/games/search?jam={slug}, jam_entries 조인). 기존 searchVisibleGames 보존(회귀 0)
- game_review_stats VIEW LEFT JOIN: avg_rating AS "avgRating"/review_count AS "reviewCount" 케이스폴딩 인용. NULLS LAST. 정렬키/tagMode 는 <choose> 내부 고정문자열
- 방문수: game_views 24h dedupe(interval) + games.view_count 비정규화 증분. GameController view 훅
- 태그 관리 API: TagController(생성/승인/비활성/부착) CSRF 전수 + /admin/tags CONTENT_MODERATE 게이트. TagSanitizer 길이2~20·화이트리스트·금칙어(banned-words.txt)
- 신규 4매퍼 #{} only(${} 0건). GameData nullable 박스필드(viewCount/avgRating/reviewCount) 확장. BibimbapApplicationTests @MockBean 4종

검증: 컨테이너 ./mvnw -o test 235/235 GREEN(신규 45: TagSanitizerTest 16·SearchControllerTest 10·TagControllerTest 19), 회귀 0. L2 격리 throwaway DB contract PASS(alias 케이스폴딩 인용/비인용 대조·NULLS LAST·AND⊆OR·ILIKE·interval dedupe). 집합전수 AC-1/2/4/5 PASS. 실 dev DB 무접촉.

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 10:44:23 +09:00
parent ba1a5fe572
commit 35f1dc3de9
20 changed files with 2036 additions and 1 deletions

View File

@ -790,3 +790,116 @@ LEFT JOIN "jam_criteria" c
GROUP BY pc."jam_id", pc."game_id";
COMMENT ON VIEW "jam_score_stats" IS
'W2-3 동결 심사 집계뷰. 출품작(jam_id,game_id)별 가중 종합·비가중 평균·채점 기준수·심사 인원. fan-out 방지 선집계(game_review_stats 선례).';
-- ===========================================================================
-- W3-1 태그 + 검색 확장 (docs/tag-ddl.sql 동기 사본 — 의미 동등, 식별자 인용 스타일만 상이)
-- FK 의존: users/games/jams 선존재(파일 앞쪽 정의). 끝에 추가하여 FK 순서 안전.
-- ===========================================================================
-- 1) 통합 태그 마스터
CREATE SEQUENCE IF NOT EXISTS "tags_id_seq";
CREATE TABLE IF NOT EXISTS "tags" (
"id" bigint DEFAULT nextval('tags_id_seq'::regclass) NOT NULL,
"name" character varying(20) NOT NULL,
"slug" character varying(40) NOT NULL,
"tag_type" character varying(10) NOT NULL,
"is_active" boolean DEFAULT false NOT NULL,
"created_by" bigint,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "tags_id_seq" OWNED BY "tags"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_name_len') THEN
ALTER TABLE "tags" ADD CONSTRAINT "chk_tags_name_len"
CHECK (char_length("name") BETWEEN 2 AND 20);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_type') THEN
ALTER TABLE "tags" ADD CONSTRAINT "chk_tags_type"
CHECK ("tag_type" IN ('GAME', 'JAM', 'COMMON'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_created_by') THEN
ALTER TABLE "tags" ADD CONSTRAINT "fk_tags_created_by"
FOREIGN KEY ("created_by") REFERENCES "users" ("id");
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "uq_tags_slug" ON "tags" ("slug");
CREATE UNIQUE INDEX IF NOT EXISTS "uq_tags_name" ON "tags" ("name");
CREATE INDEX IF NOT EXISTS "idx_tags_type_active" ON "tags" ("tag_type", "is_active");
-- 2) 게임-태그 조인 (N:M)
CREATE SEQUENCE IF NOT EXISTS "game_tags_id_seq";
CREATE TABLE IF NOT EXISTS "game_tags" (
"id" bigint DEFAULT nextval('game_tags_id_seq'::regclass) NOT NULL,
"game_id" bigint NOT NULL,
"tag_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_tags_id_seq" OWNED BY "game_tags"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_game') THEN
ALTER TABLE "game_tags" ADD CONSTRAINT "fk_game_tags_game"
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_tag') THEN
ALTER TABLE "game_tags" ADD CONSTRAINT "fk_game_tags_tag"
FOREIGN KEY ("tag_id") REFERENCES "tags" ("id");
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "uq_game_tags" ON "game_tags" ("game_id", "tag_id");
CREATE INDEX IF NOT EXISTS "idx_game_tags_tag" ON "game_tags" ("tag_id");
-- 3) 잼-태그 조인 (N:M, jams 는 W2-1 소관)
CREATE SEQUENCE IF NOT EXISTS "jam_tags_id_seq";
CREATE TABLE IF NOT EXISTS "jam_tags" (
"id" bigint DEFAULT nextval('jam_tags_id_seq'::regclass) NOT NULL,
"jam_id" bigint NOT NULL,
"tag_id" bigint NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "jam_tags_id_seq" OWNED BY "jam_tags"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_jam') THEN
ALTER TABLE "jam_tags" ADD CONSTRAINT "fk_jam_tags_jam"
FOREIGN KEY ("jam_id") REFERENCES "jams" ("id");
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_tag') THEN
ALTER TABLE "jam_tags" ADD CONSTRAINT "fk_jam_tags_tag"
FOREIGN KEY ("tag_id") REFERENCES "tags" ("id");
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "uq_jam_tags" ON "jam_tags" ("jam_id", "tag_id");
CREATE INDEX IF NOT EXISTS "idx_jam_tags_tag" ON "jam_tags" ("tag_id");
-- 4) 방문 로그 (dedupe 가능)
CREATE SEQUENCE IF NOT EXISTS "game_views_id_seq";
CREATE TABLE IF NOT EXISTS "game_views" (
"id" bigint DEFAULT nextval('game_views_id_seq'::regclass) NOT NULL,
"game_id" bigint NOT NULL,
"viewer_key" character varying(200) NOT NULL,
"viewed_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_views_id_seq" OWNED BY "game_views"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_views_game') THEN
ALTER TABLE "game_views" ADD CONSTRAINT "fk_game_views_game"
FOREIGN KEY ("game_id") REFERENCES "games" ("id");
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "idx_game_views_game" ON "game_views" ("game_id");
CREATE INDEX IF NOT EXISTS "idx_game_views_dedupe" ON "game_views" ("game_id", "viewer_key", "viewed_at");
-- 5) games 비정규화 방문수 카운터
ALTER TABLE "games" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL;
CREATE INDEX IF NOT EXISTS "idx_games_view_count" ON "games" ("view_count");

100
docs/tag-ddl.sql Normal file
View File

@ -0,0 +1,100 @@
-- docs/tag-ddl.sql
-- W3-1 태그 + 검색 확장. 멱등(IF NOT EXISTS / DO $$ guard). search_path=dev.
-- 1) 통합 태그 마스터
CREATE TABLE IF NOT EXISTS tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(20) NOT NULL,
slug VARCHAR(40) NOT NULL,
tag_type VARCHAR(10) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
created_by BIGINT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_name_len') THEN
ALTER TABLE tags ADD CONSTRAINT chk_tags_name_len
CHECK (char_length(name) BETWEEN 2 AND 20);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_type') THEN
ALTER TABLE tags ADD CONSTRAINT chk_tags_type
CHECK (tag_type IN ('GAME', 'JAM', 'COMMON'));
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_created_by') THEN
ALTER TABLE tags ADD CONSTRAINT fk_tags_created_by
FOREIGN KEY (created_by) REFERENCES users(id);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_slug ON tags (slug);
CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_name ON tags (name);
CREATE INDEX IF NOT EXISTS idx_tags_type_active ON tags (tag_type, is_active);
-- 2) 게임-태그 조인 (N:M)
CREATE TABLE IF NOT EXISTS game_tags (
id BIGSERIAL PRIMARY KEY,
game_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_game_tags ON game_tags (game_id, tag_id);
CREATE INDEX IF NOT EXISTS idx_game_tags_tag ON game_tags (tag_id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_game') THEN
ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_game
FOREIGN KEY (game_id) REFERENCES games(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_tag') THEN
ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_tag
FOREIGN KEY (tag_id) REFERENCES tags(id);
END IF;
END $$;
-- 3) 잼-태그 조인 (N:M, jams 는 W2-1 소관)
CREATE TABLE IF NOT EXISTS jam_tags (
id BIGSERIAL PRIMARY KEY,
jam_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_jam_tags ON jam_tags (jam_id, tag_id);
CREATE INDEX IF NOT EXISTS idx_jam_tags_tag ON jam_tags (tag_id);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_jam') THEN
ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_jam
FOREIGN KEY (jam_id) REFERENCES jams(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_tag') THEN
ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_tag
FOREIGN KEY (tag_id) REFERENCES tags(id);
END IF;
END $$;
-- 4) 방문 로그 (dedupe 가능)
CREATE TABLE IF NOT EXISTS game_views (
id BIGSERIAL PRIMARY KEY,
game_id BIGINT NOT NULL,
viewer_key VARCHAR(200) NOT NULL,
viewed_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_game_views_game ON game_views (game_id);
CREATE INDEX IF NOT EXISTS idx_game_views_dedupe ON game_views (game_id, viewer_key, viewed_at);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_views_game') THEN
ALTER TABLE game_views ADD CONSTRAINT fk_game_views_game
FOREIGN KEY (game_id) REFERENCES games(id);
END IF;
END $$;
-- 5) games 비정규화 방문수 카운터
ALTER TABLE games ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_games_view_count ON games (view_count);

View File

@ -0,0 +1,98 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.SearchCriteria;
import com.pandoli365.bibimbap.data.TagData;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.util.TagSanitizer;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Controller
public class SearchController {
private static final Set<String> SORT_KEYS = Set.of("latest", "likes", "views", "reviews", "rating", "relevance");
private static final Set<String> TAG_MODES = Set.of("and", "or");
private final GamesMapper gamesMapper;
private final TagsMapper tagsMapper;
public SearchController(GamesMapper gamesMapper, TagsMapper tagsMapper) {
this.gamesMapper = gamesMapper;
this.tagsMapper = tagsMapper;
}
@GetMapping("/games/search")
public String search(
@RequestParam(name = "keyword", required = false) String keyword,
@RequestParam(name = "creator", required = false) String creator,
@RequestParam(name = "tags", required = false) String tagsCsv,
@RequestParam(name = "tagMode", required = false, defaultValue = "and") String tagMode,
@RequestParam(name = "sort", required = false, defaultValue = "latest") String sort,
@RequestParam(name = "jam", required = false) String jam,
Model model
) {
String normalizedKeyword = trimToNull(keyword);
String normalizedCreator = trimToNull(creator);
List<String> tagSlugs = parseTagSlugs(tagsCsv);
String normalizedTagMode = TAG_MODES.contains(tagMode) ? tagMode : "and";
String normalizedSort = SORT_KEYS.contains(sort) ? sort : "latest";
String normalizedJam = trimToNull(jam);
SearchCriteria criteria = new SearchCriteria();
criteria.setKeyword(normalizedKeyword);
criteria.setCreator(normalizedCreator);
criteria.setTags(tagSlugs.isEmpty() ? null : tagSlugs);
criteria.setTagCount(tagSlugs.size());
criteria.setTagMode(normalizedTagMode);
criteria.setSort(normalizedSort);
criteria.setJamSlug(normalizedJam);
List<GameData> games = gamesMapper.searchGamesAdvanced(criteria);
List<TagData> availableTags = tagsMapper.listActiveByType("GAME", null);
model.addAttribute("games", games);
model.addAttribute("searching", true);
model.addAttribute("searchKeyword", normalizedKeyword == null ? "" : normalizedKeyword);
model.addAttribute("searchCreator", normalizedCreator == null ? "" : normalizedCreator);
model.addAttribute("searchSort", normalizedSort);
model.addAttribute("searchTagMode", normalizedTagMode);
model.addAttribute("selectedTagSlugs", tagSlugs);
model.addAttribute("availableTags", availableTags);
model.addAttribute("searchQuery", "");
return "index";
}
private List<String> parseTagSlugs(String tagsCsv) {
Set<String> slugs = new LinkedHashSet<>();
if (tagsCsv != null) {
for (String token : tagsCsv.split(",")) {
String slug = TagSanitizer.toSlug(token);
if (TagSanitizer.isAllowed(slug)) {
slugs.add(slug);
}
}
}
return new ArrayList<>(slugs);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isBlank() ? null : text;
}
}

View File

@ -0,0 +1,273 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.TagData;
import com.pandoli365.bibimbap.mapper.GameTagsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionGate;
import com.pandoli365.bibimbap.security.PermissionKeys;
import com.pandoli365.bibimbap.util.TagSanitizer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
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;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Controller
public class TagController {
private static final Set<String> TAG_TYPES = Set.of("GAME", "JAM", "COMMON");
private final TagsMapper tagsMapper;
private final GameTagsMapper gameTagsMapper;
private final GamesMapper gamesMapper;
private final PermissionGate permissionGate;
public TagController(TagsMapper tagsMapper,
GameTagsMapper gameTagsMapper,
GamesMapper gamesMapper,
PermissionGate permissionGate) {
this.tagsMapper = tagsMapper;
this.gameTagsMapper = gameTagsMapper;
this.gamesMapper = gamesMapper;
this.permissionGate = permissionGate;
}
@PostMapping("/tags")
@Transactional
public ResponseEntity<Map<String, Object>> createUserTag(
@RequestParam(name = "name", required = false) String name,
@RequestParam(name = "tagType", required = false) String tagType,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
String normalizedType = trimToNull(tagType);
if (!isValidTagType(normalizedType)) {
return response(HttpStatus.BAD_REQUEST, "태그 유형을 확인해 주세요.");
}
String slug = TagSanitizer.toSlug(name);
if (!TagSanitizer.isAllowed(slug)) {
return response(HttpStatus.BAD_REQUEST, "태그 이름을 확인해 주세요.");
}
TagData existing = tagsMapper.findBySlug(slug);
if (existing != null) {
return tagBody(existing.getId(), existing.getName(), existing.getSlug(), existing.getIsActive());
}
TagData tag = new TagData();
tag.setName(name.trim());
tag.setSlug(slug);
tag.setTagType(normalizedType);
tag.setIsActive(false);
tag.setCreatedBy(userId);
tagsMapper.insertTag(tag);
return tagBody(tag.getId(), tag.getName(), tag.getSlug(), false);
}
@PostMapping("/admin/tags")
@Transactional
public ResponseEntity<Map<String, Object>> createAdminTag(
@RequestParam(name = "name", required = false) String name,
@RequestParam(name = "tagType", required = false) String tagType,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
if (!permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name())) {
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
Long userId = sessionUserId(session);
String normalizedType = trimToNull(tagType);
if (!isValidTagType(normalizedType)) {
return response(HttpStatus.BAD_REQUEST, "태그 유형을 확인해 주세요.");
}
String slug = TagSanitizer.toSlug(name);
if (!TagSanitizer.isAllowed(slug)) {
return response(HttpStatus.BAD_REQUEST, "태그 이름을 확인해 주세요.");
}
TagData existing = tagsMapper.findBySlug(slug);
if (existing != null) {
return tagBody(existing.getId(), existing.getName(), existing.getSlug(), existing.getIsActive());
}
TagData tag = new TagData();
tag.setName(name.trim());
tag.setSlug(slug);
tag.setTagType(normalizedType);
tag.setIsActive(true);
tag.setCreatedBy(userId);
tagsMapper.insertTag(tag);
return tagBody(tag.getId(), tag.getName(), tag.getSlug(), true);
}
@PostMapping("/admin/tags/{id}/approve")
@Transactional
public ResponseEntity<Map<String, Object>> approveTag(
@PathVariable("id") long id,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
if (!permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name())) {
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
tagsMapper.approve(id);
Map<String, Object> body = new LinkedHashMap<>();
body.put("id", id);
body.put("isActive", true);
return ResponseEntity.ok(body);
}
@PostMapping("/admin/tags/{id}/deactivate")
@Transactional
public ResponseEntity<Map<String, Object>> deactivateTag(
@PathVariable("id") long id,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
if (!permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name())) {
return response(HttpStatus.FORBIDDEN, "권한이 없습니다.");
}
tagsMapper.deactivate(id);
Map<String, Object> body = new LinkedHashMap<>();
body.put("id", id);
body.put("isActive", false);
return ResponseEntity.ok(body);
}
@PostMapping("/games/{gameId}/tags")
@Transactional
public ResponseEntity<Map<String, Object>> attachGameTags(
@PathVariable("gameId") long gameId,
@RequestParam(name = "tagIds", required = false) List<Long> tagIds,
HttpServletRequest request,
HttpSession session
) {
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
if (tagIds == null || tagIds.isEmpty()) {
return response(HttpStatus.BAD_REQUEST, "부착할 태그를 선택해 주세요.");
}
GameData game = gamesMapper.getGame(gameId);
if (game == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
boolean owner = userId.equals(game.getUserId());
boolean moderator = permissionGate.has(session, PermissionKeys.CONTENT_MODERATE.name());
if (!owner && !moderator) {
return response(HttpStatus.FORBIDDEN, "게임 소유자 또는 모더레이터만 태그를 부착할 수 있습니다.");
}
gameTagsMapper.attachTags(gameId, tagIds);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("gameId", gameId);
body.put("tagIds", tagIds);
return ResponseEntity.ok(body);
}
@GetMapping("/tags")
@ResponseBody
public List<Map<String, Object>> listTags(
@RequestParam(name = "type", required = false) String type,
@RequestParam(name = "q", required = false) String q
) {
List<TagData> tags = tagsMapper.listActiveByType(trimToNull(type), trimToNull(q));
List<Map<String, Object>> result = new ArrayList<>();
for (TagData tag : tags) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", tag.getId());
item.put("name", tag.getName());
item.put("slug", tag.getSlug());
result.add(item);
}
return result;
}
private ResponseEntity<Map<String, Object>> tagBody(Long id, String name, String slug, Boolean isActive) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("id", id);
body.put("name", name);
body.put("slug", slug);
body.put("isActive", isActive);
return ResponseEntity.ok(body);
}
private boolean isValidTagType(String tagType) {
return tagType != null && TAG_TYPES.contains(tagType);
}
private Long sessionUserId(HttpSession session) {
if (session == null) {
return null;
}
Object userId = session.getAttribute("userId");
if (userId instanceof Number number) {
return number.longValue();
}
if (userId instanceof String text) {
try {
return Long.parseLong(text);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String text = value.trim();
return text.isBlank() ? null : text;
}
private ResponseEntity<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.game.GameCatalog;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import jakarta.servlet.http.HttpServletRequest;
@ -31,16 +32,19 @@ public class GameController {
private final GamesMapper gamesMapper;
private final GameCommentsMapper gameCommentsMapper;
private final GameReviewsMapper gameReviewsMapper;
private final GameViewsMapper gameViewsMapper;
@Value("${app.webgl.asset-origin:}")
private String webglAssetOrigin;
public GameController(GamesMapper gamesMapper,
GameCommentsMapper gameCommentsMapper,
GameReviewsMapper gameReviewsMapper) {
GameReviewsMapper gameReviewsMapper,
GameViewsMapper gameViewsMapper) {
this.gamesMapper = gamesMapper;
this.gameCommentsMapper = gameCommentsMapper;
this.gameReviewsMapper = gameReviewsMapper;
this.gameViewsMapper = gameViewsMapper;
}
public static String webglUrlForGame(int gameId) {
@ -110,9 +114,11 @@ public class GameController {
}
@GetMapping("/game/{id}")
@Transactional
public String gameDetail(@PathVariable("id") long id, Model model, HttpSession session) {
GameData game = gamesMapper.getGame(id);
if (game != null) {
recordView(id, session);
addGameModel(model, game, sessionUserId(session));
model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20));
model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id, "newest", 0, 20));
@ -306,6 +312,22 @@ public class GameController {
return lower.startsWith("http://") || lower.startsWith("https://") ? text : "";
}
private void recordView(long gameId, HttpSession session) {
String vKey = viewerKey(session);
if (!gameViewsMapper.existsRecentView(gameId, vKey)) {
gameViewsMapper.insertView(gameId, vKey);
gameViewsMapper.incrementViewCount(gameId);
}
}
private String viewerKey(HttpSession session) {
Long userId = sessionUserId(session);
if (userId != null) {
return "u:" + userId;
}
return "s:" + session.getId();
}
private Long sessionUserId(HttpSession session) {
if (session == null) {
return null;

View File

@ -17,6 +17,9 @@ public class GameData {
private Integer sortOrder;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private Integer viewCount;
private Double avgRating;
private Integer reviewCount;
public Long getId() {
return id;
@ -121,4 +124,28 @@ public class GameData {
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public Integer getViewCount() {
return viewCount;
}
public void setViewCount(Integer viewCount) {
this.viewCount = viewCount;
}
public Double getAvgRating() {
return avgRating;
}
public void setAvgRating(Double avgRating) {
this.avgRating = avgRating;
}
public Integer getReviewCount() {
return reviewCount;
}
public void setReviewCount(Integer reviewCount) {
this.reviewCount = reviewCount;
}
}

View File

@ -0,0 +1,70 @@
package com.pandoli365.bibimbap.data;
import java.util.List;
public class SearchCriteria {
private String keyword;
private String creator;
private List<String> tags;
private String tagMode;
private int tagCount;
private String sort;
private String jamSlug;
public String getKeyword() {
return keyword;
}
public void setKeyword(String keyword) {
this.keyword = keyword;
}
public String getCreator() {
return creator;
}
public void setCreator(String creator) {
this.creator = creator;
}
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public String getTagMode() {
return tagMode;
}
public void setTagMode(String tagMode) {
this.tagMode = tagMode;
}
public int getTagCount() {
return tagCount;
}
public void setTagCount(int tagCount) {
this.tagCount = tagCount;
}
public String getSort() {
return sort;
}
public void setSort(String sort) {
this.sort = sort;
}
public String getJamSlug() {
return jamSlug;
}
public void setJamSlug(String jamSlug) {
this.jamSlug = jamSlug;
}
}

View File

@ -0,0 +1,70 @@
package com.pandoli365.bibimbap.data;
import java.time.OffsetDateTime;
public class TagData {
private Long id;
private String name;
private String slug;
private String tagType;
private Boolean isActive;
private Long createdBy;
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getTagType() {
return tagType;
}
public void setTagType(String tagType) {
this.tagType = tagType;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@ -0,0 +1,67 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.TagData;
import org.apache.ibatis.annotations.Delete;
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 GameTagsMapper {
@Select("""
<script>
SELECT gt.game_id
FROM game_tags gt
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
WHERE t.slug IN
<foreach item="s" collection="tagSlugs" open="(" separator="," close=")">#{s}</foreach>
GROUP BY gt.game_id
HAVING COUNT(DISTINCT t.slug) = #{tagCount}
</script>
""")
List<Long> listGameIdsByTagsAll(@Param("tagSlugs") List<String> tagSlugs, @Param("tagCount") int tagCount);
@Select("""
<script>
SELECT DISTINCT gt.game_id
FROM game_tags gt
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
WHERE t.slug IN
<foreach item="s" collection="tagSlugs" open="(" separator="," close=")">#{s}</foreach>
</script>
""")
List<Long> listGameIdsByTagsAny(@Param("tagSlugs") List<String> tagSlugs);
@Insert("""
<script>
INSERT INTO game_tags (game_id, tag_id) VALUES
<foreach item="tid" collection="tagIds" separator=",">(#{gameId}, #{tid})</foreach>
ON CONFLICT (game_id, tag_id) DO NOTHING
</script>
""")
void attachTags(@Param("gameId") long gameId, @Param("tagIds") List<Long> tagIds);
@Select("""
SELECT
t.id,
t.name,
t.slug,
t.tag_type AS tagType,
t.is_active AS isActive,
t.created_by AS createdBy,
t.created_at AS createdAt
FROM game_tags gt
JOIN tags t ON t.id = gt.tag_id
WHERE gt.game_id = #{gameId}
AND t.is_active = TRUE
ORDER BY t.name ASC
""")
List<TagData> listByGame(long gameId);
@Delete("DELETE FROM game_tags WHERE game_id = #{gameId} AND tag_id = #{tagId}")
void detach(@Param("gameId") long gameId, @Param("tagId") long tagId);
}

View File

@ -0,0 +1,31 @@
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 GameViewsMapper {
@Select("""
SELECT EXISTS(
SELECT 1
FROM game_views
WHERE game_id = #{gameId}
AND viewer_key = #{viewerKey}
AND viewed_at >= now() - interval '24 hours'
)
""")
boolean existsRecentView(@Param("gameId") long gameId, @Param("viewerKey") String viewerKey);
@Insert("""
INSERT INTO game_views (game_id, viewer_key)
VALUES (#{gameId}, #{viewerKey})
""")
void insertView(@Param("gameId") long gameId, @Param("viewerKey") String viewerKey);
@Update("UPDATE games SET view_count = view_count + 1 WHERE id = #{gameId}")
void incrementViewCount(long gameId);
}

View File

@ -1,6 +1,7 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.SearchCriteria;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
@ -197,4 +198,66 @@ public interface GamesMapper {
AND is_delete IS NOT TRUE
""")
int softDeleteGame(@Param("id") long id);
@Select("""
<script>
SELECT g.id, g.user_id AS userId, g.name, u.display_name AS creator,
g.creator_note AS creatorNote, g.git_url AS gitUrl, g.webgl_path AS webglPath,
g.thumbnail_url AS thumbnailUrl, g.like_count AS likeCount, g.is_visible AS visible,
g.sort_order AS sortOrder, g.created_at AS createdAt, g.updated_at AS updatedAt,
g.view_count AS viewCount,
grs.avg_rating AS "avgRating",
grs.review_count AS "reviewCount"
FROM games g
JOIN users u ON u.id = g.user_id
LEFT JOIN game_review_stats grs ON grs.game_id = g.id
<if test="jamSlug != null">
JOIN jam_entries je ON je.game_id = g.id AND je.is_delete IS NOT TRUE
JOIN jams j ON j.id = je.jam_id AND j.slug = #{jamSlug}
</if>
WHERE g.is_visible IS NOT FALSE
AND g.is_delete IS NOT TRUE
AND u.is_delete IS NOT TRUE
<if test="keyword != null">
AND ( g.name ILIKE ('%' || #{keyword} || '%')
OR u.display_name ILIKE ('%' || #{keyword} || '%')
OR g.creator_note ILIKE ('%' || #{keyword} || '%') )
</if>
<if test="creator != null">
AND u.display_name ILIKE ('%' || #{creator} || '%')
</if>
<if test="tags != null and tags.size() > 0 and 'and'.equals(tagMode)">
AND g.id IN (
SELECT gt.game_id FROM game_tags gt
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
GROUP BY gt.game_id
HAVING COUNT(DISTINCT t.slug) = #{tagCount}
)
</if>
<if test="tags != null and tags.size() > 0 and 'or'.equals(tagMode)">
AND g.id IN (
SELECT gt.game_id FROM game_tags gt
JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
)
</if>
ORDER BY
<choose>
<when test="'likes'.equals(sort)"> g.like_count DESC, g.id DESC </when>
<when test="'views'.equals(sort)"> g.view_count DESC, g.id DESC </when>
<when test="'reviews'.equals(sort)"> grs.review_count DESC NULLS LAST, g.id DESC </when>
<when test="'rating'.equals(sort)"> grs.avg_rating DESC NULLS LAST, g.id DESC </when>
<when test="'relevance'.equals(sort) and tags != null and tags.size() > 0">
( SELECT COUNT(*) FROM game_tags gt2
JOIN tags t2 ON t2.id = gt2.tag_id AND t2.is_active = TRUE
WHERE gt2.game_id = g.id
AND t2.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
) DESC, g.created_at DESC, g.id DESC
</when>
<otherwise> g.sort_order ASC, g.created_at DESC, g.id DESC </otherwise>
</choose>
</script>
""")
List<GameData> searchGamesAdvanced(SearchCriteria c);
}

View File

@ -0,0 +1,39 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.TagData;
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 JamTagsMapper {
@Insert("""
<script>
INSERT INTO jam_tags (jam_id, tag_id) VALUES
<foreach item="tid" collection="tagIds" separator=",">(#{jamId}, #{tid})</foreach>
ON CONFLICT (jam_id, tag_id) DO NOTHING
</script>
""")
void attachTags(@Param("jamId") long jamId, @Param("tagIds") List<Long> tagIds);
@Select("""
SELECT
t.id,
t.name,
t.slug,
t.tag_type AS tagType,
t.is_active AS isActive,
t.created_by AS createdBy,
t.created_at AS createdAt
FROM jam_tags jt
JOIN tags t ON t.id = jt.tag_id
WHERE jt.jam_id = #{jamId}
AND t.is_active = TRUE
ORDER BY t.name ASC
""")
List<TagData> listByJam(long jamId);
}

View File

@ -0,0 +1,72 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.TagData;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface TagsMapper {
@Insert("""
INSERT INTO tags (
name,
slug,
tag_type,
is_active,
created_by
) VALUES (
#{name},
#{slug},
#{tagType},
#{isActive},
#{createdBy}
)
""")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int insertTag(TagData t);
@Update("UPDATE tags SET is_active = TRUE WHERE id = #{id}")
void approve(long id);
@Update("UPDATE tags SET is_active = FALSE WHERE id = #{id}")
void deactivate(long id);
@Select("""
<script>
SELECT
id,
name,
slug,
tag_type AS tagType,
is_active AS isActive,
created_by AS createdBy,
created_at AS createdAt
FROM tags
WHERE is_active = TRUE
<if test="tagType != null"> AND tag_type = #{tagType} </if>
<if test="q != null and q != ''"> AND (name ILIKE ('%' || #{q} || '%') OR slug ILIKE ('%' || #{q} || '%')) </if>
ORDER BY name ASC, id ASC
</script>
""")
List<TagData> listActiveByType(@Param("tagType") String tagType, @Param("q") String q);
@Select("""
SELECT
id,
name,
slug,
tag_type AS tagType,
is_active AS isActive,
created_by AS createdBy,
created_at AS createdAt
FROM tags
WHERE slug = #{slug}
""")
TagData findBySlug(String slug);
}

View File

@ -0,0 +1,85 @@
package com.pandoli365.bibimbap.util;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
public final class TagSanitizer {
// 화이트리스트: 한글(-, -, -) + 영소문자 + 숫자 + 하이픈.
// <,>,",',(,),/ 등 특수문자를 전부 배제하므로 <script> 등 XSS 페이로드는 toSlug 단계에서 무력화된다.
private static final String ALLOWED_CHAR_CLASS = "a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ-";
private static final int MIN_LENGTH = 2;
private static final int MAX_LENGTH = 20;
private static final Set<String> BANNED_WORDS = loadBannedWords();
private TagSanitizer() {
}
// 기동을 막지 않기 위해 로드 실패 예외 대신 Set 으로 폴백한다.
private static Set<String> loadBannedWords() {
Set<String> words = new HashSet<>();
try (InputStream in = TagSanitizer.class.getResourceAsStream("/banned-words.txt")) {
if (in == null) {
return Collections.emptySet();
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String word = line.trim().toLowerCase(Locale.ROOT);
if (word.isEmpty() || word.startsWith("#")) {
continue;
}
words.add(word);
}
}
} catch (Exception e) {
return Collections.emptySet();
}
return Collections.unmodifiableSet(words);
}
// trim·소문자·정규화. //숫자 보존, 공백하이픈, 제거, 연속 하이픈 축약, 양끝 하이픈 제거. null"".
public static String toSlug(String rawName) {
if (rawName == null) {
return "";
}
String s = rawName.trim().toLowerCase(Locale.ROOT);
s = s.replaceAll("\\s+", "-");
s = s.replaceAll("[^" + ALLOWED_CHAR_CLASS + "]", "");
s = s.replaceAll("-{2,}", "-");
s = s.replaceAll("^-+", "").replaceAll("-+$", "");
return s;
}
// 길이 2~20(slug 기준) + 화이트리스트(한글/영문/숫자/하이픈) + 금칙어 미매치 통합 판정.
// 입력은 toSlug 통과한 slug 라고 가정하되, 화이트리스트를 재검증하여 방어한다.
public static boolean isAllowed(String slug) {
if (slug == null || slug.isBlank()) {
return false;
}
int len = slug.length();
if (len < MIN_LENGTH || len > MAX_LENGTH) {
return false;
}
if (!slug.matches("[" + ALLOWED_CHAR_CLASS + "]+")) {
return false;
}
for (String banned : BANNED_WORDS) {
if (banned.isEmpty()) {
continue;
}
if (slug.contains(banned) || banned.contains(slug)) {
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,28 @@
# W3-1 태그 금칙어 사전 (1줄 1단어, 소문자). TagSanitizer 가 부분매치로 거부.
fuck
shit
bitch
asshole
bastard
dick
cunt
slut
whore
nigger
faggot
viagra
casino
porn
xxx
sex
gambling
loan
시발
씨발
개새끼
병신
보지
자지
섹스
도박

View File

@ -1,8 +1,12 @@
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
<%@ page import="com.pandoli365.bibimbap.data.GameData" %>
<%@ page import="com.pandoli365.bibimbap.data.TagData" %>
<%@ page import="org.springframework.web.util.HtmlUtils" %>
<%@ page import="java.util.Collections" %>
<%@ page import="java.util.Collection" %>
<%@ page import="java.util.HashSet" %>
<%@ page import="java.util.List" %>
<%@ page import="java.util.Set" %>
<%
String ctx = request.getContextPath();
jakarta.servlet.http.HttpSession homeSession = request.getSession(false);
@ -19,6 +23,49 @@
}
boolean searching = !searchQuery.isBlank();
String escapedSearchQuery = HtmlUtils.htmlEscape(searchQuery);
// 고도화 검색(GET /games/search) 폼 prefill 값. 기존 / 진입 시 전부 null → 빈값으로 안전 렌더.
String searchKeyword = "";
Object searchKeywordAttr = request.getAttribute("searchKeyword");
if (searchKeywordAttr instanceof String) {
searchKeyword = ((String) searchKeywordAttr).trim();
}
String escapedSearchKeyword = HtmlUtils.htmlEscape(searchKeyword);
String searchCreator = "";
Object searchCreatorAttr = request.getAttribute("searchCreator");
if (searchCreatorAttr instanceof String) {
searchCreator = ((String) searchCreatorAttr).trim();
}
String escapedSearchCreator = HtmlUtils.htmlEscape(searchCreator);
String searchSort = "latest";
Object searchSortAttr = request.getAttribute("searchSort");
if (searchSortAttr instanceof String && !((String) searchSortAttr).isBlank()) {
searchSort = ((String) searchSortAttr).trim();
}
String searchTagMode = "and";
Object searchTagModeAttr = request.getAttribute("searchTagMode");
if (searchTagModeAttr instanceof String && !((String) searchTagModeAttr).isBlank()) {
searchTagMode = ((String) searchTagModeAttr).trim();
}
List<TagData> availableTags = Collections.emptyList();
Object availableTagsAttr = request.getAttribute("availableTags");
if (availableTagsAttr instanceof List<?>) {
availableTags = (List<TagData>) availableTagsAttr;
}
Set<String> selectedTagSlugs = new HashSet<>();
Object selectedTagSlugsAttr = request.getAttribute("selectedTagSlugs");
if (selectedTagSlugsAttr instanceof Collection<?>) {
for (Object slugObj : (Collection<?>) selectedTagSlugsAttr) {
if (slugObj instanceof String) {
selectedTagSlugs.add((String) slugObj);
}
}
}
%>
<!DOCTYPE html>
<html lang="ko">
@ -295,6 +342,91 @@
background: rgba(255, 255, 255, 0.08);
}
/* 고도화 검색 */
.advanced-search {
margin-top: 0.75rem;
padding: 0.875rem;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.advanced-search__row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: stretch;
margin-bottom: 0.625rem;
}
.advanced-search__row:last-child {
margin-bottom: 0;
}
.advanced-search__input,
.advanced-search__select {
flex: 1;
min-width: 9rem;
box-sizing: border-box;
height: 2.75rem;
padding: 0 0.75rem;
font-size: 0.9375rem;
color: var(--text);
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.advanced-search__input:focus,
.advanced-search__select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
}
.advanced-search__tags-label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.advanced-search__tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.advanced-search__tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
font-size: 0.8125rem;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.advanced-search__tag input {
margin: 0;
}
.advanced-search__submit {
flex-shrink: 0;
min-width: 6rem;
height: 2.75rem;
padding: 0 1.25rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--search-btn-text);
background: var(--accent);
border: none;
border-radius: 10px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.advanced-search__submit:active {
transform: scale(0.98);
}
/* 카드 그리드: 모바일 2열 → 태블릿 3열 → 데스크톱 4~5열 */
.card-grid {
display: grid;
@ -479,6 +611,56 @@
</a>
<% } %>
</div>
<form class="advanced-search" role="search" action="<%= ctx %>/games/search" method="get">
<div class="advanced-search__row">
<label class="search-form__label" for="adv-keyword">게임 이름·소개 검색</label>
<input class="advanced-search__input" type="search" id="adv-keyword" name="keyword" value="<%= escapedSearchKeyword %>" placeholder="게임 이름·소개 검색" autocomplete="off" />
<label class="search-form__label" for="adv-creator">제작자(개발자) 검색</label>
<input class="advanced-search__input" type="search" id="adv-creator" name="creator" value="<%= escapedSearchCreator %>" placeholder="제작자(개발자) 검색" autocomplete="off" />
</div>
<div class="advanced-search__row">
<label class="search-form__label" for="adv-sort">정렬</label>
<select class="advanced-search__select" id="adv-sort" name="sort">
<option value="latest"<%= "latest".equals(searchSort) ? " selected" : "" %>>최신</option>
<option value="likes"<%= "likes".equals(searchSort) ? " selected" : "" %>>좋아요순</option>
<option value="views"<%= "views".equals(searchSort) ? " selected" : "" %>>방문순</option>
<option value="reviews"<%= "reviews".equals(searchSort) ? " selected" : "" %>>리뷰많은순</option>
<option value="rating"<%= "rating".equals(searchSort) ? " selected" : "" %>>평점높은순</option>
<option value="relevance"<%= "relevance".equals(searchSort) ? " selected" : "" %>>태그일치순</option>
</select>
<label class="search-form__label" for="adv-tagmode">태그 조건</label>
<select class="advanced-search__select" id="adv-tagmode" name="tagMode">
<option value="and"<%= "and".equals(searchTagMode) ? " selected" : "" %>>모두 포함</option>
<option value="or"<%= "or".equals(searchTagMode) ? " selected" : "" %>>하나라도</option>
</select>
<button class="advanced-search__submit" type="submit">상세 검색</button>
</div>
<% if (availableTags != null && !availableTags.isEmpty()) { %>
<div class="advanced-search__row">
<div style="flex:1; min-width:0;">
<span class="advanced-search__tags-label">태그 필터</span>
<div class="advanced-search__tags">
<%
for (TagData tag : availableTags) {
if (tag == null || tag.getSlug() == null || tag.getSlug().isBlank()) {
continue;
}
String tagSlug = HtmlUtils.htmlEscape(tag.getSlug());
String tagName = HtmlUtils.htmlEscape(tag.getName() == null || tag.getName().isBlank() ? tag.getSlug() : tag.getName());
boolean tagChecked = selectedTagSlugs.contains(tag.getSlug());
%>
<label class="advanced-search__tag">
<input type="checkbox" name="tags" value="<%= tagSlug %>"<%= tagChecked ? " checked" : "" %> />
<span><%= tagName %></span>
</label>
<%
}
%>
</div>
</div>
</div>
<% } %>
</form>
<div class="search-history" id="search-history" hidden>
<div class="search-history__head">
<span class="search-history__label">최근 검색</span>

View File

@ -4,6 +4,8 @@ import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GameTagsMapper;
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
import com.pandoli365.bibimbap.mapper.JamCriteriaMapper;
@ -13,6 +15,7 @@ import com.pandoli365.bibimbap.mapper.JamReviewRatingMapper;
import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper;
import com.pandoli365.bibimbap.mapper.JamScoresMapper;
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
import com.pandoli365.bibimbap.mapper.JamTagsMapper;
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
@ -20,6 +23,7 @@ import com.pandoli365.bibimbap.mapper.JamsMapper;
import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
import com.pandoli365.bibimbap.mapper.RecruitPostsMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
import com.pandoli365.bibimbap.mapper.UsersMapper;
@ -52,6 +56,18 @@ class BibimbapApplicationTests {
@MockBean
private GameReviewStatsMapper gameReviewStatsMapper;
@MockBean
private GameViewsMapper gameViewsMapper;
@MockBean
private TagsMapper tagsMapper;
@MockBean
private GameTagsMapper gameTagsMapper;
@MockBean
private JamTagsMapper jamTagsMapper;
@MockBean
private RecruitPostsMapper recruitPostsMapper;

View File

@ -0,0 +1,177 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.SearchCriteria;
import com.pandoli365.bibimbap.data.TagData;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SearchControllerTest {
@Mock
private GamesMapper gamesMapper;
@Mock
private TagsMapper tagsMapper;
@Test
void searchReturnsIndexViewWithDefaults() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
String view = controller.search(null, null, null, "and", "latest", null, model);
assertThat(view).isEqualTo("index");
assertThat(model.getAttribute("games")).isNotNull();
assertThat(model.getAttribute("searchSort")).isEqualTo("latest");
assertThat(model.getAttribute("searchTagMode")).isEqualTo("and");
}
@Test
void searchFallsBackToLatestForUnknownSort() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
controller.search(null, null, null, "and", "garbage", null, model);
assertThat(capture().getSort()).isEqualTo("latest");
assertThat(model.getAttribute("searchSort")).isEqualTo("latest");
}
@Test
void searchPassesWhitelistedSort() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
controller.search(null, null, null, "and", "views", null, model);
assertThat(capture().getSort()).isEqualTo("views");
}
@Test
void searchFallsBackToAndForUnknownTagMode() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
controller.search(null, null, null, "xxx", "latest", null, model);
assertThat(capture().getTagMode()).isEqualTo("and");
}
@Test
void searchParsesTagsAndDropsInvalidSlugs() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
// "rpg","horror" 통과, "!" toSlug "" isAllowed false 제외.
controller.search(null, null, "rpg, horror ,!", "and", "latest", null, model);
SearchCriteria criteria = capture();
assertThat(criteria.getTags()).containsExactly("rpg", "horror");
assertThat(criteria.getTagCount()).isEqualTo(2);
}
@Test
void searchSetsTagsNullWhenNoTags() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
controller.search(null, null, null, "and", "latest", null, model);
SearchCriteria criteria = capture();
assertThat(criteria.getTags()).isNull();
assertThat(criteria.getTagCount()).isEqualTo(0);
}
@Test
void searchTrimsBlankKeywordToNull() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
controller.search(" ", null, null, "and", "latest", null, model);
assertThat(capture().getKeyword()).isNull();
}
@Test
void searchPassesKeywordThrough() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
controller.search("rpg", null, null, "and", "latest", null, model);
assertThat(capture().getKeyword()).isEqualTo("rpg");
}
@Test
void searchPassesJamSlug() {
SearchController controller = controller();
stubEmptyResults();
Model model = new ExtendedModelMap();
controller.search(null, null, null, "and", "latest", "ludum-dare", model);
assertThat(capture().getJamSlug()).isEqualTo("ludum-dare");
}
@Test
void searchLoadsAvailableTagsIntoModel() {
SearchController controller = controller();
List<TagData> tags = List.of(tagData(1L, "RPG", "rpg"));
when(gamesMapper.searchGamesAdvanced(any(SearchCriteria.class))).thenReturn(List.<GameData>of());
when(tagsMapper.listActiveByType("GAME", null)).thenReturn(tags);
Model model = new ExtendedModelMap();
controller.search(null, null, null, "and", "latest", null, model);
assertThat(model.getAttribute("availableTags")).isEqualTo(tags);
}
// ---- helpers ----
private SearchController controller() {
return new SearchController(gamesMapper, tagsMapper);
}
private void stubEmptyResults() {
when(gamesMapper.searchGamesAdvanced(any(SearchCriteria.class))).thenReturn(List.<GameData>of());
when(tagsMapper.listActiveByType("GAME", null)).thenReturn(List.<TagData>of());
}
private SearchCriteria capture() {
ArgumentCaptor<SearchCriteria> captor = ArgumentCaptor.forClass(SearchCriteria.class);
verify(gamesMapper).searchGamesAdvanced(captor.capture());
return captor.getValue();
}
private TagData tagData(long id, String name, String slug) {
TagData t = new TagData();
t.setId(id);
t.setName(name);
t.setSlug(slug);
return t;
}
}

View File

@ -0,0 +1,396 @@
package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.TagData;
import com.pandoli365.bibimbap.mapper.GameTagsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.security.PermissionKeys;
import com.pandoli365.bibimbap.security.PermissionGate;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
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 TagControllerTest {
@Mock
private TagsMapper tagsMapper;
@Mock
private GameTagsMapper gameTagsMapper;
@Mock
private GamesMapper gamesMapper;
@Mock
private PermissionGate permissionGate;
private static final String MODERATE = PermissionKeys.CONTENT_MODERATE.name();
// ---- §12.3 보안 VP-1: CSRF 부재 403 (상태변경 엔드포인트는 mapper 접근 차단) ----
@Test
void createUserTagRejectsMissingCsrf() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.createUserTag("rpg", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(tagsMapper);
}
@Test
void createAdminTagRejectsMissingCsrf() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.createAdminTag("rpg", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(tagsMapper);
verifyNoInteractions(permissionGate);
}
@Test
void approveTagRejectsMissingCsrf() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.approveTag(5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(tagsMapper);
verifyNoInteractions(permissionGate);
}
@Test
void deactivateTagRejectsMissingCsrf() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.deactivateTag(5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(tagsMapper);
verifyNoInteractions(permissionGate);
}
@Test
void attachGameTagsRejectsMissingCsrf() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.attachGameTags(1L, List.of(1L, 2L), request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameTagsMapper);
verify(gamesMapper, never()).getGame(anyLong());
}
// ---- VP-2: 권한 게이트 미통과 403 ----
@Test
void createAdminTagRejectsWithoutModeratePermission() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(permissionGate.has(any(), eq(MODERATE))).thenReturn(false);
ResponseEntity<Map<String, Object>> response =
controller.createAdminTag("rpg", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(tagsMapper, never()).insertTag(any());
}
@Test
void approveTagRejectsWithoutModeratePermission() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(permissionGate.has(any(), eq(MODERATE))).thenReturn(false);
ResponseEntity<Map<String, Object>> response =
controller.approveTag(5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(tagsMapper, never()).approve(anyLong());
}
// ---- createUserTag 입력 검증 (XSS 무력화 / tagType / 로그인) ----
@Test
void createUserTagRejectsXssLikeNameAfterSlugSanitize() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
// "!" toSlug "" isAllowed false 400. 특수문자(XSS 페이로드 포함) slug 단계에서 제거된다.
ResponseEntity<Map<String, Object>> response =
controller.createUserTag("!", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(tagsMapper, never()).insertTag(any());
}
@Test
void createUserTagRejectsInvalidTagType() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.createUserTag("rpg", "INVALID", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(tagsMapper, never()).insertTag(any());
}
@Test
void createUserTagRequiresLogin() {
TagController controller = controller();
MockHttpSession session = anonSession();
MockHttpServletRequest request = csrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.createUserTag("rpg", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verify(tagsMapper, never()).insertTag(any());
}
// ---- createUserTag 성공/중복 ----
@Test
void createUserTagCreatesPendingTag() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(tagsMapper.findBySlug("rpg")).thenReturn(null);
when(tagsMapper.insertTag(any(TagData.class))).thenAnswer(inv -> {
inv.getArgument(0, TagData.class).setId(10L);
return 1;
});
ResponseEntity<Map<String, Object>> response =
controller.createUserTag("rpg", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("id", 10L);
assertThat(response.getBody()).containsEntry("isActive", false);
verify(tagsMapper).insertTag(any(TagData.class));
}
@Test
void createUserTagReturnsExistingOnDuplicate() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(tagsMapper.findBySlug("rpg")).thenReturn(tagData(5L, "RPG", "rpg", true));
ResponseEntity<Map<String, Object>> response =
controller.createUserTag("rpg", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("id", 5L);
verify(tagsMapper, never()).insertTag(any());
}
// ---- createAdminTag 성공: 즉시 활성 ----
@Test
void createAdminTagCreatesActiveTag() {
TagController controller = controller();
MockHttpSession session = loginSession(99L, "ADMIN");
MockHttpServletRequest request = csrfPost(session);
when(permissionGate.has(any(), eq(MODERATE))).thenReturn(true);
when(tagsMapper.findBySlug("rpg")).thenReturn(null);
when(tagsMapper.insertTag(any(TagData.class))).thenAnswer(inv -> {
inv.getArgument(0, TagData.class).setId(11L);
return 1;
});
ResponseEntity<Map<String, Object>> response =
controller.createAdminTag("rpg", "GAME", request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("isActive", true);
verify(tagsMapper).insertTag(any(TagData.class));
}
// ---- approve / deactivate 성공 ----
@Test
void approveTagSucceeds() {
TagController controller = controller();
MockHttpSession session = loginSession(99L, "ADMIN");
MockHttpServletRequest request = csrfPost(session);
when(permissionGate.has(any(), eq(MODERATE))).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.approveTag(5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("isActive", true);
verify(tagsMapper).approve(5L);
}
@Test
void deactivateTagSucceeds() {
TagController controller = controller();
MockHttpSession session = loginSession(99L, "ADMIN");
MockHttpServletRequest request = csrfPost(session);
when(permissionGate.has(any(), eq(MODERATE))).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.deactivateTag(5L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("isActive", false);
verify(tagsMapper).deactivate(5L);
}
// ---- attachGameTags: 소유자 / 비소유자+모더레이터 / 비소유자+비모더레이터 ----
@Test
void attachGameTagsByOwnerSucceeds() {
TagController controller = controller();
MockHttpSession session = loginSession(7L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L, 7L));
// 소유자라 모더레이터 평가가 단축될 있어 lenient 둔다.
lenient().when(permissionGate.has(any(), eq(MODERATE))).thenReturn(false);
ResponseEntity<Map<String, Object>> response =
controller.attachGameTags(1L, List.of(1L, 2L), request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameTagsMapper).attachTags(1L, List.of(1L, 2L));
}
@Test
void attachGameTagsByNonOwnerNonModeratorIsForbidden() {
TagController controller = controller();
MockHttpSession session = loginSession(8L, "USER");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L, 7L));
when(permissionGate.has(any(), eq(MODERATE))).thenReturn(false);
ResponseEntity<Map<String, Object>> response =
controller.attachGameTags(1L, List.of(1L, 2L), request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(gameTagsMapper, never()).attachTags(anyLong(), any());
}
@Test
void attachGameTagsByNonOwnerModeratorSucceeds() {
TagController controller = controller();
MockHttpSession session = loginSession(99L, "ADMIN");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L, 7L));
when(permissionGate.has(any(), eq(MODERATE))).thenReturn(true);
ResponseEntity<Map<String, Object>> response =
controller.attachGameTags(1L, List.of(1L, 2L), request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(gameTagsMapper).attachTags(1L, List.of(1L, 2L));
}
// ---- listTags 매핑 (GET, ResponseBody List<Map> 직접 반환) ----
@Test
void listTagsMapsTagFields() {
TagController controller = controller();
when(tagsMapper.listActiveByType("GAME", null))
.thenReturn(List.of(tagData(1L, "RPG", "rpg", true)));
List<Map<String, Object>> result = controller.listTags("GAME", null);
assertThat(result).hasSize(1);
assertThat(result.get(0)).containsEntry("id", 1L);
assertThat(result.get(0)).containsEntry("name", "RPG");
assertThat(result.get(0)).containsEntry("slug", "rpg");
}
// ---- helpers ----
private TagController controller() {
return new TagController(tagsMapper, gameTagsMapper, gamesMapper, permissionGate);
}
private TagData tagData(long id, String name, String slug, boolean isActive) {
TagData t = new TagData();
t.setId(id);
t.setName(name);
t.setSlug(slug);
t.setIsActive(isActive);
return t;
}
private GameData game(long id, long userId) {
GameData g = new GameData();
g.setId(id);
g.setUserId(userId);
return g;
}
private MockHttpSession loginSession(long userId, String role) {
MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", userId);
session.setAttribute("role", role);
CsrfTokens.getOrCreate(session);
return session;
}
private MockHttpSession anonSession() {
MockHttpSession session = new MockHttpSession();
CsrfTokens.getOrCreate(session);
return session;
}
private MockHttpServletRequest csrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
return request;
}
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
return request;
}
}

View File

@ -0,0 +1,106 @@
package com.pandoli365.bibimbap.util;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class TagSanitizerTest {
// --- toSlug ---
@Test
void toSlugLowercases() {
assertThat(TagSanitizer.toSlug("RPG")).isEqualTo("rpg");
}
@Test
void toSlugReplacesSpacesWithHyphen() {
assertThat(TagSanitizer.toSlug("Open World")).isEqualTo("open-world");
}
@Test
void toSlugTrimsSurroundingWhitespace() {
assertThat(TagSanitizer.toSlug(" trim me ")).isEqualTo("trim-me");
}
@Test
void toSlugReturnsEmptyForNull() {
assertThat(TagSanitizer.toSlug(null)).isEmpty();
}
@Test
void toSlugStripsSpecialChars() {
assertThat(TagSanitizer.toSlug("rpg!!!")).isEqualTo("rpg");
assertThat(TagSanitizer.toSlug("tag@#$%")).isEqualTo("tag");
}
@Test
void toSlugStripsXssChars() {
// <,>,/ 화이트리스트 문자를 제거하여 <script> 페이로드를 무력화한다.
assertThat(TagSanitizer.toSlug("a<script>b")).isEqualTo("ascriptb");
}
@Test
void toSlugCollapsesAndTrimsHyphens() {
assertThat(TagSanitizer.toSlug("--rpg--")).isEqualTo("rpg");
assertThat(TagSanitizer.toSlug("a---b")).isEqualTo("a-b");
}
@Test
void toSlugPreservesKorean() {
assertThat(TagSanitizer.toSlug("액션")).isEqualTo("액션");
assertThat(TagSanitizer.toSlug("로그 라이크")).isEqualTo("로그-라이크");
}
// --- isAllowed: 길이 경계 ---
@Test
void isAllowedRejectsTooShort() {
assertThat(TagSanitizer.isAllowed("a")).isFalse();
}
@Test
void isAllowedAcceptsMinLength() {
assertThat(TagSanitizer.isAllowed("액션")).isTrue();
}
@Test
void isAllowedAcceptsMaxLength() {
assertThat(TagSanitizer.isAllowed("a".repeat(20))).isTrue();
}
@Test
void isAllowedRejectsTooLong() {
assertThat(TagSanitizer.isAllowed("a".repeat(21))).isFalse();
}
// --- isAllowed: 화이트리스트 거부 ---
@Test
void isAllowedRejectsWhitespace() {
assertThat(TagSanitizer.isAllowed("rpg game")).isFalse();
}
@Test
void isAllowedRejectsSpecialChars() {
assertThat(TagSanitizer.isAllowed("rpg!")).isFalse();
}
// --- isAllowed: 금칙어 매치 ---
@Test
void isAllowedRejectsBannedWord() {
// banned-words.txt 시드의 영문/한글 금칙어를 toSlug isAllowed 거부한다.
assertThat(TagSanitizer.isAllowed(TagSanitizer.toSlug("fuck"))).isFalse();
assertThat(TagSanitizer.isAllowed(TagSanitizer.toSlug("시발"))).isFalse();
}
// --- isAllowed: 정상 통과 ---
@Test
void isAllowedAcceptsNormalSlug() {
assertThat(TagSanitizer.isAllowed(TagSanitizer.toSlug("RPG"))).isTrue();
assertThat(TagSanitizer.isAllowed(TagSanitizer.toSlug("호러"))).isTrue();
assertThat(TagSanitizer.isAllowed(TagSanitizer.toSlug("퍼즐"))).isTrue();
}
}