From 37f22feb90bf44e81ad8a98ca036a4a72b55463f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=91=90=ED=98=84?= <96455522+DHkimgit@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:04:52 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=BD=9C=EB=B2=A4=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20FCM=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20&=20=EC=9D=B4=EC=9A=A9=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20(#2212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 콜벤 알림 FCM 메시지 전송 기능 * feat: 콜벤 제제 내역 조회 기능 * feat: 테스트 수정 --- .../koin/common/model/MobileAppPath.java | 2 + .../domain/callvan/controller/CallvanApi.java | 28 ++++++- .../callvan/controller/CallvanController.java | 23 ++++-- .../dto/CallvanRestrictionResponse.java | 43 ++++++++++ .../event/CallvanPushNotificationEvent.java | 9 +++ .../model/CallvanPushNotification.java | 16 ++++ .../model/CallvanPushNotificationFactory.java | 71 +++++++++++++++++ .../CallvanNotificationRepository.java | 11 +++ .../CallvanReportProcessRepository.java | 20 ++++- .../service/CallvanNotificationScheduler.java | 36 ++++++--- .../service/CallvanNotificationService.java | 78 +++++++++++++++---- .../CallvanPushNotificationEventListener.java | 22 ++++++ .../CallvanPushNotificationService.java | 57 ++++++++++++++ .../CallvanRestrictionQueryService.java | 28 +++++++ .../service/CallvanRestrictionService.java | 4 +- .../model/NotificationSubscribeType.java | 1 + .../domain/NotificationApiTest.java | 35 +++++++++ 17 files changed, 445 insertions(+), 39 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanRestrictionResponse.java create mode 100644 src/main/java/in/koreatech/koin/domain/callvan/event/CallvanPushNotificationEvent.java create mode 100644 src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotification.java create mode 100644 src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotificationFactory.java create mode 100644 src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationEventListener.java create mode 100644 src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationService.java create mode 100644 src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionQueryService.java diff --git a/src/main/java/in/koreatech/koin/common/model/MobileAppPath.java b/src/main/java/in/koreatech/koin/common/model/MobileAppPath.java index 0d542d42c6..f36541adbc 100644 --- a/src/main/java/in/koreatech/koin/common/model/MobileAppPath.java +++ b/src/main/java/in/koreatech/koin/common/model/MobileAppPath.java @@ -10,6 +10,8 @@ public enum MobileAppPath { DINING("dining"), KEYWORD("keyword"), CHAT("chat"), + CALLVAN("callvan"), + CALLVAN_CHAT("callvan-chat"), CLUB("club"), TIMETABLE("timetable"), ; diff --git a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java index ec0e1659fa..e163e4743d 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanApi.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest; import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse; @@ -19,6 +20,7 @@ import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanRestrictionResponse; import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest; import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter; @@ -29,9 +31,6 @@ import java.util.List; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - import in.koreatech.koin.global.code.ApiResponseCodes; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -124,6 +123,29 @@ ResponseEntity getCallvanPosts( @UserId Integer userId ); + @ApiResponseCodes({ + OK, + NOT_FOUND_USER + }) + @Operation(summary = "내 콜밴 이용 제한 상태 조회", description = """ + ### 내 콜밴 이용 제한 상태 조회 API + 로그인한 사용자의 현재 활성화된 콜밴 이용 제한 상태를 조회합니다. + + #### 인증 조건 + - **학생(STUDENT)** 권한을 가진 사용자만 호출 가능합니다. + + #### 비즈니스 로직 + 1. 현재 활성화된 정지 상태가 있으면 `is_restricted=true`를 반환합니다. + 2. 14일 이용 정지인 경우 `restriction_type=TEMPORARY_RESTRICTION_14_DAYS`와 `restricted_until`을 반환합니다. + 3. 영구 이용 정지인 경우 `restriction_type=PERMANENT_RESTRICTION`을 반환하고 `restricted_until`은 null입니다. + 4. 경고(`WARNING`)와 만료된 14일 정지는 이 API에서 반환하지 않습니다. + 5. 활성화된 정지가 없으면 `is_restricted=false`와 null 필드들을 반환합니다. + """) + @GetMapping("/restriction") + ResponseEntity getCallvanRestriction( + @Auth(permit = {STUDENT}) Integer userId + ); + @ApiResponseCodes({ OK, NOT_FOUND_ARTICLE diff --git a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java index d3c5a8eadb..0555ae43b4 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/controller/CallvanController.java @@ -12,26 +12,28 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest; +import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateRequest; import in.koreatech.koin.domain.callvan.dto.CallvanPostCreateResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostDetailResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse; import in.koreatech.koin.domain.callvan.dto.CallvanPostSearchResponse.CallvanPostResponse; +import in.koreatech.koin.domain.callvan.dto.CallvanRestrictionResponse; import in.koreatech.koin.domain.callvan.dto.CallvanUserReportCreateRequest; import in.koreatech.koin.domain.callvan.model.enums.CallvanLocation; import in.koreatech.koin.domain.callvan.model.filter.CallvanAuthorFilter; import in.koreatech.koin.domain.callvan.model.filter.CallvanPostSortCriteria; import in.koreatech.koin.domain.callvan.model.filter.CallvanPostStatusFilter; -import in.koreatech.koin.domain.callvan.service.CallvanPostQueryService; +import in.koreatech.koin.domain.callvan.service.CallvanChatService; +import in.koreatech.koin.domain.callvan.service.CallvanNotificationService; import in.koreatech.koin.domain.callvan.service.CallvanPostCreateService; import in.koreatech.koin.domain.callvan.service.CallvanPostJoinService; +import in.koreatech.koin.domain.callvan.service.CallvanPostQueryService; import in.koreatech.koin.domain.callvan.service.CallvanPostStatusService; -import in.koreatech.koin.domain.callvan.service.CallvanChatService; -import in.koreatech.koin.domain.callvan.service.CallvanNotificationService; +import in.koreatech.koin.domain.callvan.service.CallvanRestrictionQueryService; import in.koreatech.koin.domain.callvan.service.CallvanUserReportService; -import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageRequest; -import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse; -import in.koreatech.koin.domain.callvan.dto.CallvanChatMessageResponse; import in.koreatech.koin.global.auth.Auth; import in.koreatech.koin.global.auth.UserId; import jakarta.validation.Valid; @@ -52,6 +54,7 @@ public class CallvanController implements CallvanApi { private final CallvanPostStatusService callvanPostStatusService; private final CallvanChatService callvanChatService; private final CallvanNotificationService callvanNotificationService; + private final CallvanRestrictionQueryService callvanRestrictionQueryService; private final CallvanUserReportService callvanUserReportService; @PostMapping @@ -84,6 +87,14 @@ public ResponseEntity getCallvanPosts( return ResponseEntity.ok().body(response); } + @GetMapping("/restriction") + public ResponseEntity getCallvanRestriction( + @Auth(permit = {STUDENT}) Integer userId + ) { + CallvanRestrictionResponse response = callvanRestrictionQueryService.getRestriction(userId); + return ResponseEntity.ok(response); + } + @GetMapping("/posts/{postId}/summary") public ResponseEntity getCallvanPostSummary( @PathVariable Integer postId, diff --git a/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanRestrictionResponse.java b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanRestrictionResponse.java new file mode 100644 index 0000000000..2b9717d127 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/dto/CallvanRestrictionResponse.java @@ -0,0 +1,43 @@ +package in.koreatech.koin.domain.callvan.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.callvan.model.CallvanReportProcess; +import in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record CallvanRestrictionResponse( + @Schema(description = "콜벤 기능 이용 제한 여부", example = "true", requiredMode = REQUIRED) + boolean isRestricted, + + @Schema( + description = "현재 활성화된 이용 제한 유형. 제한이 없으면 null이며 WARNING(1차 경고)는 반환되지 않습니다.", + example = "TEMPORARY_RESTRICTION_14_DAYS" + ) + CallvanReportProcessType restrictionType, + + @Schema( + description = "임시 이용 제한 종료 시각. 영구 제한 또는 미제한이면 null입니다.", + example = "2026-04-21T12:00:00" + ) + LocalDateTime restrictedUntil +) { + + public static CallvanRestrictionResponse from(CallvanReportProcess process) { + return new CallvanRestrictionResponse( + true, + process.getProcessType(), + process.getRestrictedUntil() + ); + } + + public static CallvanRestrictionResponse unrestricted() { + return new CallvanRestrictionResponse(false, null, null); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanPushNotificationEvent.java b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanPushNotificationEvent.java new file mode 100644 index 0000000000..49b2e16867 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/event/CallvanPushNotificationEvent.java @@ -0,0 +1,9 @@ +package in.koreatech.koin.domain.callvan.event; + +import java.util.List; + +public record CallvanPushNotificationEvent( + List notificationIds +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotification.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotification.java new file mode 100644 index 0000000000..25b94f0d91 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotification.java @@ -0,0 +1,16 @@ +package in.koreatech.koin.domain.callvan.model; + +import in.koreatech.koin.common.model.MobileAppPath; +import in.koreatech.koin.domain.user.model.User; + +public record CallvanPushNotification( + MobileAppPath mobileAppPath, + String schemeUri, + String title, + String message, + String imageUrl, + String type, + User recipient +) { + +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotificationFactory.java b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotificationFactory.java new file mode 100644 index 0000000000..3dd7201f54 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/model/CallvanPushNotificationFactory.java @@ -0,0 +1,71 @@ +package in.koreatech.koin.domain.callvan.model; + +import static in.koreatech.koin.common.model.MobileAppPath.CALLVAN; +import static in.koreatech.koin.common.model.MobileAppPath.CALLVAN_CHAT; +import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.DEPARTURE_UPCOMING; +import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.NEW_MESSAGE; +import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.PARTICIPANT_JOINED; +import static in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType.RECRUITMENT_COMPLETE; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import in.koreatech.koin.common.model.MobileAppPath; + +@Component +public class CallvanPushNotificationFactory { + + public CallvanPushNotification from(CallvanNotification notification) { + MobileAppPath path = notification.getNotificationType() == NEW_MESSAGE ? CALLVAN_CHAT : CALLVAN; + String schemeUri = generateSchemeUri(notification); + String title = generateTitle(notification); + String message = generateMessage(notification); + + return new CallvanPushNotification( + path, + schemeUri, + title, + message, + null, + notification.getNotificationType().name().toLowerCase(), + notification.getRecipient() + ); + } + + private String generateTitle(CallvanNotification notification) { + return switch (notification.getNotificationType()) { + case NEW_MESSAGE -> "콜벤팟 %s님의 메시지".formatted(notification.getSenderNickname()); + case PARTICIPANT_JOINED -> "콜벤팟 새 참여자"; + case RECRUITMENT_COMPLETE -> "콜벤팟 인원 모집 완료"; + case DEPARTURE_UPCOMING -> "콜벤팟 출발 30분 전"; + case REPORT_WARNING, REPORT_RESTRICTION_14_DAYS, REPORT_PERMANENT_RESTRICTION -> "콜벤팟 이용 안내"; + }; + } + + private String generateMessage(CallvanNotification notification) { + return switch (notification.getNotificationType()) { + case PARTICIPANT_JOINED -> "%s님이 콜벤팟에 참여했어요".formatted(notification.getJoinedMemberNickname()); + case DEPARTURE_UPCOMING -> "%s -> %s 콜벤팟이 30분 뒤 출발해요".formatted( + getLocationName(notification.getDepartureCustomName(), notification.getDepartureType().getName()), + getLocationName(notification.getArrivalCustomName(), notification.getArrivalType().getName()) + ); + case NEW_MESSAGE, RECRUITMENT_COMPLETE, REPORT_WARNING, REPORT_RESTRICTION_14_DAYS, + REPORT_PERMANENT_RESTRICTION -> notification.getMessagePreview(); + }; + } + + private String generateSchemeUri(CallvanNotification notification) { + Integer postId = notification.getPost() != null ? notification.getPost().getId() : null; + if (notification.getNotificationType() == NEW_MESSAGE) { + return "callvan-chat?postId=%d&chatRoomId=%d".formatted(postId, notification.getChatRoom().getId()); + } + return "callvan?id=%d".formatted(postId); + } + + private String getLocationName(String customName, String defaultName) { + if (StringUtils.hasText(customName)) { + return customName; + } + return defaultName; + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanNotificationRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanNotificationRepository.java index f782cf2f8e..e4fc1fb734 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanNotificationRepository.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanNotificationRepository.java @@ -13,6 +13,17 @@ public interface CallvanNotificationRepository extends JpaRepository findAllByRecipientIdOrderByCreatedAtDesc(Integer recipientId); + @Query(""" + SELECT DISTINCT n + FROM CallvanNotification n + JOIN FETCH n.recipient + LEFT JOIN FETCH n.post + LEFT JOIN FETCH n.chatRoom + WHERE n.id IN :notificationIds + AND n.isDeleted = false + """) + List findAllByIdInWithRelations(@Param("notificationIds") List notificationIds); + @Modifying(clearAutomatically = true) @Query("UPDATE CallvanNotification n SET n.isRead = true WHERE n.recipient.id = :recipientId AND n.isDeleted = false") void updateIsReadByRecipientId(@Param("recipientId") Integer recipientId); diff --git a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportProcessRepository.java b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportProcessRepository.java index 74c4469ca0..587c885ef1 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportProcessRepository.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/repository/CallvanReportProcessRepository.java @@ -2,6 +2,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -18,7 +19,7 @@ public interface CallvanReportProcessRepository extends Repository 0 THEN true ELSE false END + SELECT process FROM CallvanReportProcess process WHERE process.report.reported.id = :userId AND process.isDeleted = false @@ -30,9 +31,24 @@ SELECT CASE WHEN COUNT(process) > 0 THEN true ELSE false END AND process.restrictedUntil >= :now ) ) + ORDER BY CASE + WHEN process.processType = in.koreatech.koin.domain.callvan.model.enums.CallvanReportProcessType.PERMANENT_RESTRICTION + THEN 0 + ELSE 1 + END ASC, + process.createdAt DESC, + process.id DESC """) - boolean existsActiveRestrictionByReportedUserId( + List findAllActiveRestrictionsByReportedUserId( @Param("userId") Integer userId, @Param("now") LocalDateTime now ); + + default Optional findActiveRestrictionByReportedUserId(Integer userId, LocalDateTime now) { + return findAllActiveRestrictionsByReportedUserId(userId, now).stream().findFirst(); + } + + default boolean existsActiveRestrictionByReportedUserId(Integer userId, LocalDateTime now) { + return findActiveRestrictionByReportedUserId(userId, now).isPresent(); + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationScheduler.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationScheduler.java index 0b5994db4d..b60f05fca0 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationScheduler.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationScheduler.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Set; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -14,6 +15,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import in.koreatech.koin.domain.callvan.event.CallvanPushNotificationEvent; import in.koreatech.koin.domain.callvan.model.CallvanNotification; import in.koreatech.koin.domain.callvan.model.CallvanPost; import in.koreatech.koin.domain.callvan.model.enums.CallvanNotificationType; @@ -29,12 +31,14 @@ @RequiredArgsConstructor public class CallvanNotificationScheduler { + private static final String NOTIFICATION_QUEUE_KEY = "callvan:notification:queue"; + private final CallvanPostRepository callvanPostRepository; private final CallvanNotificationRepository callvanNotificationRepository; private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; + private final ApplicationEventPublisher eventPublisher; private final ZoneId systemZone = ZoneId.of("Asia/Seoul"); - private static final String NOTIFICATION_QUEUE_KEY = "callvan:notification:queue"; public void scheduleNotification(CallvanPost post) { LocalDateTime departureTime = LocalDateTime.of( @@ -45,8 +49,10 @@ public void scheduleNotification(CallvanPost post) { LocalDateTime now = LocalDateTime.now(); if (notificationTime.isBefore(now) || notificationTime.isEqual(now)) { - log.info("콜벤팟 알림 시간이 이미 지나서 스케줄링하지 않음 - postId: {}, notificationTime: {}", - post.getId(), notificationTime + log.info( + "콜벤팟 알림 시간이 이미 지나서 스케줄링하지 않음 - postId: {}, notificationTime: {}", + post.getId(), + notificationTime ); return; } @@ -71,9 +77,7 @@ public void scheduleNotification(CallvanPost post) { public void processScheduledNotifications() { long now = ZonedDateTime.now(systemZone).toEpochSecond(); - Set tasks = redisTemplate.opsForZSet() - .rangeByScore(NOTIFICATION_QUEUE_KEY, 0, now); - + Set tasks = redisTemplate.opsForZSet().rangeByScore(NOTIFICATION_QUEUE_KEY, 0, now); if (tasks == null || tasks.isEmpty()) { return; } @@ -82,7 +86,6 @@ public void processScheduledNotifications() { try { CallvanNotificationTask task = objectMapper.readValue(taskJson, CallvanNotificationTask.class); processNotification(task); - redisTemplate.opsForZSet().remove(NOTIFICATION_QUEUE_KEY, taskJson); } catch (Exception e) { log.info("콜벤팟 알림 작업 처리 실패 : {}", e.getMessage()); @@ -91,9 +94,7 @@ public void processScheduledNotifications() { } private void processNotification(CallvanNotificationTask task) { - CallvanPost post = callvanPostRepository.findById(task.getPostId()) - .orElse(null); - + CallvanPost post = callvanPostRepository.findById(task.getPostId()).orElse(null); if (post == null || post.getIsDeleted()) { return; } @@ -115,12 +116,25 @@ private void processNotification(CallvanNotificationTask task) { .build()) .toList(); - callvanNotificationRepository.saveAll(notifications); + List savedNotifications = callvanNotificationRepository.saveAllAndFlush(notifications); + publishPushNotifications(savedNotifications); + } + + private void publishPushNotifications(List notifications) { + if (notifications.isEmpty()) { + return; + } + + List notificationIds = notifications.stream() + .map(CallvanNotification::getId) + .toList(); + eventPublisher.publishEvent(new CallvanPushNotificationEvent(notificationIds)); } @Builder @Getter private static class CallvanNotificationTask { + private Integer postId; private CallvanNotificationType type; } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java index 87d8a19c51..5650f0d9b5 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanNotificationService.java @@ -2,10 +2,12 @@ import java.util.List; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.callvan.dto.CallvanNotificationResponse; +import in.koreatech.koin.domain.callvan.event.CallvanPushNotificationEvent; import in.koreatech.koin.domain.callvan.model.CallvanNotification; import in.koreatech.koin.domain.callvan.model.CallvanPost; import in.koreatech.koin.domain.callvan.model.enums.CallvanMessageType; @@ -27,9 +29,11 @@ public class CallvanNotificationService { private static final String CALLVAN_WARNING_MESSAGE = "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 주의 안내가 전달되었습니다. 이후 동일한 문제가 반복될 경우 콜벤 기능 이용이 제한될 수 있습니다."; private static final String CALLVAN_RESTRICTION_14_DAYS_MESSAGE = "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 14일간 콜벤 기능 이용이 제한되었습니다."; private static final String CALLVAN_PERMANENT_RESTRICTION_MESSAGE = "콜벤팟 이용 과정에서 신고가 접수되어 운영 검토 후 콜벤 기능 이용이 영구적으로 제한되었습니다."; + private final CallvanPostRepository callvanPostRepository; private final CallvanNotificationRepository callvanNotificationRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; public List getNotifications(Integer userId) { return callvanNotificationRepository.findAllByRecipientIdOrderByCreatedAtDesc(userId).stream() @@ -63,12 +67,19 @@ public void notifyRecruitmentCompleted(Integer postId) { List notifications = post.getParticipants().stream() .filter(p -> !p.getIsDeleted()) - .map(p -> buildNotification(p.getMember(), CallvanNotificationType.RECRUITMENT_COMPLETE, post, - null, "해당 콜벤팟 인원이 모두 모집되었습니다. 콜벤을 예약하세요", null)) + .map(p -> buildNotification( + p.getMember(), + CallvanNotificationType.RECRUITMENT_COMPLETE, + post, + null, + "해당 콜벤팟 인원이 모두 모집되었습니다. 콜벤을 예약하세요", + null + )) .toList(); if (!notifications.isEmpty()) { - callvanNotificationRepository.saveAll(notifications); + List savedNotifications = callvanNotificationRepository.saveAllAndFlush(notifications); + publishPushNotifications(savedNotifications); } } @@ -79,17 +90,28 @@ public void notifyParticipantJoined(Integer postId, Integer joinUserId, String j List notifications = post.getParticipants().stream() .filter(p -> !p.getIsDeleted()) .filter(p -> !p.getMember().getId().equals(joinUserId)) - .map(p -> buildNotification(p.getMember(), CallvanNotificationType.PARTICIPANT_JOINED, post, - null, null, joinUserNickname)) + .map(p -> buildNotification( + p.getMember(), + CallvanNotificationType.PARTICIPANT_JOINED, + post, + null, + null, + joinUserNickname + )) .toList(); if (!notifications.isEmpty()) { - callvanNotificationRepository.saveAll(notifications); + List savedNotifications = callvanNotificationRepository.saveAllAndFlush(notifications); + publishPushNotifications(savedNotifications); } } @Transactional - public void notifyNewMessageReceived(Integer postId, Integer senderId, String senderNickname, String messageContent, + public void notifyNewMessageReceived( + Integer postId, + Integer senderId, + String senderNickname, + String messageContent, CallvanMessageType messageType ) { CallvanPost post = callvanPostRepository.getById(postId); @@ -98,12 +120,19 @@ public void notifyNewMessageReceived(Integer postId, Integer senderId, String se List notifications = post.getParticipants().stream() .filter(p -> !p.getIsDeleted()) .filter(p -> !p.getMember().getId().equals(senderId)) - .map(p -> buildNotification(p.getMember(), CallvanNotificationType.NEW_MESSAGE, post, - senderNickname, notificationContent, null)) + .map(p -> buildNotification( + p.getMember(), + CallvanNotificationType.NEW_MESSAGE, + post, + senderNickname, + notificationContent, + null + )) .toList(); if (!notifications.isEmpty()) { - callvanNotificationRepository.saveAll(notifications); + List savedNotifications = callvanNotificationRepository.saveAllAndFlush(notifications); + publishPushNotifications(savedNotifications); } } @@ -126,14 +155,20 @@ public void notifyReportSanction(Integer recipientId, Integer postId, CallvanRep default -> throw CustomException.of(ApiResponseCode.ILLEGAL_ARGUMENT); }; - CallvanNotification callvanNotification = buildNotification( - recipient, notificationType, post, null, message, null); - - callvanNotificationRepository.save(callvanNotification); + CallvanNotification callvanNotification = buildNotification(recipient, notificationType, post, null, message, + null); + CallvanNotification savedNotification = callvanNotificationRepository.saveAndFlush(callvanNotification); + publishPushNotifications(List.of(savedNotification)); } - private CallvanNotification buildNotification(User recipient, CallvanNotificationType type, CallvanPost post, - String senderNickname, String messagePreview, String joinedMemberNickname) { + private CallvanNotification buildNotification( + User recipient, + CallvanNotificationType type, + CallvanPost post, + String senderNickname, + String messagePreview, + String joinedMemberNickname + ) { return CallvanNotification.builder() .recipient(recipient) .notificationType(type) @@ -152,4 +187,15 @@ private CallvanNotification buildNotification(User recipient, CallvanNotificatio .joinedMemberNickname(joinedMemberNickname) .build(); } + + private void publishPushNotifications(List notifications) { + if (notifications.isEmpty()) { + return; + } + + List notificationIds = notifications.stream() + .map(CallvanNotification::getId) + .toList(); + eventPublisher.publishEvent(new CallvanPushNotificationEvent(notificationIds)); + } } diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationEventListener.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationEventListener.java new file mode 100644 index 0000000000..7e4a881077 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationEventListener.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.callvan.service; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import in.koreatech.koin.domain.callvan.event.CallvanPushNotificationEvent; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CallvanPushNotificationEventListener { + + private final CallvanPushNotificationService callvanPushNotificationService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCallvanPushRequested(CallvanPushNotificationEvent event) { + callvanPushNotificationService.pushNotifications(event.notificationIds()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationService.java new file mode 100644 index 0000000000..2b21c79d46 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanPushNotificationService.java @@ -0,0 +1,57 @@ +package in.koreatech.koin.domain.callvan.service; + +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.CALLVAN; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import in.koreatech.koin.domain.callvan.model.CallvanNotification; +import in.koreatech.koin.domain.callvan.model.CallvanPushNotification; +import in.koreatech.koin.domain.callvan.model.CallvanPushNotificationFactory; +import in.koreatech.koin.domain.callvan.repository.CallvanNotificationRepository; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.infrastructure.fcm.FcmClient; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanPushNotificationService { + + private final CallvanNotificationRepository callvanNotificationRepository; + private final NotificationSubscribeRepository notificationSubscribeRepository; + private final CallvanPushNotificationFactory callvanPushNotificationFactory; + private final FcmClient fcmClient; + + public void pushNotifications(List notificationIds) { + if (notificationIds.isEmpty()) { + return; + } + + List notifications = callvanNotificationRepository.findAllByIdInWithRelations(notificationIds); + notifications.forEach(this::pushNotificationIfEligible); + } + + private void pushNotificationIfEligible(CallvanNotification notification) { + String deviceToken = notification.getRecipient().getDeviceToken(); + Integer recipientId = notification.getRecipient().getId(); + if (!StringUtils.hasText(deviceToken) + || !notificationSubscribeRepository.existsByUserIdAndSubscribeTypeAndDetailTypeIsNull(recipientId, CALLVAN)) { + return; + } + + CallvanPushNotification pushNotification = callvanPushNotificationFactory.from(notification); + fcmClient.sendMessage( + deviceToken, + pushNotification.title(), + pushNotification.message(), + pushNotification.imageUrl(), + pushNotification.mobileAppPath(), + pushNotification.schemeUri(), + pushNotification.type() + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionQueryService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionQueryService.java new file mode 100644 index 0000000000..3bf031fbce --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionQueryService.java @@ -0,0 +1,28 @@ +package in.koreatech.koin.domain.callvan.service; + +import java.time.Clock; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.callvan.dto.CallvanRestrictionResponse; +import in.koreatech.koin.domain.callvan.repository.CallvanReportProcessRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CallvanRestrictionQueryService { + + private final CallvanReportProcessRepository callvanReportProcessRepository; + private final Clock clock; + + public CallvanRestrictionResponse getRestriction(Integer userId) { + LocalDateTime now = LocalDateTime.now(clock); + + return callvanReportProcessRepository.findActiveRestrictionByReportedUserId(userId, now) + .map(CallvanRestrictionResponse::from) + .orElseGet(CallvanRestrictionResponse::unrestricted); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionService.java b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionService.java index 6e65c43f39..b9b022b1f5 100644 --- a/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionService.java +++ b/src/main/java/in/koreatech/koin/domain/callvan/service/CallvanRestrictionService.java @@ -1,5 +1,6 @@ package in.koreatech.koin.domain.callvan.service; +import java.time.Clock; import java.time.LocalDateTime; import org.springframework.stereotype.Service; @@ -16,11 +17,12 @@ public class CallvanRestrictionService { private final CallvanReportProcessRepository callvanReportProcessRepository; + private final Clock clock; public void validateNotRestricted(Integer userId) { boolean isRestricted = callvanReportProcessRepository.existsActiveRestrictionByReportedUserId( userId, - LocalDateTime.now() + LocalDateTime.now(clock) ); if (isRestricted) { diff --git a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribeType.java b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribeType.java index 7366548a9f..ff4e50cf9c 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribeType.java +++ b/src/main/java/in/koreatech/koin/domain/notification/model/NotificationSubscribeType.java @@ -18,6 +18,7 @@ public enum NotificationSubscribeType { DINING_IMAGE_UPLOAD(List.of()), ARTICLE_KEYWORD(List.of()), LOST_ITEM_CHAT(List.of()), + CALLVAN(List.of()), MARKETING(List.of()), ; diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/NotificationApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/NotificationApiTest.java index d551962ad6..b526a0bbbf 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/NotificationApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/NotificationApiTest.java @@ -126,6 +126,13 @@ void setUp() { \s ] }, + { + "type": "CALLVAN", + "is_permit": false, + "detail_subscribes": [ + \s + ] + }, { "type": "MARKETING", "is_permit": false, @@ -251,6 +258,13 @@ void setUp() { \s ] }, + { + "type": "CALLVAN", + "is_permit": false, + "detail_subscribes": [ + \s + ] + }, { "type": "MARKETING", "is_permit": false, @@ -376,6 +390,13 @@ void setUp() { \s ] }, + { + "type": "CALLVAN", + "is_permit": false, + "detail_subscribes": [ + \s + ] + }, { "type": "MARKETING", "is_permit": false, @@ -509,6 +530,13 @@ void setUp() { \s ] }, + { + "type": "CALLVAN", + "is_permit": false, + "detail_subscribes": [ + \s + ] + }, { "type": "MARKETING", "is_permit": false, @@ -639,6 +667,13 @@ void setUp() { \s ] }, + { + "type": "CALLVAN", + "is_permit": false, + "detail_subscribes": [ + \s + ] + }, { "type": "MARKETING", "is_permit": false, From ffd78ffe88794ee2c896866e2c288407771e1df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=91=90=ED=98=84?= <96455522+DHkimgit@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:33:29 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EA=B5=90=EB=82=B4=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=EB=AC=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20URL=20?= =?UTF-8?q?=EC=84=9C=EB=B9=99=20(#2227)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/domain/coopshop/dto/CoopShopResponse.java | 4 ++++ .../koin/domain/coopshop/dto/CoopShopsResponse.java | 8 ++++++-- .../in/koreatech/koin/domain/coopshop/model/CoopName.java | 3 +++ .../resources/db/migration/V234__add_coopshop_icon.sql | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V234__add_coopshop_icon.sql diff --git a/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopResponse.java b/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopResponse.java index 28235aa242..67e4a0f2c8 100644 --- a/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopResponse.java +++ b/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopResponse.java @@ -36,6 +36,9 @@ public record CoopShopResponse( @Schema(example = "공휴일 휴무", description = "생협 매장 특이사항") String remarks, + @Schema(example = "https://kap-test.s3.ap-northeast-2.amazonaws.com/assets/img/coopshop/bookstore.svg", description = "아이콘 url") + String iconUrl, + @JsonFormat(pattern = "yyyy-MM-dd") @Schema(example = "2024-06-26", description = "학식 운영시간 업데이트 날짜", requiredMode = REQUIRED) LocalDateTime updatedAt @@ -52,6 +55,7 @@ public static CoopShopResponse from(CoopShop coopShop) { coopShop.getPhone(), coopShop.getLocation(), coopShop.getRemarks(), + coopShop.getCoopName().getIconUrl(), coopShop.getUpdatedAt() ); } diff --git a/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopsResponse.java b/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopsResponse.java index 39c3a63628..741627c2df 100644 --- a/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopsResponse.java +++ b/src/main/java/in/koreatech/koin/domain/coopshop/dto/CoopShopsResponse.java @@ -66,7 +66,10 @@ public record InnerCoopShop( String location, @Schema(example = "공휴일 휴무", description = "생협 매장 특이사항") - String remarks + String remarks, + + @Schema(example = "https://kap-test.s3.ap-northeast-2.amazonaws.com/assets/img/coopshop/bookstore.svg", description = "아이콘 url") + String iconUrl ) { public static InnerCoopShop from(CoopShop coopShop) { @@ -78,7 +81,8 @@ public static InnerCoopShop from(CoopShop coopShop) { .toList(), coopShop.getPhone(), coopShop.getLocation(), - coopShop.getRemarks() + coopShop.getRemarks(), + coopShop.getCoopName().getIconUrl() ); } diff --git a/src/main/java/in/koreatech/koin/domain/coopshop/model/CoopName.java b/src/main/java/in/koreatech/koin/domain/coopshop/model/CoopName.java index 22ad8c3eec..ccaee7e2fc 100644 --- a/src/main/java/in/koreatech/koin/domain/coopshop/model/CoopName.java +++ b/src/main/java/in/koreatech/koin/domain/coopshop/model/CoopName.java @@ -27,6 +27,9 @@ public class CoopName { @Column(name = "name", nullable = false) private String name; + @Column(name = "icon_url") + private String iconUrl; + @Builder private CoopName( String name diff --git a/src/main/resources/db/migration/V234__add_coopshop_icon.sql b/src/main/resources/db/migration/V234__add_coopshop_icon.sql new file mode 100644 index 0000000000..d97d1ed7b7 --- /dev/null +++ b/src/main/resources/db/migration/V234__add_coopshop_icon.sql @@ -0,0 +1 @@ +ALTER TABLE `coop_names` ADD `icon_url` VARCHAR(500) NULL AFTER `name`; From 9ef87fa8c3236d8ccb386d65058c6b129efcb38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 14 May 2026 13:56:29 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EC=83=81=EC=A0=90=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=ED=98=9C=ED=83=9D=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상점 soft delete 시 남아 있는 혜택 매핑을 함께 삭제한다 - 삭제된 상점의 혜택 매핑이 조회 오류로 이어지지 않도록 데이터 정합성을 맞춘다 - 상점 삭제 API 회귀 테스트에 혜택 매핑 삭제 검증을 추가한다 --- .../AdminBenefitCategoryMapRepository.java | 7 ++++++ .../admin/shop/service/AdminShopService.java | 3 +++ .../acceptance/admin/AdminShopApiTest.java | 25 +++++++++++++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java index 56125d12b8..721a9d4ccf 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java @@ -45,6 +45,13 @@ void deleteByBenefitCategoryIdAndShopIds( @Modifying @Query(""" DELETE FROM BenefitCategoryMap bcm + WHERE bcm.shop.id = :shopId + """) + void deleteByShopId(@Param("shopId") Integer shopId); + + @Modifying + @Query(""" + DELETE FROM BenefitCategoryMap bcm WHERE bcm.benefitCategory.id = :benefitId """) void deleteByBenefitCategoryId(@Param("benefitId") Integer benefitId); diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index 630811fe3e..e2ff22d2e7 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; import in.koreatech.koin.admin.shop.dto.shop.AdminCreateShopCategoryRequest; import in.koreatech.koin.admin.shop.dto.shop.AdminCreateShopRequest; import in.koreatech.koin.admin.shop.dto.shop.AdminModifyShopCategoriesOrderRequest; @@ -57,6 +58,7 @@ public class AdminShopService { private final AdminShopCategoryRepository adminShopCategoryRepository; private final AdminShopCategoryMapRepository adminShopCategoryMapRepository; private final AdminShopParentCategoryRepository adminShopParentCategoryRepository; + private final AdminBenefitCategoryMapRepository adminBenefitCategoryMapRepository; public AdminShopsResponse getShops(Integer page, Integer limit, Boolean isDeleted) { Integer total = adminShopRepository.countAllByIsDeleted(isDeleted); @@ -221,6 +223,7 @@ public void modifyShopCategoriesOrder(AdminModifyShopCategoriesOrderRequest requ @RefreshShopsCache public void deleteShop(Integer shopId) { Shop shop = adminShopRepository.getById(shopId); + adminBenefitCategoryMapRepository.deleteByShopId(shopId); shop.delete(); } diff --git a/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java index 7b40b5aa4d..d0accb3d79 100644 --- a/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java @@ -17,6 +17,8 @@ import org.springframework.transaction.support.TransactionTemplate; import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.acceptance.fixture.BenefitCategoryAcceptanceFixture; +import in.koreatech.koin.acceptance.fixture.BenefitCategoryMapAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.MenuAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.MenuCategoryAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.ShopAcceptanceFixture; @@ -24,12 +26,14 @@ import in.koreatech.koin.acceptance.fixture.ShopNotificationMessageAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.ShopParentCategoryAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; +import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; +import in.koreatech.koin.admin.manager.model.Admin; import in.koreatech.koin.admin.shop.repository.menu.AdminMenuCategoryRepository; import in.koreatech.koin.admin.shop.repository.menu.AdminMenuRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopCategoryRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopParentCategoryRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopRepository; -import in.koreatech.koin.admin.manager.model.Admin; +import in.koreatech.koin.domain.benefit.model.BenefitCategory; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.menu.Menu; import in.koreatech.koin.domain.shop.model.menu.MenuCategory; @@ -62,6 +66,9 @@ class AdminShopApiTest extends AcceptanceTest { @Autowired private AdminShopRepository adminShopRepository; + @Autowired + private AdminBenefitCategoryMapRepository adminBenefitCategoryMapRepository; + @Autowired private AdminMenuRepository adminMenuRepository; @@ -89,6 +96,12 @@ class AdminShopApiTest extends AcceptanceTest { @Autowired private MenuCategoryAcceptanceFixture menuCategoryFixture; + @Autowired + private BenefitCategoryAcceptanceFixture benefitCategoryFixture; + + @Autowired + private BenefitCategoryMapAcceptanceFixture benefitCategoryMapFixture; + private Owner owner_현수; private Owner owner_준영; private Shop shop_마슬랜; @@ -935,6 +948,8 @@ void setUp() { @Test void 어드민이_상점을_삭제한다() throws Exception { Shop shop = shopFixture.영업중이_아닌_신전_떡볶이(owner_현수); + BenefitCategory benefitCategory = benefitCategoryFixture.배달비_무료(); + benefitCategoryMapFixture.혜택_추가(shop, benefitCategory); mockMvc.perform( delete("/admin/shops/{id}", shop.getId()) @@ -943,7 +958,13 @@ void setUp() { .andExpect(status().isOk()); Shop deletedShop = adminShopRepository.getById(shop.getId()); - assertSoftly(softly -> softly.assertThat(deletedShop.isDeleted()).isTrue()); + assertSoftly(softly -> { + softly.assertThat(deletedShop.isDeleted()).isTrue(); + softly.assertThat(adminBenefitCategoryMapRepository.findAllByBenefitCategoryIdAndShopIds( + benefitCategory.getId(), + List.of(shop.getId()) + )).isEmpty(); + }); } @Test From 0fe6ef3a7ffed5e2d2a44f124c4ce689aa0a7e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 14 May 2026 14:41:04 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EC=83=81=EC=A0=90=20=ED=98=9C=ED=83=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 매핑 데이터가 남아 있어도 삭제된 상점은 혜택 상점 조회에서 제외한다 - 상점 목록의 혜택 상세 조회도 삭제되지 않은 상점 매핑만 반영하도록 맞춘다 - 삭제된 상점 매핑이 남아 있는 회귀 케이스를 추가해 조회 오류를 방지한다 --- .../BenefitCategoryMapRepository.java | 10 +++++++++- .../benefit/service/ShopBenefitService.java | 3 ++- .../koin/acceptance/domain/BenefitApiTest.java | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java b/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java index 1555fc1731..3115293a37 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/repository/BenefitCategoryMapRepository.java @@ -4,17 +4,25 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.benefit.model.BenefitCategoryMap; public interface BenefitCategoryMapRepository extends Repository { - List findByBenefitCategoryId(Integer benefitCategoryId); + @Query(""" + SELECT bcm FROM BenefitCategoryMap bcm + JOIN FETCH bcm.shop s + WHERE bcm.benefitCategory.id = :benefitCategoryId + AND s.isDeleted = false + """) + List findActiveShopMapsByBenefitCategoryId(@Param("benefitCategoryId") Integer benefitCategoryId); @Query(""" SELECT bcm FROM BenefitCategoryMap bcm JOIN FETCH bcm.shop s JOIN FETCH bcm.benefitCategory bc + WHERE s.isDeleted = false """) List findAllWithFetchJoin(); diff --git a/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java b/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java index 9d41fb04fd..b9aae96dec 100644 --- a/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java +++ b/src/main/java/in/koreatech/koin/domain/benefit/service/ShopBenefitService.java @@ -35,7 +35,8 @@ public BenefitCategoryResponse getBenefitCategories() { } public BenefitShopsResponse getBenefitShops(Integer benefitId) { - List benefitCategoryMaps = benefitCategoryMapRepository.findByBenefitCategoryId(benefitId); + List benefitCategoryMaps = + benefitCategoryMapRepository.findActiveShopMapsByBenefitCategoryId(benefitId); LocalDateTime now = LocalDateTime.now(clock); List innerShopResponses = benefitCategoryMaps.stream() diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/BenefitApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/BenefitApiTest.java index 8975e05bf9..afc29ebd52 100644 --- a/src/test/java/in/koreatech/koin/acceptance/domain/BenefitApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/domain/BenefitApiTest.java @@ -101,6 +101,23 @@ void setup() { benefitCategoryMapFixture.혜택_추가(마슬랜, 배달비_무료); benefitCategoryMapFixture.혜택_추가(영업중인_티바, 배달비_무료); benefitCategoryMapFixture.혜택_추가(영업중이_아닌_신전_떡볶이, 배달비_무료); + Shop 삭제된_굿모닝살로만치킨 = shopFixture.builder() + .owner(현수_사장님) + .name("굿모닝살로만치킨") + .internalName("굿모닝살로만치킨") + .phone("010-0000-0000") + .address("천안시 동남구 병천면 1600") + .description("삭제된 상점입니다.") + .delivery(true) + .deliveryPrice(3000) + .payCard(true) + .payBank(true) + .isDeleted(true) + .isEvent(false) + .remarks("비고") + .hit(0) + .build(); + benefitCategoryMapFixture.설명이_포함된_혜택_추가(삭제된_굿모닝살로만치킨, 배달비_무료, "배달비 무료"); shopReviewFixture.리뷰_4점(성빈_학생, 마슬랜); shopReviewFixture.리뷰_4점(성빈_학생, 영업중인_티바); From 1f814f940f068eacde15c1e00d188c81b76639e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 14 May 2026 14:49:32 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=ED=98=9C=ED=83=9D=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EC=82=AD=EC=A0=9C=20=EC=B2=98=EB=A6=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상점 soft delete 시 혜택 매핑을 삭제하지 않도록 되돌린다 - 기존 매핑 데이터는 조회 단계의 삭제 상점 필터링으로 방어한다 - 상점 삭제 API 테스트는 soft delete 상태만 검증하도록 복원한다 --- .../AdminBenefitCategoryMapRepository.java | 7 ------ .../admin/shop/service/AdminShopService.java | 3 --- .../acceptance/admin/AdminShopApiTest.java | 23 +------------------ 3 files changed, 1 insertion(+), 32 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java index 721a9d4ccf..79c5b51ca5 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java @@ -42,13 +42,6 @@ void deleteByBenefitCategoryIdAndShopIds( @Param("benefitId") Integer benefitId, @Param("shopIds") List shopIds); - @Modifying - @Query(""" - DELETE FROM BenefitCategoryMap bcm - WHERE bcm.shop.id = :shopId - """) - void deleteByShopId(@Param("shopId") Integer shopId); - @Modifying @Query(""" DELETE FROM BenefitCategoryMap bcm diff --git a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java index e2ff22d2e7..630811fe3e 100644 --- a/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java +++ b/src/main/java/in/koreatech/koin/admin/shop/service/AdminShopService.java @@ -15,7 +15,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; import in.koreatech.koin.admin.shop.dto.shop.AdminCreateShopCategoryRequest; import in.koreatech.koin.admin.shop.dto.shop.AdminCreateShopRequest; import in.koreatech.koin.admin.shop.dto.shop.AdminModifyShopCategoriesOrderRequest; @@ -58,7 +57,6 @@ public class AdminShopService { private final AdminShopCategoryRepository adminShopCategoryRepository; private final AdminShopCategoryMapRepository adminShopCategoryMapRepository; private final AdminShopParentCategoryRepository adminShopParentCategoryRepository; - private final AdminBenefitCategoryMapRepository adminBenefitCategoryMapRepository; public AdminShopsResponse getShops(Integer page, Integer limit, Boolean isDeleted) { Integer total = adminShopRepository.countAllByIsDeleted(isDeleted); @@ -223,7 +221,6 @@ public void modifyShopCategoriesOrder(AdminModifyShopCategoriesOrderRequest requ @RefreshShopsCache public void deleteShop(Integer shopId) { Shop shop = adminShopRepository.getById(shopId); - adminBenefitCategoryMapRepository.deleteByShopId(shopId); shop.delete(); } diff --git a/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java index d0accb3d79..ef304c0d6a 100644 --- a/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java @@ -17,8 +17,6 @@ import org.springframework.transaction.support.TransactionTemplate; import in.koreatech.koin.acceptance.AcceptanceTest; -import in.koreatech.koin.acceptance.fixture.BenefitCategoryAcceptanceFixture; -import in.koreatech.koin.acceptance.fixture.BenefitCategoryMapAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.MenuAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.MenuCategoryAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.ShopAcceptanceFixture; @@ -26,14 +24,12 @@ import in.koreatech.koin.acceptance.fixture.ShopNotificationMessageAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.ShopParentCategoryAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; -import in.koreatech.koin.admin.benefit.repository.AdminBenefitCategoryMapRepository; import in.koreatech.koin.admin.manager.model.Admin; import in.koreatech.koin.admin.shop.repository.menu.AdminMenuCategoryRepository; import in.koreatech.koin.admin.shop.repository.menu.AdminMenuRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopCategoryRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopParentCategoryRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopRepository; -import in.koreatech.koin.domain.benefit.model.BenefitCategory; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.menu.Menu; import in.koreatech.koin.domain.shop.model.menu.MenuCategory; @@ -66,9 +62,6 @@ class AdminShopApiTest extends AcceptanceTest { @Autowired private AdminShopRepository adminShopRepository; - @Autowired - private AdminBenefitCategoryMapRepository adminBenefitCategoryMapRepository; - @Autowired private AdminMenuRepository adminMenuRepository; @@ -96,12 +89,6 @@ class AdminShopApiTest extends AcceptanceTest { @Autowired private MenuCategoryAcceptanceFixture menuCategoryFixture; - @Autowired - private BenefitCategoryAcceptanceFixture benefitCategoryFixture; - - @Autowired - private BenefitCategoryMapAcceptanceFixture benefitCategoryMapFixture; - private Owner owner_현수; private Owner owner_준영; private Shop shop_마슬랜; @@ -948,8 +935,6 @@ void setUp() { @Test void 어드민이_상점을_삭제한다() throws Exception { Shop shop = shopFixture.영업중이_아닌_신전_떡볶이(owner_현수); - BenefitCategory benefitCategory = benefitCategoryFixture.배달비_무료(); - benefitCategoryMapFixture.혜택_추가(shop, benefitCategory); mockMvc.perform( delete("/admin/shops/{id}", shop.getId()) @@ -958,13 +943,7 @@ void setUp() { .andExpect(status().isOk()); Shop deletedShop = adminShopRepository.getById(shop.getId()); - assertSoftly(softly -> { - softly.assertThat(deletedShop.isDeleted()).isTrue(); - softly.assertThat(adminBenefitCategoryMapRepository.findAllByBenefitCategoryIdAndShopIds( - benefitCategory.getId(), - List.of(shop.getId()) - )).isEmpty(); - }); + assertSoftly(softly -> softly.assertThat(deletedShop.isDeleted()).isTrue()); } @Test From ac1a3a168aae02b0209563e7a98bb398ccf7c911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=9B=88?= <2dh2@naver.com> Date: Thu, 14 May 2026 14:51:33 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=82=AD=EC=A0=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=9C=EA=B1=B0=20=EB=B2=94=EC=9C=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 혜택 매핑 삭제 처리 제거 과정에서 남은 import 순서와 공백 변경을 되돌린다 - 최종 변경 범위를 삭제된 상점 혜택 조회 필터링으로 한정한다 --- .../benefit/repository/AdminBenefitCategoryMapRepository.java | 2 +- .../in/koreatech/koin/acceptance/admin/AdminShopApiTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java index 79c5b51ca5..56125d12b8 100644 --- a/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java +++ b/src/main/java/in/koreatech/koin/admin/benefit/repository/AdminBenefitCategoryMapRepository.java @@ -44,7 +44,7 @@ void deleteByBenefitCategoryIdAndShopIds( @Modifying @Query(""" - DELETE FROM BenefitCategoryMap bcm + DELETE FROM BenefitCategoryMap bcm WHERE bcm.benefitCategory.id = :benefitId """) void deleteByBenefitCategoryId(@Param("benefitId") Integer benefitId); diff --git a/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java b/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java index ef304c0d6a..7b40b5aa4d 100644 --- a/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java +++ b/src/test/java/in/koreatech/koin/acceptance/admin/AdminShopApiTest.java @@ -24,12 +24,12 @@ import in.koreatech.koin.acceptance.fixture.ShopNotificationMessageAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.ShopParentCategoryAcceptanceFixture; import in.koreatech.koin.acceptance.fixture.UserAcceptanceFixture; -import in.koreatech.koin.admin.manager.model.Admin; import in.koreatech.koin.admin.shop.repository.menu.AdminMenuCategoryRepository; import in.koreatech.koin.admin.shop.repository.menu.AdminMenuRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopCategoryRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopParentCategoryRepository; import in.koreatech.koin.admin.shop.repository.shop.AdminShopRepository; +import in.koreatech.koin.admin.manager.model.Admin; import in.koreatech.koin.domain.owner.model.Owner; import in.koreatech.koin.domain.shop.model.menu.Menu; import in.koreatech.koin.domain.shop.model.menu.MenuCategory;