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 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 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 resolveTokens(Long userId, String deviceId) { if (userId != null) { List 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 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()); } } } }