From aa79be196ab2dd7979746d629670bb038056dcf1 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 1 Jun 2026 02:12:55 +0900 Subject: [PATCH] =?UTF-8?q?404=EC=97=90=EB=9F=AC=20=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TradeNotificationListRow.tsx | 6 +- frontend/src/hooks/useAppSettings.ts | 9 ++ frontend/src/utils/backendApi.ts | 33 ++-- frontend/src/utils/strategyMissingCache.ts | 63 ++++++++ .../src/utils/strategyReferenceSanitize.ts | 145 ++++++++++++++++++ 5 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 frontend/src/utils/strategyMissingCache.ts create mode 100644 frontend/src/utils/strategyReferenceSanitize.ts diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index 8140156..2f58101 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -7,7 +7,7 @@ import type { TickerData } from '../../hooks/useMarketTicker'; import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash'; import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot'; import type { StrategyDto } from '../../utils/backendApi'; -import { loadStrategies, loadStrategy } from '../../utils/backendApi'; +import { isStrategyMissingOnServer, loadStrategies, loadStrategy } from '../../utils/backendApi'; import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext'; import { buildSignalDetailRows, @@ -118,7 +118,9 @@ const TradeNotificationListRow: React.FC = ({ void loadStrategies().then(list => { if (!cancelled) setStrategies(list ?? []); }); - if (!item.strategyId) return () => { cancelled = true; }; + if (!item.strategyId || isStrategyMissingOnServer(item.strategyId)) { + return () => { cancelled = true; }; + } void loadStrategy(item.strategyId).then(s => { if (!cancelled && s) setStrategy(s); }); diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index c054c89..d26ab11 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -27,7 +27,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { + hasRegisteredUser, loadAppSettings, + loadStrategies, saveAppSettings, type AppSettingsDto, } from '../utils/backendApi'; @@ -106,6 +108,13 @@ function ensureLoaded(): Promise { } } } + if (hasRegisteredUser()) { + try { + await loadStrategies(); + } catch (e) { + console.warn('[useAppSettings] strategies preload failed', e); + } + } _loadPromise = null; if (generation === _loadGeneration) { notifyAppSettingsListeners(); diff --git a/frontend/src/utils/backendApi.ts b/frontend/src/utils/backendApi.ts index 55155f8..ca02607 100644 --- a/frontend/src/utils/backendApi.ts +++ b/frontend/src/utils/backendApi.ts @@ -6,6 +6,12 @@ */ import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage'; +import { + isStrategyMissingOnServer, + markStrategyMissingFromPath, +} from './strategyMissingCache'; + +export { isStrategyMissingOnServer } from './strategyMissingCache'; /** 백엔드 REST base (Docker 빌드: `/api`, 로컬 Vite: `.env` 또는 localhost:8080) */ export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api'; @@ -90,18 +96,6 @@ async function requestOrThrow(path: string, init?: RequestInit): Promise { return JSON.parse(text) as T; } -/** 서버에 없는 전략 ID — 삭제·만료 후 반복 404 방지 (태블릿·Safari 네트워크 로그 완화) */ -const strategyMissingIds = new Set(); - -export function isStrategyMissingOnServer(strategyId: number): boolean { - return strategyId > 0 && strategyMissingIds.has(strategyId); -} - -function markStrategyMissingFromPath(pathOnly: string): void { - const m = pathOnly.match(/^\/strategies\/(\d+)(?:\/|$)/); - if (m) strategyMissingIds.add(Number(m[1])); -} - async function request(path: string, init?: RequestInit): Promise { try { const res = await fetch(`${API_BASE}${path}`, { @@ -1073,15 +1067,22 @@ export interface StrategyDto { updatedAt?: string; } -/** 전략 목록 로드 */ +/** 전략 목록 로드 + localStorage·설정의 stale strategyId 정리 */ export async function loadStrategies(): Promise { if (!hasRegisteredUser()) return []; - return (await request('/strategies')) ?? []; + const list = (await request('/strategies')) ?? []; + try { + const { applyStrategyReferenceSanitize } = await import('./strategyReferenceSanitize'); + await applyStrategyReferenceSanitize(list); + } catch (e) { + console.warn('[backendApi] strategy reference sanitize failed', e); + } + return list; } /** 단일 전략 로드 */ export async function loadStrategy(id: number): Promise { - if (!hasRegisteredUser() || id <= 0 || strategyMissingIds.has(id)) return null; + if (!hasRegisteredUser() || id <= 0 || isStrategyMissingOnServer(id)) return null; return request(`/strategies/${id}`); } @@ -1426,7 +1427,7 @@ const repairInflight = new Set(); /** DB DSL TIMEFRAME 래핑 보정 — 전략 없으면 호출 안 함(404 방지) */ export async function repairStrategyTimeframes(strategyId: number): Promise { - if (!hasRegisteredUser() || strategyId <= 0 || strategyMissingIds.has(strategyId)) return false; + if (!hasRegisteredUser() || strategyId <= 0 || isStrategyMissingOnServer(strategyId)) return false; if (repairSkipIds.has(strategyId) || repairInflight.has(strategyId)) return false; repairInflight.add(strategyId); diff --git a/frontend/src/utils/strategyMissingCache.ts b/frontend/src/utils/strategyMissingCache.ts new file mode 100644 index 0000000..d1b4122 --- /dev/null +++ b/frontend/src/utils/strategyMissingCache.ts @@ -0,0 +1,63 @@ +/** + * 서버에 없는 전략 ID — 세션 간 404 반복 방지 (sessionStorage) + */ +const STORAGE_KEY = 'gc_strategy_missing_ids_v1'; +const MAX_STORED = 64; + +const strategyMissingIds = new Set(); + +function persistStrategyMissingCache(): void { + try { + const arr = [...strategyMissingIds].filter(id => id > 0).slice(-MAX_STORED); + if (arr.length === 0) sessionStorage.removeItem(STORAGE_KEY); + else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(arr)); + } catch { + /* private mode · 용량 */ + } +} + +/** 앱 기동 시 이전 탭에서 기록한 missing ID 복원 */ +export function hydrateStrategyMissingCache(): void { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return; + for (const n of parsed) { + const id = Number(n); + if (id > 0) strategyMissingIds.add(id); + } + } catch { + try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* ok */ } + } +} + +export function isStrategyMissingOnServer(strategyId: number): boolean { + return strategyId > 0 && strategyMissingIds.has(strategyId); +} + +export function markStrategyMissingOnServer(strategyId: number): void { + if (strategyId <= 0) return; + strategyMissingIds.add(strategyId); + persistStrategyMissingCache(); +} + +/** 전략 목록에 다시 나타난 ID 는 missing 에서 제거 */ +export function reconcileStrategyMissingWithValidIds(validIds: Iterable): void { + const valid = new Set(validIds); + let changed = false; + for (const id of strategyMissingIds) { + if (valid.has(id)) { + strategyMissingIds.delete(id); + changed = true; + } + } + if (changed) persistStrategyMissingCache(); +} + +export function markStrategyMissingFromPath(pathOnly: string): void { + const m = pathOnly.match(/^\/strategies\/(\d+)(?:\/|$)/); + if (m) markStrategyMissingOnServer(Number(m[1])); +} + +hydrateStrategyMissingCache(); diff --git a/frontend/src/utils/strategyReferenceSanitize.ts b/frontend/src/utils/strategyReferenceSanitize.ts new file mode 100644 index 0000000..d907179 --- /dev/null +++ b/frontend/src/utils/strategyReferenceSanitize.ts @@ -0,0 +1,145 @@ +/** + * 삭제·만료된 전략 ID — localStorage·ui_preferences·app-settings 정리 + */ +import type { StrategyDto, AppSettingsDto } from './backendApi'; +import { saveAppSettings } from './backendApi'; +import { + markStrategyMissingOnServer, + reconcileStrategyMissingWithValidIds, +} from './strategyMissingCache'; +import { getAppSettingsCache } from '../hooks/useAppSettings'; +import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb'; +import type { UiPreferences } from '../types/uiPreferences'; +import type { VirtualSessionConfig, VirtualTargetItem } from './virtualTradingStorage'; + +const LEGACY_TARGETS_KEY = 'gc_virtual_targets_v1'; + +function validStrategyIdSet(list: StrategyDto[]): Set { + return new Set( + list.map(s => s.id).filter((id): id is number => id != null && id > 0), + ); +} + +function readLegacyTargets(): VirtualTargetItem[] { + try { + const raw = localStorage.getItem(LEGACY_TARGETS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as VirtualTargetItem[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function writeLegacyTargets(items: VirtualTargetItem[]): void { + try { + if (items.length === 0) localStorage.removeItem(LEGACY_TARGETS_KEY); + else localStorage.setItem(LEGACY_TARGETS_KEY, JSON.stringify(items)); + } catch { + /* ok */ + } +} + +function sanitizeLegacyTargets(validIds: Set): boolean { + const legacy = readLegacyTargets(); + if (legacy.length === 0) return false; + + let changed = false; + const next = legacy.map(t => { + if (t.strategyId != null && t.strategyId > 0 && !validIds.has(t.strategyId)) { + markStrategyMissingOnServer(t.strategyId); + changed = true; + return { ...t, strategyId: null }; + } + return t; + }); + + if (changed) writeLegacyTargets(next); + return changed; +} + +function buildVirtualPatch( + validIds: Set, +): Partial> | null { + const ui = getUiPreferences(); + const virtual = ui.virtual; + if (!virtual) return null; + + const patch: Partial> = {}; + let changed = false; + + const session = virtual.session; + const gid = session?.globalStrategyId; + if (session && gid != null && gid > 0 && !validIds.has(gid)) { + markStrategyMissingOnServer(gid); + const nextSession: VirtualSessionConfig = { + globalStrategyId: null, + executionType: session.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE', + positionMode: session.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY', + running: session.running === true, + }; + patch.session = nextSession; + changed = true; + } + + const targets = virtual.targets; + if (targets?.length) { + let targetsChanged = false; + const nextTargets = targets.map(t => { + if (t.strategyId != null && t.strategyId > 0 && !validIds.has(t.strategyId)) { + markStrategyMissingOnServer(t.strategyId); + targetsChanged = true; + return { ...t, strategyId: null }; + } + return t; + }); + if (targetsChanged) { + patch.targets = nextTargets; + changed = true; + } + } + + return changed ? patch : null; +} + +function buildAppSettingsPatch(validIds: Set): AppSettingsDto | null { + const cache = getAppSettingsCache(); + const sid = cache.liveStrategyId; + if (sid == null || sid <= 0 || validIds.has(sid)) return null; + + markStrategyMissingOnServer(sid); + return { + liveStrategyId: null, + liveStrategyCheck: false, + }; +} + +/** + * 전략 목록 로드 직후 호출 — stale ID 제거·missing 캐시 동기화 + */ +export async function applyStrategyReferenceSanitize(strategies: StrategyDto[]): Promise { + const validIds = validStrategyIdSet(strategies); + reconcileStrategyMissingWithValidIds(validIds); + + const legacyChanged = sanitizeLegacyTargets(validIds); + const virtualPatch = buildVirtualPatch(validIds); + const appPatch = buildAppSettingsPatch(validIds); + + if (virtualPatch) { + patchUiPreferences({ virtual: virtualPatch }, true); + } + + if (appPatch) { + try { + const saved = await saveAppSettings(appPatch); + if (saved) Object.assign(getAppSettingsCache(), saved); + } catch (e) { + console.warn('[strategySanitize] app-settings 저장 실패:', e); + Object.assign(getAppSettingsCache(), appPatch); + } + } + + if (legacyChanged || virtualPatch || appPatch) { + console.info('[strategySanitize] 삭제된 전략 ID 참조를 정리했습니다.'); + } +}