/** * 실시간 매매 시그널 알림 Context * * - STOMP 수신 시 토스트 스택(미확인)에 추가 * - 알림 목록·읽음 상태는 localStorage + 서버 trade-signals 이력 병합 */ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import type { TradeSignalInfo } from '../components/TradeAlertModal'; import { deleteAllTradeSignals, deleteTradeSignal, deleteTradeSignalsBatch, loadTradeSignals, type TradeSignalDto, } from '../utils/backendApi'; import { normalizeTradeAlertSoundId, playTradeAlertSound, type TradeAlertSoundId, } from '../utils/tradeAlertSound'; import { normalizeStrategyId } from '../utils/resolveNotificationStrategy'; import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb'; import { useAppSettings } from '../hooks/useAppSettings'; const MAX_HISTORY = 300; /** 팝업 대기열 최대 건수 (페이지 슬라이딩) */ const MAX_TOAST_QUEUE = 50; export interface TradeNotificationItem extends TradeSignalInfo { /** 클라이언트 고유 키 (market:time:signal) */ id: string; dbId?: number; isRead: boolean; receivedAt: number; } interface TradeNotificationContextValue { /** 우측 스택에 표시 중인 미확인 알림 */ toastNotifications: TradeNotificationItem[]; /** 전체 이력 (목록 화면) */ allNotifications: TradeNotificationItem[]; unreadCount: number; addNotification: (signal: TradeSignalInfo & { dbId?: number }) => void; dismissToast: (id: string) => void; /** 지정 id 알림만 팝업 대기열에서 닫기 (읽음 처리) */ dismissToasts: (ids: string[]) => void; /** 팝업 대기열(toastNotifications) 전체 닫기 — 모든 페이지 포함 */ dismissAllToasts: () => void; markAsRead: (id: string) => void; markAllAsRead: () => void; /** 알림 목록에서 단건 삭제 */ deleteNotification: (id: string) => Promise; /** 지정 id 알림 일괄 삭제 (목록 선택 삭제) */ deleteNotifications: (ids: string[]) => Promise; /** 읽지 않은(미확인) 알림만 삭제 */ deleteUnreadNotifications: () => Promise; /** 알림 목록 전체 삭제 */ deleteAllNotifications: () => Promise; refreshHistory: (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => Promise; detailSignal: TradeSignalInfo | null; /** 상세 모달을 화면 중앙에 표시 (알림 목록에서 열 때) */ detailCentered: boolean; openDetail: (item: TradeNotificationItem, options?: { centered?: boolean }) => void; closeDetail: () => void; } const TradeNotificationContext = createContext(null); function makeId(signal: Pick): string { return `${signal.market}:${signal.candleTime}:${signal.signalType}`; } function dtoToItem(dto: TradeSignalDto, readIds: Set): TradeNotificationItem { const id = `${dto.market}:${dto.candleTime}:${dto.signalType}`; return { id, dbId: dto.id, market: dto.market, signalType: dto.signalType, price: dto.price, candleTime: dto.candleTime, strategyName: dto.strategyName, strategyId: normalizeStrategyId(dto.strategyId) ?? undefined, executionType: dto.executionType, candleType: dto.candleType, isRead: readIds.has(id), receivedAt: new Date(dto.createdAt).getTime() || dto.candleTime * 1000, }; } function loadReadIds(): Set { const ids = getUiPreferences().tradeNotifications?.readIds; return new Set(ids ?? []); } function saveReadIds(ids: Set, immediate = false) { patchUiPreferences({ tradeNotifications: { readIds: [...ids] } }, immediate); } function loadHiddenIds(): Set { const ids = getUiPreferences().tradeNotifications?.hiddenIds; return new Set(ids ?? []); } function saveHiddenIds(ids: Set) { patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } }); } /** 전체닫기·삭제 시각 (epoch 초) 로드 */ function loadSuppressBefore(): number { return getUiPreferences().tradeNotifications?.suppressBefore ?? 0; } /** 전체닫기·삭제 시각 저장 */ function saveSuppressBefore(ts: number, immediate = false) { patchUiPreferences({ tradeNotifications: { suppressBefore: ts } }, immediate); } export function useTradeNotification(): TradeNotificationContextValue { const ctx = useContext(TradeNotificationContext); if (!ctx) { throw new Error('useTradeNotification must be used within TradeNotificationProvider'); } return ctx; } interface ProviderProps { children: React.ReactNode; /** 매매 알림 팝업 ON (설정) */ popupEnabled: boolean; /** 매매 알림 사운드 ON (설정) */ soundEnabled?: boolean; /** 알림음 ID */ soundId?: string; /** 로그인·게스트 전환 시 app-settings 재로드 키 */ settingsSessionKey?: number; } export const TradeNotificationProvider: React.FC = ({ children, popupEnabled, soundEnabled = true, soundId = 'bell', settingsSessionKey = 0, }) => { const { isLoaded: settingsLoaded } = useAppSettings(settingsSessionKey); const alertSoundId = normalizeTradeAlertSoundId(soundId) as TradeAlertSoundId; const [readIds, setReadIds] = useState>(() => loadReadIds()); const readIdsRef = useRef(readIds); readIdsRef.current = readIds; const [toastNotifications, setToastNotifications] = useState([]); const toastNotificationsRef = useRef(toastNotifications); toastNotificationsRef.current = toastNotifications; const [allNotifications, setAllNotifications] = useState([]); const allNotificationsRef = useRef(allNotifications); allNotificationsRef.current = allNotifications; /** * addNotification 에서 수신된 모든 알림 ID 를 동기적으로 누적. * dismissAllToasts 가 호출될 때 allNotificationsRef 가 stale 한 경우에도 * 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다. */ const allSeenIdsRef = useRef>(new Set()); /** * 전체닫기·삭제 실행 시각 (epoch 초). * candleTime < suppressBefore 인 오래된 신호는 팝업을 띄우지 않는다. * 백엔드 backfill 이 STOMP 로 수십 일치 과거 신호를 재전송해도 무시한다. */ const suppressBeforeRef = useRef(loadSuppressBefore()); const [detailSignal, setDetailSignal] = useState(null); const [detailCentered, setDetailCentered] = useState(false); const unreadCount = useMemo( () => allNotifications.filter(n => !n.isRead).length, [allNotifications], ); const refreshHistory = useCallback(async (opts?: { serverOnly?: boolean; rawServerList?: boolean }) => { try { const dtos = await loadTradeSignals(); /** * readIds 의 최신 상태를 가져올 때: * - readIdsRef.current : dismissAllToasts / markAllAsRead 직후 즉시 반영되는 인메모리 값 * - loadReadIds() : DB 캐시 기반 값 (약간의 지연 가능) * 두 Set 의 합집합(union)을 사용하면 어느 쪽 타이밍 경쟁에서도 읽음 상태가 보존된다. */ const cacheRead = loadReadIds(); const memRead = readIdsRef.current; const suppressBefore = suppressBeforeRef.current; /** * suppressBefore 이전 캔들 신호: readIds 에 추가하고 isRead=true 처리. * DB 에 남아있는 오래된 신호가 폴링에서 미읽음으로 잡히는 것을 방지한다. */ const suppressed = dtos.filter( d => suppressBefore > 0 && (d.candleTime ?? 0) < suppressBefore, ); let effectiveRead = new Set([...cacheRead, ...memRead]); if (suppressed.length > 0) { const suppressedIds = suppressed.map( d => `${d.market}:${d.candleTime}:${d.signalType}`, ); const nextRead = new Set([...effectiveRead, ...suppressedIds]); if (nextRead.size !== effectiveRead.size) { readIdsRef.current = nextRead; effectiveRead = nextRead; saveReadIds(nextRead, true); } } const read = effectiveRead; const hidden = opts?.rawServerList ? new Set() : loadHiddenIds(); const items = dtos .map(d => dtoToItem(d, read)) .filter(item => opts?.rawServerList || !hidden.has(item.id)); let newlyArrived: TradeNotificationItem[] = []; setAllNotifications(prev => { if (!opts?.rawServerList) { /** * 일반 폴링·serverOnly 경로: 이미 목록에 없는 미읽음 신호만 신규 도착으로 판정. */ newlyArrived = items.filter( item => !item.isRead && !readIdsRef.current.has(item.id) && // readIds 에 없는(진짜 새) 알림만 !prev.some(p => p.id === item.id), // 이미 목록에 없는 알림만 ); } else if (popupEnabled) { /** * rawServerList=true (앱 시작·로그인 직후) + 팝업 ON: * * 기존 로직은 서버 이력을 "실시간 신규 알림"으로 취급하지 않아 * suppressBefore 이후 생성된 미읽음 신호가 배지에는 표시되지만 * 팝업은 전혀 뜨지 않는 문제가 있었다. * * 서버 재시작·짧은 연결 끊김 등으로 STOMP 를 놓친 신호도 팝업으로 * 보여줘야 하므로, suppressBefore 이후에 생성된 미읽음 신호 전체를 * newlyArrived 에 포함시킨다. * * suppressBefore == 0 (전체닫기 이력 없음) 인 경우 최근 2시간 이내 * 신호로 범위를 제한해 과도한 팝업을 방지한다. */ const cutoff = suppressBeforeRef.current > 0 ? suppressBeforeRef.current * 1000 // 마지막 전체닫기 이후 신호 : Date.now() - 2 * 60 * 60 * 1000; // 또는 최근 2시간 newlyArrived = items.filter( item => !item.isRead && !readIdsRef.current.has(item.id) && item.receivedAt > cutoff, ); } if (opts?.serverOnly || opts?.rawServerList) { return items .sort((a, b) => b.receivedAt - a.receivedAt) .slice(0, MAX_HISTORY); } const map = new Map(); for (const n of items) { const wasRead = read.has(n.id) || prev.find(p => p.id === n.id)?.isRead; map.set(n.id, wasRead ? { ...n, isRead: true } : n); } 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 }); } return [...map.values()] .filter(n => !hidden.has(n.id)) .sort((a, b) => b.receivedAt - a.receivedAt) .slice(0, MAX_HISTORY); }); if (popupEnabled && newlyArrived.length > 0) { setToastNotifications(tprev => { const map = new Map(tprev.map(t => [t.id, t])); for (const n of newlyArrived) map.set(n.id, n); return [...map.values()].slice(0, MAX_TOAST_QUEUE); }); // rawServerList (앱 시작) 시 기존 미읽음 신호 팝업은 사운드 없이 조용히 표시 if (soundEnabled && !opts?.rawServerList) { playTradeAlertSound(alertSoundId); } } } catch (e) { console.warn('[TradeNotification] 이력 로드 실패:', e); } }, [popupEnabled, soundEnabled, alertSoundId]); const prevSessionKeyRef = useRef(settingsSessionKey); useEffect(() => { if (!settingsLoaded) return; const isSessionSwitch = prevSessionKeyRef.current !== settingsSessionKey; prevSessionKeyRef.current = settingsSessionKey; /** * 앱 설정이 DB 에서 로드 완료된 시점에 readIds 를 즉시 동기화한다. * STOMP 가 앱 시작 직후 구독 시 readIds 가 아직 빈 상태여서 이미 닫은 * 알림이 다시 팝업으로 뜨는 타이밍 버그를 방지한다. * 세션 전환(로그인·로그아웃) 시 allSeenIdsRef 를 초기화해 이전 세션 ID 잔류 방지. */ allSeenIdsRef.current = new Set(); const dbRead = loadReadIds(); const merged = new Set([...readIdsRef.current, ...dbRead]); if (merged.size !== readIdsRef.current.size) { readIdsRef.current = merged; setReadIds(merged); } // 전체닫기·삭제 시각 복원 (재접속 후에도 오래된 신호 팝업 방지) suppressBeforeRef.current = loadSuppressBefore(); /** * [버그 수정] 기존 코드는 항상 setToastNotifications([]) 로 초기화했는데, * 이 경우 STOMP 가 설정 로드보다 먼저 도착한 토스트까지 소거되는 race condition 이 있었다. * 세션 전환(로그인·로그아웃) 시에만 초기화하고, 단순 설정 로드 완료 시에는 * 초기화하지 않는다 — rawServerList 로드가 최신 토스트를 올바르게 추가한다. */ if (isSessionSwitch) { setToastNotifications([]); } void refreshHistory({ serverOnly: true, rawServerList: true }); }, [refreshHistory, settingsLoaded, settingsSessionKey]); const markAsRead = useCallback((id: string) => { setReadIds(prev => { const next = new Set(prev); next.add(id); saveReadIds(next); readIdsRef.current = next; return next; }); setToastNotifications(prev => prev.filter(n => n.id !== id)); setAllNotifications(prev => prev.map(n => (n.id === id ? { ...n, isRead: true } : n)), ); }, []); const addNotification = useCallback((signal: TradeSignalInfo & { dbId?: number }) => { const id = makeId(signal); // 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조) allSeenIdsRef.current.add(id); /** * ── 오래된 신호 억제 (핵심 필터) ─────────────────────────────────────── * 전체닫기·삭제 시각(suppressBefore) 이전에 시작된 캔들의 신호는 * 백엔드 backfill 이 STOMP 로 재전송해도 팝업을 띄우지 않는다. * candleTime 은 epoch 초 단위이고 suppressBefore 도 epoch 초이다. */ const isSuppressed = suppressBeforeRef.current > 0 && signal.candleTime < suppressBeforeRef.current; /** * 읽음 여부 3중 확인: * 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref * 2. loadReadIds() — DB 캐시 기반 값 * 3. allNotificationsRef — functional updater 가 isRead:true 예약 후 ref 미반영 커버 */ const alreadyRead = isSuppressed || readIdsRef.current.has(id) || loadReadIds().has(id) || (allNotificationsRef.current.find(n => n.id === id)?.isRead === true); // readIds 에 누락된 읽음 항목 보정 if (alreadyRead && !readIdsRef.current.has(id)) { const next = new Set(readIdsRef.current); next.add(id); readIdsRef.current = next; saveReadIds(next); } setAllNotifications(prev => { const existing = prev.find(n => n.id === id); if (existing) { // 읽음 처리된 항목은 isRead 를 false 로 되돌리지 않는다 if (existing.isRead || alreadyRead) return prev; return prev.map(n => n.id === id ? { ...n, isRead: false, receivedAt: Date.now() } : n, ); } const item: TradeNotificationItem = { ...signal, id, dbId: signal.dbId, candleType: signal.candleType ?? '1m', isRead: alreadyRead, receivedAt: Date.now(), }; return [item, ...prev].slice(0, MAX_HISTORY); }); // 이미 읽음 처리된 알림은 팝업·알림음 모두 생략 if (alreadyRead) return; if (soundEnabled) { playTradeAlertSound(alertSoundId); } if (!popupEnabled) return; const toastItem: TradeNotificationItem = { ...signal, id, dbId: signal.dbId, candleType: signal.candleType ?? '1m', isRead: false, receivedAt: Date.now(), }; setToastNotifications(prev => { const rest = prev.filter(n => n.id !== id); return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE); }); }, [popupEnabled, soundEnabled, alertSoundId]); const dismissToast = useCallback((id: string) => { markAsRead(id); }, [markAsRead]); const dismissToasts = useCallback((ids: string[]) => { if (ids.length === 0) return; const idSet = new Set(ids); const toastSnapshot = toastNotificationsRef.current; setReadIds(r => { const next = new Set(r); ids.forEach(i => next.add(i)); saveReadIds(next); readIdsRef.current = next; return next; }); setToastNotifications(prev => prev.filter(n => !idSet.has(n.id))); setAllNotifications(all => { const byId = new Map(all.map(n => [n.id, n])); for (const id of ids) { const existing = byId.get(id); if (existing) { byId.set(id, { ...existing, isRead: true }); } else { const fromToast = toastSnapshot.find(n => n.id === id); if (fromToast) byId.set(id, { ...fromToast, isRead: true }); } } return [...byId.values()] .sort((a, b) => b.receivedAt - a.receivedAt) .slice(0, MAX_HISTORY); }); }, []); const dismissAllToasts = useCallback(() => { /** * 전체 닫기 — 모든 알림을 읽음 처리하고 즉시 DB 저장. * * allSeenIdsRef: addNotification 에서 동기적으로 누적된 ID 집합. * - allNotificationsRef.current 가 stale 하더라도(배치 미반영) * 동일 틱에 수신된 STOMP 신호 ID 도 포함 → readIds 에서 누락 없음. * side-effect(readIdsRef, saveReadIds)를 functional updater 밖에서 실행하여 * React concurrent 렌더링에서의 중복 실행 문제를 방지. */ const nextRead = new Set([ ...readIdsRef.current, ...toastNotificationsRef.current.map(n => n.id), ...allNotificationsRef.current.map(n => n.id), ...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID ]); // 전체닫기 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지 const nowSec = Math.floor(Date.now() / 1000); suppressBeforeRef.current = nowSec; saveSuppressBefore(nowSec, true); // 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조) readIdsRef.current = nextRead; // 2) React state 갱신 setReadIds(nextRead); setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); setToastNotifications([]); // 3) 인메모리 캐시 + 즉시 DB 저장 (immediate=true) saveReadIds(nextRead, true); }, []); const markAllAsRead = useCallback(() => { const nextRead = new Set([ ...readIdsRef.current, ...allNotificationsRef.current.map(n => n.id), ...allSeenIdsRef.current, ]); readIdsRef.current = nextRead; setReadIds(nextRead); setAllNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); setToastNotifications([]); saveReadIds(nextRead, true); }, []); const purgeNotifications = useCallback((ids: string[]) => { if (ids.length === 0) return; const idSet = new Set(ids); // hiddenIds: 목록에서 숨김 처리 const hidden = loadHiddenIds(); ids.forEach(id => hidden.add(id)); saveHiddenIds(hidden); /** * readIds 에서 삭제하지 않고 오히려 추가한다. * 삭제된 ID 를 readIds 에서 제거하면, 백엔드가 동일 신호를 STOMP 로 재전송할 때 * alreadyRead = false 가 되어 팝업이 다시 뜨는 버그가 발생한다. */ const nextRead = new Set([...readIdsRef.current, ...ids]); readIdsRef.current = nextRead; setReadIds(nextRead); saveReadIds(nextRead, true); setToastNotifications(prev => prev.filter(n => !idSet.has(n.id))); setAllNotifications(prev => prev.filter(n => !idSet.has(n.id))); if (detailSignal) { const detailId = makeId({ market: detailSignal.market, candleTime: detailSignal.candleTime, signalType: detailSignal.signalType, }); if (idSet.has(detailId)) { setDetailSignal(null); setDetailCentered(false); } } }, [detailSignal]); const findNotificationById = useCallback((id: string): TradeNotificationItem | undefined => { return allNotifications.find(n => n.id === id) ?? toastNotificationsRef.current.find(n => n.id === id); }, [allNotifications]); const deleteNotifications = useCallback(async (ids: string[]) => { const unique = [...new Set(ids)].filter(Boolean); if (unique.length === 0) return; const dbIds: number[] = []; for (const id of unique) { const item = findNotificationById(id); if (item?.dbId != null) dbIds.push(item.dbId); } purgeNotifications(unique); if (dbIds.length > 0) { try { await deleteTradeSignalsBatch(dbIds); } catch (e) { console.warn('[TradeNotification] 일괄 삭제 실패:', e); } } }, [findNotificationById, purgeNotifications]); const deleteNotification = useCallback(async (id: string) => { await deleteNotifications([id]); }, [deleteNotifications]); const deleteUnreadNotifications = useCallback(async () => { const unread = allNotifications.filter(n => !n.isRead); if (unread.length === 0) return; await deleteNotifications(unread.map(n => n.id)); }, [allNotifications, deleteNotifications]); const deleteAllNotifications = useCallback(async () => { const ids = [ ...new Set([ ...allNotifications.map(n => n.id), ...toastNotificationsRef.current.map(n => n.id), ...allSeenIdsRef.current, ]), ]; // 전체 삭제 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지 const nowSec = Math.floor(Date.now() / 1000); suppressBeforeRef.current = nowSec; saveSuppressBefore(nowSec, true); /** * readIds 에 삭제된 ID 를 추가 (초기화 X). * 백엔드가 동일 신호를 STOMP 로 재전송해도 suppressBefore + readIds 이중 필터. */ const nextRead = new Set([...readIdsRef.current, ...ids]); readIdsRef.current = nextRead; setReadIds(nextRead); saveReadIds(nextRead, true); // hiddenIds 초기화 (전체 삭제 후 목록 완전히 비움) saveHiddenIds(new Set()); setToastNotifications([]); setAllNotifications([]); if (detailSignal) { setDetailSignal(null); setDetailCentered(false); } try { await deleteAllTradeSignals(); } catch (e) { console.warn('[TradeNotification] 전체 삭제 실패:', e); } }, [allNotifications, detailSignal]); const openDetail = useCallback((item: TradeNotificationItem, options?: { centered?: boolean }) => { setDetailCentered(options?.centered ?? false); setDetailSignal({ market: item.market, signalType: item.signalType, price: item.price, candleTime: item.candleTime, strategyName: item.strategyName, strategyId: item.strategyId, executionType: item.executionType, candleType: item.candleType, }); markAsRead(item.id); }, [markAsRead]); const closeDetail = useCallback(() => { setDetailSignal(null); setDetailCentered(false); }, []); const value: TradeNotificationContextValue = { toastNotifications, allNotifications, unreadCount, addNotification, dismissToast, dismissToasts, dismissAllToasts, markAsRead, markAllAsRead, deleteNotification, deleteNotifications, deleteUnreadNotifications, deleteAllNotifications, refreshHistory, detailSignal, detailCentered, openDetail, closeDetail, }; return ( {children} ); };