검증게시판 단계 추가

This commit is contained in:
Macbook
2026-05-28 16:24:14 +09:00
parent f64dc1e983
commit 7e3644cb62
375 changed files with 4539 additions and 251294 deletions
+47
View File
@@ -0,0 +1,47 @@
# Backend FCM — 전략 시그널 → Android 푸시
## 흐름
```
BarBuilder / LiveStrategyScheduler
→ TradeSignalService.save(userId, deviceId, …)
→ FcmPushService.sendTradeSignalIfEnabled(…)
→ Firebase Cloud Messaging → Android 앱
```
## 발송 조건 (모두 필요)
1. `firebase.enabled=true` + `firebase-service-account.json` 유효
2. 해당 사용자/기기 `gc_app_settings.fcm_push_enabled = true`
3. `gc_fcm_token` 에 활성 토큰 등록 (앱 로그인 후 자동 등록)
4. 로그인 사용자: `userId` 기준 토큰 조회 (모바일·웹 deviceId 달라도 OK)
## exdev 서버 설정
```bash
# .env 또는 docker-compose 환경
FIREBASE_ENABLED=true
FIREBASE_SERVICE_ACCOUNT=/app/firebase-service-account.json
```
`docker-compose.yml` 볼륨 (주석 해제):
```yaml
- ${FIREBASE_SERVICE_ACCOUNT_PATH}:/app/firebase-service-account.json:ro
```
goldenApp과 동일 프로젝트 **goldenanalysisapp** 서비스 계정 JSON 사용.
## 확인
```bash
curl http://exdev.co.kr/api/fcm/status
# {"available":true}
# 앱에서 FCM 푸시 ON 후
curl -X POST http://exdev.co.kr/api/fcm/test \
-H "x-user-id: 1" \
-H "x-device-id: <앱 device id>"
```
로그: `[FCM] trade_signal sent N/M tokens market=KRW-BTC BUY`
@@ -5,6 +5,10 @@ public enum VerificationIssueStage {
REGISTERED,
IN_FIX,
FIX_COMPLETE,
/** 확인 요청 — 검증 담당자 확인 대기 */
CONFIRM_REQUEST,
/** 재검증 요청 — 수정 후 재검증 필요 */
RE_VERIFY_REQUEST,
RE_REGISTERED,
COMPLETE
}
@@ -14,5 +14,7 @@ public interface GcFcmTokenRepository extends JpaRepository<GcFcmToken, Long> {
List<GcFcmToken> findByDeviceIdAndActiveTrue(String deviceId);
List<GcFcmToken> findByUserIdAndActiveTrue(Long userId);
void deleteByDeviceId(String deviceId);
}
@@ -3,7 +3,13 @@ package com.goldenchart.service;
import com.goldenchart.entity.GcFcmToken;
import com.goldenchart.repository.GcFcmTokenRepository;
import com.google.firebase.FirebaseApp;
import com.google.firebase.messaging.*;
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;
@@ -19,6 +25,9 @@ import java.util.Optional;
@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;
@@ -59,51 +68,90 @@ public class FcmPushService {
}
/**
* 매매 시그널 FCM — deviceId 일치 토큰 + fcm_push_enabled 설정 시.
* 전략 조건 일치(매매 시그널) 시 FCM 푸시 — fcm_push_enabled + 등록된 토큰 대상.
*/
public void sendTradeSignalIfEnabled(String deviceId, Long userId, String market,
String signalType, double price,
String strategyName, Long signalId) {
if (!isAvailable() || deviceId == null) return;
var app = appSettingsService.getEntity(userId, deviceId);
if (!Boolean.TRUE.equals(app.getFcmPushEnabled())) return;
List<GcFcmToken> tokens = tokenRepo.findByDeviceIdAndActiveTrue(deviceId);
if (tokens.isEmpty()) {
tokens = tokenRepo.findByActiveTrue();
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;
}
if (tokens.isEmpty()) return;
String title = "BUY".equals(signalType) ? "🟢 매수 시그널" : "🔴 매도 시그널";
String body = String.format("%s · ₩%,.0f%s",
market, price,
strategyName != null ? " · " + strategyName : "");
strategyName != null && !strategyName.isBlank() ? " · " + strategyName : "");
int sent = 0;
for (GcFcmToken t : tokens) {
try {
Message msg = Message.builder()
.setToken(t.getToken())
.setNotification(Notification.builder().setTitle(title).setBody(body).build())
.putData("type", "trade_signal")
.putData("signalId", signalId != null ? String.valueOf(signalId) : "")
.putData("market", market)
.putData("signalType", signalType)
.putData("price", String.valueOf(price))
.build();
FirebaseMessaging.getInstance().send(msg);
t.setLastUsedAt(LocalDateTime.now());
tokenRepo.save(t);
} catch (FirebaseMessagingException e) {
log.warn("[FCM] send failed: {}", e.getMessagingErrorCode());
if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED
|| e.getMessagingErrorCode() == MessagingErrorCode.INVALID_ARGUMENT) {
t.setActive(false);
tokenRepo.save(t);
}
} catch (Exception e) {
log.warn("[FCM] send error: {}", e.getMessage());
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) {
@@ -111,9 +159,11 @@ public class FcmPushService {
log.warn("[FCM] test skipped — Firebase not initialized");
return;
}
List<GcFcmToken> tokens = deviceId != null
? tokenRepo.findByDeviceIdAndActiveTrue(deviceId)
: tokenRepo.findByActiveTrue();
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()
@@ -44,8 +44,10 @@ public class TradeSignalService {
.executionType(executionType)
.build();
GcTradeSignal saved = repo.save(entity);
log.info("[TradeSignal] saved {} {} @ {} market={}", signalType, candleType, price, market);
if (deviceId != null) {
log.info("[TradeSignal] saved {} {} @ {} market={} userId={} deviceId={}",
signalType, candleType, price, market, userId, deviceId);
// 로그인 사용자는 live 설정에 userId만 있고 deviceId가 null — FCM은 userId 기준으로 발송
if (userId != null || (deviceId != null && !deviceId.isBlank())) {
fcmPushService.sendTradeSignalIfEnabled(
deviceId, userId, market, signalType, price, strategyName, saved.getId());
}
@@ -23,6 +23,12 @@ goldenchart:
allowed-origins:
- "*"
# FCM — exdev: FIREBASE_ENABLED=true + 서비스 계정 JSON 마운트
firebase:
enabled: ${FIREBASE_ENABLED:false}
# Docker 볼륨 마운트 시: FIREBASE_SERVICE_ACCOUNT=/app/firebase-service-account.json
service-account-file: ${FIREBASE_SERVICE_ACCOUNT:firebase-service-account.json}
logging:
level:
com.goldenchart: INFO
@@ -0,0 +1,2 @@
-- 검증 이슈 단계: 확인요청(CONFIRM_REQUEST), 재검증 요청(RE_VERIFY_REQUEST)
-- stage 컬럼은 VARCHAR 이므로 기존 행 마이그레이션 불필요. 신규 enum 값만 애플리케이션에서 사용.