From 775f83e1b3825f18d89ee4562d325834c9a80795 Mon Sep 17 00:00:00 2001 From: Macbook Date: Fri, 29 May 2026 20:42:35 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EB=B4=89=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/LiveStrategyStartupRunner.java | 3 + .../com/goldenchart/dto/CandleBarDto.java | 7 ++ .../repository/GcTradeSignalRepository.java | 1 + .../service/LiveStrategySettingsService.java | 65 ++++++++++++++++++- .../service/TradeSignalService.java | 23 +++++-- .../com/goldenchart/websocket/BarBuilder.java | 14 ++-- .../websocket/LiveStrategyScheduler.java | 4 ++ frontend/src/App.tsx | 16 ++--- .../src/components/LiveSignalNotifier.tsx | 14 +--- .../src/contexts/TradeNotificationContext.tsx | 2 + frontend/src/hooks/useLiveStrategyMarkers.ts | 25 +++++++ frontend/src/utils/strategyConditionSerde.ts | 23 ++++++- frontend/src/utils/tradeSignalOwnership.ts | 38 +++++++++++ 13 files changed, 200 insertions(+), 35 deletions(-) create mode 100644 frontend/src/utils/tradeSignalOwnership.ts diff --git a/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java index dd0b859..6fc875b 100644 --- a/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java +++ b/backend/src/main/java/com/goldenchart/config/LiveStrategyStartupRunner.java @@ -5,6 +5,7 @@ import com.goldenchart.entity.GcWatchlist; import com.goldenchart.repository.GcWatchlistRepository; import com.goldenchart.service.AppSettingsService; import com.goldenchart.service.LiveStrategyEvaluator; +import com.goldenchart.service.LiveStrategySettingsService; import com.goldenchart.service.StrategyConditionTimeframeService; import com.goldenchart.websocket.DynamicSubscriptionManager; import com.goldenchart.websocket.UpbitWebSocketClient; @@ -27,11 +28,13 @@ public class LiveStrategyStartupRunner implements ApplicationRunner { private final GcWatchlistRepository watchlistRepo; private final DynamicSubscriptionManager subscriptionManager; private final LiveStrategyEvaluator liveStrategyEvaluator; + private final LiveStrategySettingsService liveStrategySettingsService; private final StrategyConditionTimeframeService conditionTimeframes; private final UpbitWebSocketClient upbitWebSocketClient; @Override public void run(ApplicationArguments args) { + liveStrategySettingsService.reconcileOrphanAndStaleSettings(); // 디바이스별 전역 설정이 켜져 있고 전략이 지정된 경우, DB 관심종목 전체 고정 구독 int pinned = 0; for (GcWatchlist w : watchlistRepo.findAll()) { diff --git a/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java b/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java index e078755..495546b 100644 --- a/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java +++ b/backend/src/main/java/com/goldenchart/dto/CandleBarDto.java @@ -44,4 +44,11 @@ public class CandleBarDto { /** 시그널이 발생한 평가 분봉 (예: 1m, 3m, 5m) */ private String candleType; + + /** 시그널 소유 — STOMP 수신 클라이언트가 본인 알림만 표시할 때 사용 */ + private Long strategyId; + private Long userId; + private String deviceId; + /** CANDLE_CLOSE | REALTIME_TICK */ + private String executionType; } diff --git a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java index c264d32..3351f5e 100644 --- a/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java +++ b/backend/src/main/java/com/goldenchart/repository/GcTradeSignalRepository.java @@ -13,6 +13,7 @@ public interface GcTradeSignalRepository extends JpaRepository findByDeviceIdOrderByCreatedAtDesc(String deviceId); List findByUserIdOrderByCreatedAtDesc(Long userId); List findByDeviceIdAndMarketOrderByCreatedAtDesc(String deviceId, String market); + List findByUserIdAndMarketOrderByCreatedAtDesc(Long userId, String market); @Modifying @Query("DELETE FROM GcTradeSignal e WHERE e.userId = :userId") diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java index b560cae..177395a 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategySettingsService.java @@ -6,6 +6,7 @@ import com.goldenchart.entity.GcAppSettings; import com.goldenchart.entity.GcLiveStrategySettings; import com.goldenchart.entity.GcWatchlist; import com.goldenchart.repository.GcLiveStrategySettingsRepository; +import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.repository.GcWatchlistRepository; import com.goldenchart.websocket.DynamicSubscriptionManager; import lombok.RequiredArgsConstructor; @@ -26,6 +27,7 @@ import java.util.Optional; public class LiveStrategySettingsService { private final GcLiveStrategySettingsRepository repo; + private final GcStrategyRepository strategyRepo; private final GcWatchlistRepository watchlistRepo; private final AppSettingsService appSettingsService; private final DynamicSubscriptionManager subscriptionManager; @@ -35,10 +37,11 @@ public class LiveStrategySettingsService { // ── 조회 ────────────────────────────────────────────────────────────────── - @Transactional(readOnly = true) + @Transactional public LiveStrategySettingsDto get(Long userId, String deviceId, String market) { GcAppSettings app = appSettingsService.getEntity(userId, deviceId); Optional entity = findEntity(userId, deviceId, market); + entity.ifPresent(this::reconcileOneRow); LiveStrategySettingsDto dto = entity.isPresent() ? toDto(entity.get()) : defaultDtoFromApp(market, app); @@ -48,12 +51,15 @@ public class LiveStrategySettingsService { return dto; } - @Transactional(readOnly = true) + @Transactional public List listActive(Long userId, String deviceId) { List rows = userId != null ? repo.findByUserIdAndIsLiveCheckTrue(userId) : repo.findByDeviceIdAndIsLiveCheckTrue( deviceId != null && !deviceId.isBlank() ? deviceId : "anonymous"); + for (GcLiveStrategySettings row : rows) { + reconcileOneRow(row); + } if (rows.isEmpty()) { return List.of(); } @@ -116,6 +122,61 @@ public class LiveStrategySettingsService { // ── 저장/갱신 — 전역 템플릿 + 관심종목 전체 동기화 ───────────────────────── + /** + * 기동·설정 조회 시 — strategy_id 누락·삭제 전략·회원 전역 전략 불일치 보정. + * + * @return 수정된 행 수 + */ + @Transactional + public int reconcileOrphanAndStaleSettings() { + int fixed = 0; + for (GcLiveStrategySettings row : repo.findAllByIsLiveCheckTrue()) { + if (reconcileOneRow(row)) { + fixed++; + } + } + if (fixed > 0) { + log.info("[LiveStrategySettings] reconcile fixed {} active row(s)", fixed); + } + return fixed; + } + + private boolean reconcileOneRow(GcLiveStrategySettings row) { + boolean changed = false; + GcAppSettings app = appSettingsService.getEntity(row.getUserId(), row.getDeviceId()); + + if (row.getStrategyId() == null) { + row.setIsLiveCheck(false); + changed = true; + } else if (!strategyRepo.existsById(row.getStrategyId())) { + if (row.getUserId() != null + && Boolean.TRUE.equals(app.getLiveStrategyCheck()) + && app.getLiveStrategyId() != null + && strategyRepo.existsById(app.getLiveStrategyId())) { + row.setStrategyId(app.getLiveStrategyId()); + } else { + row.setIsLiveCheck(false); + } + changed = true; + } else if (row.getUserId() != null + && Boolean.TRUE.equals(app.getLiveStrategyCheck()) + && app.getLiveStrategyId() != null + && !app.getLiveStrategyId().equals(row.getStrategyId()) + && strategyRepo.existsById(app.getLiveStrategyId())) { + row.setStrategyId(app.getLiveStrategyId()); + conditionTimeframes.invalidate(app.getLiveStrategyId()); + changed = true; + } + + if (changed) { + repo.save(row); + if (row.getMarket() != null) { + liveStrategyEvaluator.invalidateCache(row.getMarket()); + } + } + return changed; + } + public LiveStrategySettingsDto save(Long userId, String deviceId, LiveStrategySettingsDto dto) { if (!Boolean.TRUE.equals(dto.getSkipGlobalTemplate())) { diff --git a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java index 7adb36c..6320be1 100644 --- a/backend/src/main/java/com/goldenchart/service/TradeSignalService.java +++ b/backend/src/main/java/com/goldenchart/service/TradeSignalService.java @@ -2,6 +2,7 @@ package com.goldenchart.service; import com.goldenchart.dto.TradeSignalDto; import com.goldenchart.entity.GcTradeSignal; +import com.goldenchart.repository.GcStrategyRepository; import com.goldenchart.repository.GcTradeSignalRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +23,7 @@ import java.util.List; public class TradeSignalService { private final GcTradeSignalRepository repo; + private final GcStrategyRepository strategyRepo; private final FcmPushService fcmPushService; // ── 저장 ───────────────────────────────────────────────────────────────── @@ -31,12 +33,13 @@ public class TradeSignalService { String signalType, double price, long candleTime, String candleType, String executionType) { + String resolvedName = resolveStrategyName(strategyId, strategyName); GcTradeSignal entity = GcTradeSignal.builder() .deviceId(deviceId) .userId(userId) .market(market) .strategyId(strategyId) - .strategyName(strategyName) + .strategyName(resolvedName) .signalType(signalType) .price(price) .candleTime(candleTime) @@ -49,7 +52,7 @@ public class TradeSignalService { // 로그인 사용자는 live 설정에 userId만 있고 deviceId가 null — FCM은 userId 기준으로 발송 if (userId != null || (deviceId != null && !deviceId.isBlank())) { fcmPushService.sendTradeSignalIfEnabled( - deviceId, userId, market, signalType, price, strategyName, saved.getId()); + deviceId, userId, market, signalType, price, resolvedName, saved.getId()); } return toDto(saved); } @@ -66,9 +69,11 @@ public class TradeSignalService { @Transactional(readOnly = true) public List listByMarket(Long userId, String deviceId, String market) { - return repo.findByDeviceIdAndMarketOrderByCreatedAtDesc( - deviceId != null ? deviceId : "", market) - .stream().map(this::toDto).toList(); + List list = userId != null + ? repo.findByUserIdAndMarketOrderByCreatedAtDesc(userId, market) + : repo.findByDeviceIdAndMarketOrderByCreatedAtDesc( + deviceId != null ? deviceId : "", market); + return list.stream().map(this::toDto).toList(); } // ── 삭제 ───────────────────────────────────────────────────────────────── @@ -117,6 +122,14 @@ public class TradeSignalService { return deviceId.equals(entity.getDeviceId()); } + private String resolveStrategyName(Long strategyId, String strategyName) { + if (strategyName != null && !strategyName.isBlank()) return strategyName; + if (strategyId == null) return null; + return strategyRepo.findById(strategyId) + .map(s -> s.getName()) + .orElse(null); + } + // ── 변환 ───────────────────────────────────────────────────────────────── private TradeSignalDto toDto(GcTradeSignal e) { diff --git a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java index e9cd102..ff2dc9a 100644 --- a/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java +++ b/backend/src/main/java/com/goldenchart/websocket/BarBuilder.java @@ -196,7 +196,7 @@ public class BarBuilder { if (!"BUY".equals(closeSignal) && !"SELL".equals(closeSignal)) continue; - publishStrategySignal(market, candleType, signalBar, closeSignal); + publishStrategySignal(market, candleType, signalBar, closeSignal, s, signalExecType); try { tradeSignalService.save( s.getDeviceId(), s.getUserId(), @@ -216,8 +216,9 @@ public class BarBuilder { } } - /** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 */ - private void publishStrategySignal(String market, String candleType, Bar bar, String signal) { + /** 평가 분봉 STOMP 채널로 봉 마감 시그널 발행 (소유자·전략 메타 포함) */ + private void publishStrategySignal(String market, String candleType, Bar bar, String signal, + GcLiveStrategySettings setting, String executionType) { long barStartEpoch = bar.getEndTime().getEpochSecond() - bar.getTimePeriod().getSeconds(); CandleBarDto dto = CandleBarDto.builder() .time(barStartEpoch) @@ -228,9 +229,14 @@ public class BarBuilder { .volume(bar.getVolume().doubleValue()) .signal(signal) .candleType(candleType) + .strategyId(setting.getStrategyId()) + .userId(setting.getUserId()) + .deviceId(setting.getDeviceId()) + .executionType(executionType) .build(); broker.publish(market, candleType, dto); - log.info("[BarBuilder] bar-close signal={} market={} candleType={}", signal, market, candleType); + log.info("[BarBuilder] bar-close signal={} market={} candleType={} strategyId={} userId={}", + signal, market, candleType, setting.getStrategyId(), setting.getUserId()); } /** 현재 진행 중인 1분봉을 STOMP 로 발행 (실시간 틱 렌더링용, 시그널 없음). */ diff --git a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java index 0026c1d..0efa405 100644 --- a/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java +++ b/backend/src/main/java/com/goldenchart/websocket/LiveStrategyScheduler.java @@ -86,6 +86,10 @@ public class LiveStrategyScheduler { .volume(lastBar.getVolume().doubleValue()) .signal(signal) .candleType(candleType) + .strategyId(s.getStrategyId()) + .userId(s.getUserId()) + .deviceId(s.getDeviceId()) + .executionType("REALTIME_TICK") .build(); broker.publish(market, candleType, dto); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9a0acb6..ff23fda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -838,8 +838,12 @@ function App() { const filtered = activeLiveSettings.filter(s => monitoredMarkets.includes(s.market)); if (filtered.length > 0) return expandLiveStrategySubscriptions(filtered); if (marketSubscriptions.length > 0) return marketSubscriptions; - return monitoredMarkets.map(m => ({ market: m, candleType: '1m' })); - }, [monitoredMarkets, activeLiveSettings, marketSubscriptions]); + const types = liveStrategyCandleTypes?.length ? liveStrategyCandleTypes : []; + if (types.length > 0) { + return monitoredMarkets.flatMap(m => types.map(candleType => ({ market: m, candleType }))); + } + return []; + }, [monitoredMarkets, activeLiveSettings, marketSubscriptions, liveStrategyCandleTypes]); useEffect(() => { if (monitoredMarkets.length === 0) { @@ -1683,6 +1687,7 @@ function App() { popupEnabled={appDefaults.tradeAlertPopup ?? true} soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true} soundId={appDefaults.tradeAlertSound ?? 'bell'} + settingsSessionKey={authUser?.userId ?? 0} > 0} popupEnabled={appDefaults.tradeAlertPopup ?? true} - strategyId={liveStrategySettings.strategyId ?? undefined} - executionType={liveStrategySettings.executionType ?? 'CANDLE_CLOSE'} - strategyName={liveStrategyName} - strategyNameByMarket={liveStrategyNameByMarket} - executionTypeByMarket={Object.fromEntries( - activeLiveSettings.map(s => [s.market, s.executionType ?? 'CANDLE_CLOSE']), - )} chartMarkersActive={menuPage === 'chart'} onMarkersChange={markers => { managerRef.current?.setLiveStrategyMarkers(markers, appDefaults.btShowPrice); diff --git a/frontend/src/components/LiveSignalNotifier.tsx b/frontend/src/components/LiveSignalNotifier.tsx index 171f3f1..59128d5 100644 --- a/frontend/src/components/LiveSignalNotifier.tsx +++ b/frontend/src/components/LiveSignalNotifier.tsx @@ -16,11 +16,6 @@ interface Props { activeMarket: string; enabled: boolean; popupEnabled: boolean; - strategyId: number | undefined; - executionType: string; - strategyName: string | null; - strategyNameByMarket?: Record; - executionTypeByMarket?: Record; onMarkersChange: (markers: LiveMarker[]) => void; /** 차트 화면일 때만 마커를 차트에 반영 */ chartMarkersActive: boolean; @@ -32,10 +27,6 @@ export const LiveSignalNotifier = forwardRef(fu activeMarket, enabled, popupEnabled: _popupEnabled, - strategyName, - executionType, - strategyNameByMarket, - executionTypeByMarket, onMarkersChange, chartMarkersActive, }, ref) { @@ -63,11 +54,10 @@ export const LiveSignalNotifier = forwardRef(fu signalType: marker.signal, price: marker.price, candleTime: marker.time, - strategyName: strategyNameByMarket?.[marker.market] ?? strategyName, - executionType: executionTypeByMarket?.[marker.market] ?? executionType, + strategyId: marker.strategyId ?? undefined, + executionType: marker.executionType ?? undefined, candleType: marker.candleType ?? '1m', }); - // STOMP만 수신·DB 저장 누락 시 배지 동기화 window.setTimeout(() => { void refreshHistory(); }, 800); }, }); diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index e0ff50e..32bfc5d 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -187,6 +187,7 @@ export const TradeNotificationProvider: React.FC = ({ } for (const n of prev) { if (hidden.has(n.id)) continue; + // 서버 이력에 없는 STOMP-only 항목은 세션 전환 후 제거 (serverOnly가 아닐 때만 병합) if (!map.has(n.id)) map.set(n.id, n); else if (n.isRead) map.set(n.id, { ...map.get(n.id)!, isRead: true }); } @@ -213,6 +214,7 @@ export const TradeNotificationProvider: React.FC = ({ useEffect(() => { if (!settingsLoaded) return; + setToastNotifications([]); void refreshHistory({ serverOnly: true, rawServerList: true }); }, [refreshHistory, settingsLoaded, settingsSessionKey]); diff --git a/frontend/src/hooks/useLiveStrategyMarkers.ts b/frontend/src/hooks/useLiveStrategyMarkers.ts index 8e26566..7b89e0a 100644 --- a/frontend/src/hooks/useLiveStrategyMarkers.ts +++ b/frontend/src/hooks/useLiveStrategyMarkers.ts @@ -9,6 +9,7 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { parseStompJson } from '../utils/stompMessage'; import { subscribeStompTopic } from '../utils/stompChartBroker'; +import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership'; export interface LiveMarker { time: number; @@ -16,6 +17,10 @@ export interface LiveMarker { price: number; market: string; candleType: string; + strategyId?: number | null; + userId?: number | null; + deviceId?: string | null; + executionType?: string | null; } export interface MarketCandleSub { @@ -57,9 +62,17 @@ export function useLiveStrategyMarkers({ const pushMarker = useCallback((market: string, candleType: string, data: { time: number; close: number; signal: string; + strategyId?: number | null; + userId?: number | null; + deviceId?: string | null; + executionType?: string | null; }) => { if (data.signal !== 'BUY' && data.signal !== 'SELL') return; + if (!signalBelongsToCurrentSession({ userId: data.userId, deviceId: data.deviceId })) { + return; + } + const key = `${market}:${candleType}:${data.time}:${data.signal}`; if (seenRef.current.has(key)) return; seenRef.current.add(key); @@ -70,6 +83,10 @@ export function useLiveStrategyMarkers({ price: data.close, market, candleType, + strategyId: data.strategyId, + userId: data.userId, + deviceId: data.deviceId, + executionType: data.executionType, }; const prev = markersByMarketRef.current[market] ?? []; @@ -120,6 +137,10 @@ export function useLiveStrategyMarkers({ close: number; signal?: string; candleType?: string; + strategyId?: number | null; + userId?: number | null; + deviceId?: string | null; + executionType?: string | null; }>(msg); if (!data?.signal || data.signal === 'NONE') return; const resolvedCandleType = data.candleType ?? ct; @@ -127,6 +148,10 @@ export function useLiveStrategyMarkers({ time: data.time, close: data.close, signal: data.signal, + strategyId: data.strategyId, + userId: data.userId, + deviceId: data.deviceId, + executionType: data.executionType, }); }); }); diff --git a/frontend/src/utils/strategyConditionSerde.ts b/frontend/src/utils/strategyConditionSerde.ts index cde0d0f..aa6bad9 100644 --- a/frontend/src/utils/strategyConditionSerde.ts +++ b/frontend/src/utils/strategyConditionSerde.ts @@ -155,7 +155,9 @@ export function updateStartCandleTypes( candleTypes: string[], ): EditorConditionState { const types = normalizeCandleTypesList(candleTypes); - const primary = types[0]; + // 조건 노드 left/rightCandleType 동기화는 "대표 분봉"에만 사용되므로, + // multi 분봉에서 stale 1m 이 앞에 오는 경우 1m 로 동기화되지 않도록 non-1m 을 우선한다. + const primary = types.find(ct => normalizeStartCandleType(ct) !== '1m') ?? types[0]; const nextMeta = { ...state.startMeta, [startId]: { candleTypes: types, candleType: primary }, @@ -197,7 +199,13 @@ function normalizeCandleTypesList(types: string[]): string[] { out.push(ct); } } - return out.length > 0 ? out : [DEFAULT_START_CANDLE]; + if (out.length === 0) return [DEFAULT_START_CANDLE]; + // 레거시/불일치로 multi 분봉에 stale 1m 이 섞인 경우가 있어, + // multi 인데 non-1m 이 있으면 1m 을 기본적으로 제외한다. + if (out.length > 1 && out.includes('1m') && out.some(ct => ct !== '1m')) { + return out.filter(ct => ct !== '1m'); + } + return out; } function readTimeframeCandleTypes(node: LogicNode): string[] { @@ -467,7 +475,16 @@ export function mergeStartMetaForLoad( }; for (const [startId, meta] of Object.entries(decoded)) { if (stored[startId]) { - const types = normalizeCandleTypesList(getStartCandleTypes(stored[startId])); + const storedTypes = normalizeCandleTypesList(getStartCandleTypes(stored[startId])); + const decodedTypes = normalizeCandleTypesList(getStartCandleTypes(meta)); + // flow_layout_json 에 stale 1m 이 남아 있는 경우가 있어, + // DSL 에 1m 이 없고 non-1m 이 있으면 DSL 쪽을 우선한다. + const shouldPreferDecoded = + storedTypes.includes('1m') + && storedTypes.some(ct => ct !== '1m') + && !decodedTypes.includes('1m') + && decodedTypes.some(ct => ct !== '1m'); + const types = shouldPreferDecoded ? decodedTypes : storedTypes; merged[startId] = { candleTypes: types, candleType: types[0] }; } else { const types = normalizeCandleTypesList(getStartCandleTypes(meta)); diff --git a/frontend/src/utils/tradeSignalOwnership.ts b/frontend/src/utils/tradeSignalOwnership.ts new file mode 100644 index 0000000..ad85f9a --- /dev/null +++ b/frontend/src/utils/tradeSignalOwnership.ts @@ -0,0 +1,38 @@ +/** + * STOMP·실시간 시그널 소유자 검증 — 로그인 사용자는 user_id, 비로그인은 device_id 기준. + */ +export interface SignalOwnership { + userId?: number | null; + deviceId?: string | null; +} + +function readDeviceId(): string { + return localStorage.getItem('gc_device_id') ?? ''; +} + +function readUserId(): number | null { + const raw = localStorage.getItem('gc_user_id'); + if (!raw) return null; + const id = Number(raw); + return Number.isFinite(id) && id > 0 ? id : null; +} + +export function getCurrentSignalSession(): { userId: number | null; deviceId: string } { + return { userId: readUserId(), deviceId: readDeviceId() }; +} + +/** STOMP 페이로드·DB 시그널이 현재 세션 소유인지 */ +export function signalBelongsToCurrentSession(owner: SignalOwnership): boolean { + const { userId, deviceId } = getCurrentSignalSession(); + + if (owner.userId != null && owner.userId > 0) { + return userId != null && userId === owner.userId; + } + + if (owner.deviceId != null && owner.deviceId !== '') { + return userId == null && owner.deviceId === deviceId; + } + + // 소유자 메타 없음(레거시 브로드캐스트) — 타 사용자 시그널 유입 방지 + return false; +}