Skip to content

[FEAT/#362] 백오피스 푸시 알림 그룹 발송 및 문의 키워드 검색 구현#369

Open
leesumin0526 wants to merge 2 commits into
developfrom
feat/#362-sumin-backoffice
Open

[FEAT/#362] 백오피스 푸시 알림 그룹 발송 및 문의 키워드 검색 구현#369
leesumin0526 wants to merge 2 commits into
developfrom
feat/#362-sumin-backoffice

Conversation

@leesumin0526

@leesumin0526 leesumin0526 commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

#️⃣연관된 이슈

close #362

📝작업 내용

백오피스 푸시 알림 그룹 발송 및 문의 키워드 검색 구현


요약

POST /backoffice/notifications/push — 기존 단건 전송에서 그룹(전체/역할별)/개인 자유 메시지 발송으로 전면 교체
GET /backoffice/notifications — 발송 이력 조회 신규 구현
GET /backoffice/inquirieskeyword 파라미터 추가 (제목/본문 검색)


A. 신규 엔티티 · DTO · Repository

파일 변경 설명
PushTargetType.java 신규 ALL / STUDENT / UNION / PARTNER / INDIVIDUAL
BackofficePushLog.java 신규 푸시 발송 이력 엔티티 (targetType, recipientCount, sentAt 등)
BackofficePushLogRepository.java 신규 이력 전체 조회 및 keyword(title/body) 검색
BackofficePushSendRequestDTO.java 신규 자유 메시지 푸시 요청 DTO (기존 BackofficePushRequestDTO 대체)
BackofficePushLogResponseDTO.java 신규 발송 이력 응답 DTO

B. 백오피스 알림 서비스

파일 변경 설명
BackofficeNotificationService.java 수정 sendPush 시그니처 교체, getPushLogs 추가
BackofficeNotificationServiceImpl.java 수정 targetType별 수신자 분기(ALL/STUDENT/UNION/PARTNER/INDIVIDUAL) + BackofficePushLog 저장
BackofficeNotificationController.java 수정 POST /push DTO 교체, GET /backoffice/notifications 추가
BackofficePushRequestDTO.java 삭제 구 DTO 제거

C. 알림 공통 서비스

파일 변경 설명
NotificationCommandService.java 수정 createAndQueue(receiverId, title, body, deepLink) 자유 메시지 오버로드 추가
NotificationCommandServiceImpl.java 수정 자유 메시지 오버로드 구현 (NotificationType.BACKOFFICE 사용)
NotificationType.java 수정 BACKOFFICE 타입 추가
MemberRepository.java 수정 findByRole(UserRole) 추가

D. 문의 키워드 검색

파일 변경 설명
InquiryRepository.java 수정 findByStatusAndTitleContainingOrStatusAndContentContaining, findByTitleContainingOrContentContaining 추가
InquiryService.java 수정 getAllInquiries 시그니처에 keyword 추가
InquiryServiceImpl.java 수정 keyword + status 조합 분기 처리
BackofficeInquiryController.java 수정 GET /backoffice/inquirieskeyword 파라미터 추가

🔎코드 설명(스크린샷(선택))

프론트엔드 수정, 삭제가 필요한 부분들은 아래를 참고해 주세요
스크린샷 2026-07-04 오후 2 48 28

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

고민사항 및 의견 받고 싶은 부분 있으면 적어두기

비고 (Optional)

참고했던 링크 등 참고 사항을 적어주세요. 코드 리뷰하는 사람이 참고해야 하는 내용을 자유로운 형식으로 적을 수 있습니다.

@leesumin0526 leesumin0526 linked an issue Jul 4, 2026 that may be closed by this pull request
2 tasks
- 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 추가
@leesumin0526 leesumin0526 force-pushed the feat/#362-sumin-backoffice branch from 81a2165 to 5b64403 Compare July 4, 2026 05:50
@leesumin0526 leesumin0526 self-assigned this Jul 4, 2026
@leesumin0526 leesumin0526 added the ✨ feature New feature or request label Jul 4, 2026

@kimyw1018 kimyw1018 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니당

main으로 pr올리셨습니당

@leesumin0526 leesumin0526 changed the base branch from main to develop July 6, 2026 12:58
@Override
@Transactional(readOnly = true)
public PageResponseDTO<BackofficeOutboxResponseDTO> getFailedOutboxes(int page, int size) {
if (page < 1) throw new DatabaseException(ErrorStatus.PAGE_UNDER_ONE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 page 관련 발생한 예외는 DB 내부의 문제라기보다는, 클라이언트가 파라미터(page)를 잘못 보내서 생긴 검증 오류(Bad Request)에 가까운 것이니 커스텀 예외를 던지는 것이 좋아보입니다

package com.assu.server.domain.backoffice.entity;

public enum PushTargetType {
ALL, STUDENT, UNION, PARTNER, INDIVIDUAL

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UNION이 학생회를 의미하는 것이라면 ADMIN을 사용해서 저희 유비쿼터스 용어를 지키면 좋을 것 같습니다

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 유비쿼터스 언어 문서를 작성해놓는 것도 굉장히 좋을 것 같네요! @leesumin0526

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

첨 들어봐서 찾아봤는데 좋을 것 같아요. 저희 용어도 헷갈리는 게 많으니까

@BackofficeAudited(action = "PUSH_SEND", targetId = "#request.receiverId()")
@Operation(
summary = "운영자 수동 푸시 알림 전송 API",
description = "# [v2.0 (2026-07-04)]\n" +

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버전은 소수점 단위만 우선 올려주는 것으로 해요. 정수 단위는 전체 버전을 표현할 때 사용하기 위함이라고 생각합니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

흠 알겠습니다 다른 api들 버전도 체크해 봐야겠네요

@PathVariable("outboxId") Long outboxId
) {
backofficeNotificationService.retryOutbox(outboxId);
return BaseResponse.onSuccess(SuccessStatus._OK, "Outbox retry queued successfully. outboxId=" + outboxId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backofficeNotificationService.retryOutbox(outboxId);의 응답을 onSuccess() 메소드에 담아 DTO로 변환하는 것으로 해요. 가독성과 유지보수를 위해 ID값을 String과 함께 조합해서 반환하는 것을 지양하는 것이 좋아보입니다.

@Schema(description = "알림 타입", example = "STAMP")
String type,

@Schema(description = "알림 제목", example = "스탬프 10개 달성! 이벤트 응모 완료 🎁")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이모티콘 사용 관련하여, DB 저장 및 조회 시 깨질 위험을 방지하기 위해서
저희 DB 테이블 캐릭터 셋이 utf8mb4였던 것 같긴 한데, 이를 한번 확인해보면 좋을 것 같아요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요건 그때 안 깨지는 거 확인해서 넣어놨습니다!

public interface BackofficeNotificationService {

/** 그룹/개인 자유 메시지 푸시 발송 */
void sendPush(BackofficePushSendRequestDTO request, Long sentByMemberId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendPush라는 메소드 명만으로는 그룹이나 개인에게 전송한다는 요구사항이 명확하게 드러나지 않으니 메소드 명에 드러내면 좋을 것 같습니다. 그리고 주석을 지우면 좋을 것 같습니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

받는 대상이 안 정해져있어서 저렇게 했는데 좋은 이름을 함 생각해 볼게유

@2ghrms 2ghrms left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다~~ 회의때 얘기할 것이 조금 있는 것 같으니 참고해주세용


Page<Inquiry> findByTitleContainingOrContentContaining(
String title, String content, Pageable pageable
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 보면 기존 메소드들은 사이에 개행이 되어있지 않은데 개행을 추가하셔서 통일하면 좋을 것 같습니다 -> 이런 린트나 코드 형식 관련은 팀 회의에서 정하면 더 좋을 것 같고 문서화해봅시당 @leesumin0526

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵넵 좋아요 AI 관련 세팅하는 김에 요런것들 잘 문서화해서 레포에 올려두면 좋을 것 같네여

/** 백오피스 전체 문의 목록 조회 (소유권 검증 없음, keyword 검색 지원) */
@Override
@Transactional(readOnly = true)
public PageResponseDTO<InquiryResponseDTO> getAllInquiries(Inquiry.StatusFilter status, String keyword, int page, int size) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

페이지로 조회하기에 getAll보다는 그냥 getInquiries가 깔끔할 것 같습니다

Notification createAndQueue(Long receiverId, NotificationType type, Long refId, Map<String, Object> ctx);

/** 자유 메시지 직접 지정하는 푸시 (백오피스 수동 발송용) */
void createAndQueue(Long receiverId, String title, String body, String deepLink);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석을 일부러 백오피스 메소드인 것을 표현해주려고 달아주시는 것 같은데 차라리 NotificationBackofficeService 이런식으로 빼주는 것도 좋아보여요. 이것도 회의때 얘기하면 좋을 것 같습니다 @leesumin0526

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

흠 알겠슴다 서비스도 구분... 흠

package com.assu.server.domain.backoffice.entity;

public enum PushTargetType {
ALL, STUDENT, UNION, PARTNER, INDIVIDUAL

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 유비쿼터스 언어 문서를 작성해놓는 것도 굉장히 좋을 것 같네요! @leesumin0526

Comment on lines +52 to +59
@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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requestParam이 많아질 경우 ModelAttribute를 고려해봐도 좋을 것 같아요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER));
yield List.of(member);
}
case ALL -> memberRepository.findAll();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체 멤버를 한번에 메모리에 올리게되어서 회원 수가 많아지면 OOM이날 수 있어요. 또 각 회원마다 반복문이 또 돌아서 트랜잭션이 쌓이게 돼요. 페이지네이션이나 배치 처리 등으로 DB 부하를 덜어주어야할 것 같아요

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오케욤

Comment on lines +71 to +72
if (request.receiverId() == null) {
throw new DatabaseException(ErrorStatus.NO_SUCH_MEMBER);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

receiverId가 Null 일 경우는 NO_SUCH_MEMBER 말고 다른 errorStatus를 남기는게 좋을 것 같습니다.
NO_SUCH_MEMBER: DB조회를 했는데 해당 멤버가 없다. 의미라고 생각합니다.
하지만 이번 경우는 DB조회조차 안되었기 때문에 request 부분에 @NotNull을 붙여도 좋을 것 같아요

.build());
}

private List<Member> resolveRecipients(BackofficePushSendRequestDTO request) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 변수명 어케할지 정하면 좋을 것 같아요. 지난번에 길어도
BackofficePushSendRequestDTO 라면 backoffciePushSendRequestDTO로 하기로 했었던 것 같은데 다시한번 정해보면 좋을 것 같습니다!

Page<Inquiry> findByMemberIdAndStatus(Long memberId, Status status, Pageable pageable);
Page<Inquiry> findByStatus(Status status, Pageable pageable);

Page<Inquiry> findByStatusAndTitleContainingOrStatusAndContentContaining(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OR 연산자 우선순위 취약성
JPA는 이를 WHERE (status=status1 AND title LIKE ?) OR (status=status2 AND content LIKE ?)로 해석합니다. 현재는 status1 == status2로 항상 호출되어 의도와 일치하지만, 나중에 다른 값으로 호출하면 의도치 않은 상태의 레코드가 섞여 반환됩니다. 명시적 @query로 교체하는 것이 안전합니다

Comment on lines +43 to +66
@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());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 sendPush가 class 단에 작성된 @transactional 때문에 createAndQueue 호출이 같은 트랜잭션에 묶이게 됩니다. 그러면 만약 중간에 실패한다면 전체가 롤백되고 로그가 남지 않아 에러 추적이 어렵습니다.
각 수신자별 createAndQueue가 독립 트랜잭션으로 커밋되게 만들면 한명 실패해도 나머지는 정상 전송이 되고, 실패 시 로그를 남기면 해당 멤버만 로그 추적이 용이할 것 같습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT/#362] 관리자 페이지 API (문의, 푸시)

4 participants