From e28fa60ca6b2383cf3da242b97adeac5ffd75dc3 Mon Sep 17 00:00:00 2001 From: art Date: Mon, 29 Jun 2026 10:59:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(hub):=20W3-4=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=97=88=EB=B8=8C=20keyset=20=ED=8E=98=EC=9D=B4=EC=A7=95=20+?= =?UTF-8?q?=20=EC=A7=84=ED=96=89=EC=A4=91=20=EC=9E=BC=20=EB=B0=B0=EB=84=88?= =?UTF-8?q?=20(=EB=8B=A8=EA=B3=841+2=20=ED=86=B5=ED=95=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keyset 페이징: GamesMapper.listVisibleKeyset/searchVisibleKeyset 신규 — 커서 (sort_order,created_at,id) 3-튜플 OR 분해(혼합방향 ASC/DESC/DESC), LIMIT #{limit}, #{} only. 기존 getVisibleGames/searchVisibleGames 보존(회귀 0) - WebMvcController: cursor 파라미터 + nextCursor 산정 + parseCursor/encodeCursor(형식오류→첫페이지 폴백, throw 0) + HubCursor record + HUB_PAGE_SIZE=24. 검색/비검색 동일 커서 규약(공통 헬퍼) - 진행중 잼 배너(단계2): JamsMapper.listActive/countActive 신규(status IN RECRUIT/DEV/EVAL, is_visible). 최신 1건 + "외 N-1개" 라벨, N=0 미렌더. CTA → W3-1 /games/search?jam={slug} - index.jsp: 더보기 링크(hasNextPage, q+cursor URLEncoder) + 잼 배너(title HtmlUtils.htmlEscape, slug URLEncoder). 기존 렌더루프+W3-1 태그 UI 보존 - docs/games-hub-ddl.sql: idx_games_visible_keyset 멱등(games 컬럼 0변경) + schema.sql 동기 - BibimbapApplicationTests 무변경(JamsMapper/GamesMapper @MockBean W2-1 기등록 재사용) 검증: 컨테이너 ./mvnw -o test 243/243 GREEN(신규 8: WebMvcControllerTest), 회귀 0. L2 격리 throwaway DB contract PASS(혼합방향 keyset 경계 무중복/무누락·동일 sort_order/created_at tie-break·검색 keyset·잼 status 필터). 정렬키 5곳 동일·${} 0. 실 dev DB 무접촉. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD --- db/schema.sql | 3 + docs/games-hub-ddl.sql | 7 + .../bibimbap/controller/WebMvcController.java | 88 +++++++- .../bibimbap/mapper/GamesMapper.java | 80 +++++++ .../bibimbap/mapper/JamsMapper.java | 32 +++ src/main/webapp/WEB-INF/views/index.jsp | 125 +++++++++++ .../controller/WebMvcControllerTest.java | 206 ++++++++++++++++++ 7 files changed, 532 insertions(+), 9 deletions(-) create mode 100644 docs/games-hub-ddl.sql create mode 100644 src/test/java/com/pandoli365/bibimbap/controller/WebMvcControllerTest.java diff --git a/db/schema.sql b/db/schema.sql index c2e27a3..de881bd 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -105,6 +105,9 @@ ALTER SEQUENCE "games_id_seq" OWNED BY "games"."id"; CREATE INDEX IF NOT EXISTS "idx_games_visible_order" ON "games" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC); +CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset" + ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC); + -- --------------------------------------------------------------------------- -- game_comments (비권위 복원본 — 매퍼 존재하나 컨트롤러 미연결) -- --------------------------------------------------------------------------- diff --git a/docs/games-hub-ddl.sql b/docs/games-hub-ddl.sql new file mode 100644 index 0000000..c7d0a62 --- /dev/null +++ b/docs/games-hub-ddl.sql @@ -0,0 +1,7 @@ +-- W3-4 게임 허브 keyset 페이징 성능 인덱스. games 컬럼 변경 0(인덱스만). 멱등. +-- db/apply-local-ddl.sh 글롭 docs/*-ddl.sql 자동 적용 + db/schema.sql 동기 사본. + +-- 정렬키 = sort_order ASC, created_at DESC, id DESC (GamesMapper.getVisibleGames 와 동일) +-- 인덱스 없이도 keyset 정합은 WHERE 비교가 보장하며, 인덱스는 깊은 페이지/대량 seek 최적화용이다. +CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset" + ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java index 0b5c430..d773d77 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java @@ -1,6 +1,9 @@ package com.pandoli365.bibimbap.controller; +import com.pandoli365.bibimbap.data.GameData; +import com.pandoli365.bibimbap.data.JamData; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -13,6 +16,10 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -20,10 +27,14 @@ import java.util.List; @Controller public class WebMvcController implements WebMvcConfigurer, ErrorController { - private final GamesMapper gamesMapper; + private static final int HUB_PAGE_SIZE = 24; - public WebMvcController(GamesMapper gamesMapper) { + private final GamesMapper gamesMapper; + private final JamsMapper jamsMapper; + + public WebMvcController(GamesMapper gamesMapper, JamsMapper jamsMapper) { this.gamesMapper = gamesMapper; + this.jamsMapper = jamsMapper; } @RequestMapping("/error") @@ -50,8 +61,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { } @GetMapping("/") - public ModelAndView indexView(@RequestParam(name = "q", required = false) String query) { - return indexModelAndView(query); + public ModelAndView indexView(@RequestParam(name = "q", required = false) String query, + @RequestParam(name = "cursor", required = false) String cursor) { + return indexModelAndView(query, cursor); } @GetMapping("/{pageName}") @@ -95,22 +107,80 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController { mv.setViewName("operation-policy"); break; default: - return indexModelAndView(query); + return indexModelAndView(query, null); } return mv; } - private ModelAndView indexModelAndView(String query) { + private ModelAndView indexModelAndView(String query, String cursor) { String normalizedQuery = query == null ? "" : query.trim(); + HubCursor parsed = parseCursor(cursor); // null = 첫 페이지 + Integer cSort = parsed == null ? null : parsed.sortOrder(); + OffsetDateTime cCreated = parsed == null ? null : parsed.createdAt(); + Long cId = parsed == null ? null : parsed.id(); + + List rows = normalizedQuery.isBlank() + ? gamesMapper.listVisibleKeyset(cSort, cCreated, cId, HUB_PAGE_SIZE + 1) + : gamesMapper.searchVisibleKeyset(normalizedQuery, cSort, cCreated, cId, HUB_PAGE_SIZE + 1); + + String nextCursor = null; + if (rows.size() > HUB_PAGE_SIZE) { + rows = new ArrayList<>(rows.subList(0, HUB_PAGE_SIZE)); + GameData last = rows.get(HUB_PAGE_SIZE - 1); + nextCursor = encodeCursor(last); + } + ModelAndView mv = new ModelAndView("index"); - mv.addObject("games", normalizedQuery.isBlank() - ? gamesMapper.getVisibleGames() - : gamesMapper.searchVisibleGames(normalizedQuery)); + mv.addObject("games", rows); mv.addObject("searchQuery", normalizedQuery); + mv.addObject("nextCursor", nextCursor); // nullable — JSP 가 null 시 더보기 미표시 + + // 진행중 잼 배너 (activeCount==0 → attr 미주입 → 배너 미렌더) + int activeCount = jamsMapper.countActive(); + if (activeCount > 0) { + List active = jamsMapper.listActive(1); + if (!active.isEmpty()) { + mv.addObject("activeJam", active.get(0)); + mv.addObject("activeJamCount", activeCount); + } + } return mv; } + private String encodeCursor(GameData last) { + if (last == null || last.getSortOrder() == null + || last.getCreatedAt() == null || last.getId() == null) { + return null; + } + return last.getSortOrder() + "_" + + last.getCreatedAt().toInstant().toEpochMilli() + "_" + + last.getId(); + } + + // "10_1718000000000_57" → HubCursor | null. 사용자 입력 신뢰 금지 — 형식오류는 첫 페이지 폴백. + private HubCursor parseCursor(String cursor) { + if (cursor == null || cursor.isBlank()) { + return null; + } + String[] parts = cursor.split("_"); + if (parts.length != 3) { + return null; + } + try { + int sortOrder = Integer.parseInt(parts[0]); + long millis = Long.parseLong(parts[1]); + long id = Long.parseLong(parts[2]); + OffsetDateTime createdAt = OffsetDateTime.ofInstant( + Instant.ofEpochMilli(millis), ZoneOffset.UTC); + return new HubCursor(sortOrder, createdAt, id); + } catch (NumberFormatException e) { + return null; + } + } + + private record HubCursor(Integer sortOrder, OffsetDateTime createdAt, Long id) {} + private boolean isLoggedIn(HttpSession session) { return session != null && session.getAttribute("userId") != null; } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java index 391faea..b23d820 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -260,4 +260,84 @@ public interface GamesMapper { """) List searchGamesAdvanced(SearchCriteria c); + + @Select(""" + + """) + List listVisibleKeyset(@Param("cursorSortOrder") Integer cursorSortOrder, + @Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + @Param("limit") int limit); + + @Select(""" + + """) + List searchVisibleKeyset(@Param("query") String query, + @Param("cursorSortOrder") Integer cursorSortOrder, + @Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt, + @Param("cursorId") Long cursorId, + @Param("limit") int limit); } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java index 3ec848d..ac13225 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java @@ -181,4 +181,36 @@ public interface JamsMapper { AND is_delete IS NOT TRUE """) int softDelete(long jamId); + + @Select(""" + SELECT id, slug, title, description, status, + recruit_start_at AS recruitStartAt, + dev_start_at AS devStartAt, + eval_start_at AS evalStartAt, + eval_end_at AS evalEndAt, + discord_url AS discordUrl, + prize_info AS prizeInfo, + sponsor_info AS sponsorInfo, + is_visible AS isVisible, + created_by AS createdBy, + created_at AS createdAt, + updated_at AS updatedAt, + is_delete AS isDelete + FROM jams + WHERE status IN ('RECRUIT', 'DEV', 'EVAL') + AND is_visible IS NOT FALSE + AND is_delete IS NOT TRUE + ORDER BY created_at DESC, id DESC + LIMIT #{limit} + """) + List listActive(@Param("limit") int limit); + + @Select(""" + SELECT COUNT(*) + FROM jams + WHERE status IN ('RECRUIT', 'DEV', 'EVAL') + AND is_visible IS NOT FALSE + AND is_delete IS NOT TRUE + """) + int countActive(); } diff --git a/src/main/webapp/WEB-INF/views/index.jsp b/src/main/webapp/WEB-INF/views/index.jsp index 309f0dd..5b6ca7a 100644 --- a/src/main/webapp/WEB-INF/views/index.jsp +++ b/src/main/webapp/WEB-INF/views/index.jsp @@ -66,6 +66,25 @@ } } } + + String nextCursor = ""; + Object nextCursorAttr = request.getAttribute("nextCursor"); + if (nextCursorAttr instanceof String) { + nextCursor = ((String) nextCursorAttr).trim(); + } + boolean hasNextPage = !nextCursor.isBlank(); + + com.pandoli365.bibimbap.data.JamData activeJam = null; + Object activeJamAttr = request.getAttribute("activeJam"); + if (activeJamAttr instanceof com.pandoli365.bibimbap.data.JamData) { + activeJam = (com.pandoli365.bibimbap.data.JamData) activeJamAttr; + } + int activeJamCount = 0; + Object activeJamCountAttr = request.getAttribute("activeJamCount"); + if (activeJamCountAttr instanceof Integer) { + activeJamCount = (Integer) activeJamCountAttr; + } + boolean showJamBanner = activeJamCount > 0 && activeJam != null && activeJam.getSlug() != null && !activeJam.getSlug().isBlank(); %> @@ -585,6 +604,83 @@ text-align: center; line-height: 1.6; } + .jam-banner { + width: min(100%, 48rem); + margin: 0 auto 1.25rem; + } + .jam-banner__link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + border: 1px solid rgba(232, 165, 75, 0.4); + border-radius: 12px; + background: var(--card-bg); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + text-decoration: none; + color: var(--text); + } + .jam-banner__link:hover { + border-color: rgba(232, 165, 75, 0.7); + box-shadow: 0 6px 16px var(--card-shadow); + } + .jam-banner__badge { + flex-shrink: 0; + padding: 0.25rem 0.55rem; + font-size: 0.6875rem; + font-weight: 800; + color: #1a1a1a; + background: var(--accent); + border-radius: 6px; + } + .jam-banner__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; + } + .jam-banner__title { + font-size: 0.9375rem; + font-weight: 700; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .jam-banner__meta { + font-size: 0.75rem; + color: var(--text-muted); + } + .jam-banner__arrow { + flex-shrink: 0; + font-size: 1.1rem; + color: var(--accent); + } + .hub-more { + display: flex; + justify-content: center; + margin-top: 1.5rem; + } + .hub-more__link { + min-height: 3rem; + padding: 0 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--card-bg); + color: var(--text); + font-size: 0.9375rem; + font-weight: 700; + text-decoration: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + } + .hub-more__link:hover { + border-color: rgba(232, 165, 75, 0.5); + box-shadow: 0 6px 16px var(--card-shadow); + } @@ -670,6 +766,23 @@ + <% if (showJamBanner) { + String jamTitle = HtmlUtils.htmlEscape(activeJam.getTitle() == null || activeJam.getTitle().isBlank() ? "게임잼" : activeJam.getTitle()); + String jamSlugParam = java.net.URLEncoder.encode(activeJam.getSlug(), java.nio.charset.StandardCharsets.UTF_8); + String jamCountLabel = activeJamCount > 1 ? ("외 " + (activeJamCount - 1) + "개 진행 중") : "진행 중"; + %> +
+ + 게임잼 + + <%= jamTitle %> + <%= jamCountLabel %> · 출품작 보러가기 + + + +
+ <% } %> +
<% if (games.isEmpty()) { %> <% if (searching) { %> @@ -717,6 +830,18 @@ } %>
+ + <% if (hasNextPage) { + String moreHref = ctx + "/?"; + if (searching) { + moreHref += "q=" + java.net.URLEncoder.encode(searchQuery, java.nio.charset.StandardCharsets.UTF_8) + "&"; + } + moreHref += "cursor=" + java.net.URLEncoder.encode(nextCursor, java.nio.charset.StandardCharsets.UTF_8); + %> + + <% } %>