[FEAT/#362] 백오피스 푸시 알림 그룹 발송 및 문의 키워드 검색 구현#369
Conversation
- PushTargetType(ALL/STUDENT/UNION/PARTNER/INDIVIDUAL) 기반 그룹 푸시 발송 - BackofficePushLog 엔티티 및 발송 이력 조회 API(GET /backoffice/notifications) 추가 - NotificationCommandService에 자유 메시지 오버로드(title/body/deepLink) 추가 - NotificationType에 BACKOFFICE 타입 추가 - MemberRepository에 역할별 조회(findByRole) 추가 - GET /backoffice/inquiries에 keyword 파라미터 추가 (제목/본문 검색)
- NotificationOutboxRepository: findByStatus, resetToPendingById 추가 - ErrorStatus: OUTBOX_NOT_FOUND, OUTBOX_NOT_FAILED 추가 - WebSocketConfig: allowedOrigin assu.shop → assu.site 변경 - .gitignore: .DS_Store, node_modules 추가
81a2165 to
5b64403
Compare
kimyw1018
left a comment
There was a problem hiding this comment.
수고하셨습니당
main으로 pr올리셨습니당
| @Override | ||
| @Transactional(readOnly = true) | ||
| public PageResponseDTO<BackofficeOutboxResponseDTO> getFailedOutboxes(int page, int size) { | ||
| if (page < 1) throw new DatabaseException(ErrorStatus.PAGE_UNDER_ONE); |
There was a problem hiding this comment.
해당 page 관련 발생한 예외는 DB 내부의 문제라기보다는, 클라이언트가 파라미터(page)를 잘못 보내서 생긴 검증 오류(Bad Request)에 가까운 것이니 커스텀 예외를 던지는 것이 좋아보입니다
| package com.assu.server.domain.backoffice.entity; | ||
|
|
||
| public enum PushTargetType { | ||
| ALL, STUDENT, UNION, PARTNER, INDIVIDUAL |
There was a problem hiding this comment.
UNION이 학생회를 의미하는 것이라면 ADMIN을 사용해서 저희 유비쿼터스 용어를 지키면 좋을 것 같습니다
There was a problem hiding this comment.
저희 유비쿼터스 언어 문서를 작성해놓는 것도 굉장히 좋을 것 같네요! @leesumin0526
There was a problem hiding this comment.
첨 들어봐서 찾아봤는데 좋을 것 같아요. 저희 용어도 헷갈리는 게 많으니까
| @BackofficeAudited(action = "PUSH_SEND", targetId = "#request.receiverId()") | ||
| @Operation( | ||
| summary = "운영자 수동 푸시 알림 전송 API", | ||
| description = "# [v2.0 (2026-07-04)]\n" + |
There was a problem hiding this comment.
버전은 소수점 단위만 우선 올려주는 것으로 해요. 정수 단위는 전체 버전을 표현할 때 사용하기 위함이라고 생각합니다!
There was a problem hiding this comment.
흠 알겠습니다 다른 api들 버전도 체크해 봐야겠네요
| @PathVariable("outboxId") Long outboxId | ||
| ) { | ||
| backofficeNotificationService.retryOutbox(outboxId); | ||
| return BaseResponse.onSuccess(SuccessStatus._OK, "Outbox retry queued successfully. outboxId=" + outboxId); |
There was a problem hiding this comment.
backofficeNotificationService.retryOutbox(outboxId);의 응답을 onSuccess() 메소드에 담아 DTO로 변환하는 것으로 해요. 가독성과 유지보수를 위해 ID값을 String과 함께 조합해서 반환하는 것을 지양하는 것이 좋아보입니다.
| @Schema(description = "알림 타입", example = "STAMP") | ||
| String type, | ||
|
|
||
| @Schema(description = "알림 제목", example = "스탬프 10개 달성! 이벤트 응모 완료 🎁") |
There was a problem hiding this comment.
이모티콘 사용 관련하여, DB 저장 및 조회 시 깨질 위험을 방지하기 위해서
저희 DB 테이블 캐릭터 셋이 utf8mb4였던 것 같긴 한데, 이를 한번 확인해보면 좋을 것 같아요
There was a problem hiding this comment.
요건 그때 안 깨지는 거 확인해서 넣어놨습니다!
| public interface BackofficeNotificationService { | ||
|
|
||
| /** 그룹/개인 자유 메시지 푸시 발송 */ | ||
| void sendPush(BackofficePushSendRequestDTO request, Long sentByMemberId); |
There was a problem hiding this comment.
sendPush라는 메소드 명만으로는 그룹이나 개인에게 전송한다는 요구사항이 명확하게 드러나지 않으니 메소드 명에 드러내면 좋을 것 같습니다. 그리고 주석을 지우면 좋을 것 같습니다
There was a problem hiding this comment.
받는 대상이 안 정해져있어서 저렇게 했는데 좋은 이름을 함 생각해 볼게유
2ghrms
left a comment
There was a problem hiding this comment.
고생하셨습니다~~ 회의때 얘기할 것이 조금 있는 것 같으니 참고해주세용
|
|
||
| Page<Inquiry> findByTitleContainingOrContentContaining( | ||
| String title, String content, Pageable pageable | ||
| ); |
There was a problem hiding this comment.
지금 보면 기존 메소드들은 사이에 개행이 되어있지 않은데 개행을 추가하셔서 통일하면 좋을 것 같습니다 -> 이런 린트나 코드 형식 관련은 팀 회의에서 정하면 더 좋을 것 같고 문서화해봅시당 @leesumin0526
There was a problem hiding this comment.
넵넵 좋아요 AI 관련 세팅하는 김에 요런것들 잘 문서화해서 레포에 올려두면 좋을 것 같네여
| /** 백오피스 전체 문의 목록 조회 (소유권 검증 없음, keyword 검색 지원) */ | ||
| @Override | ||
| @Transactional(readOnly = true) | ||
| public PageResponseDTO<InquiryResponseDTO> getAllInquiries(Inquiry.StatusFilter status, String keyword, int page, int size) { |
There was a problem hiding this comment.
페이지로 조회하기에 getAll보다는 그냥 getInquiries가 깔끔할 것 같습니다
| Notification createAndQueue(Long receiverId, NotificationType type, Long refId, Map<String, Object> ctx); | ||
|
|
||
| /** 자유 메시지 직접 지정하는 푸시 (백오피스 수동 발송용) */ | ||
| void createAndQueue(Long receiverId, String title, String body, String deepLink); |
There was a problem hiding this comment.
주석을 일부러 백오피스 메소드인 것을 표현해주려고 달아주시는 것 같은데 차라리 NotificationBackofficeService 이런식으로 빼주는 것도 좋아보여요. 이것도 회의때 얘기하면 좋을 것 같습니다 @leesumin0526
There was a problem hiding this comment.
흠 알겠슴다 서비스도 구분... 흠
| package com.assu.server.domain.backoffice.entity; | ||
|
|
||
| public enum PushTargetType { | ||
| ALL, STUDENT, UNION, PARTNER, INDIVIDUAL |
There was a problem hiding this comment.
저희 유비쿼터스 언어 문서를 작성해놓는 것도 굉장히 좋을 것 같네요! @leesumin0526
| @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 |
There was a problem hiding this comment.
requestParam이 많아질 경우 ModelAttribute를 고려해봐도 좋을 것 같아요
| .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); | ||
| yield List.of(member); | ||
| } | ||
| case ALL -> memberRepository.findAll(); |
There was a problem hiding this comment.
전체 멤버를 한번에 메모리에 올리게되어서 회원 수가 많아지면 OOM이날 수 있어요. 또 각 회원마다 반복문이 또 돌아서 트랜잭션이 쌓이게 돼요. 페이지네이션이나 배치 처리 등으로 DB 부하를 덜어주어야할 것 같아요
| if (request.receiverId() == null) { | ||
| throw new DatabaseException(ErrorStatus.NO_SUCH_MEMBER); |
There was a problem hiding this comment.
receiverId가 Null 일 경우는 NO_SUCH_MEMBER 말고 다른 errorStatus를 남기는게 좋을 것 같습니다.
NO_SUCH_MEMBER: DB조회를 했는데 해당 멤버가 없다. 의미라고 생각합니다.
하지만 이번 경우는 DB조회조차 안되었기 때문에 request 부분에 @NotNull을 붙여도 좋을 것 같아요
| .build()); | ||
| } | ||
|
|
||
| private List<Member> resolveRecipients(BackofficePushSendRequestDTO request) { |
There was a problem hiding this comment.
저희 변수명 어케할지 정하면 좋을 것 같아요. 지난번에 길어도
BackofficePushSendRequestDTO 라면 backoffciePushSendRequestDTO로 하기로 했었던 것 같은데 다시한번 정해보면 좋을 것 같습니다!
| Page<Inquiry> findByMemberIdAndStatus(Long memberId, Status status, Pageable pageable); | ||
| Page<Inquiry> findByStatus(Status status, Pageable pageable); | ||
|
|
||
| Page<Inquiry> findByStatusAndTitleContainingOrStatusAndContentContaining( |
There was a problem hiding this comment.
OR 연산자 우선순위 취약성
JPA는 이를 WHERE (status=status1 AND title LIKE ?) OR (status=status2 AND content LIKE ?)로 해석합니다. 현재는 status1 == status2로 항상 호출되어 의도와 일치하지만, 나중에 다른 값으로 호출하면 의도치 않은 상태의 레코드가 섞여 반환됩니다. 명시적 @query로 교체하는 것이 안전합니다
| @Override | ||
| public void sendPush(BackofficePushSendRequestDTO request, Long sentByMemberId) { | ||
| List<Member> 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()); | ||
| } |
There was a problem hiding this comment.
현재 sendPush가 class 단에 작성된 @transactional 때문에 createAndQueue 호출이 같은 트랜잭션에 묶이게 됩니다. 그러면 만약 중간에 실패한다면 전체가 롤백되고 로그가 남지 않아 에러 추적이 어렵습니다.
각 수신자별 createAndQueue가 독립 트랜잭션으로 커밋되게 만들면 한명 실패해도 나머지는 정상 전송이 되고, 실패 시 로그를 남기면 해당 멤버만 로그 추적이 용이할 것 같습니다!
#️⃣연관된 이슈
📝작업 내용
요약
POST /backoffice/notifications/push— 기존 단건 전송에서 그룹(전체/역할별)/개인 자유 메시지 발송으로 전면 교체GET /backoffice/notifications— 발송 이력 조회 신규 구현GET /backoffice/inquiries—keyword파라미터 추가 (제목/본문 검색)A. 신규 엔티티 · DTO · Repository
PushTargetType.javaALL / STUDENT / UNION / PARTNER / INDIVIDUALBackofficePushLog.javaBackofficePushLogRepository.javaBackofficePushSendRequestDTO.javaBackofficePushRequestDTO대체)BackofficePushLogResponseDTO.javaB. 백오피스 알림 서비스
BackofficeNotificationService.javasendPush시그니처 교체,getPushLogs추가BackofficeNotificationServiceImpl.javatargetType별 수신자 분기(ALL/STUDENT/UNION/PARTNER/INDIVIDUAL) +BackofficePushLog저장BackofficeNotificationController.javaPOST /pushDTO 교체,GET /backoffice/notifications추가BackofficePushRequestDTO.javaC. 알림 공통 서비스
NotificationCommandService.javacreateAndQueue(receiverId, title, body, deepLink)자유 메시지 오버로드 추가NotificationCommandServiceImpl.javaNotificationType.BACKOFFICE사용)NotificationType.javaBACKOFFICE타입 추가MemberRepository.javafindByRole(UserRole)추가D. 문의 키워드 검색
InquiryRepository.javafindByStatusAndTitleContainingOrStatusAndContentContaining,findByTitleContainingOrContentContaining추가InquiryService.javagetAllInquiries시그니처에keyword추가InquiryServiceImpl.javaBackofficeInquiryController.javaGET /backoffice/inquiries에keyword파라미터 추가🔎코드 설명(스크린샷(선택))
프론트엔드 수정, 삭제가 필요한 부분들은 아래를 참고해 주세요

💬고민사항 및 리뷰 요구사항 (Optional)
비고 (Optional)