feat(badge): W4 배지 표시면 배선 — 게임카드 creator 칩 + 프로필 myBadges 주입
GameReviewController authorBadges 정본 패턴 답습. UserBadgesQueryMapper 배치조회(listActiveBadgeKeysByUserIds) 재사용 — 신규 SQL 0. - WebMvcController: UserBadgesQueryMapper 주입. profile→myBadges, indexModelAndView→creatorBadges(userId 그룹핑) 모델 주입 - SearchController: /games/search→creatorBadges 주입 - index.jsp: 게임카드 creator 배지 칩 렌더(badgeLabelIndex, HtmlUtils.htmlEscape) - 테스트 5건 신규(그룹핑/빈맵/myBadges). L1 352/352 GREEN, 회귀 0 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
af298ce8a0
commit
3d10449e67
|
|
@ -3,8 +3,10 @@ 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.data.UserBadgeKeyRow;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.TagsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
|
||||
import com.pandoli365.bibimbap.util.TagSanitizer;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
|
|
@ -12,8 +14,10 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Controller
|
||||
|
|
@ -24,10 +28,13 @@ public class SearchController {
|
|||
|
||||
private final GamesMapper gamesMapper;
|
||||
private final TagsMapper tagsMapper;
|
||||
private final UserBadgesQueryMapper userBadgesQueryMapper;
|
||||
|
||||
public SearchController(GamesMapper gamesMapper, TagsMapper tagsMapper) {
|
||||
public SearchController(GamesMapper gamesMapper, TagsMapper tagsMapper,
|
||||
UserBadgesQueryMapper userBadgesQueryMapper) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.tagsMapper = tagsMapper;
|
||||
this.userBadgesQueryMapper = userBadgesQueryMapper;
|
||||
}
|
||||
|
||||
@GetMapping("/games/search")
|
||||
|
|
@ -63,6 +70,18 @@ public class SearchController {
|
|||
List<TagData> availableTags = tagsMapper.listActiveByType("GAME", null);
|
||||
|
||||
model.addAttribute("games", games);
|
||||
List<Long> creatorUserIds = games.stream()
|
||||
.map(GameData::getUserId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, List<String>> creatorBadges = new LinkedHashMap<>();
|
||||
if (!creatorUserIds.isEmpty()) {
|
||||
for (UserBadgeKeyRow row : userBadgesQueryMapper.listActiveBadgeKeysByUserIds(creatorUserIds)) {
|
||||
creatorBadges.computeIfAbsent(row.getUserId(), k -> new ArrayList<>()).add(row.getBadgeKey());
|
||||
}
|
||||
}
|
||||
model.addAttribute("creatorBadges", creatorBadges);
|
||||
model.addAttribute("searching", true);
|
||||
model.addAttribute("searchKeyword", normalizedKeyword == null ? "" : normalizedKeyword);
|
||||
model.addAttribute("searchCreator", normalizedCreator == null ? "" : normalizedCreator);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package com.pandoli365.bibimbap.controller;
|
|||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
|
@ -21,7 +23,9 @@ import java.time.OffsetDateTime;
|
|||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
//이곳에서는 뷰만 제어
|
||||
@Controller
|
||||
|
|
@ -31,10 +35,13 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
|
||||
private final GamesMapper gamesMapper;
|
||||
private final JamsMapper jamsMapper;
|
||||
private final UserBadgesQueryMapper userBadgesQueryMapper;
|
||||
|
||||
public WebMvcController(GamesMapper gamesMapper, JamsMapper jamsMapper) {
|
||||
public WebMvcController(GamesMapper gamesMapper, JamsMapper jamsMapper,
|
||||
UserBadgesQueryMapper userBadgesQueryMapper) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.jamsMapper = jamsMapper;
|
||||
this.userBadgesQueryMapper = userBadgesQueryMapper;
|
||||
}
|
||||
|
||||
@RequestMapping("/error")
|
||||
|
|
@ -95,6 +102,10 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
return new ModelAndView("redirect:/login");
|
||||
}
|
||||
mv.addObject("myGames", gamesMapper.getGamesByUserId(sessionUserId(session)));
|
||||
List<String> myBadges = userBadgesQueryMapper
|
||||
.listActiveBadgeKeysByUserIds(java.util.List.of(sessionUserId(session)))
|
||||
.stream().map(UserBadgeKeyRow::getBadgeKey).toList();
|
||||
mv.addObject("myBadges", myBadges);
|
||||
mv.setViewName("profile");
|
||||
break;
|
||||
case "signup":
|
||||
|
|
@ -133,6 +144,18 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
|
||||
ModelAndView mv = new ModelAndView("index");
|
||||
mv.addObject("games", rows);
|
||||
List<Long> creatorUserIds = rows.stream()
|
||||
.map(GameData::getUserId)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, List<String>> creatorBadges = new LinkedHashMap<>();
|
||||
if (!creatorUserIds.isEmpty()) {
|
||||
for (UserBadgeKeyRow row : userBadgesQueryMapper.listActiveBadgeKeysByUserIds(creatorUserIds)) {
|
||||
creatorBadges.computeIfAbsent(row.getUserId(), k -> new ArrayList<>()).add(row.getBadgeKey());
|
||||
}
|
||||
}
|
||||
mv.addObject("creatorBadges", creatorBadges);
|
||||
mv.addObject("searchQuery", normalizedQuery);
|
||||
mv.addObject("nextCursor", nextCursor); // nullable — JSP 가 null 시 더보기 미표시
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
<%@ page import="java.util.HashSet" %>
|
||||
<%@ page import="java.util.List" %>
|
||||
<%@ page import="java.util.Set" %>
|
||||
<%@ page import="java.util.Map" %>
|
||||
<%@ page import="java.util.ArrayList" %>
|
||||
<%!
|
||||
private String badgeLabelIndex(String key) {
|
||||
if ("REVIEWER".equals(key)) return "리뷰어";
|
||||
if ("TECHNICIAN".equals(key)) return "테크니션";
|
||||
return key;
|
||||
}
|
||||
%>
|
||||
<%
|
||||
String ctx = request.getContextPath();
|
||||
jakarta.servlet.http.HttpSession homeSession = request.getSession(false);
|
||||
|
|
@ -85,6 +94,21 @@
|
|||
activeJamCount = (Integer) activeJamCountAttr;
|
||||
}
|
||||
boolean showJamBanner = activeJamCount > 0 && activeJam != null && activeJam.getSlug() != null && !activeJam.getSlug().isBlank();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, List<String>> creatorBadges = new java.util.LinkedHashMap<>();
|
||||
Object creatorBadgesAttr = request.getAttribute("creatorBadges");
|
||||
if (creatorBadgesAttr instanceof Map<?, ?>) {
|
||||
for (Map.Entry<?, ?> entry : ((Map<?, ?>) creatorBadgesAttr).entrySet()) {
|
||||
if (entry.getKey() instanceof Long && entry.getValue() instanceof List<?>) {
|
||||
List<String> badgeList = new ArrayList<>();
|
||||
for (Object b : (List<?>) entry.getValue()) {
|
||||
if (b instanceof String) badgeList.add((String) b);
|
||||
}
|
||||
creatorBadges.put((Long) entry.getKey(), badgeList);
|
||||
}
|
||||
}
|
||||
}
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
|
@ -590,6 +614,22 @@
|
|||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
.card__badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(232, 165, 75, 0.4);
|
||||
border-radius: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.home-empty {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 11rem;
|
||||
|
|
@ -823,6 +863,18 @@
|
|||
<div class="card__body">
|
||||
<h2 class="card__game-name" id="<%= titleId %>"><%= gameName %></h2>
|
||||
<p class="card__creator"><%= creator %></p>
|
||||
<%
|
||||
List<String> cardBadges = game.getUserId() != null
|
||||
? creatorBadges.getOrDefault(game.getUserId(), java.util.Collections.emptyList())
|
||||
: java.util.Collections.emptyList();
|
||||
if (!cardBadges.isEmpty()) {
|
||||
%>
|
||||
<div class="card__badges">
|
||||
<% for (String bk : cardBadges) { %>
|
||||
<span class="card__badge"><%= HtmlUtils.htmlEscape(badgeLabelIndex(bk)) %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
<p class="card__likes">좋아요 <%= String.format("%,d", likeCount) %></p>
|
||||
</div>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ 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.data.UserBadgeKeyRow;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.TagsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
|
@ -14,6 +16,7 @@ import org.springframework.ui.ExtendedModelMap;
|
|||
import org.springframework.ui.Model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
|
|
@ -29,6 +32,9 @@ class SearchControllerTest {
|
|||
@Mock
|
||||
private TagsMapper tagsMapper;
|
||||
|
||||
@Mock
|
||||
private UserBadgesQueryMapper userBadgesQueryMapper;
|
||||
|
||||
@Test
|
||||
void searchReturnsIndexViewWithDefaults() {
|
||||
SearchController controller = controller();
|
||||
|
|
@ -150,10 +156,45 @@ class SearchControllerTest {
|
|||
assertThat(model.getAttribute("availableTags")).isEqualTo(tags);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_creatorBadges_groupedByUserId() {
|
||||
GameData g1 = gameWithUser(1L, 10L);
|
||||
GameData g2 = gameWithUser(2L, 20L);
|
||||
when(gamesMapper.searchGamesAdvanced(any(SearchCriteria.class))).thenReturn(List.of(g1, g2));
|
||||
when(tagsMapper.listActiveByType("GAME", null)).thenReturn(List.<TagData>of());
|
||||
|
||||
UserBadgeKeyRow row1 = badgeRow(10L, "REVIEWER");
|
||||
UserBadgeKeyRow row2 = badgeRow(20L, "TECHNICIAN");
|
||||
when(userBadgesQueryMapper.listActiveBadgeKeysByUserIds(List.of(10L, 20L)))
|
||||
.thenReturn(List.of(row1, row2));
|
||||
|
||||
Model model = new ExtendedModelMap();
|
||||
controller().search(null, null, null, "and", "latest", null, model);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, List<String>> creatorBadges = (Map<Long, List<String>>) model.getAttribute("creatorBadges");
|
||||
assertThat(creatorBadges).isNotNull();
|
||||
assertThat(creatorBadges.get(10L)).containsExactly("REVIEWER");
|
||||
assertThat(creatorBadges.get(20L)).containsExactly("TECHNICIAN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_creatorBadges_emptyMapWhenNoGames() {
|
||||
when(gamesMapper.searchGamesAdvanced(any(SearchCriteria.class))).thenReturn(List.<GameData>of());
|
||||
when(tagsMapper.listActiveByType("GAME", null)).thenReturn(List.<TagData>of());
|
||||
|
||||
Model model = new ExtendedModelMap();
|
||||
controller().search(null, null, null, "and", "latest", null, model);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, List<String>> creatorBadges = (Map<Long, List<String>>) model.getAttribute("creatorBadges");
|
||||
assertThat(creatorBadges).isNotNull().isEmpty();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private SearchController controller() {
|
||||
return new SearchController(gamesMapper, tagsMapper);
|
||||
return new SearchController(gamesMapper, tagsMapper, userBadgesQueryMapper);
|
||||
}
|
||||
|
||||
private void stubEmptyResults() {
|
||||
|
|
@ -174,4 +215,19 @@ class SearchControllerTest {
|
|||
t.setSlug(slug);
|
||||
return t;
|
||||
}
|
||||
|
||||
private GameData gameWithUser(long id, Long userId) {
|
||||
GameData g = new GameData();
|
||||
g.setId(id);
|
||||
g.setName("game-" + id);
|
||||
g.setUserId(userId);
|
||||
return g;
|
||||
}
|
||||
|
||||
private UserBadgeKeyRow badgeRow(Long userId, String key) {
|
||||
UserBadgeKeyRow r = new UserBadgeKeyRow();
|
||||
r.setUserId(userId);
|
||||
r.setBadgeKey(key);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ package com.pandoli365.bibimbap.controller;
|
|||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
|
@ -16,12 +19,14 @@ import java.time.OffsetDateTime;
|
|||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
|
@ -38,6 +43,9 @@ class WebMvcControllerTest {
|
|||
@Mock
|
||||
private JamsMapper jamsMapper;
|
||||
|
||||
@Mock
|
||||
private UserBadgesQueryMapper userBadgesQueryMapper;
|
||||
|
||||
@Test
|
||||
void firstPage_returnsPageSizeAndNextCursor() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
|
|
@ -171,10 +179,69 @@ class WebMvcControllerTest {
|
|||
verify(jamsMapper, never()).listActive(anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void indexView_creatorBadges_groupedByUserId() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
// games: userId=10L, userId=20L, userId=10L (10L 중복)
|
||||
List<GameData> rows = List.of(
|
||||
gameWithUser(1L, 10L),
|
||||
gameWithUser(2L, 20L),
|
||||
gameWithUser(3L, 10L)
|
||||
);
|
||||
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(rows);
|
||||
|
||||
UserBadgeKeyRow row1 = badgeRow(10L, "REVIEWER");
|
||||
UserBadgeKeyRow row2 = badgeRow(10L, "TECHNICIAN");
|
||||
UserBadgeKeyRow row3 = badgeRow(20L, "REVIEWER");
|
||||
when(userBadgesQueryMapper.listActiveBadgeKeysByUserIds(List.of(10L, 20L)))
|
||||
.thenReturn(List.of(row1, row2, row3));
|
||||
|
||||
ModelAndView mv = controller().indexView(null, null);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, List<String>> creatorBadges = (Map<Long, List<String>>) mv.getModel().get("creatorBadges");
|
||||
assertThat(creatorBadges).isNotNull();
|
||||
assertThat(creatorBadges.get(10L)).containsExactly("REVIEWER", "TECHNICIAN");
|
||||
assertThat(creatorBadges.get(20L)).containsExactly("REVIEWER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void indexView_creatorBadges_emptyMapWhenNoGames() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
ModelAndView mv = controller().indexView(null, null);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, List<String>> creatorBadges = (Map<Long, List<String>>) mv.getModel().get("creatorBadges");
|
||||
assertThat(creatorBadges).isNotNull().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void profileView_myBadges_injectedIntoModel() {
|
||||
HttpSession session = mock(HttpSession.class);
|
||||
when(session.getAttribute("userId")).thenReturn(42L);
|
||||
when(gamesMapper.getGamesByUserId(42L)).thenReturn(List.of());
|
||||
|
||||
UserBadgeKeyRow row = badgeRow(42L, "REVIEWER");
|
||||
when(userBadgesQueryMapper.listActiveBadgeKeysByUserIds(List.of(42L)))
|
||||
.thenReturn(List.of(row));
|
||||
|
||||
org.springframework.mock.web.MockHttpServletRequest request =
|
||||
new org.springframework.mock.web.MockHttpServletRequest();
|
||||
ModelAndView mv = controller().mainView("profile", null, null, session, request);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> myBadges = (List<String>) mv.getModel().get("myBadges");
|
||||
assertThat(myBadges).containsExactly("REVIEWER");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private WebMvcController controller() {
|
||||
return new WebMvcController(gamesMapper, jamsMapper);
|
||||
return new WebMvcController(gamesMapper, jamsMapper, userBadgesQueryMapper);
|
||||
}
|
||||
|
||||
private List<GameData> games(int count) {
|
||||
|
|
@ -203,4 +270,22 @@ class WebMvcControllerTest {
|
|||
j.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC));
|
||||
return j;
|
||||
}
|
||||
|
||||
private GameData gameWithUser(long id, Long userId) {
|
||||
GameData g = new GameData();
|
||||
g.setId(id);
|
||||
g.setSortOrder((int) id);
|
||||
g.setCreatedAt(OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(1_700_000_000_000L + id), ZoneOffset.UTC));
|
||||
g.setName("game-" + id);
|
||||
g.setUserId(userId);
|
||||
return g;
|
||||
}
|
||||
|
||||
private UserBadgeKeyRow badgeRow(Long userId, String key) {
|
||||
UserBadgeKeyRow r = new UserBadgeKeyRow();
|
||||
r.setUserId(userId);
|
||||
r.setBadgeKey(key);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue