From 91938e0fc4d935368d0d7cf9a54b7eb2bad0c350 Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:46:25 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=83=88=20=ED=95=AD=EB=AA=A9=EC=9D=98?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=EC=88=9C=EC=84=9C=EB=A5=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EB=A7=88=EC=A7=80?= =?UTF-8?q?=EB=A7=89=20=EC=88=9C=EC=84=9C=20+=201=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10곳이 새 항목의 display_order를 "현재 행 수"로 정했는데, 삭제가 순서를 다시 매기지 않아 삭제 뒤 새 항목이 기존 항목과 같은 순서를 받았다(예: [0, 2]에 추가하면 2가 겹친다). 같은 순서끼리는 정렬이 정해지지 않아 공개 페이지 순서가 흔들릴 수 있었다. - DisplayOrders.next(형제 목록, 순서 getter): 비어 있으면 0, 아니면 마지막 순서 + 1. - 활동 분류, 커리큘럼·주차·토픽, 게임, 게임·트랙 페이지 부원, 멘토 슬롯, Q&A, 트랙 페이지에 적용한다. --- .../service/AdminActivityCategoryService.java | 3 +- .../service/AdminCurriculumService.java | 5 ++- .../service/AdminCurriculumTreeService.java | 7 ++-- .../game/service/AdminGameMemberService.java | 3 +- .../game/service/AdminGameService.java | 3 +- .../global/util/DisplayOrders.java | 14 ++++++++ .../home/service/AdminMentorSlotService.java | 3 +- .../home/service/AdminQnaService.java | 3 +- .../service/AdminTrackPageMemberService.java | 4 ++- .../track/service/AdminTrackPageService.java | 3 +- .../global/DisplayOrdersTest.java | 23 ++++++++++++ ...AdminQnaAndRecruitLinkIntegrationTest.java | 35 +++++++++++++++++++ 12 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java index eb7e208..a400e87 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java @@ -39,7 +39,8 @@ public AdminActivityCategoryResponse createCategory(ActivityCategoryCreateReques throw new ActivityException(ActivityExceptionType.CATEGORY_SLUG_DUPLICATED); } - int displayOrder = activityCategoryRepository.findAllByOrderByDisplayOrderAsc().size(); + int displayOrder = DisplayOrders.next(activityCategoryRepository.findAllByOrderByDisplayOrderAsc(), + ActivityCategory::getDisplayOrder); ActivityCategory category = activityCategoryRepository.save(ActivityCategory.builder() .slug(request.slug()) .name(request.name()) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java index aae0476..6532f4c 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumService.java @@ -8,6 +8,7 @@ import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum; import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository; import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; @@ -54,7 +55,9 @@ public AdminCurriculumSummaryResponse createCurriculum(Long trackPageId, Curricu throw new CurriculumException(CurriculumExceptionType.NAME_REQUIRED); } - int displayOrder = curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); + int displayOrder = DisplayOrders.next( + curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId), + Curriculum::getDisplayOrder); Curriculum curriculum = curriculumRepository.save(Curriculum.builder() .trackPage(trackPage) .name(name) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java index b8164dc..e6dfc8b 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/service/AdminCurriculumTreeService.java @@ -75,7 +75,9 @@ public CurriculumWeekResponse createWeek(Long curriculumId, WeekRequest request, .orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND)); requireCurriculumAccess(memberId, curriculum.getTrackPage()); - int displayOrder = curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId).size(); + int displayOrder = DisplayOrders.next( + curriculumWeekRepository.findAllByCurriculum_IdOrderByDisplayOrderAsc(curriculumId), + CurriculumWeek::getDisplayOrder); CurriculumWeek week = curriculumWeekRepository.save(CurriculumWeek.builder() .curriculum(curriculum) .weekFrom(request.weekFrom()) @@ -119,7 +121,8 @@ public void reorderWeeks(Long curriculumId, OrderRequest request, Long memberId) public CurriculumTopicResponse createTopic(Long weekId, TopicRequest request, Long memberId) { CurriculumWeek week = findWeekOrThrow(weekId); requireCurriculumAccess(memberId, week.getCurriculum().getTrackPage()); - int displayOrder = curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId).size(); + int displayOrder = DisplayOrders.next(curriculumTopicRepository.findAllByWeek_IdOrderByDisplayOrderAsc(weekId), + CurriculumTopic::getDisplayOrder); CurriculumTopic topic = curriculumTopicRepository.save(CurriculumTopic.builder() .week(week) .title(request.title()) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java index f4c3ae5..e25d289 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameMemberService.java @@ -68,7 +68,8 @@ public List attachMembers(Long gameId, GameMembersAttac } } - int nextOrder = gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId).size(); + int nextOrder = DisplayOrders.next(gameMemberRepository.findAllByGame_IdOrderByDisplayOrderAsc(gameId), + GameMember::getDisplayOrder); for (Long memberId : memberIds) { gameMemberRepository.save(GameMember.builder() .game(game) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java index 34eada1..a46dd69 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameService.java @@ -101,7 +101,8 @@ public AdminGameDetailResponse createGame(GameCreateRequest request, Long member .oneLiner(request.oneLiner()) .track(track) .teamLabel(request.teamLabel()) - .displayOrder((int) gameRepository.count()) + .displayOrder(DisplayOrders.next(gameRepository.findAllByOrderByDisplayOrderAsc(), + Game::getDisplayOrder)) .published(false) .build(); diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java index 9067176..81b0545 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.ToIntFunction; /** * {@code display_order} 재부여 순수 함수. 특정 엔티티 타입에 의존하지 않는다 — @@ -22,6 +23,19 @@ public final class DisplayOrders { private DisplayOrders() { } + /** + * 새 항목의 display_order. 삭제가 순서를 다시 매기지 않아 번호에 빈칸이 생기므로, 개수가 아니라 + * 마지막 순서 + 1이다(개수를 쓰면 기존 항목과 순서가 겹친다). + * + * @param siblingsInOrder 같은 부모 아래 형제 항목, display_order 오름차순 + */ + public static int next(List siblingsInOrder, ToIntFunction displayOrder) { + if (siblingsInOrder.isEmpty()) { + return 0; + } + return displayOrder.applyAsInt(siblingsInOrder.getLast()) + 1; + } + /** * @param requestedIds 새 순서대로 나열된 id 목록 (배열 인덱스가 곧 새 display_order) * @param existingIds 현재 그 부모 아래 존재하는 전체 id 집합 diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java index aeef46c..80442bc 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminMentorSlotService.java @@ -44,7 +44,8 @@ public List addSlot(MentorSlotCreateRequest request) { throw new HomeException(HomeExceptionType.MENTOR_ALREADY_ASSIGNED); } - int nextOrder = (int) mentorSlotRepository.count(); + int nextOrder = DisplayOrders.next(mentorSlotRepository.findAllByOrderByDisplayOrderAsc(), + MentorSlot::getDisplayOrder); mentorSlotRepository.save(MentorSlot.builder().member(member).displayOrder(nextOrder).build()); return getSlots(); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java index 903a5a3..91efba2 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/home/service/AdminQnaService.java @@ -35,7 +35,8 @@ public AdminQnaResponse createQnaItem(QnaCreateRequest request) { QnaItem saved = qnaItemRepository.save(QnaItem.builder() .question(request.question()) .answer(request.answer()) - .displayOrder((int) qnaItemRepository.count()) + .displayOrder(DisplayOrders.next(qnaItemRepository.findAllByOrderByDisplayOrderAsc(), + QnaItem::getDisplayOrder)) .published(true) .build()); return AdminQnaResponse.from(saved); diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java index 8ad20be..555a4e2 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageMemberService.java @@ -63,7 +63,9 @@ public List attachMembers(Long trackPageId, TrackP } } - int nextOrder = trackPageMemberRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size(); + int nextOrder = DisplayOrders.next( + trackPageMemberRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId), + TrackPageMember::getDisplayOrder); for (Long memberId : memberIds) { trackPageMemberRepository.save(TrackPageMember.builder() .trackPage(trackPage) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java index d61b7be..e057ab8 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/AdminTrackPageService.java @@ -103,7 +103,8 @@ public AdminTrackPageDetailResponse createTrackPage(TrackPageCreateRequest reque .slug(slug) .displayName(request.displayName()) .tagline(request.tagline()) - .displayOrder((int) trackPageRepository.count()) + .displayOrder(DisplayOrders.next(trackPageRepository.findAllByOrderByDisplayOrderAsc(), + TrackPage::getDisplayOrder)) .published(true) .build(); diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java new file mode 100644 index 0000000..521ea8e --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/DisplayOrdersTest.java @@ -0,0 +1,23 @@ +package com.bcsdlab.bcsdinternalapiv2.global; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import java.util.List; +import java.util.function.ToIntFunction; +import org.junit.jupiter.api.Test; + +class DisplayOrdersTest { + + private static final ToIntFunction SELF = Integer::intValue; + + @Test + void 형제가_없으면_0이다() { + assertThat(DisplayOrders.next(List.of(), SELF)).isZero(); + } + + @Test + void 삭제로_빈_번호가_있어도_마지막_순서_다음이다() { + assertThat(DisplayOrders.next(List.of(0, 2), SELF)).isEqualTo(3); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java index fa64e7a..5406e42 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/home/AdminQnaAndRecruitLinkIntegrationTest.java @@ -1,5 +1,7 @@ package com.bcsdlab.bcsdinternalapiv2.home; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -21,6 +23,8 @@ import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.ArrayList; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -186,4 +190,35 @@ private String login(String studentNumber) throws Exception { .andReturn().getResponse().getContentAsString(); return objectMapper.readTree(body).get("accessToken").asText(); } + + @Test + @DisplayName("Q&A를 지운 뒤 새로 만들면 남은 항목과 순서가 겹치지 않고 맨 뒤에 붙는다") + void 삭제_후_생성한_Q_A는_맨_뒤에_붙는다() throws Exception { + long first = createQna("첫 질문"); + long second = createQna("둘째 질문"); + createQna("셋째 질문"); + + mockMvc.perform(delete("/v1/admin/qna/" + second).header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + long fourth = createQna("넷째 질문"); + + String list = mockMvc.perform(get("/v1/admin/qna").header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + List orders = new ArrayList<>(); + objectMapper.readTree(list).forEach(item -> orders.add(item.get("displayOrder").asInt())); + assertThat(orders).doesNotHaveDuplicates(); + assertThat(objectMapper.readTree(list).get(0).get("id").asLong()).isEqualTo(first); + assertThat(objectMapper.readTree(list).get(2).get("id").asLong()).isEqualTo(fourth); + } + + private long createQna(String question) throws Exception { + String body = mockMvc.perform(post("/v1/admin/qna") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"question\":\"%s\",\"answer\":\"답변\"}".formatted(question))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).get("id").asLong(); + } }