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:
이정수 2026-06-29 14:37:00 +09:00
parent af298ce8a0
commit 3d10449e67
5 changed files with 239 additions and 4 deletions

View File

@ -3,8 +3,10 @@ package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.SearchCriteria; import com.pandoli365.bibimbap.data.SearchCriteria;
import com.pandoli365.bibimbap.data.TagData; import com.pandoli365.bibimbap.data.TagData;
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper; import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
import com.pandoli365.bibimbap.util.TagSanitizer; import com.pandoli365.bibimbap.util.TagSanitizer;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
@ -12,8 +14,10 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
@Controller @Controller
@ -24,10 +28,13 @@ public class SearchController {
private final GamesMapper gamesMapper; private final GamesMapper gamesMapper;
private final TagsMapper tagsMapper; 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.gamesMapper = gamesMapper;
this.tagsMapper = tagsMapper; this.tagsMapper = tagsMapper;
this.userBadgesQueryMapper = userBadgesQueryMapper;
} }
@GetMapping("/games/search") @GetMapping("/games/search")
@ -63,6 +70,18 @@ public class SearchController {
List<TagData> availableTags = tagsMapper.listActiveByType("GAME", null); List<TagData> availableTags = tagsMapper.listActiveByType("GAME", null);
model.addAttribute("games", games); 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("searching", true);
model.addAttribute("searchKeyword", normalizedKeyword == null ? "" : normalizedKeyword); model.addAttribute("searchKeyword", normalizedKeyword == null ? "" : normalizedKeyword);
model.addAttribute("searchCreator", normalizedCreator == null ? "" : normalizedCreator); model.addAttribute("searchCreator", normalizedCreator == null ? "" : normalizedCreator);

View File

@ -2,8 +2,10 @@ package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.JamData; import com.pandoli365.bibimbap.data.JamData;
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.JamsMapper; import com.pandoli365.bibimbap.mapper.JamsMapper;
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
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;
@ -21,7 +23,9 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
//이곳에서는 뷰만 제어 //이곳에서는 뷰만 제어
@Controller @Controller
@ -31,10 +35,13 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
private final GamesMapper gamesMapper; private final GamesMapper gamesMapper;
private final JamsMapper jamsMapper; 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.gamesMapper = gamesMapper;
this.jamsMapper = jamsMapper; this.jamsMapper = jamsMapper;
this.userBadgesQueryMapper = userBadgesQueryMapper;
} }
@RequestMapping("/error") @RequestMapping("/error")
@ -95,6 +102,10 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
return new ModelAndView("redirect:/login"); return new ModelAndView("redirect:/login");
} }
mv.addObject("myGames", gamesMapper.getGamesByUserId(sessionUserId(session))); 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"); mv.setViewName("profile");
break; break;
case "signup": case "signup":
@ -133,6 +144,18 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
ModelAndView mv = new ModelAndView("index"); ModelAndView mv = new ModelAndView("index");
mv.addObject("games", rows); 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("searchQuery", normalizedQuery);
mv.addObject("nextCursor", nextCursor); // nullable JSP null 더보기 미표시 mv.addObject("nextCursor", nextCursor); // nullable JSP null 더보기 미표시

View File

@ -7,6 +7,15 @@
<%@ page import="java.util.HashSet" %> <%@ page import="java.util.HashSet" %>
<%@ page import="java.util.List" %> <%@ page import="java.util.List" %>
<%@ page import="java.util.Set" %> <%@ 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(); String ctx = request.getContextPath();
jakarta.servlet.http.HttpSession homeSession = request.getSession(false); jakarta.servlet.http.HttpSession homeSession = request.getSession(false);
@ -85,6 +94,21 @@
activeJamCount = (Integer) activeJamCountAttr; activeJamCount = (Integer) activeJamCountAttr;
} }
boolean showJamBanner = activeJamCount > 0 && activeJam != null && activeJam.getSlug() != null && !activeJam.getSlug().isBlank(); 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> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
@ -590,6 +614,22 @@
letter-spacing: -0.01em; letter-spacing: -0.01em;
font-variant-numeric: tabular-nums; 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 { .home-empty {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: 11rem; min-height: 11rem;
@ -823,6 +863,18 @@
<div class="card__body"> <div class="card__body">
<h2 class="card__game-name" id="<%= titleId %>"><%= gameName %></h2> <h2 class="card__game-name" id="<%= titleId %>"><%= gameName %></h2>
<p class="card__creator"><%= creator %></p> <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> <p class="card__likes">좋아요 <%= String.format("%,d", likeCount) %></p>
</div> </div>
</a> </a>

View File

@ -3,8 +3,10 @@ package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.SearchCriteria; import com.pandoli365.bibimbap.data.SearchCriteria;
import com.pandoli365.bibimbap.data.TagData; import com.pandoli365.bibimbap.data.TagData;
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.TagsMapper; import com.pandoli365.bibimbap.mapper.TagsMapper;
import com.pandoli365.bibimbap.mapper.UserBadgesQueryMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@ -14,6 +16,7 @@ import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@ -29,6 +32,9 @@ class SearchControllerTest {
@Mock @Mock
private TagsMapper tagsMapper; private TagsMapper tagsMapper;
@Mock
private UserBadgesQueryMapper userBadgesQueryMapper;
@Test @Test
void searchReturnsIndexViewWithDefaults() { void searchReturnsIndexViewWithDefaults() {
SearchController controller = controller(); SearchController controller = controller();
@ -150,10 +156,45 @@ class SearchControllerTest {
assertThat(model.getAttribute("availableTags")).isEqualTo(tags); 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 ---- // ---- helpers ----
private SearchController controller() { private SearchController controller() {
return new SearchController(gamesMapper, tagsMapper); return new SearchController(gamesMapper, tagsMapper, userBadgesQueryMapper);
} }
private void stubEmptyResults() { private void stubEmptyResults() {
@ -174,4 +215,19 @@ class SearchControllerTest {
t.setSlug(slug); t.setSlug(slug);
return t; 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;
}
} }

View File

@ -2,8 +2,11 @@ package com.pandoli365.bibimbap.controller;
import com.pandoli365.bibimbap.data.GameData; import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.JamData; import com.pandoli365.bibimbap.data.JamData;
import com.pandoli365.bibimbap.data.UserBadgeKeyRow;
import com.pandoli365.bibimbap.mapper.GamesMapper; import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.mapper.JamsMapper; 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.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@ -16,12 +19,14 @@ import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ -38,6 +43,9 @@ class WebMvcControllerTest {
@Mock @Mock
private JamsMapper jamsMapper; private JamsMapper jamsMapper;
@Mock
private UserBadgesQueryMapper userBadgesQueryMapper;
@Test @Test
void firstPage_returnsPageSizeAndNextCursor() { void firstPage_returnsPageSizeAndNextCursor() {
when(jamsMapper.countActive()).thenReturn(0); when(jamsMapper.countActive()).thenReturn(0);
@ -171,10 +179,69 @@ class WebMvcControllerTest {
verify(jamsMapper, never()).listActive(anyInt()); 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 ---- // ---- helpers ----
private WebMvcController controller() { private WebMvcController controller() {
return new WebMvcController(gamesMapper, jamsMapper); return new WebMvcController(gamesMapper, jamsMapper, userBadgesQueryMapper);
} }
private List<GameData> games(int count) { 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)); j.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC));
return j; 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;
}
} }