From 35f1dc3de94b22ea0dc44fca6d12c6851cbfb080 Mon Sep 17 00:00:00 2001 From: art Date: Mon, 29 Jun 2026 10:44:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(search):=20W3-1=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20+=20=EA=B2=80=EC=83=89=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=E2=80=94=20tags/game=5Ftags/jam=5Ftags/game=5Fview?= =?UTF-8?q?s=20+=20=EB=8B=A4=EC=A4=91=ED=83=9C=EA=B7=B8=C2=B7=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=ED=82=A4=C2=B7=EB=B0=A9=EB=AC=B8=EC=88=98=C2=B7?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 신규 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 는 내부 고정문자열 - 방문수: 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) Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD --- db/schema.sql | 113 +++++ docs/tag-ddl.sql | 100 +++++ .../bibimbap/controller/SearchController.java | 98 +++++ .../bibimbap/controller/TagController.java | 273 ++++++++++++ .../controller/api/GameController.java | 24 +- .../pandoli365/bibimbap/data/GameData.java | 27 ++ .../bibimbap/data/SearchCriteria.java | 70 ++++ .../com/pandoli365/bibimbap/data/TagData.java | 70 ++++ .../bibimbap/mapper/GameTagsMapper.java | 67 +++ .../bibimbap/mapper/GameViewsMapper.java | 31 ++ .../bibimbap/mapper/GamesMapper.java | 63 +++ .../bibimbap/mapper/JamTagsMapper.java | 39 ++ .../bibimbap/mapper/TagsMapper.java | 72 ++++ .../bibimbap/util/TagSanitizer.java | 85 ++++ src/main/resources/banned-words.txt | 28 ++ src/main/webapp/WEB-INF/views/index.jsp | 182 ++++++++ .../bibimbap/BibimbapApplicationTests.java | 16 + .../controller/SearchControllerTest.java | 177 ++++++++ .../controller/TagControllerTest.java | 396 ++++++++++++++++++ .../bibimbap/util/TagSanitizerTest.java | 106 +++++ 20 files changed, 2036 insertions(+), 1 deletion(-) create mode 100644 docs/tag-ddl.sql create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/SearchController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/controller/TagController.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/SearchCriteria.java create mode 100644 src/main/java/com/pandoli365/bibimbap/data/TagData.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/GameTagsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/GameViewsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/JamTagsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/mapper/TagsMapper.java create mode 100644 src/main/java/com/pandoli365/bibimbap/util/TagSanitizer.java create mode 100644 src/main/resources/banned-words.txt create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/SearchControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/TagControllerTest.java create mode 100644 src/test/java/com/pandoli365/bibimbap/util/TagSanitizerTest.java diff --git a/db/schema.sql b/db/schema.sql index 19b98af..c2e27a3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -790,3 +790,116 @@ LEFT JOIN "jam_criteria" c GROUP BY pc."jam_id", pc."game_id"; COMMENT ON VIEW "jam_score_stats" IS 'W2-3 동결 심사 집계뷰. 출품작(jam_id,game_id)별 가중 종합·비가중 평균·채점 기준수·심사 인원. fan-out 방지 선집계(game_review_stats 선례).'; + +-- =========================================================================== +-- W3-1 태그 + 검색 확장 (docs/tag-ddl.sql 동기 사본 — 의미 동등, 식별자 인용 스타일만 상이) +-- FK 의존: users/games/jams 선존재(파일 앞쪽 정의). 끝에 추가하여 FK 순서 안전. +-- =========================================================================== + +-- 1) 통합 태그 마스터 +CREATE SEQUENCE IF NOT EXISTS "tags_id_seq"; +CREATE TABLE IF NOT EXISTS "tags" ( + "id" bigint DEFAULT nextval('tags_id_seq'::regclass) NOT NULL, + "name" character varying(20) NOT NULL, + "slug" character varying(40) NOT NULL, + "tag_type" character varying(10) NOT NULL, + "is_active" boolean DEFAULT false NOT NULL, + "created_by" bigint, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "tags_id_seq" OWNED BY "tags"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_name_len') THEN + ALTER TABLE "tags" ADD CONSTRAINT "chk_tags_name_len" + CHECK (char_length("name") BETWEEN 2 AND 20); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_type') THEN + ALTER TABLE "tags" ADD CONSTRAINT "chk_tags_type" + CHECK ("tag_type" IN ('GAME', 'JAM', 'COMMON')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_created_by') THEN + ALTER TABLE "tags" ADD CONSTRAINT "fk_tags_created_by" + FOREIGN KEY ("created_by") REFERENCES "users" ("id"); + END IF; +END +$$; +CREATE UNIQUE INDEX IF NOT EXISTS "uq_tags_slug" ON "tags" ("slug"); +CREATE UNIQUE INDEX IF NOT EXISTS "uq_tags_name" ON "tags" ("name"); +CREATE INDEX IF NOT EXISTS "idx_tags_type_active" ON "tags" ("tag_type", "is_active"); + +-- 2) 게임-태그 조인 (N:M) +CREATE SEQUENCE IF NOT EXISTS "game_tags_id_seq"; +CREATE TABLE IF NOT EXISTS "game_tags" ( + "id" bigint DEFAULT nextval('game_tags_id_seq'::regclass) NOT NULL, + "game_id" bigint NOT NULL, + "tag_id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "game_tags_id_seq" OWNED BY "game_tags"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_game') THEN + ALTER TABLE "game_tags" ADD CONSTRAINT "fk_game_tags_game" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_tag') THEN + ALTER TABLE "game_tags" ADD CONSTRAINT "fk_game_tags_tag" + FOREIGN KEY ("tag_id") REFERENCES "tags" ("id"); + END IF; +END +$$; +CREATE UNIQUE INDEX IF NOT EXISTS "uq_game_tags" ON "game_tags" ("game_id", "tag_id"); +CREATE INDEX IF NOT EXISTS "idx_game_tags_tag" ON "game_tags" ("tag_id"); + +-- 3) 잼-태그 조인 (N:M, jams 는 W2-1 소관) +CREATE SEQUENCE IF NOT EXISTS "jam_tags_id_seq"; +CREATE TABLE IF NOT EXISTS "jam_tags" ( + "id" bigint DEFAULT nextval('jam_tags_id_seq'::regclass) NOT NULL, + "jam_id" bigint NOT NULL, + "tag_id" bigint NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "jam_tags_id_seq" OWNED BY "jam_tags"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_jam') THEN + ALTER TABLE "jam_tags" ADD CONSTRAINT "fk_jam_tags_jam" + FOREIGN KEY ("jam_id") REFERENCES "jams" ("id"); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_tag') THEN + ALTER TABLE "jam_tags" ADD CONSTRAINT "fk_jam_tags_tag" + FOREIGN KEY ("tag_id") REFERENCES "tags" ("id"); + END IF; +END +$$; +CREATE UNIQUE INDEX IF NOT EXISTS "uq_jam_tags" ON "jam_tags" ("jam_id", "tag_id"); +CREATE INDEX IF NOT EXISTS "idx_jam_tags_tag" ON "jam_tags" ("tag_id"); + +-- 4) 방문 로그 (dedupe 가능) +CREATE SEQUENCE IF NOT EXISTS "game_views_id_seq"; +CREATE TABLE IF NOT EXISTS "game_views" ( + "id" bigint DEFAULT nextval('game_views_id_seq'::regclass) NOT NULL, + "game_id" bigint NOT NULL, + "viewer_key" character varying(200) NOT NULL, + "viewed_at" timestamp with time zone DEFAULT now() NOT NULL, + PRIMARY KEY ("id") +); +ALTER SEQUENCE "game_views_id_seq" OWNED BY "game_views"."id"; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_views_game') THEN + ALTER TABLE "game_views" ADD CONSTRAINT "fk_game_views_game" + FOREIGN KEY ("game_id") REFERENCES "games" ("id"); + END IF; +END +$$; +CREATE INDEX IF NOT EXISTS "idx_game_views_game" ON "game_views" ("game_id"); +CREATE INDEX IF NOT EXISTS "idx_game_views_dedupe" ON "game_views" ("game_id", "viewer_key", "viewed_at"); + +-- 5) games 비정규화 방문수 카운터 +ALTER TABLE "games" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL; +CREATE INDEX IF NOT EXISTS "idx_games_view_count" ON "games" ("view_count"); diff --git a/docs/tag-ddl.sql b/docs/tag-ddl.sql new file mode 100644 index 0000000..963b7ff --- /dev/null +++ b/docs/tag-ddl.sql @@ -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); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/SearchController.java b/src/main/java/com/pandoli365/bibimbap/controller/SearchController.java new file mode 100644 index 0000000..49529e8 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/SearchController.java @@ -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 SORT_KEYS = Set.of("latest", "likes", "views", "reviews", "rating", "relevance"); + private static final Set 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 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 games = gamesMapper.searchGamesAdvanced(criteria); + List 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 parseTagSlugs(String tagsCsv) { + Set 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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/TagController.java b/src/main/java/com/pandoli365/bibimbap/controller/TagController.java new file mode 100644 index 0000000..5266798 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/TagController.java @@ -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 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> 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> 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> 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 body = new LinkedHashMap<>(); + body.put("id", id); + body.put("isActive", true); + return ResponseEntity.ok(body); + } + + @PostMapping("/admin/tags/{id}/deactivate") + @Transactional + public ResponseEntity> 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 body = new LinkedHashMap<>(); + body.put("id", id); + body.put("isActive", false); + return ResponseEntity.ok(body); + } + + @PostMapping("/games/{gameId}/tags") + @Transactional + public ResponseEntity> attachGameTags( + @PathVariable("gameId") long gameId, + @RequestParam(name = "tagIds", required = false) List 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 body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("gameId", gameId); + body.put("tagIds", tagIds); + return ResponseEntity.ok(body); + } + + @GetMapping("/tags") + @ResponseBody + public List> listTags( + @RequestParam(name = "type", required = false) String type, + @RequestParam(name = "q", required = false) String q + ) { + List tags = tagsMapper.listActiveByType(trimToNull(type), trimToNull(q)); + List> result = new ArrayList<>(); + for (TagData tag : tags) { + Map 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> tagBody(Long id, String name, String slug, Boolean isActive) { + Map 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> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index b76c9bb..67173a9 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -4,6 +4,7 @@ import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.game.GameCatalog; import com.pandoli365.bibimbap.mapper.GameCommentsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper; +import com.pandoli365.bibimbap.mapper.GameViewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.security.CsrfTokens; import jakarta.servlet.http.HttpServletRequest; @@ -31,16 +32,19 @@ public class GameController { private final GamesMapper gamesMapper; private final GameCommentsMapper gameCommentsMapper; private final GameReviewsMapper gameReviewsMapper; + private final GameViewsMapper gameViewsMapper; @Value("${app.webgl.asset-origin:}") private String webglAssetOrigin; public GameController(GamesMapper gamesMapper, GameCommentsMapper gameCommentsMapper, - GameReviewsMapper gameReviewsMapper) { + GameReviewsMapper gameReviewsMapper, + GameViewsMapper gameViewsMapper) { this.gamesMapper = gamesMapper; this.gameCommentsMapper = gameCommentsMapper; this.gameReviewsMapper = gameReviewsMapper; + this.gameViewsMapper = gameViewsMapper; } public static String webglUrlForGame(int gameId) { @@ -110,9 +114,11 @@ public class GameController { } @GetMapping("/game/{id}") + @Transactional public String gameDetail(@PathVariable("id") long id, Model model, HttpSession session) { GameData game = gamesMapper.getGame(id); if (game != null) { + recordView(id, session); addGameModel(model, game, sessionUserId(session)); model.addAttribute("comments", gameCommentsMapper.listGameComments(id, "oldest", 0, 20)); model.addAttribute("reviews", gameReviewsMapper.listGameReviews(id, "newest", 0, 20)); @@ -306,6 +312,22 @@ public class GameController { return lower.startsWith("http://") || lower.startsWith("https://") ? text : ""; } + private void recordView(long gameId, HttpSession session) { + String vKey = viewerKey(session); + if (!gameViewsMapper.existsRecentView(gameId, vKey)) { + gameViewsMapper.insertView(gameId, vKey); + gameViewsMapper.incrementViewCount(gameId); + } + } + + private String viewerKey(HttpSession session) { + Long userId = sessionUserId(session); + if (userId != null) { + return "u:" + userId; + } + return "s:" + session.getId(); + } + private Long sessionUserId(HttpSession session) { if (session == null) { return null; diff --git a/src/main/java/com/pandoli365/bibimbap/data/GameData.java b/src/main/java/com/pandoli365/bibimbap/data/GameData.java index 6a68f07..533065f 100644 --- a/src/main/java/com/pandoli365/bibimbap/data/GameData.java +++ b/src/main/java/com/pandoli365/bibimbap/data/GameData.java @@ -17,6 +17,9 @@ public class GameData { private Integer sortOrder; private OffsetDateTime createdAt; private OffsetDateTime updatedAt; + private Integer viewCount; + private Double avgRating; + private Integer reviewCount; public Long getId() { return id; @@ -121,4 +124,28 @@ public class GameData { public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } + + public Integer getViewCount() { + return viewCount; + } + + public void setViewCount(Integer viewCount) { + this.viewCount = viewCount; + } + + public Double getAvgRating() { + return avgRating; + } + + public void setAvgRating(Double avgRating) { + this.avgRating = avgRating; + } + + public Integer getReviewCount() { + return reviewCount; + } + + public void setReviewCount(Integer reviewCount) { + this.reviewCount = reviewCount; + } } diff --git a/src/main/java/com/pandoli365/bibimbap/data/SearchCriteria.java b/src/main/java/com/pandoli365/bibimbap/data/SearchCriteria.java new file mode 100644 index 0000000..df0fc81 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/SearchCriteria.java @@ -0,0 +1,70 @@ +package com.pandoli365.bibimbap.data; + +import java.util.List; + +public class SearchCriteria { + + private String keyword; + private String creator; + private List 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 getTags() { + return tags; + } + + public void setTags(List 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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/TagData.java b/src/main/java/com/pandoli365/bibimbap/data/TagData.java new file mode 100644 index 0000000..09ba203 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/TagData.java @@ -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; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameTagsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameTagsMapper.java new file mode 100644 index 0000000..9a186f4 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameTagsMapper.java @@ -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(""" + + """) + List listGameIdsByTagsAll(@Param("tagSlugs") List tagSlugs, @Param("tagCount") int tagCount); + + @Select(""" + + """) + List listGameIdsByTagsAny(@Param("tagSlugs") List tagSlugs); + + @Insert(""" + + """) + void attachTags(@Param("gameId") long gameId, @Param("tagIds") List 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 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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameViewsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameViewsMapper.java new file mode 100644 index 0000000..aa9095e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameViewsMapper.java @@ -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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java index 6a1cbbf..391faea 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -1,6 +1,7 @@ package com.pandoli365.bibimbap.mapper; import com.pandoli365.bibimbap.data.GameData; +import com.pandoli365.bibimbap.data.SearchCriteria; import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; @@ -197,4 +198,66 @@ public interface GamesMapper { AND is_delete IS NOT TRUE """) int softDeleteGame(@Param("id") long id); + + @Select(""" + + """) + List searchGamesAdvanced(SearchCriteria c); } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamTagsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamTagsMapper.java new file mode 100644 index 0000000..a559b4b --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamTagsMapper.java @@ -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(""" + + """) + void attachTags(@Param("jamId") long jamId, @Param("tagIds") List 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 listByJam(long jamId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/TagsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/TagsMapper.java new file mode 100644 index 0000000..b79efe0 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/TagsMapper.java @@ -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(""" + + """) + List 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); +} diff --git a/src/main/java/com/pandoli365/bibimbap/util/TagSanitizer.java b/src/main/java/com/pandoli365/bibimbap/util/TagSanitizer.java new file mode 100644 index 0000000..39438fb --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/util/TagSanitizer.java @@ -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 { + + // 화이트리스트: 한글(가-힣, ㄱ-ㅎ, ㅏ-ㅣ) + 영소문자 + 숫자 + 하이픈. + // <,>,",',(,),/ 등 특수문자를 전부 배제하므로