feat(hub): W3-4 메인 허브 keyset 페이징 + 진행중 잼 배너 (단계1+2 통합)
- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
parent
35f1dc3de9
commit
e28fa60ca6
|
|
@ -105,6 +105,9 @@ ALTER SEQUENCE "games_id_seq" OWNED BY "games"."id";
|
||||||
CREATE INDEX IF NOT EXISTS "idx_games_visible_order"
|
CREATE INDEX IF NOT EXISTS "idx_games_visible_order"
|
||||||
ON "games" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC);
|
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 (비권위 복원본 — 매퍼 존재하나 컨트롤러 미연결)
|
-- game_comments (비권위 복원본 — 매퍼 존재하나 컨트롤러 미연결)
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package com.pandoli365.bibimbap.controller;
|
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.GamesMapper;
|
||||||
|
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||||
import jakarta.servlet.RequestDispatcher;
|
import jakarta.servlet.RequestDispatcher;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpSession;
|
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.ModelAndView;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
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.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|
@ -20,10 +27,14 @@ import java.util.List;
|
||||||
@Controller
|
@Controller
|
||||||
public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
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.gamesMapper = gamesMapper;
|
||||||
|
this.jamsMapper = jamsMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequestMapping("/error")
|
@RequestMapping("/error")
|
||||||
|
|
@ -50,8 +61,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("/")
|
||||||
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query) {
|
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query,
|
||||||
return indexModelAndView(query);
|
@RequestParam(name = "cursor", required = false) String cursor) {
|
||||||
|
return indexModelAndView(query, cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{pageName}")
|
@GetMapping("/{pageName}")
|
||||||
|
|
@ -95,22 +107,80 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
||||||
mv.setViewName("operation-policy");
|
mv.setViewName("operation-policy");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return indexModelAndView(query);
|
return indexModelAndView(query, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return mv;
|
return mv;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ModelAndView indexModelAndView(String query) {
|
private ModelAndView indexModelAndView(String query, String cursor) {
|
||||||
String normalizedQuery = query == null ? "" : query.trim();
|
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<GameData> 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");
|
ModelAndView mv = new ModelAndView("index");
|
||||||
mv.addObject("games", normalizedQuery.isBlank()
|
mv.addObject("games", rows);
|
||||||
? gamesMapper.getVisibleGames()
|
|
||||||
: gamesMapper.searchVisibleGames(normalizedQuery));
|
|
||||||
mv.addObject("searchQuery", normalizedQuery);
|
mv.addObject("searchQuery", normalizedQuery);
|
||||||
|
mv.addObject("nextCursor", nextCursor); // nullable — JSP 가 null 시 더보기 미표시
|
||||||
|
|
||||||
|
// 진행중 잼 배너 (activeCount==0 → attr 미주입 → 배너 미렌더)
|
||||||
|
int activeCount = jamsMapper.countActive();
|
||||||
|
if (activeCount > 0) {
|
||||||
|
List<JamData> active = jamsMapper.listActive(1);
|
||||||
|
if (!active.isEmpty()) {
|
||||||
|
mv.addObject("activeJam", active.get(0));
|
||||||
|
mv.addObject("activeJamCount", activeCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
return mv;
|
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) {
|
private boolean isLoggedIn(HttpSession session) {
|
||||||
return session != null && session.getAttribute("userId") != null;
|
return session != null && session.getAttribute("userId") != null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -260,4 +260,84 @@ public interface GamesMapper {
|
||||||
</script>
|
</script>
|
||||||
""")
|
""")
|
||||||
List<GameData> searchGamesAdvanced(SearchCriteria c);
|
List<GameData> searchGamesAdvanced(SearchCriteria c);
|
||||||
|
|
||||||
|
@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
|
||||||
|
FROM games g
|
||||||
|
JOIN users u ON u.id = g.user_id
|
||||||
|
WHERE g.is_visible IS NOT FALSE
|
||||||
|
AND g.is_delete IS NOT TRUE
|
||||||
|
AND u.is_delete IS NOT TRUE
|
||||||
|
<if test="cursorSortOrder != null and cursorCreatedAt != null and cursorId != null">
|
||||||
|
AND (
|
||||||
|
g.sort_order > #{cursorSortOrder}
|
||||||
|
OR (g.sort_order = #{cursorSortOrder} AND g.created_at < #{cursorCreatedAt})
|
||||||
|
OR (g.sort_order = #{cursorSortOrder} AND g.created_at = #{cursorCreatedAt} AND g.id < #{cursorId})
|
||||||
|
)
|
||||||
|
</if>
|
||||||
|
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<GameData> listVisibleKeyset(@Param("cursorSortOrder") Integer cursorSortOrder,
|
||||||
|
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
|
||||||
|
@Param("cursorId") Long cursorId,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
@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
|
||||||
|
FROM games g
|
||||||
|
JOIN users u ON u.id = g.user_id
|
||||||
|
WHERE g.is_visible IS NOT FALSE
|
||||||
|
AND g.is_delete IS NOT TRUE
|
||||||
|
AND u.is_delete IS NOT TRUE
|
||||||
|
AND (
|
||||||
|
g.name ILIKE CONCAT('%', #{query}, '%')
|
||||||
|
OR u.display_name ILIKE CONCAT('%', #{query}, '%')
|
||||||
|
OR g.creator_note ILIKE CONCAT('%', #{query}, '%')
|
||||||
|
)
|
||||||
|
<if test="cursorSortOrder != null and cursorCreatedAt != null and cursorId != null">
|
||||||
|
AND (
|
||||||
|
g.sort_order > #{cursorSortOrder}
|
||||||
|
OR (g.sort_order = #{cursorSortOrder} AND g.created_at < #{cursorCreatedAt})
|
||||||
|
OR (g.sort_order = #{cursorSortOrder} AND g.created_at = #{cursorCreatedAt} AND g.id < #{cursorId})
|
||||||
|
)
|
||||||
|
</if>
|
||||||
|
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
|
||||||
|
LIMIT #{limit}
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<GameData> searchVisibleKeyset(@Param("query") String query,
|
||||||
|
@Param("cursorSortOrder") Integer cursorSortOrder,
|
||||||
|
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
|
||||||
|
@Param("cursorId") Long cursorId,
|
||||||
|
@Param("limit") int limit);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -181,4 +181,36 @@ public interface JamsMapper {
|
||||||
AND is_delete IS NOT TRUE
|
AND is_delete IS NOT TRUE
|
||||||
""")
|
""")
|
||||||
int softDelete(long jamId);
|
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<JamData> 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
%>
|
%>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
|
|
@ -585,6 +604,83 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -670,6 +766,23 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<% 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) + "개 진행 중") : "진행 중";
|
||||||
|
%>
|
||||||
|
<section class="jam-banner" aria-label="진행 중인 게임잼">
|
||||||
|
<a class="jam-banner__link" href="<%= ctx %>/games/search?jam=<%= jamSlugParam %>">
|
||||||
|
<span class="jam-banner__badge">게임잼</span>
|
||||||
|
<span class="jam-banner__text">
|
||||||
|
<strong class="jam-banner__title"><%= jamTitle %></strong>
|
||||||
|
<span class="jam-banner__meta"><%= jamCountLabel %> · 출품작 보러가기</span>
|
||||||
|
</span>
|
||||||
|
<span class="jam-banner__arrow" aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<section class="card-grid" aria-label="추천 목록">
|
<section class="card-grid" aria-label="추천 목록">
|
||||||
<% if (games.isEmpty()) { %>
|
<% if (games.isEmpty()) { %>
|
||||||
<% if (searching) { %>
|
<% if (searching) { %>
|
||||||
|
|
@ -717,6 +830,18 @@
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<% 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);
|
||||||
|
%>
|
||||||
|
<nav class="hub-more" aria-label="더 많은 게임">
|
||||||
|
<a class="hub-more__link" href="<%= moreHref %>">더 보기</a>
|
||||||
|
</nav>
|
||||||
|
<% } %>
|
||||||
</main>
|
</main>
|
||||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
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 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.web.servlet.ModelAndView;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class WebMvcControllerTest {
|
||||||
|
|
||||||
|
private static final int HUB_PAGE_SIZE = 24;
|
||||||
|
private static final int FETCH_LIMIT = HUB_PAGE_SIZE + 1;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private GamesMapper gamesMapper;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private JamsMapper jamsMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstPage_returnsPageSizeAndNextCursor() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(0);
|
||||||
|
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(games(FETCH_LIMIT));
|
||||||
|
|
||||||
|
ModelAndView mv = controller().indexView(null, null);
|
||||||
|
|
||||||
|
assertThat(mv.getViewName()).isEqualTo("index");
|
||||||
|
List<?> games = (List<?>) mv.getModel().get("games");
|
||||||
|
assertThat(games).hasSize(HUB_PAGE_SIZE);
|
||||||
|
|
||||||
|
// nextCursor 는 잘린 24건 중 마지막(index 23) 행 기준이어야 한다.
|
||||||
|
GameData boundary = (GameData) games.get(HUB_PAGE_SIZE - 1);
|
||||||
|
String expected = boundary.getSortOrder() + "_"
|
||||||
|
+ boundary.getCreatedAt().toInstant().toEpochMilli() + "_"
|
||||||
|
+ boundary.getId();
|
||||||
|
assertThat(mv.getModel().get("nextCursor")).isEqualTo(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstPage_noNextCursor_whenResultsAtOrBelowPageSize() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(0);
|
||||||
|
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(games(HUB_PAGE_SIZE));
|
||||||
|
|
||||||
|
ModelAndView mv = controller().indexView(null, null);
|
||||||
|
|
||||||
|
List<?> games = (List<?>) mv.getModel().get("games");
|
||||||
|
assertThat(games).hasSize(HUB_PAGE_SIZE);
|
||||||
|
assertThat(mv.getModel().get("nextCursor")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void secondPage_parsesCursorAndPassesToMapper() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(0);
|
||||||
|
when(gamesMapper.listVisibleKeyset(any(), any(), any(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
controller().indexView(null, "10_1718000000000_57");
|
||||||
|
|
||||||
|
ArgumentCaptor<Integer> sortCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||||
|
ArgumentCaptor<OffsetDateTime> createdCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
|
||||||
|
ArgumentCaptor<Long> idCaptor = ArgumentCaptor.forClass(Long.class);
|
||||||
|
ArgumentCaptor<Integer> limitCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||||
|
verify(gamesMapper).listVisibleKeyset(
|
||||||
|
sortCaptor.capture(), createdCaptor.capture(), idCaptor.capture(), limitCaptor.capture());
|
||||||
|
|
||||||
|
assertThat(sortCaptor.getValue()).isEqualTo(10);
|
||||||
|
assertThat(idCaptor.getValue()).isEqualTo(57L);
|
||||||
|
assertThat(createdCaptor.getValue().toInstant().toEpochMilli()).isEqualTo(1718000000000L);
|
||||||
|
assertThat(limitCaptor.getValue()).isEqualTo(FETCH_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedCursor_fallsBackToFirstPage() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(0);
|
||||||
|
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
WebMvcController controller = controller();
|
||||||
|
// 각각: 구분자 없음 / 컴포넌트 3 미만 / 숫자 파싱 실패 — 모두 첫 페이지 폴백, 예외 없음.
|
||||||
|
controller.indexView(null, "garbage");
|
||||||
|
controller.indexView(null, "1_2");
|
||||||
|
controller.indexView(null, "x_y_z");
|
||||||
|
|
||||||
|
verify(gamesMapper, never()).listVisibleKeyset(
|
||||||
|
any(Integer.class), any(OffsetDateTime.class), any(Long.class), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchQuery_usesSearchKeysetAndPreservesQuery() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(0);
|
||||||
|
when(gamesMapper.searchVisibleKeyset(eq("플랫폼"), isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(games(FETCH_LIMIT));
|
||||||
|
|
||||||
|
ModelAndView mv = controller().indexView("플랫폼", null);
|
||||||
|
|
||||||
|
assertThat(mv.getModel().get("searchQuery")).isEqualTo("플랫폼");
|
||||||
|
assertThat(mv.getModel().get("nextCursor")).isNotNull();
|
||||||
|
verify(gamesMapper).searchVisibleKeyset("플랫폼", null, null, null, FETCH_LIMIT);
|
||||||
|
verify(gamesMapper, never()).listVisibleKeyset(any(), any(), any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchQuery_withCursor_passesBoth() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(0);
|
||||||
|
when(gamesMapper.searchVisibleKeyset(eq("플랫폼"), any(), any(), any(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
controller().indexView("플랫폼", "10_1718000000000_57");
|
||||||
|
|
||||||
|
ArgumentCaptor<String> queryCaptor = ArgumentCaptor.forClass(String.class);
|
||||||
|
ArgumentCaptor<Integer> sortCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||||
|
ArgumentCaptor<OffsetDateTime> createdCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
|
||||||
|
ArgumentCaptor<Long> idCaptor = ArgumentCaptor.forClass(Long.class);
|
||||||
|
ArgumentCaptor<Integer> limitCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||||
|
verify(gamesMapper).searchVisibleKeyset(
|
||||||
|
queryCaptor.capture(), sortCaptor.capture(), createdCaptor.capture(),
|
||||||
|
idCaptor.capture(), limitCaptor.capture());
|
||||||
|
|
||||||
|
assertThat(queryCaptor.getValue()).isEqualTo("플랫폼");
|
||||||
|
assertThat(sortCaptor.getValue()).isEqualTo(10);
|
||||||
|
assertThat(idCaptor.getValue()).isEqualTo(57L);
|
||||||
|
assertThat(limitCaptor.getValue()).isEqualTo(FETCH_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void banner_renderedWhenActiveJamExists() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(3);
|
||||||
|
when(jamsMapper.listActive(1)).thenReturn(List.of(jam("summer-jam", "여름 게임잼")));
|
||||||
|
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
ModelAndView mv = controller().indexView(null, null);
|
||||||
|
|
||||||
|
assertThat(mv.getModel().get("activeJam")).isNotNull();
|
||||||
|
assertThat(mv.getModel().get("activeJamCount")).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void banner_notRendered_whenNoActiveJam() {
|
||||||
|
when(jamsMapper.countActive()).thenReturn(0);
|
||||||
|
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||||
|
.thenReturn(new ArrayList<>());
|
||||||
|
|
||||||
|
ModelAndView mv = controller().indexView(null, null);
|
||||||
|
|
||||||
|
assertThat(mv.getModel().get("activeJam")).isNull();
|
||||||
|
assertThat(mv.getModel().get("activeJamCount")).isNull();
|
||||||
|
verify(jamsMapper, never()).listActive(anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private WebMvcController controller() {
|
||||||
|
return new WebMvcController(gamesMapper, jamsMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<GameData> games(int count) {
|
||||||
|
List<GameData> rows = new ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
// id/sortOrder/createdAt 모두 유일한 값으로 채워 nextCursor 인코딩이 행마다 구분되게 한다.
|
||||||
|
rows.add(game((long) (i + 1), i + 1, 1_700_000_000_000L + i));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameData game(long id, int sortOrder, long createdAtMillis) {
|
||||||
|
GameData g = new GameData();
|
||||||
|
g.setId(id);
|
||||||
|
g.setSortOrder(sortOrder);
|
||||||
|
g.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(createdAtMillis), ZoneOffset.UTC));
|
||||||
|
g.setName("game-" + id);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JamData jam(String slug, String title) {
|
||||||
|
JamData j = new JamData();
|
||||||
|
j.setSlug(slug);
|
||||||
|
j.setTitle(title);
|
||||||
|
j.setStatus("RECRUIT");
|
||||||
|
j.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC));
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue