From d701cd87578a718a1b252ed4085755ab7acb8d07 Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:46:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[FEAT/#362]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EB=B0=9C=EC=86=A1=20=EB=B0=8F=20=EB=AC=B8=EC=9D=98?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=80=EC=83=89=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PushTargetType(ALL/STUDENT/UNION/PARTNER/INDIVIDUAL) 기반 그룹 푸시 발송 - BackofficePushLog 엔티티 및 발송 이력 조회 API(GET /backoffice/notifications) 추가 - NotificationCommandService에 자유 메시지 오버로드(title/body/deepLink) 추가 - NotificationType에 BACKOFFICE 타입 추가 - MemberRepository에 역할별 조회(findByRole) 추가 - GET /backoffice/inquiries에 keyword 파라미터 추가 (제목/본문 검색) --- .../BackofficeInquiryController.java | 56 +++++++ .../BackofficeNotificationController.java | 130 ++++++++++++++++ .../dto/BackofficeOutboxResponseDTO.java | 46 ++++++ .../dto/BackofficePushLogResponseDTO.java | 38 +++++ .../dto/BackofficePushSendRequestDTO.java | 27 ++++ .../backoffice/entity/BackofficePushLog.java | 41 ++++++ .../backoffice/entity/PushTargetType.java | 5 + .../BackofficePushLogRepository.java | 13 ++ .../BackofficeNotificationService.java | 21 +++ .../BackofficeNotificationServiceImpl.java | 139 ++++++++++++++++++ .../inquiry/repository/InquiryRepository.java | 11 ++ .../inquiry/service/InquiryService.java | 4 + .../inquiry/service/InquiryServiceImpl.java | 38 +++++ .../member/repository/MemberRepository.java | 2 + .../notification/entity/NotificationType.java | 3 +- .../service/NotificationCommandService.java | 3 + .../NotificationCommandServiceImpl.java | 34 +++++ 17 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/assu/server/domain/backoffice/controller/BackofficeNotificationController.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOutboxResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushLogResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushSendRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/entity/BackofficePushLog.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/entity/PushTargetType.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/repository/BackofficePushLogRepository.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationService.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationServiceImpl.java diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java index 207a010b..c7f78cff 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java @@ -2,18 +2,24 @@ import com.assu.server.domain.backoffice.annotation.BackofficeAudited; import com.assu.server.domain.inquiry.dto.InquiryAnswerRequestDTO; +import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; import com.assu.server.domain.inquiry.service.InquiryService; import com.assu.server.global.apiPayload.BaseResponse; import com.assu.server.global.apiPayload.code.status.SuccessStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import com.assu.server.domain.common.dto.PageResponseDTO; +import com.assu.server.domain.inquiry.entity.Inquiry; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "Backoffice", description = "백오피스 운영 API") @@ -25,6 +31,56 @@ public class BackofficeInquiryController { private final InquiryService inquiryService; + @Operation( + summary = "운영자 전체 문의 목록 조회 API", + description = "# [v1.1 (2026-07-04)]\n" + + "- 모든 사용자의 문의를 페이징으로 조회합니다.\n" + + "- `status` 파라미터로 상태 필터링 가능합니다 (ALL / WAITING / ANSWERED).\n" + + "- `keyword`가 있으면 제목 또는 본문에서 검색합니다.\n\n" + + "**Query Parameters:**\n" + + "- `status` (default: ALL): 문의 상태 필터\n" + + "- `keyword` (optional): 제목 또는 본문 검색어\n" + + "- `page` (default: 1): 페이지 번호\n" + + "- `size` (default: 20): 페이지 크기\n\n" + + "**Response:**\n" + + "- 200(OK): 문의 목록 페이지 반환\n" + + "- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음" + ) + @GetMapping + public BaseResponse> listAllInquiries( + @Parameter(description = "상태 필터 (ALL / WAITING / ANSWERED)", example = "ALL") + @RequestParam(defaultValue = "ALL") Inquiry.StatusFilter status, + @Parameter(description = "제목 또는 본문 검색어") + @RequestParam(required = false) String keyword, + @Parameter(description = "페이지 번호 (1 이상)", example = "1") + @RequestParam(defaultValue = "1") int page, + @Parameter(description = "페이지 크기 (1~200)", example = "20") + @RequestParam(defaultValue = "20") int size + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, inquiryService.getAllInquiries(status, keyword, page, size)); + } + + @Operation( + summary = "운영자 문의 상세 조회 API", + description = "# [v1.0 (2026-06-25)]()\n" + + "- 특정 문의의 상세 내용을 조회합니다.\n" + + "- 소유권 검증 없이 모든 문의에 접근 가능합니다.\n\n" + + "**Path Variable:**\n" + + "- `inquiryId` (Long, required): 문의 ID\n\n" + + "**Response:**\n" + + "- 200(OK): 문의 상세 반환\n" + + "- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + + "- 404(NOT_FOUND): 존재하지 않는 문의 ID" + ) + @GetMapping("/{inquiryId}") + public BaseResponse getInquiry( + @PathVariable("inquiryId") Long inquiryId + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, inquiryService.getById(inquiryId)); + } + @BackofficeAudited(action = "INQUIRY_ANSWER", targetId = "#inquiryId") @Operation( summary = "운영자 문의 답변 API", diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeNotificationController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeNotificationController.java new file mode 100644 index 00000000..ecf75019 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeNotificationController.java @@ -0,0 +1,130 @@ +package com.assu.server.domain.backoffice.controller; + +import com.assu.server.domain.backoffice.annotation.BackofficeAudited; +import com.assu.server.domain.backoffice.dto.BackofficeOutboxResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficePushLogResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficePushSendRequestDTO; +import com.assu.server.domain.backoffice.service.BackofficeNotificationService; +import com.assu.server.domain.common.dto.PageResponseDTO; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Backoffice", description = "백오피스 운영 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/backoffice/notifications") +@PreAuthorize("hasRole('BACKOFFICE')") +public class BackofficeNotificationController { + + private final BackofficeNotificationService backofficeNotificationService; + + @BackofficeAudited(action = "PUSH_SEND", targetId = "#request.receiverId()") + @Operation( + summary = "운영자 수동 푸시 알림 전송 API", + description = "# [v2.0 (2026-07-04)]\n" + + "- 그룹 또는 특정 사용자에게 자유 메시지 푸시 알림을 전송합니다.\n" + + "- `targetType`에 따라 수신자 범위가 결정됩니다.\n\n" + + "**Request Body:**\n" + + "- `targetType` (required): ALL / STUDENT / UNION / PARTNER / INDIVIDUAL\n" + + "- `receiverId` (INDIVIDUAL일 때만 필수): 수신자 멤버 ID\n" + + "- `title` (required): 푸시 제목\n" + + "- `body` (required): 푸시 본문\n" + + "- `deepLink` (optional): 딥링크 URL\n\n" + + "**Response:**\n" + + "- 200(OK): 발송 완료\n" + + "- 400(BAD_REQUEST): 필수 파라미터 누락\n" + + "- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + + "- 404(NOT_FOUND): 존재하지 않는 수신자 멤버 ID" + ) + @PostMapping("/push") + public BaseResponse sendPush( + @RequestBody @Valid BackofficePushSendRequestDTO request, + @AuthenticationPrincipal PrincipalDetails principal + ) { + backofficeNotificationService.sendPush(request, principal.getMemberId()); + return BaseResponse.onSuccess(SuccessStatus._OK, "Push notifications sent successfully. targetType=" + request.targetType()); + } + + @Operation( + summary = "푸시 발송 이력 조회 API", + description = "# [v2.0 (2026-07-04)]\n" + + "- 백오피스에서 발송한 푸시 알림 이력을 페이징으로 조회합니다.\n" + + "- `keyword`가 있으면 제목/본문에서 검색합니다.\n\n" + + "**Query Parameters:**\n" + + "- `keyword` (optional): 제목 또는 본문 검색어\n" + + "- `page` (default: 1): 페이지 번호\n" + + "- `size` (default: 20): 페이지 크기\n\n" + + "**Response:**\n" + + "- 200(OK): 푸시 이력 목록 페이지 반환\n" + + "- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음" + ) + @GetMapping + public BaseResponse> getPushLogs( + @Parameter(description = "제목 또는 본문 검색어") + @RequestParam(required = false) String keyword, + @Parameter(description = "페이지 번호 (1 이상)", example = "1") + @RequestParam(defaultValue = "1") int page, + @Parameter(description = "페이지 크기 (1~200)", example = "20") + @RequestParam(defaultValue = "20") int size + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeNotificationService.getPushLogs(keyword, page, size)); + } + + @Operation( + summary = "전송 실패 알림 목록 조회 API", + description = "# [v1.0 (2026-06-25)]\n" + + "- 전송 상태가 FAILED인 알림 Outbox 목록을 페이징으로 조회합니다.\n" + + "- 자동 재시도 한도(3회)를 초과하여 실패 처리된 알림을 확인할 때 사용합니다.\n\n" + + "**Query Parameters:**\n" + + "- `page` (default: 1): 페이지 번호\n" + + "- `size` (default: 20): 페이지 크기\n\n" + + "**Response:**\n" + + "- 200(OK): 실패 알림 목록 페이지 반환\n" + + "- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음" + ) + @GetMapping("/outbox/failed") + public BaseResponse> getFailedOutboxes( + @Parameter(description = "페이지 번호 (1 이상)", example = "1") + @RequestParam(defaultValue = "1") int page, + @Parameter(description = "페이지 크기 (1~200)", example = "20") + @RequestParam(defaultValue = "20") int size + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeNotificationService.getFailedOutboxes(page, size)); + } + + @BackofficeAudited(action = "PUSH_RETRY", targetId = "#outboxId") + @Operation( + summary = "실패 알림 수동 재전송 API", + description = "# [v1.0 (2026-06-25)]\n" + + "- FAILED 상태의 특정 알림 Outbox를 수동으로 재전송합니다.\n" + + "- 자동 재시도 횟수(retryCount)와 무관하게 재전송 가능합니다.\n" + + "- 재전송 시 Outbox 상태가 PENDING으로 초기화되고 전송 파이프라인에 재등록됩니다.\n\n" + + "**Path Variable:**\n" + + "- `outboxId` (Long, required): 재전송할 Outbox ID\n\n" + + "**Response:**\n" + + "- 200(OK): 재전송 큐 등록 성공\n" + + "- 400(BAD_REQUEST): FAILED 상태가 아닌 Outbox\n" + + "- 401(UNAUTHORIZED): 인증 실패 또는 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + + "- 404(NOT_FOUND): 존재하지 않는 Outbox ID" + ) + @PostMapping("/outbox/{outboxId}/retry") + public BaseResponse retryOutbox( + @PathVariable("outboxId") Long outboxId + ) { + backofficeNotificationService.retryOutbox(outboxId); + return BaseResponse.onSuccess(SuccessStatus._OK, "Outbox retry queued successfully. outboxId=" + outboxId); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOutboxResponseDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOutboxResponseDTO.java new file mode 100644 index 00000000..d9dc0e4e --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOutboxResponseDTO.java @@ -0,0 +1,46 @@ +package com.assu.server.domain.backoffice.dto; + +import com.assu.server.domain.notification.entity.Notification; +import com.assu.server.domain.notification.entity.NotificationOutbox; +import io.swagger.v3.oas.annotations.media.Schema; + +public record BackofficeOutboxResponseDTO( + + @Schema(description = "Outbox ID", example = "7") + Long outboxId, + + @Schema(description = "알림 ID", example = "15") + Long notificationId, + + @Schema(description = "수신자 멤버 ID", example = "42") + Long receiverId, + + @Schema(description = "알림 타입", example = "STAMP") + String type, + + @Schema(description = "알림 제목", example = "스탬프 10개 달성! 이벤트 응모 완료 🎁") + String title, + + @Schema(description = "알림 미리보기 메시지", example = "스탬프 10개가 적립되어 기프티콘 증정 이벤트에 자동으로 응모되었어요.") + String messagePreview, + + @Schema(description = "Outbox 상태 (PENDING / SENDING / DISPATCHED / SENT / FAILED)", example = "FAILED") + String status, + + @Schema(description = "재시도 횟수", example = "3") + int retryCount +) { + public static BackofficeOutboxResponseDTO from(NotificationOutbox outbox) { + Notification n = outbox.getNotification(); + return new BackofficeOutboxResponseDTO( + outbox.getId(), + n.getId(), + n.getReceiver().getId(), + n.getType().name(), + n.getTitle(), + n.getMessagePreview(), + outbox.getStatus().name(), + outbox.getRetryCount() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushLogResponseDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushLogResponseDTO.java new file mode 100644 index 00000000..c125d1cd --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushLogResponseDTO.java @@ -0,0 +1,38 @@ +package com.assu.server.domain.backoffice.dto; + +import com.assu.server.domain.backoffice.entity.BackofficePushLog; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +public record BackofficePushLogResponseDTO( + + @Schema(description = "로그 ID", example = "1") + Long logId, + + @Schema(description = "발송 대상 유형", example = "ALL") + String targetType, + + @Schema(description = "푸시 제목", example = "공지사항") + String title, + + @Schema(description = "푸시 본문", example = "안녕하세요, 공지사항입니다.") + String body, + + @Schema(description = "실제 발송된 수신자 수", example = "150") + int recipientCount, + + @Schema(description = "발송 시각") + LocalDateTime sentAt +) { + public static BackofficePushLogResponseDTO from(BackofficePushLog log) { + return new BackofficePushLogResponseDTO( + log.getId(), + log.getTargetType().name(), + log.getTitle(), + log.getBody(), + log.getRecipientCount(), + log.getSentAt() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushSendRequestDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushSendRequestDTO.java new file mode 100644 index 00000000..eb16b48d --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficePushSendRequestDTO.java @@ -0,0 +1,27 @@ +package com.assu.server.domain.backoffice.dto; + +import com.assu.server.domain.backoffice.entity.PushTargetType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record BackofficePushSendRequestDTO( + + @NotNull + @Schema(description = "발송 대상 유형 (ALL / STUDENT / UNION / PARTNER / INDIVIDUAL)", example = "ALL") + PushTargetType targetType, + + @Schema(description = "수신자 멤버 ID (targetType=INDIVIDUAL일 때만 필수)", example = "42") + Long receiverId, + + @NotBlank + @Schema(description = "푸시 제목", example = "공지사항") + String title, + + @NotBlank + @Schema(description = "푸시 본문", example = "안녕하세요, 공지사항입니다.") + String body, + + @Schema(description = "딥링크 URL (선택)", example = "/notice/1") + String deepLink +) {} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/entity/BackofficePushLog.java b/src/main/java/com/assu/server/domain/backoffice/entity/BackofficePushLog.java new file mode 100644 index 00000000..a08399f4 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/entity/BackofficePushLog.java @@ -0,0 +1,41 @@ +package com.assu.server.domain.backoffice.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BackofficePushLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PushTargetType targetType; + + private Long receiverId; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + private String deepLink; + + @Column(nullable = false) + private Long sentByMemberId; + + @Column(nullable = false) + private int recipientCount; + + @Column(nullable = false) + private LocalDateTime sentAt; +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/entity/PushTargetType.java b/src/main/java/com/assu/server/domain/backoffice/entity/PushTargetType.java new file mode 100644 index 00000000..222ae1da --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/entity/PushTargetType.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.backoffice.entity; + +public enum PushTargetType { + ALL, STUDENT, UNION, PARTNER, INDIVIDUAL +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/repository/BackofficePushLogRepository.java b/src/main/java/com/assu/server/domain/backoffice/repository/BackofficePushLogRepository.java new file mode 100644 index 00000000..3e6675f2 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/repository/BackofficePushLogRepository.java @@ -0,0 +1,13 @@ +package com.assu.server.domain.backoffice.repository; + +import com.assu.server.domain.backoffice.entity.BackofficePushLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BackofficePushLogRepository extends JpaRepository { + + Page findByTitleContainingOrBodyContaining( + String title, String body, Pageable pageable + ); +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationService.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationService.java new file mode 100644 index 00000000..9ef4cd9d --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationService.java @@ -0,0 +1,21 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.backoffice.dto.BackofficeOutboxResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficePushLogResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficePushSendRequestDTO; +import com.assu.server.domain.common.dto.PageResponseDTO; + +public interface BackofficeNotificationService { + + /** 그룹/개인 자유 메시지 푸시 발송 */ + void sendPush(BackofficePushSendRequestDTO request, Long sentByMemberId); + + /** 푸시 발송 이력 목록 조회 */ + PageResponseDTO getPushLogs(String keyword, int page, int size); + + /** 전송 실패(FAILED) 알림 목록 페이징 조회 */ + PageResponseDTO getFailedOutboxes(int page, int size); + + /** 특정 실패 알림 수동 재전송 */ + void retryOutbox(Long outboxId); +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationServiceImpl.java new file mode 100644 index 00000000..50b983ca --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeNotificationServiceImpl.java @@ -0,0 +1,139 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.backoffice.dto.BackofficeOutboxResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficePushLogResponseDTO; +import com.assu.server.domain.backoffice.dto.BackofficePushSendRequestDTO; +import com.assu.server.domain.backoffice.entity.BackofficePushLog; +import com.assu.server.domain.backoffice.entity.PushTargetType; +import com.assu.server.domain.backoffice.repository.BackofficePushLogRepository; +import com.assu.server.domain.common.dto.PageResponseDTO; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.notification.entity.Notification; +import com.assu.server.domain.notification.entity.NotificationOutbox; +import com.assu.server.domain.notification.entity.OutboxCreatedEvent; +import com.assu.server.domain.notification.repository.NotificationOutboxRepository; +import com.assu.server.domain.notification.service.NotificationCommandService; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.DatabaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@Transactional +@RequiredArgsConstructor +public class BackofficeNotificationServiceImpl implements BackofficeNotificationService { + + private final NotificationCommandService notificationCommandService; + private final NotificationOutboxRepository outboxRepository; + private final ApplicationEventPublisher eventPublisher; + private final MemberRepository memberRepository; + private final BackofficePushLogRepository pushLogRepository; + + @Override + public void sendPush(BackofficePushSendRequestDTO request, Long sentByMemberId) { + List recipients = resolveRecipients(request); + + for (Member recipient : recipients) { + notificationCommandService.createAndQueue( + recipient.getId(), + request.title(), + request.body(), + request.deepLink() + ); + } + + pushLogRepository.save(BackofficePushLog.builder() + .targetType(request.targetType()) + .receiverId(request.targetType() == PushTargetType.INDIVIDUAL ? request.receiverId() : null) + .title(request.title()) + .body(request.body()) + .deepLink(request.deepLink()) + .sentByMemberId(sentByMemberId) + .recipientCount(recipients.size()) + .sentAt(LocalDateTime.now()) + .build()); + } + + private List resolveRecipients(BackofficePushSendRequestDTO request) { + return switch (request.targetType()) { + case INDIVIDUAL -> { + if (request.receiverId() == null) { + throw new DatabaseException(ErrorStatus.NO_SUCH_MEMBER); + } + Member member = memberRepository.findMemberById(request.receiverId()) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); + yield List.of(member); + } + case ALL -> memberRepository.findAll(); + case STUDENT -> memberRepository.findByRole(UserRole.STUDENT); + case UNION -> memberRepository.findByRole(UserRole.ADMIN); + case PARTNER -> memberRepository.findByRole(UserRole.PARTNER); + }; + } + + @Override + @Transactional(readOnly = true) + public PageResponseDTO getPushLogs(String keyword, int page, int size) { + if (page < 1) throw new DatabaseException(ErrorStatus.PAGE_UNDER_ONE); + if (size < 1 || size > 200) throw new DatabaseException(ErrorStatus.PAGE_SIZE_INVALID); + + Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "id")); + Page result; + if (keyword != null && !keyword.isBlank()) { + result = pushLogRepository + .findByTitleContainingOrBodyContaining(keyword, keyword, pageable) + .map(BackofficePushLogResponseDTO::from); + } else { + result = pushLogRepository.findAll(pageable).map(BackofficePushLogResponseDTO::from); + } + return PageResponseDTO.of(result); + } + + @Override + @Transactional(readOnly = true) + public PageResponseDTO getFailedOutboxes(int page, int size) { + if (page < 1) throw new DatabaseException(ErrorStatus.PAGE_UNDER_ONE); + if (size < 1 || size > 200) throw new DatabaseException(ErrorStatus.PAGE_SIZE_INVALID); + + Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "id")); + Page result = outboxRepository + .findByStatus(NotificationOutbox.Status.FAILED, pageable) + .map(BackofficeOutboxResponseDTO::from); + return PageResponseDTO.of(result); + } + + @Override + public void retryOutbox(Long outboxId) { + NotificationOutbox outbox = outboxRepository.findById(outboxId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.OUTBOX_NOT_FOUND)); + + if (outbox.getStatus() != NotificationOutbox.Status.FAILED) { + throw new DatabaseException(ErrorStatus.OUTBOX_NOT_FAILED); + } + + outboxRepository.resetToPendingById(outboxId); + + Notification n = outbox.getNotification(); + eventPublisher.publishEvent(new OutboxCreatedEvent( + outbox.getId(), + n.getReceiver().getId(), + n.getTitle(), + n.getMessagePreview(), + n.getType().name(), + n.getRefId(), + n.getDeeplink(), + n.getId() + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/inquiry/repository/InquiryRepository.java b/src/main/java/com/assu/server/domain/inquiry/repository/InquiryRepository.java index 32d0e87b..6edbf261 100644 --- a/src/main/java/com/assu/server/domain/inquiry/repository/InquiryRepository.java +++ b/src/main/java/com/assu/server/domain/inquiry/repository/InquiryRepository.java @@ -8,4 +8,15 @@ public interface InquiryRepository extends JpaRepository { Page findByMemberId(Long memberId, Pageable pageable); Page findByMemberIdAndStatus(Long memberId, Status status, Pageable pageable); + Page findByStatus(Status status, Pageable pageable); + + Page findByStatusAndTitleContainingOrStatusAndContentContaining( + Status status1, String title, + Status status2, String content, + Pageable pageable + ); + + Page findByTitleContainingOrContentContaining( + String title, String content, Pageable pageable + ); } diff --git a/src/main/java/com/assu/server/domain/inquiry/service/InquiryService.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryService.java index 4b86bf6c..6238b955 100644 --- a/src/main/java/com/assu/server/domain/inquiry/service/InquiryService.java +++ b/src/main/java/com/assu/server/domain/inquiry/service/InquiryService.java @@ -13,4 +13,8 @@ public interface InquiryService { InquiryResponseDTO get(Long inquiryId, Long memberId); void answer(Long inquiryId, String answerText); Page list(Inquiry.StatusFilter status, Pageable pageable, Long memberId); + + // 백오피스 전용 (소유권 검증 없음) + PageResponseDTO getAllInquiries(Inquiry.StatusFilter status, String keyword, int page, int size); + InquiryResponseDTO getById(Long inquiryId); } diff --git a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java index 5fa04548..3283a5a1 100644 --- a/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java +++ b/src/main/java/com/assu/server/domain/inquiry/service/InquiryServiceImpl.java @@ -72,6 +72,44 @@ public InquiryResponseDTO get(Long id, Long memberId) { return InquiryResponseDTO.from(inquiry); } + /** 백오피스 전체 문의 목록 조회 (소유권 검증 없음, keyword 검색 지원) */ + @Override + @Transactional(readOnly = true) + public PageResponseDTO getAllInquiries(Inquiry.StatusFilter status, String keyword, int page, int size) { + if (page < 1) throw new DatabaseException(ErrorStatus.PAGE_UNDER_ONE); + if (size < 1 || size > 200) throw new DatabaseException(ErrorStatus.PAGE_SIZE_INVALID); + + Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "id")); + boolean hasKeyword = keyword != null && !keyword.isBlank(); + + Page result; + if (hasKeyword) { + result = switch (status) { + case WAITING -> inquiryRepository.findByStatusAndTitleContainingOrStatusAndContentContaining( + Status.WAITING, keyword, Status.WAITING, keyword, pageable); + case ANSWERED -> inquiryRepository.findByStatusAndTitleContainingOrStatusAndContentContaining( + Status.ANSWERED, keyword, Status.ANSWERED, keyword, pageable); + case ALL -> inquiryRepository.findByTitleContainingOrContentContaining(keyword, keyword, pageable); + }; + } else { + result = switch (status) { + case WAITING -> inquiryRepository.findByStatus(Status.WAITING, pageable); + case ANSWERED -> inquiryRepository.findByStatus(Status.ANSWERED, pageable); + case ALL -> inquiryRepository.findAll(pageable); + }; + } + return PageResponseDTO.of(result.map(InquiryResponseDTO::from)); + } + + /** 백오피스 단건 조회 (소유권 검증 없음) */ + @Override + @Transactional(readOnly = true) + public InquiryResponseDTO getById(Long inquiryId) { + Inquiry inquiry = inquiryRepository.findById(inquiryId) + .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_INQUIRY)); + return InquiryResponseDTO.from(inquiry); + } + /** 답변 저장(상태 ANSWERED 전환) */ @Override public void answer(Long inquiryId, String answerText) { diff --git a/src/main/java/com/assu/server/domain/member/repository/MemberRepository.java b/src/main/java/com/assu/server/domain/member/repository/MemberRepository.java index 6f57e55d..99a2a08d 100644 --- a/src/main/java/com/assu/server/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/assu/server/domain/member/repository/MemberRepository.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; +import com.assu.server.domain.common.enums.UserRole; import com.assu.server.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -12,4 +13,5 @@ public interface MemberRepository extends JpaRepository { Optional findMemberById(Long id); List findByDeletedAtBefore(LocalDateTime deletedAt); + List findByRole(UserRole role); } diff --git a/src/main/java/com/assu/server/domain/notification/entity/NotificationType.java b/src/main/java/com/assu/server/domain/notification/entity/NotificationType.java index 421a5588..b048fcbd 100644 --- a/src/main/java/com/assu/server/domain/notification/entity/NotificationType.java +++ b/src/main/java/com/assu/server/domain/notification/entity/NotificationType.java @@ -9,7 +9,8 @@ public enum NotificationType { ORDER("order"), PARTNER_ALL("partner_all"), // 채팅, 주문 안내 ADMIN_ALL("admin_all"), // 채팅, 제휴 건의, 제휴 제안 - STAMP("stamp"); + STAMP("stamp"), + BACKOFFICE("backoffice"); private final String code; NotificationType(String code) { this.code = code; } diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandService.java b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandService.java index 97aeadf5..88f41091 100644 --- a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandService.java +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandService.java @@ -9,6 +9,9 @@ public interface NotificationCommandService { Notification createAndQueue(Long receiverId, NotificationType type, Long refId, Map ctx); + + /** 자유 메시지 직접 지정하는 푸시 (백오피스 수동 발송용) */ + void createAndQueue(Long receiverId, String title, String body, String deepLink); void markRead(Long notificationId, Long currentMemberId) throws AccessDeniedException; void queue(QueueNotificationRequestDTO req); Map toggle(Long memberId, NotificationType type); diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java index e0693d29..1f5dfc85 100644 --- a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java @@ -28,6 +28,40 @@ public class NotificationCommandServiceImpl implements NotificationCommandServic private final MemberRepository memberRepository; private final org.springframework.context.ApplicationEventPublisher eventPublisher; + @Override + public void createAndQueue(Long receiverId, String title, String body, String deepLink) { + Member member = memberRepository.findMemberById(receiverId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NO_SUCH_MEMBER)); + + Notification notification = Notification.builder() + .receiver(member) + .type(NotificationType.BACKOFFICE) + .refId(null) + .title(title) + .messagePreview(body) + .deeplink(deepLink != null ? deepLink : "/backoffice") + .build(); + notificationRepository.save(notification); + + NotificationOutbox outbox = NotificationOutbox.builder() + .notification(notification) + .status(NotificationOutbox.Status.PENDING) + .retryCount(0) + .build(); + outboxRepository.save(outbox); + + eventPublisher.publishEvent(new OutboxCreatedEvent( + outbox.getId(), + member.getId(), + notification.getTitle(), + notification.getMessagePreview(), + NotificationType.BACKOFFICE.name(), + null, + notification.getDeeplink(), + notification.getId() + )); + } + @Override public Notification createAndQueue(Long receiverId, NotificationType type, Long refId, Map ctx) { Member member = memberRepository.findMemberById(receiverId) From 5b64403262651f4a85dab3bd825e76dfe74e2deb Mon Sep 17 00:00:00 2001 From: MiN <81948815+leesumin0526@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:47:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[FEAT/#362]=20main=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NotificationOutboxRepository: findByStatus, resetToPendingById 추가 - ErrorStatus: OUTBOX_NOT_FOUND, OUTBOX_NOT_FAILED 추가 - WebSocketConfig: allowedOrigin assu.shop → assu.site 변경 - .gitignore: .DS_Store, node_modules 추가 --- .gitignore | 7 +++++++ .../repository/NotificationOutboxRepository.java | 13 +++++++++++++ .../global/apiPayload/code/status/ErrorStatus.java | 2 ++ .../assu/server/global/config/WebSocketConfig.java | 2 +- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5433cd1e..2b32360d 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,10 @@ src/main/resources/application-prod.yml ### Firebase ### src/main/resources/firebase/ + +### macOS ### +.DS_Store +**/.DS_Store + +### Node ### +node_modules/ diff --git a/src/main/java/com/assu/server/domain/notification/repository/NotificationOutboxRepository.java b/src/main/java/com/assu/server/domain/notification/repository/NotificationOutboxRepository.java index 8930dee7..5cd29c85 100644 --- a/src/main/java/com/assu/server/domain/notification/repository/NotificationOutboxRepository.java +++ b/src/main/java/com/assu/server/domain/notification/repository/NotificationOutboxRepository.java @@ -2,6 +2,8 @@ import com.assu.server.domain.notification.entity.NotificationOutbox; import com.assu.server.domain.notification.entity.NotificationOutbox.Status; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -40,4 +42,15 @@ public interface NotificationOutboxRepository extends JpaRepository findByStatusAndRetryCountLessThan(NotificationOutbox.Status status, int maxRetryCount); + + Page findByStatus(NotificationOutbox.Status status, Pageable pageable); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE NotificationOutbox o + SET o.status = :#{T(com.assu.server.domain.notification.entity.NotificationOutbox$Status).PENDING} + WHERE o.id = :id + AND o.status = :#{T(com.assu.server.domain.notification.entity.NotificationOutbox$Status).FAILED} + """) + int resetToPendingById(@Param("id") Long id); } diff --git a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java index 6b3d89e6..329a90f0 100644 --- a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java @@ -88,6 +88,8 @@ public enum ErrorStatus implements BaseErrorCode { NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND,"NOTIFICATION_4003","존재하지 않는 알림입니다."), NOTIFICATION_ACCESS_DENIED(HttpStatus.FORBIDDEN,"NOTIFICATION_4004","해당 알림에 접근할 권한이 없습니다."), MISSING_NOTIFICATION_FIELD(HttpStatus.BAD_REQUEST,"NOTIFICATION_4005","알림 생성에 필요한 필드가 누락되었습니다."), + OUTBOX_NOT_FOUND(HttpStatus.NOT_FOUND,"NOTIFICATION_4006","존재하지 않는 알림 Outbox입니다."), + OUTBOX_NOT_FAILED(HttpStatus.BAD_REQUEST,"NOTIFICATION_4007","FAILED 상태인 Outbox만 수동 재전송할 수 있습니다."), // 문의(Inquiry) INVALID_INQUIRY_STATUS_FILTER(HttpStatus.BAD_REQUEST,"INQUIRY_4001","status는 [all, waiting, answered] 중 하나여야 합니다."), diff --git a/src/main/java/com/assu/server/global/config/WebSocketConfig.java b/src/main/java/com/assu/server/global/config/WebSocketConfig.java index 7e669ccb..d6e30872 100644 --- a/src/main/java/com/assu/server/global/config/WebSocketConfig.java +++ b/src/main/java/com/assu/server/global/config/WebSocketConfig.java @@ -24,7 +24,7 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") // 클라이언트 WebSocket 연결 지점 .setAllowedOriginPatterns( "*", - "https://assu.shop", + "https://assu.site", "http://localhost:63342", "http://localhost:5173", // Vite 기본 "http://localhost:3000", // CRA/Next 기본