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:
parent
ba1a5fe572
commit
35f1dc3de9
113
db/schema.sql
113
db/schema.sql
|
|
@ -790,3 +790,116 @@ LEFT JOIN "jam_criteria" c
|
||||||
GROUP BY pc."jam_id", pc."game_id";
|
GROUP BY pc."jam_id", pc."game_id";
|
||||||
COMMENT ON VIEW "jam_score_stats" IS
|
COMMENT ON VIEW "jam_score_stats" IS
|
||||||
'W2-3 동결 심사 집계뷰. 출품작(jam_id,game_id)별 가중 종합·비가중 평균·채점 기준수·심사 인원. fan-out 방지 선집계(game_review_stats 선례).';
|
'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");
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.GameData;
|
||||||
import com.pandoli365.bibimbap.game.GameCatalog;
|
import com.pandoli365.bibimbap.game.GameCatalog;
|
||||||
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
|
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
@ -31,16 +32,19 @@ public class GameController {
|
||||||
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;
|
||||||
|
|
||||||
@Value("${app.webgl.asset-origin:}")
|
@Value("${app.webgl.asset-origin:}")
|
||||||
private String webglAssetOrigin;
|
private String webglAssetOrigin;
|
||||||
|
|
||||||
public GameController(GamesMapper gamesMapper,
|
public GameController(GamesMapper gamesMapper,
|
||||||
GameCommentsMapper gameCommentsMapper,
|
GameCommentsMapper gameCommentsMapper,
|
||||||
GameReviewsMapper gameReviewsMapper) {
|
GameReviewsMapper gameReviewsMapper,
|
||||||
|
GameViewsMapper gameViewsMapper) {
|
||||||
this.gamesMapper = gamesMapper;
|
this.gamesMapper = gamesMapper;
|
||||||
this.gameCommentsMapper = gameCommentsMapper;
|
this.gameCommentsMapper = gameCommentsMapper;
|
||||||
this.gameReviewsMapper = gameReviewsMapper;
|
this.gameReviewsMapper = gameReviewsMapper;
|
||||||
|
this.gameViewsMapper = gameViewsMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String webglUrlForGame(int gameId) {
|
public static String webglUrlForGame(int gameId) {
|
||||||
|
|
@ -110,9 +114,11 @@ public class GameController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/game/{id}")
|
@GetMapping("/game/{id}")
|
||||||
|
@Transactional
|
||||||
public String gameDetail(@PathVariable("id") long id, Model model, HttpSession session) {
|
public String gameDetail(@PathVariable("id") long id, Model model, HttpSession session) {
|
||||||
GameData game = gamesMapper.getGame(id);
|
GameData game = gamesMapper.getGame(id);
|
||||||
if (game != null) {
|
if (game != null) {
|
||||||
|
recordView(id, session);
|
||||||
addGameModel(model, game, sessionUserId(session));
|
addGameModel(model, game, sessionUserId(session));
|
||||||
model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20));
|
model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20));
|
||||||
model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id, "newest", 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 : "";
|
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) {
|
private Long sessionUserId(HttpSession session) {
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ public class GameData {
|
||||||
private Integer sortOrder;
|
private Integer sortOrder;
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
private OffsetDateTime updatedAt;
|
private OffsetDateTime updatedAt;
|
||||||
|
private Integer viewCount;
|
||||||
|
private Double avgRating;
|
||||||
|
private Integer reviewCount;
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
|
|
@ -121,4 +124,28 @@ public class GameData {
|
||||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||||
this.updatedAt = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.pandoli365.bibimbap.mapper;
|
package com.pandoli365.bibimbap.mapper;
|
||||||
|
|
||||||
import com.pandoli365.bibimbap.data.GameData;
|
import com.pandoli365.bibimbap.data.GameData;
|
||||||
|
import com.pandoli365.bibimbap.data.SearchCriteria;
|
||||||
import org.apache.ibatis.annotations.Delete;
|
import org.apache.ibatis.annotations.Delete;
|
||||||
import org.apache.ibatis.annotations.Insert;
|
import org.apache.ibatis.annotations.Insert;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
@ -197,4 +198,66 @@ public interface GamesMapper {
|
||||||
AND is_delete IS NOT TRUE
|
AND is_delete IS NOT TRUE
|
||||||
""")
|
""")
|
||||||
int softDeleteGame(@Param("id") long id);
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
시발
|
||||||
|
씨발
|
||||||
|
개새끼
|
||||||
|
병신
|
||||||
|
좆
|
||||||
|
보지
|
||||||
|
자지
|
||||||
|
섹스
|
||||||
|
도박
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %>
|
<%@ 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.GameData" %>
|
||||||
|
<%@ page import="com.pandoli365.bibimbap.data.TagData" %>
|
||||||
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
<%@ page import="org.springframework.web.util.HtmlUtils" %>
|
||||||
<%@ page import="java.util.Collections" %>
|
<%@ page import="java.util.Collections" %>
|
||||||
|
<%@ page import="java.util.Collection" %>
|
||||||
|
<%@ page import="java.util.HashSet" %>
|
||||||
<%@ page import="java.util.List" %>
|
<%@ page import="java.util.List" %>
|
||||||
|
<%@ page import="java.util.Set" %>
|
||||||
<%
|
<%
|
||||||
String ctx = request.getContextPath();
|
String ctx = request.getContextPath();
|
||||||
jakarta.servlet.http.HttpSession homeSession = request.getSession(false);
|
jakarta.servlet.http.HttpSession homeSession = request.getSession(false);
|
||||||
|
|
@ -19,6 +23,49 @@
|
||||||
}
|
}
|
||||||
boolean searching = !searchQuery.isBlank();
|
boolean searching = !searchQuery.isBlank();
|
||||||
String escapedSearchQuery = HtmlUtils.htmlEscape(searchQuery);
|
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>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
|
@ -295,6 +342,91 @@
|
||||||
background: rgba(255, 255, 255, 0.08);
|
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열 */
|
/* 카드 그리드: 모바일 2열 → 태블릿 3열 → 데스크톱 4~5열 */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -479,6 +611,56 @@
|
||||||
</a>
|
</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</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" id="search-history" hidden>
|
||||||
<div class="search-history__head">
|
<div class="search-history__head">
|
||||||
<span class="search-history__label">최근 검색</span>
|
<span class="search-history__label">최근 검색</span>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ 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;
|
||||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
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.GamesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
|
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamCriteriaMapper;
|
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.JamScoreStatsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamScoresMapper;
|
import com.pandoli365.bibimbap.mapper.JamScoresMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.JamTagsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
|
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.PermissionsMapper;
|
||||||
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.TagsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
import com.pandoli365.bibimbap.mapper.UserAuthIdentitiesMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
import com.pandoli365.bibimbap.mapper.UserPermissionsMapper;
|
||||||
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
import com.pandoli365.bibimbap.mapper.UsersMapper;
|
||||||
|
|
@ -52,6 +56,18 @@ class BibimbapApplicationTests {
|
||||||
@MockBean
|
@MockBean
|
||||||
private GameReviewStatsMapper gameReviewStatsMapper;
|
private GameReviewStatsMapper gameReviewStatsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private GameViewsMapper gameViewsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private TagsMapper tagsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private GameTagsMapper gameTagsMapper;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private JamTagsMapper jamTagsMapper;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private RecruitPostsMapper recruitPostsMapper;
|
private RecruitPostsMapper recruitPostsMapper;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue