diff --git a/src/main/java/com/pandoli365/bibimbap/controller/SearchController.java b/src/main/java/com/pandoli365/bibimbap/controller/SearchController.java index 49529e8..ae82df6 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/SearchController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/SearchController.java @@ -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 availableTags = tagsMapper.listActiveByType("GAME", null); model.addAttribute("games", games); + List creatorUserIds = games.stream() + .map(GameData::getUserId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map> 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); diff --git a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java index d773d77..268de24 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java @@ -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 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 creatorUserIds = rows.stream() + .map(GameData::getUserId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map> 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 시 더보기 미표시 diff --git a/src/main/webapp/WEB-INF/views/index.jsp b/src/main/webapp/WEB-INF/views/index.jsp index 5b6ca7a..d5831a2 100644 --- a/src/main/webapp/WEB-INF/views/index.jsp +++ b/src/main/webapp/WEB-INF/views/index.jsp @@ -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> 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 badgeList = new ArrayList<>(); + for (Object b : (List) entry.getValue()) { + if (b instanceof String) badgeList.add((String) b); + } + creatorBadges.put((Long) entry.getKey(), badgeList); + } + } + } %> @@ -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 @@

<%= gameName %>

<%= creator %>

+ <% + List cardBadges = game.getUserId() != null + ? creatorBadges.getOrDefault(game.getUserId(), java.util.Collections.emptyList()) + : java.util.Collections.emptyList(); + if (!cardBadges.isEmpty()) { + %> +
+ <% for (String bk : cardBadges) { %> + <%= HtmlUtils.htmlEscape(badgeLabelIndex(bk)) %> + <% } %> +
+ <% } %>
diff --git a/src/test/java/com/pandoli365/bibimbap/controller/SearchControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/SearchControllerTest.java index 6aa7058..b8aa2b1 100644 --- a/src/test/java/com/pandoli365/bibimbap/controller/SearchControllerTest.java +++ b/src/test/java/com/pandoli365/bibimbap/controller/SearchControllerTest.java @@ -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.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> creatorBadges = (Map>) 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.of()); + when(tagsMapper.listActiveByType("GAME", null)).thenReturn(List.of()); + + Model model = new ExtendedModelMap(); + controller().search(null, null, null, "and", "latest", null, model); + + @SuppressWarnings("unchecked") + Map> creatorBadges = (Map>) 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; + } } diff --git a/src/test/java/com/pandoli365/bibimbap/controller/WebMvcControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/WebMvcControllerTest.java index a7dc75d..e7851fd 100644 --- a/src/test/java/com/pandoli365/bibimbap/controller/WebMvcControllerTest.java +++ b/src/test/java/com/pandoli365/bibimbap/controller/WebMvcControllerTest.java @@ -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 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> creatorBadges = (Map>) 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> creatorBadges = (Map>) 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 myBadges = (List) 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 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; + } }