Files
goldenChart/backend/src/main/java/com/goldenchart/service/FcmPushService.java
T
2026-05-28 16:24:14 +09:00

184 lines
7.4 KiB
Java

package com.goldenchart.service;
import com.goldenchart.entity.GcFcmToken;
import com.goldenchart.repository.GcFcmTokenRepository;
import com.google.firebase.FirebaseApp;
import com.google.firebase.messaging.AndroidConfig;
import com.google.firebase.messaging.AndroidNotification;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.MessagingErrorCode;
import com.google.firebase.messaging.Notification;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class FcmPushService {
/** Capacitor Android 앱 알림 채널 (app/android strings.xml 과 동일) */
private static final String ANDROID_CHANNEL_ID = "goldenchart_trade_signals";
private final GcFcmTokenRepository tokenRepo;
private final AppSettingsService appSettingsService;
@Value("${firebase.enabled:false}")
private boolean firebaseEnabled;
@Transactional
public void registerToken(Long userId, String deviceId, String token) {
if (token == null || token.isBlank()) return;
String dev = deviceId != null ? deviceId : "anonymous";
Optional<GcFcmToken> existing = tokenRepo.findByToken(token);
if (existing.isPresent()) {
GcFcmToken t = existing.get();
t.setUserId(userId);
t.setDeviceId(dev);
t.setActive(true);
t.setLastUsedAt(LocalDateTime.now());
tokenRepo.save(t);
} else {
tokenRepo.save(GcFcmToken.builder()
.token(token)
.userId(userId)
.deviceId(dev)
.active(true)
.lastUsedAt(LocalDateTime.now())
.build());
}
log.info("[FCM] token registered device={}", dev);
}
@Transactional
public void deleteByDevice(String deviceId) {
if (deviceId != null) tokenRepo.deleteByDeviceId(deviceId);
}
public boolean isAvailable() {
return firebaseEnabled && !FirebaseApp.getApps().isEmpty();
}
/**
* 전략 조건 일치(매매 시그널) 시 FCM 푸시 — fcm_push_enabled + 등록된 토큰 대상.
*/
public void sendTradeSignalIfEnabled(String deviceId, Long userId, String market,
String signalType, double price,
String strategyName, Long signalId) {
if (!isAvailable()) {
log.debug("[FCM] skip trade_signal {} {} — Firebase unavailable", market, signalType);
return;
}
var app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getFcmPushEnabled())) {
log.debug("[FCM] skip trade_signal {} — fcm_push_enabled=false userId={} deviceId={}",
market, userId, deviceId);
return;
}
List<GcFcmToken> tokens = resolveTokens(userId, deviceId);
if (tokens.isEmpty()) {
log.info("[FCM] no active tokens for trade_signal market={} userId={} deviceId={}",
market, userId, deviceId);
return;
}
String title = "BUY".equals(signalType) ? "🟢 매수 시그널" : "🔴 매도 시그널";
String body = String.format("%s · ₩%,.0f%s",
market, price,
strategyName != null && !strategyName.isBlank() ? " · " + strategyName : "");
int sent = 0;
for (GcFcmToken t : tokens) {
if (sendTradeSignalMessage(t, title, body, market, signalType, price, signalId)) {
sent++;
}
}
log.info("[FCM] trade_signal sent {}/{} tokens market={} {}", sent, tokens.size(), market, signalType);
}
private List<GcFcmToken> resolveTokens(Long userId, String deviceId) {
if (userId != null) {
List<GcFcmToken> byUser = tokenRepo.findByUserIdAndActiveTrue(userId);
if (!byUser.isEmpty()) return byUser;
}
if (deviceId != null && !deviceId.isBlank()) {
return tokenRepo.findByDeviceIdAndActiveTrue(deviceId);
}
return List.of();
}
private boolean sendTradeSignalMessage(GcFcmToken t, String title, String body,
String market, String signalType,
double price, Long signalId) {
try {
Message msg = Message.builder()
.setToken(t.getToken())
.setNotification(Notification.builder().setTitle(title).setBody(body).build())
.setAndroidConfig(AndroidConfig.builder()
.setPriority(AndroidConfig.Priority.HIGH)
.setNotification(AndroidNotification.builder()
.setChannelId(ANDROID_CHANNEL_ID)
.build())
.build())
.putData("type", "trade_signal")
.putData("signalId", signalId != null ? String.valueOf(signalId) : "")
.putData("market", market != null ? market : "")
.putData("signalType", signalType != null ? signalType : "")
.putData("price", String.valueOf(price))
.build();
FirebaseMessaging.getInstance().send(msg);
t.setLastUsedAt(LocalDateTime.now());
tokenRepo.save(t);
return true;
} catch (FirebaseMessagingException e) {
log.warn("[FCM] send failed tokenId={} code={}", t.getId(), e.getMessagingErrorCode());
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED
|| e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
t.setActive(false);
tokenRepo.save(t);
}
return false;
} catch (Exception e) {
log.warn("[FCM] send error: {}", e.getMessage());
return false;
}
}
public void sendTest(Long userId, String deviceId) {
if (!isAvailable()) {
log.warn("[FCM] test skipped — Firebase not initialized");
return;
}
List<GcFcmToken> tokens = resolveTokens(userId, deviceId);
if (tokens.isEmpty()) {
log.warn("[FCM] test skipped — no tokens userId={} deviceId={}", userId, deviceId);
return;
}
for (GcFcmToken t : tokens) {
try {
Message msg = Message.builder()
.setToken(t.getToken())
.setNotification(Notification.builder()
.setTitle("GoldenChart 테스트")
.setBody("FCM 푸시가 정상 동작합니다.")
.build())
.putData("type", "test")
.build();
FirebaseMessaging.getInstance().send(msg);
} catch (Exception e) {
log.warn("[FCM] test send failed: {}", e.getMessage());
}
}
}
}