Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,18 @@ jobs:
AWS_ACCESS_KEY_ID: AWSACCESSKEYIDISSECRET
AWS_SECRET_ACCESS_KEY: AWSSECRETACCESSKEYISSECRET
# FRONT
FRONT_DOMAIN_URL: http://localhost:3000
FRONT_DOMAIN_URL_V2: http://localhost:3000
FRONT_DOMAIN_URL_LOCAL: http://localhost:3000
# FCM
FCM_CREDENTIALS_PATH: ""
FCM_PROJECT_ID: ""
# SMTP
MAIL_HOST: smtp.gmail.com
MAIL_PORT: 587
MAIL_USERNAME: test@example.com
MAIL_PASSWORD: test_password
MAIL_FROM: test@example.com

steps:
- name: Checkout code
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ src/test/resources/application.yml
.env
.env.*

# Firebase 서비스 계정 키
config/firebase-service-account.json
**/firebase-service-account.json
**/*firebase-adminsdk*.json

### Gradle ###
.gradle/
build/
Expand Down
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ dependencies {
// Spring Retry
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'

// Spring Mail
implementation 'org.springframework.boot:spring-boot-starter-mail'

// Firebase Admin SDK (FCM 푸시 알림)
implementation 'com.google.firebase:firebase-admin:9.2.0'
}

tasks.named('test') {
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ services:
- rabbitmq
env_file:
- .env
volumes:
- ./config/firebase-service-account.json:/app/config/firebase-service-account.json:ro
networks:
- realmatch-network

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.RealMatch.business.application.event;

/**
* 캠페인이 자동으로 확정되었을 때 발행되는 이벤트
*/
public record AutoConfirmedEvent(
Long campaignId,
Long brandUserId,
Long creatorUserId,
String campaignName
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.RealMatch.business.application.event;

/**
* 캠페인이 완료되었을 때 발행되는 이벤트
*/
public record CampaignCompletedEvent(
Long campaignId,
Long brandUserId,
Long creatorUserId,
String campaignName
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.example.RealMatch.business.application.event;

/**
* 정산이 가능한 상태가 되었을 때 발행되는 이벤트
*/
public record SettlementReadyEvent(
Long campaignId,
Long creatorUserId,
Long brandUserId,
String campaignName,
Long settlementAmount
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@

import com.example.RealMatch.brand.domain.entity.Brand;
import com.example.RealMatch.brand.domain.repository.BrandRepository;
import com.example.RealMatch.business.application.event.AutoConfirmedEvent;
import com.example.RealMatch.business.application.event.CampaignApplySentEvent;
import com.example.RealMatch.business.application.event.CampaignCompletedEvent;
import com.example.RealMatch.business.application.event.CampaignProposalSentEvent;
import com.example.RealMatch.business.application.event.CampaignProposalStatusChangedEvent;
import com.example.RealMatch.business.application.event.SettlementReadyEvent;
import com.example.RealMatch.business.domain.enums.ProposalDirection;
import com.example.RealMatch.business.domain.enums.ProposalStatus;
import com.example.RealMatch.notification.application.dto.CreateNotificationCommand;
Expand Down Expand Up @@ -222,6 +225,129 @@ public void handleCampaignApplySent(CampaignApplySentEvent event) {
}
}

// ==================== 데모데이 이후용: CampaignCompletedEvent ====================

/**
* CampaignCompletedEvent 구독.
* 크리에이터에게 CAMPAIGN_COMPLETED 알림 생성.
* (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작)
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCampaignCompleted(CampaignCompletedEvent event) {
if (event == null) {
LOG.warn("[Notification] Invalid CampaignCompletedEvent: event is null");
return;
}

try {
String eventId = String.format("CAMPAIGN_COMPLETED:%d", event.campaignId());
String brandName = findBrandNameByUserId(event.brandUserId());

MessageTemplate template = messageTemplateService.createCampaignCompletedMessage(brandName);

CreateNotificationCommand command = CreateNotificationCommand.builder()
.eventId(eventId)
.userId(event.creatorUserId())
.kind(NotificationKind.CAMPAIGN_COMPLETED)
.title(template.title())
.body(template.body())
.referenceType(ReferenceType.CAMPAIGN)
.referenceId(String.valueOf(event.campaignId()))
.campaignId(event.campaignId())
.build();

notificationService.create(command);

LOG.info("[Notification] Created CAMPAIGN_COMPLETED. eventId={}, campaignId={}, userId={}",
eventId, event.campaignId(), event.creatorUserId());
} catch (Exception e) {
LOG.error("[Notification] Failed to create CAMPAIGN_COMPLETED. campaignId={}, userId={}",
event.campaignId(), event.creatorUserId(), e);
}
}

// ==================== 데모데이 이후용: SettlementReadyEvent ====================

/**
* SettlementReadyEvent 구독.
* 크리에이터에게 SETTLEMENT_READY 알림 생성.
* (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작)
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleSettlementReady(SettlementReadyEvent event) {
if (event == null) {
LOG.warn("[Notification] Invalid SettlementReadyEvent: event is null");
return;
}

try {
String eventId = String.format("SETTLEMENT_READY:%d", event.campaignId());
String brandName = findBrandNameByUserId(event.brandUserId());

MessageTemplate template = messageTemplateService.createSettlementReadyMessage(brandName);

CreateNotificationCommand command = CreateNotificationCommand.builder()
.eventId(eventId)
.userId(event.creatorUserId())
.kind(NotificationKind.SETTLEMENT_READY)
.title(template.title())
.body(template.body())
.referenceType(ReferenceType.CAMPAIGN)
.referenceId(String.valueOf(event.campaignId()))
.campaignId(event.campaignId())
.build();

notificationService.create(command);

LOG.info("[Notification] Created SETTLEMENT_READY. eventId={}, campaignId={}, userId={}",
eventId, event.campaignId(), event.creatorUserId());
} catch (Exception e) {
LOG.error("[Notification] Failed to create SETTLEMENT_READY. campaignId={}, userId={}",
event.campaignId(), event.creatorUserId(), e);
}
}

// ==================== 데모데이 이후용: AutoConfirmedEvent ====================

/**
* AutoConfirmedEvent 구독.
* 브랜드에게 AUTO_CONFIRMED 알림 생성.
* (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작)
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAutoConfirmed(AutoConfirmedEvent event) {
if (event == null) {
LOG.warn("[Notification] Invalid AutoConfirmedEvent: event is null");
return;
}

try {
String eventId = String.format("AUTO_CONFIRMED:%d", event.campaignId());
String brandName = findBrandNameByUserId(event.brandUserId());

MessageTemplate template = messageTemplateService.createAutoConfirmedMessage(brandName);

CreateNotificationCommand command = CreateNotificationCommand.builder()
.eventId(eventId)
.userId(event.brandUserId())
.kind(NotificationKind.AUTO_CONFIRMED)
.title(template.title())
.body(template.body())
.referenceType(ReferenceType.CAMPAIGN)
.referenceId(String.valueOf(event.campaignId()))
.campaignId(event.campaignId())
.build();

notificationService.create(command);

LOG.info("[Notification] Created AUTO_CONFIRMED. eventId={}, campaignId={}, userId={}",
eventId, event.campaignId(), event.brandUserId());
} catch (Exception e) {
LOG.error("[Notification] Failed to create AUTO_CONFIRMED. campaignId={}, userId={}",
event.campaignId(), event.brandUserId(), e);
}
}

// ==================== 공통 헬퍼 ====================

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.example.RealMatch.notification.application.service;

import java.util.Optional;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.RealMatch.notification.domain.entity.FcmToken;
import com.example.RealMatch.notification.domain.repository.FcmTokenRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional
public class FcmTokenService {

private static final Logger LOG = LoggerFactory.getLogger(FcmTokenService.class);

private final FcmTokenRepository fcmTokenRepository;

/**
* FCM 토큰을 등록한다.
* 동일한 토큰이 이미 존재하면 소유자를 현재 유저로 재할당한다 (디바이스 로그아웃→재로그인 대응).
*/
public void registerToken(Long userId, String token, String deviceInfo) {
Optional<FcmToken> existing = fcmTokenRepository.findByToken(token);

if (existing.isPresent()) {
FcmToken fcmToken = existing.get();
if (!fcmToken.getUserId().equals(userId)) {
fcmToken.reassignTo(userId);
LOG.info("[FCM] Token reassigned. token={}..., newUserId={}", token.substring(0, Math.min(token.length(), 10)), userId);
}
return;
}

FcmToken fcmToken = FcmToken.builder()
.userId(userId)
.token(token)
.deviceInfo(deviceInfo)
.build();
fcmTokenRepository.save(fcmToken);
LOG.info("[FCM] Token registered. userId={}, deviceInfo={}", userId, deviceInfo);
}

/**
* FCM 토큰을 삭제한다 (로그아웃 시 호출).
*/
public void removeToken(String token) {
fcmTokenRepository.deleteByToken(token);
LOG.info("[FCM] Token removed. token={}...", token.substring(0, Math.min(10, token.length())));
}

/**
* 유저의 모든 FCM 토큰을 삭제한다 (회원 탈퇴 시 호출).
*/
public void removeAllTokensByUserId(Long userId) {
fcmTokenRepository.deleteByUserId(userId);
LOG.info("[FCM] All tokens removed. userId={}", userId);
}
}
Loading
Loading