Skip to content

Commit cda0c4d

Browse files
authored
[FEAT] 이벤트 구독 및 알림 저장 (#328)
<!-- ## PR 제목 컨벤션 [TYPE] 설명 (#이슈번호) 예시: - [FEAT] 회원가입 API 구현 (#14) - [FIX] 이미지 업로드 시 NPE 수정 (#23) - [REFACTOR] 토큰 로직 분리 (#8) - [DOCS] ERD 스키마 업데이트 (#6) - [CHORE] CI/CD 파이프라인 추가 (#3) - [RELEASE] v1.0.0 배포 (#30) TYPE: FEAT, FIX, DOCS, REFACTOR, TEST, CHORE, RENAME, REMOVE, RELEASE --> ## Summary <!-- 변경 사항을 간단히 설명해주세요 --> 비즈니스 이벤트를 구독하여 알림을 생성하고, 발송 대기 상태의 Delivery 레코드를 자동 생성하는 기능을 구현했습니다. ## Changes <!-- 변경된 내용을 목록으로 작성해주세요 --> ### 핵심 기능 구현 - **NotificationEventListener** 추가 - `@TransactionalEventListener(AFTER_COMMIT)`로 3종 이벤트 구독 - `CampaignProposalSentEvent` → `PROPOSAL_RECEIVED` 알림 생성 - `CampaignProposalStatusChangedEvent` → `CAMPAIGN_MATCHED` / `PROPOSAL_SENT` 알림 생성 - `CampaignApplySentEvent` → `CAMPAIGN_APPLIED` 알림 생성 - 각 알림 생성은 독립적으로 예외 처리 (하나 실패해도 다른 알림 생성에 영향 없음) - **NotificationDelivery** 엔티티 및 Repository 추가 - 알림 발송 상태 추적용 엔티티 (`PENDING`, `IN_PROGRESS`, `SENT`, `FAILED`) - 채널별 발송 상태 관리 (PUSH, EMAIL) - 재시도 정책을 위한 `attemptCount` 필드 추가 - 멱등성 보장을 위한 `idempotency_key` UNIQUE 제약 - **NotificationService.create()** 개선 - `REQUIRES_NEW` 트랜잭션으로 알림 생성 (비즈니스 트랜잭션과 분리) - 알림 저장 시 `NotificationDelivery` PENDING 레코드 자동 생성 - 멱등성 보장: `eventId:kind:receiverId:channel` 조합으로 중복 방지 - **NotificationChannelResolver** 추가 - 알림 종류별 발송 채널 자동 결정 - PRD 기준으로 채널 매핑 (예: `CAMPAIGN_MATCHED` → PUSH + EMAIL) - **NotificationMessageTemplateService** 추가 - 이벤트 데이터를 기반으로 알림 제목/본문 생성 - 브랜드명, 크리에이터명 등 동적 데이터 포함 ### 데이터베이스 스키마 - `notification_delivery` 테이블 생성 스크립트 추가 - `attempt_count` 컬럼 추가를 위한 ALTER 스크립트 추가 - `idempotency_key` UNIQUE 제약 추가를 위한 ALTER 스크립트 추가 ### 테스트 - **NotificationEventListenerIntegrationTest** 추가 - 이벤트 발행 → 알림 생성 → Delivery 생성 전체 플로우 검증 - 멱등성 테스트 포함 - `@TransactionalEventListener(AFTER_COMMIT)` 테스트를 위해 명시적 트랜잭션 커밋 방식 사용 - **NotificationChannelResolverTest** 추가 - 알림 종류별 채널 매핑 검증 - **NotificationMessageTemplateServiceTest** 추가 - 메시지 템플릿 생성 로직 검증 ## Type of Change <!-- 해당하는 항목에 x 표시해주세요 --> - [ ] Bug fix (기존 기능에 영향을 주지 않는 버그 수정) - [x] New feature (기존 기능에 영향을 주지 않는 새로운 기능 추가) - [ ] Breaking change (기존 기능에 영향을 주는 수정) - [ ] Refactoring (기능 변경 없는 코드 개선) - [ ] Documentation (문서 수정) - [ ] Chore (빌드, 설정 등 기타 변경) - [ ] Release (develop → main 배포) ## Related Issues <!-- 관련 이슈 번호를 작성해주세요 (예: Closes #123, Fixes #456) --> Closes #217 ## 참고 사항 <!-- 리뷰어가 알아야 할 추가 정보가 있다면 작성해주세요 -->
1 parent c25708c commit cda0c4d

File tree

12 files changed

+1054
-1
lines changed

12 files changed

+1054
-1
lines changed

src/main/java/com/example/RealMatch/brand/domain/repository/BrandRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ public interface BrandRepository extends JpaRepository<Brand, Long> {
2626

2727
Optional<Brand> findByUser(User user);
2828

29+
@Query("""
30+
select b from Brand b
31+
where b.user.id = :userId
32+
""")
33+
Optional<Brand> findByUserId(@Param("userId") Long userId);
34+
2935
@Query("""
3036
select b.id
3137
from Brand b

src/main/java/com/example/RealMatch/notification/application/dto/CreateNotificationCommand.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@Builder
1111
public class CreateNotificationCommand {
1212

13+
private final String eventId; // 이벤트 식별자 (멱등성 보장용)
1314
private final Long userId;
1415
private final NotificationKind kind;
1516
private final String title;
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package com.example.RealMatch.notification.application.event;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.stereotype.Component;
6+
import org.springframework.transaction.event.TransactionPhase;
7+
import org.springframework.transaction.event.TransactionalEventListener;
8+
9+
import com.example.RealMatch.brand.domain.entity.Brand;
10+
import com.example.RealMatch.brand.domain.repository.BrandRepository;
11+
import com.example.RealMatch.business.application.event.CampaignApplySentEvent;
12+
import com.example.RealMatch.business.application.event.CampaignProposalSentEvent;
13+
import com.example.RealMatch.business.application.event.CampaignProposalStatusChangedEvent;
14+
import com.example.RealMatch.business.domain.enums.ProposalDirection;
15+
import com.example.RealMatch.business.domain.enums.ProposalStatus;
16+
import com.example.RealMatch.notification.application.dto.CreateNotificationCommand;
17+
import com.example.RealMatch.notification.application.service.NotificationMessageTemplateService;
18+
import com.example.RealMatch.notification.application.service.NotificationMessageTemplateService.MessageTemplate;
19+
import com.example.RealMatch.notification.application.service.NotificationService;
20+
import com.example.RealMatch.notification.domain.entity.enums.NotificationKind;
21+
import com.example.RealMatch.notification.domain.entity.enums.ReferenceType;
22+
import com.example.RealMatch.user.domain.entity.User;
23+
import com.example.RealMatch.user.domain.repository.UserRepository;
24+
25+
import lombok.RequiredArgsConstructor;
26+
27+
@Component
28+
@RequiredArgsConstructor
29+
public class NotificationEventListener {
30+
31+
private static final Logger LOG = LoggerFactory.getLogger(NotificationEventListener.class);
32+
33+
private final NotificationService notificationService;
34+
private final NotificationMessageTemplateService messageTemplateService;
35+
private final BrandRepository brandRepository;
36+
private final UserRepository userRepository;
37+
38+
// ==================== CampaignProposalSentEvent ====================
39+
40+
/**
41+
* CampaignProposalSentEvent 구독.
42+
* proposalDirection=BRAND_TO_CREATOR일 때 PROPOSAL_RECEIVED 알림 생성.
43+
*/
44+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
45+
public void handleCampaignProposalSent(CampaignProposalSentEvent event) {
46+
if (event == null) {
47+
LOG.warn("[Notification] Invalid CampaignProposalSentEvent: event is null");
48+
return;
49+
}
50+
51+
if (event.proposalDirection() != ProposalDirection.BRAND_TO_CREATOR) {
52+
LOG.debug("[Notification] Skipping notification for CREATOR_TO_BRAND proposal. proposalId={}",
53+
event.proposalId());
54+
return;
55+
}
56+
57+
try {
58+
String eventId = generateProposalSentEventId(event.proposalId(), event.isReProposal());
59+
String brandName = findBrandNameByUserId(event.brandUserId());
60+
61+
MessageTemplate template = messageTemplateService.createProposalReceivedMessage(brandName);
62+
63+
CreateNotificationCommand command = CreateNotificationCommand.builder()
64+
.eventId(eventId)
65+
.userId(event.creatorUserId())
66+
.kind(NotificationKind.PROPOSAL_RECEIVED)
67+
.title(template.title())
68+
.body(template.body())
69+
.referenceType(ReferenceType.CAMPAIGN_PROPOSAL)
70+
.referenceId(String.valueOf(event.proposalId()))
71+
.campaignId(event.campaignId())
72+
.proposalId(event.proposalId())
73+
.build();
74+
75+
notificationService.create(command);
76+
77+
LOG.info("[Notification] Created PROPOSAL_RECEIVED. eventId={}, proposalId={}, userId={}",
78+
eventId, event.proposalId(), event.creatorUserId());
79+
} catch (Exception e) {
80+
LOG.error("[Notification] Failed to create PROPOSAL_RECEIVED. proposalId={}, userId={}",
81+
event.proposalId(), event.creatorUserId(), e);
82+
}
83+
}
84+
85+
// ==================== CampaignProposalStatusChangedEvent ====================
86+
87+
/**
88+
* CampaignProposalStatusChangedEvent 구독.
89+
* <ul>
90+
* <li>MATCHED → 크리에이터에게 CAMPAIGN_MATCHED + 제안 보낸 사람에게 PROPOSAL_SENT(수락)</li>
91+
* <li>REJECTED → 제안 보낸 사람에게 PROPOSAL_SENT(거절)</li>
92+
* </ul>
93+
* 각 알림 생성은 독립적으로 예외 처리한다.
94+
*/
95+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
96+
public void handleCampaignProposalStatusChanged(CampaignProposalStatusChangedEvent event) {
97+
if (event == null) {
98+
LOG.warn("[Notification] Invalid CampaignProposalStatusChangedEvent: event is null");
99+
return;
100+
}
101+
102+
if (event.newStatus() == ProposalStatus.MATCHED) {
103+
// 1) 크리에이터에게 CAMPAIGN_MATCHED
104+
createCampaignMatchedNotification(event);
105+
// 2) 제안 보낸 사람에게 PROPOSAL_SENT (수락) — 독립 try-catch
106+
createProposalSentNotification(event, true);
107+
} else if (event.newStatus() == ProposalStatus.REJECTED) {
108+
createProposalSentNotification(event, false);
109+
}
110+
}
111+
112+
/**
113+
* CAMPAIGN_MATCHED 알림 생성 (크리에이터에게)
114+
*/
115+
private void createCampaignMatchedNotification(CampaignProposalStatusChangedEvent event) {
116+
try {
117+
String eventId = generateProposalStatusChangedEventId(event.proposalId(), event.newStatus());
118+
String brandName = findBrandNameByUserId(event.brandUserId());
119+
120+
MessageTemplate template = messageTemplateService.createCampaignMatchedMessage(brandName);
121+
122+
CreateNotificationCommand command = CreateNotificationCommand.builder()
123+
.eventId(eventId)
124+
.userId(event.creatorUserId())
125+
.kind(NotificationKind.CAMPAIGN_MATCHED)
126+
.title(template.title())
127+
.body(template.body())
128+
.referenceType(ReferenceType.CAMPAIGN_PROPOSAL)
129+
.referenceId(String.valueOf(event.proposalId()))
130+
.campaignId(event.campaignId())
131+
.proposalId(event.proposalId())
132+
.build();
133+
134+
notificationService.create(command);
135+
136+
LOG.info("[Notification] Created CAMPAIGN_MATCHED. eventId={}, proposalId={}, userId={}",
137+
eventId, event.proposalId(), event.creatorUserId());
138+
} catch (Exception e) {
139+
LOG.error("[Notification] Failed to create CAMPAIGN_MATCHED. proposalId={}, userId={}",
140+
event.proposalId(), event.creatorUserId(), e);
141+
}
142+
}
143+
144+
/**
145+
* PROPOSAL_SENT 알림 생성 (제안 보낸 사람에게, 수락/거절 공통)
146+
*/
147+
private void createProposalSentNotification(CampaignProposalStatusChangedEvent event, boolean isAccepted) {
148+
try {
149+
String eventId = generateProposalStatusChangedEventId(event.proposalId(), event.newStatus());
150+
// proposalDirection을 이용해 senderUserId 결정 (DB 조회 불필요)
151+
Long senderUserId = event.proposalDirection() == ProposalDirection.BRAND_TO_CREATOR
152+
? event.brandUserId()
153+
: event.creatorUserId();
154+
155+
String brandName = findBrandNameByUserId(event.brandUserId());
156+
157+
MessageTemplate template = messageTemplateService.createProposalSentMessage(brandName, isAccepted);
158+
159+
CreateNotificationCommand command = CreateNotificationCommand.builder()
160+
.eventId(eventId)
161+
.userId(senderUserId)
162+
.kind(NotificationKind.PROPOSAL_SENT)
163+
.title(template.title())
164+
.body(template.body())
165+
.referenceType(ReferenceType.CAMPAIGN_PROPOSAL)
166+
.referenceId(String.valueOf(event.proposalId()))
167+
.campaignId(event.campaignId())
168+
.proposalId(event.proposalId())
169+
.build();
170+
171+
notificationService.create(command);
172+
173+
String resultLabel = isAccepted ? "accepted" : "rejected";
174+
LOG.info("[Notification] Created PROPOSAL_SENT ({}). eventId={}, proposalId={}, userId={}",
175+
resultLabel, eventId, event.proposalId(), senderUserId);
176+
} catch (Exception e) {
177+
LOG.error("[Notification] Failed to create PROPOSAL_SENT. proposalId={}, newStatus={}",
178+
event.proposalId(), event.newStatus(), e);
179+
}
180+
}
181+
182+
// ==================== CampaignApplySentEvent ====================
183+
184+
/**
185+
* CampaignApplySentEvent 구독.
186+
* 브랜드에게 CAMPAIGN_APPLIED 알림 생성.
187+
*/
188+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
189+
public void handleCampaignApplySent(CampaignApplySentEvent event) {
190+
if (event == null) {
191+
LOG.warn("[Notification] Invalid CampaignApplySentEvent: event is null");
192+
return;
193+
}
194+
195+
try {
196+
String eventId = generateApplySentEventId(event.applyId());
197+
User creator = userRepository.findById(event.creatorUserId())
198+
.orElseThrow(() -> new IllegalStateException(
199+
"User not found: " + event.creatorUserId()));
200+
String creatorName = resolveDisplayName(creator);
201+
202+
MessageTemplate template = messageTemplateService.createCampaignAppliedMessage(creatorName);
203+
204+
CreateNotificationCommand command = CreateNotificationCommand.builder()
205+
.eventId(eventId)
206+
.userId(event.brandUserId())
207+
.kind(NotificationKind.CAMPAIGN_APPLIED)
208+
.title(template.title())
209+
.body(template.body())
210+
.referenceType(ReferenceType.CAMPAIGN_APPLY)
211+
.referenceId(String.valueOf(event.applyId()))
212+
.campaignId(event.campaignId())
213+
.build();
214+
215+
notificationService.create(command);
216+
217+
LOG.info("[Notification] Created CAMPAIGN_APPLIED. eventId={}, applyId={}, userId={}",
218+
eventId, event.applyId(), event.brandUserId());
219+
} catch (Exception e) {
220+
LOG.error("[Notification] Failed to create CAMPAIGN_APPLIED. applyId={}, userId={}",
221+
event.applyId(), event.brandUserId(), e);
222+
}
223+
}
224+
225+
// ==================== 공통 헬퍼 ====================
226+
227+
/**
228+
* brandUserId(User PK)로 Brand를 조회하여 brandName을 반환한다.
229+
*/
230+
private String findBrandNameByUserId(Long brandUserId) {
231+
Brand brand = brandRepository.findByUserId(brandUserId)
232+
.orElseThrow(() -> new IllegalStateException(
233+
"Brand not found for userId: " + brandUserId));
234+
return brand.getBrandName();
235+
}
236+
237+
/**
238+
* User의 표시 이름을 결정한다. (nickname 우선, 없으면 name)
239+
*/
240+
private String resolveDisplayName(User user) {
241+
if (user.getNickname() != null && !user.getNickname().isEmpty()) {
242+
return user.getNickname();
243+
}
244+
return user.getName();
245+
}
246+
247+
// ==================== EventId 생성 (멱등성 보장용) ====================
248+
249+
/**
250+
* CampaignProposalSentEvent의 결정적 eventId 생성
251+
*/
252+
private String generateProposalSentEventId(Long proposalId, boolean isReProposal) {
253+
String type = isReProposal ? "RE_PROPOSAL_SENT" : "PROPOSAL_SENT";
254+
return String.format("%s:%d", type, proposalId);
255+
}
256+
257+
/**
258+
* CampaignProposalStatusChangedEvent의 결정적 eventId 생성
259+
*/
260+
private String generateProposalStatusChangedEventId(Long proposalId, ProposalStatus newStatus) {
261+
return String.format("PROPOSAL_STATUS_CHANGED:%d:%s", proposalId, newStatus);
262+
}
263+
264+
/**
265+
* CampaignApplySentEvent의 결정적 eventId 생성
266+
*/
267+
private String generateApplySentEventId(Long applyId) {
268+
return String.format("APPLY_SENT:%d", applyId);
269+
}
270+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.example.RealMatch.notification.application.service;
2+
3+
import java.util.Collections;
4+
import java.util.EnumMap;
5+
import java.util.EnumSet;
6+
import java.util.Map;
7+
import java.util.Set;
8+
9+
import org.springframework.stereotype.Component;
10+
11+
import com.example.RealMatch.notification.domain.entity.enums.NotificationKind;
12+
import com.example.RealMatch.user.domain.entity.enums.NotificationChannel;
13+
14+
/**
15+
* NotificationKind별 즉시 발송 채널을 결정한다.
16+
*
17+
* PRD §5.2 기준:
18+
* - PROPOSAL_RECEIVED : PUSH만 (이메일은 1일 후 스케줄러에서 별도 처리)
19+
* - PROPOSAL_SENT : PUSH만
20+
* - CAMPAIGN_APPLIED : PUSH만
21+
* - CAMPAIGN_MATCHED : PUSH + EMAIL (매칭 직후 즉시 이메일)
22+
* - CAMPAIGN_COMPLETED: PUSH + EMAIL (데모데이 이후)
23+
* - SETTLEMENT_READY : PUSH만
24+
* - AUTO_CONFIRMED : EMAIL만 (데모데이 이후)
25+
* - CHAT_MESSAGE : PUSH만
26+
*/
27+
@Component
28+
public class NotificationChannelResolver {
29+
30+
private static final Map<NotificationKind, Set<NotificationChannel>> CHANNEL_MAP;
31+
32+
static {
33+
Map<NotificationKind, Set<NotificationChannel>> map = new EnumMap<>(NotificationKind.class);
34+
35+
map.put(NotificationKind.PROPOSAL_RECEIVED,
36+
EnumSet.of(NotificationChannel.PUSH));
37+
map.put(NotificationKind.PROPOSAL_SENT,
38+
EnumSet.of(NotificationChannel.PUSH));
39+
map.put(NotificationKind.CAMPAIGN_APPLIED,
40+
EnumSet.of(NotificationChannel.PUSH));
41+
map.put(NotificationKind.CAMPAIGN_MATCHED,
42+
EnumSet.of(NotificationChannel.PUSH, NotificationChannel.EMAIL));
43+
map.put(NotificationKind.CAMPAIGN_COMPLETED,
44+
EnumSet.of(NotificationChannel.PUSH, NotificationChannel.EMAIL));
45+
map.put(NotificationKind.SETTLEMENT_READY,
46+
EnumSet.of(NotificationChannel.PUSH));
47+
map.put(NotificationKind.AUTO_CONFIRMED,
48+
EnumSet.of(NotificationChannel.EMAIL));
49+
map.put(NotificationKind.CHAT_MESSAGE,
50+
EnumSet.of(NotificationChannel.PUSH));
51+
52+
CHANNEL_MAP = Collections.unmodifiableMap(map);
53+
}
54+
55+
public Set<NotificationChannel> resolveChannels(NotificationKind kind) {
56+
return CHANNEL_MAP.getOrDefault(kind, Collections.emptySet());
57+
}
58+
}

0 commit comments

Comments
 (0)