diff --git a/backend/src/main/java/com/goldenchart/controller/BacktestSettingsController.java b/backend/src/main/java/com/goldenchart/controller/BacktestSettingsController.java index 7d872d9..9546fa7 100644 --- a/backend/src/main/java/com/goldenchart/controller/BacktestSettingsController.java +++ b/backend/src/main/java/com/goldenchart/controller/BacktestSettingsController.java @@ -16,14 +16,25 @@ public class BacktestSettingsController { @GetMapping public ResponseEntity get( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId) { - return ResponseEntity.ok(service.get(deviceId)); + return ResponseEntity.ok(service.get(parseUserId(userIdHeader), deviceId)); } @PostMapping public ResponseEntity save( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, @RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId, @RequestBody BacktestSettingsDto dto) { - return ResponseEntity.ok(service.save(deviceId, dto)); + return ResponseEntity.ok(service.save(parseUserId(userIdHeader), deviceId, dto)); + } + + private Long parseUserId(String header) { + if (header == null || header.isBlank()) return null; + try { + return Long.parseLong(header); + } catch (NumberFormatException e) { + return null; + } } } diff --git a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java index 51b969a..3779cf8 100644 --- a/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java +++ b/backend/src/main/java/com/goldenchart/entity/GcBacktestSettings.java @@ -22,6 +22,9 @@ public class GcBacktestSettings { @Column(name = "device_id", length = 100) private String deviceId; + @Column(name = "user_id") + private Long userId; + // ── 자본 설정 ────────────────────────────────────────────────────────────── @Column(name = "initial_capital", nullable = false, precision = 20, scale = 2) @Builder.Default diff --git a/backend/src/main/java/com/goldenchart/repository/GcBacktestSettingsRepository.java b/backend/src/main/java/com/goldenchart/repository/GcBacktestSettingsRepository.java index 0b116f1..f4f409b 100644 --- a/backend/src/main/java/com/goldenchart/repository/GcBacktestSettingsRepository.java +++ b/backend/src/main/java/com/goldenchart/repository/GcBacktestSettingsRepository.java @@ -7,4 +7,6 @@ import java.util.Optional; public interface GcBacktestSettingsRepository extends JpaRepository { Optional findFirstByDeviceIdOrderByUpdatedAtDesc(String deviceId); + + Optional findFirstByUserIdOrderByUpdatedAtDesc(Long userId); } diff --git a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java index 3dac6a3..2774c29 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestSettingsService.java @@ -3,32 +3,99 @@ package com.goldenchart.service; import com.goldenchart.dto.BacktestSettingsDto; import com.goldenchart.entity.GcBacktestSettings; import com.goldenchart.repository.GcBacktestSettingsRepository; +import com.goldenchart.trading.TradingAccess; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Service @RequiredArgsConstructor public class BacktestSettingsService { private final GcBacktestSettingsRepository repo; - /** device_id 기준 설정 조회 — 없으면 기본값 반환 */ + /** userId 우선, 없으면 deviceId — 레거시 user:{id}·기기별 행 자동 승격 */ @Transactional(readOnly = true) - public BacktestSettingsDto get(String deviceId) { - return repo.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId) + public BacktestSettingsDto get(Long userId, String deviceId) { + return findEntity(userId, deviceId) .map(this::toDto) .orElseGet(BacktestSettingsDto::new); } - /** upsert — 기존 설정이 있으면 덮어쓰기, 없으면 새 행 삽입 */ - @Transactional - public BacktestSettingsDto save(String deviceId, BacktestSettingsDto dto) { - GcBacktestSettings entity = repo - .findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId) - .orElseGet(GcBacktestSettings::new); + /** + * 레거시 호출 — scopeKey가 {@code user:{userId}} 이면 userId 조회로 위임. + */ + @Transactional(readOnly = true) + public BacktestSettingsDto get(String scopeKey) { + if (scopeKey != null && scopeKey.startsWith("user:")) { + try { + long uid = Long.parseLong(scopeKey.substring(5)); + return get(uid, null); + } catch (NumberFormatException ignored) { + /* fall through */ + } + } + return get(null, scopeKey); + } - entity.setDeviceId(deviceId); + @Transactional + public BacktestSettingsDto save(Long userId, String deviceId, BacktestSettingsDto dto) { + GcBacktestSettings entity = findOrCreate(userId, deviceId); + applyFields(entity, dto, userId, deviceId); + return toDto(repo.save(entity)); + } + + // ── 조회·생성 ───────────────────────────────────────────────────────────── + + private Optional findEntity(Long userId, String deviceId) { + if (userId != null && userId > 0) { + Optional byUser = repo.findFirstByUserIdOrderByUpdatedAtDesc(userId); + if (byUser.isPresent()) return byUser; + + if (deviceId != null && !deviceId.isBlank()) { + Optional byDevice = repo.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId); + if (byDevice.isPresent()) { + return Optional.of(promoteToUser(byDevice.get(), userId)); + } + } + + Optional legacy = repo.findFirstByDeviceIdOrderByUpdatedAtDesc( + TradingAccess.accountDeviceKey(userId)); + if (legacy.isPresent()) { + return Optional.of(promoteToUser(legacy.get(), userId)); + } + return Optional.empty(); + } + if (deviceId != null && !deviceId.isBlank()) { + return repo.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId); + } + return Optional.empty(); + } + + private GcBacktestSettings findOrCreate(Long userId, String deviceId) { + return findEntity(userId, deviceId) + .orElseGet(() -> GcBacktestSettings.builder() + .userId(userId != null && userId > 0 ? userId : null) + .deviceId(deviceId) + .build()); + } + + /** 기기별 행을 로그인 사용자 소유로 1회 승격 (다른 PC와 공유) */ + private GcBacktestSettings promoteToUser(GcBacktestSettings entity, Long userId) { + entity.setUserId(userId); + return repo.save(entity); + } + + private void applyFields(GcBacktestSettings entity, BacktestSettingsDto dto, + Long userId, String deviceId) { + if (userId != null && userId > 0) { + entity.setUserId(userId); + } + if (deviceId != null && !deviceId.isBlank()) { + entity.setDeviceId(deviceId); + } entity.setInitialCapital(dto.getInitialCapital()); entity.setCommissionType(dto.getCommissionType()); entity.setCommissionRate(dto.getCommissionRate()); @@ -58,8 +125,6 @@ public class BacktestSettingsService { "SCAN_SIGNALS".equals(dto.getTradeExecutionMode()) ? "SCAN_SIGNALS" : "BACKTEST_ENGINE"); - - return toDto(repo.save(entity)); } // ── 변환 헬퍼 ───────────────────────────────────────────────────────────── diff --git a/backend/src/main/java/com/goldenchart/service/LiveRiskMonitorService.java b/backend/src/main/java/com/goldenchart/service/LiveRiskMonitorService.java index 84929b5..f6a5175 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveRiskMonitorService.java +++ b/backend/src/main/java/com/goldenchart/service/LiveRiskMonitorService.java @@ -95,7 +95,7 @@ public class LiveRiskMonitorService { GcAppSettings app = appSettingsRepo.findByUserId(userId).orElse(null); if (app == null) continue; - BacktestSettingsDto risk = backtestSettingsService.get(cacheKey); + BacktestSettingsDto risk = backtestSettingsService.get(userId, null); double avg = pos[1]; double pnlPct = (tradePrice - avg) / avg * 100.0; diff --git a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java index 56fbed3..c32d840 100644 --- a/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java +++ b/backend/src/main/java/com/goldenchart/service/LiveStrategyEvaluator.java @@ -389,11 +389,8 @@ public class LiveStrategyEvaluator { if (!globalLive && Boolean.TRUE.equals(s.getIsLiveCheck())) { return s.getPositionMode() != null ? s.getPositionMode() : "LONG_ONLY"; } - String deviceKey = s.getUserId() != null - ? TradingAccess.accountDeviceKey(s.getUserId()) - : s.getDeviceId(); - if (deviceKey != null && !deviceKey.isBlank()) { - BacktestSettingsDto bt = backtestSettingsService.get(deviceKey); + if (s.getUserId() != null || (s.getDeviceId() != null && !s.getDeviceId().isBlank())) { + BacktestSettingsDto bt = backtestSettingsService.get(s.getUserId(), s.getDeviceId()); if (bt.getPositionMode() != null) { return "SIGNAL_ONLY".equals(bt.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY"; } diff --git a/backend/src/main/java/com/goldenchart/trading/pipeline/RealtimePositionMonitor.java b/backend/src/main/java/com/goldenchart/trading/pipeline/RealtimePositionMonitor.java index 3cea2f0..1d6ff8f 100644 --- a/backend/src/main/java/com/goldenchart/trading/pipeline/RealtimePositionMonitor.java +++ b/backend/src/main/java/com/goldenchart/trading/pipeline/RealtimePositionMonitor.java @@ -75,7 +75,7 @@ public class RealtimePositionMonitor { double avg = pos.getAvgPrice().doubleValue(); if (avg <= 0) continue; - BacktestSettingsDto risk = backtestSettingsService.get(TradingAccess.accountDeviceKey(userId)); + BacktestSettingsDto risk = backtestSettingsService.get(userId, null); double pnlPct = (tradePrice - avg) / avg * 100.0; if (Boolean.TRUE.equals(risk.getStopLossEnabled()) diff --git a/backend/src/main/resources/db/migration/V70__backtest_settings_user_id.sql b/backend/src/main/resources/db/migration/V70__backtest_settings_user_id.sql new file mode 100644 index 0000000..8e99659 --- /dev/null +++ b/backend/src/main/resources/db/migration/V70__backtest_settings_user_id.sql @@ -0,0 +1,11 @@ +-- 백테스트 설정: 로그인 사용자(user_id) 단위 공유 (기존 device_id 전용 → AppSettings와 동일 패턴) +ALTER TABLE gc_backtest_settings + ADD COLUMN user_id BIGINT NULL AFTER device_id; + +-- 레거시 user:{id} device_id 행 → user_id 승격 +UPDATE gc_backtest_settings +SET user_id = CAST(SUBSTRING(device_id, 6) AS UNSIGNED) +WHERE device_id LIKE 'user:%' + AND user_id IS NULL; + +CREATE INDEX idx_gc_backtest_settings_user_id ON gc_backtest_settings (user_id); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3f85e6..d39123d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,8 +28,10 @@ import { syncDocumentTheme } from './utils/documentTheme'; import { loadLocalTheme, migrateDbThemeToLocalIfNeeded, + resolveThemeForSession, saveLocalTheme, } from './utils/localThemeStorage'; +import { hasRegisteredUser } from './utils/backendApi'; import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions'; import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions'; import { clearAdminUnlock } from './utils/adminUnlock'; @@ -694,9 +696,12 @@ function App() { setTheme(t => { const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark'; saveLocalTheme(next); + if (hasRegisteredUser()) { + saveAppDef({ defaultTheme: next }); + } return next; }); - }, []); + }, [saveAppDef]); useEffect(() => { syncDocumentTheme(theme); @@ -704,7 +709,12 @@ function App() { useEffect(() => { if (!appSettingsLoaded) return; - setTheme(migrateDbThemeToLocalIfNeeded(getAppSettingsCache().defaultTheme)); + const dbTheme = getAppSettingsCache().defaultTheme; + if (hasRegisteredUser()) { + setTheme(resolveThemeForSession(dbTheme, true)); + } else { + setTheme(migrateDbThemeToLocalIfNeeded(dbTheme)); + } }, [appSettingsLoaded]); const openNotificationsPage = useCallback( diff --git a/frontend/src/chart/useChartWorkspace.ts b/frontend/src/chart/useChartWorkspace.ts index c2143c9..b1bd3e2 100644 --- a/frontend/src/chart/useChartWorkspace.ts +++ b/frontend/src/chart/useChartWorkspace.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react'; +import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo, type SetStateAction } from 'react'; import DrawingToolbar from '../components/DrawingToolbar'; import BottomBar from '../components/BottomBar'; import TradingChart from '../components/TradingChart'; @@ -90,7 +90,8 @@ import { import { sortIndicatorsByTypeOrder, } from '../utils/indicatorListOrder'; -import { getAppSettingsCache } from '../hooks/useAppSettings'; +import { getAppSettingsCache, isAppSettingsCacheLoaded, subscribeAppSettings } from '../hooks/useAppSettings'; +import { loadMagnetMode, saveMagnetMode, type MagnetMode } from '../utils/chartMagnetModeStorage'; import type { AppSettingsDto } from '../utils/backendApi'; import MarketPanel from '../components/MarketPanel'; import { type MenuPage } from '../components/TopMenuBar'; @@ -245,7 +246,22 @@ export function useChartWorkspace({ const [drawingsLocked, setDrawingsLocked] = useState(false); const [drawingsVisible, setDrawingsVisible] = useState(true); const [indicatorsVisible, setIndicatorsVisible] = useState(true); - const [magnetMode, setMagnetMode] = useState<'off'|'weak'|'strong'>('off'); + const [magnetMode, setMagnetModeState] = useState('off'); + const setMagnetMode = useCallback((value: SetStateAction) => { + setMagnetModeState(prev => { + const next = typeof value === 'function' ? value(prev) : value; + if (isAppSettingsCacheLoaded()) saveMagnetMode(next); + return next; + }); + }, []); + + useEffect(() => { + const syncMagnet = () => { + if (isAppSettingsCacheLoaded()) setMagnetModeState(loadMagnetMode()); + }; + syncMagnet(); + return subscribeAppSettings(syncMagnet); + }, []); const [snapToIndicator, setSnapToIndicator] = useState(false); const [keepLockedOnDelete,setKeepLockedOnDelete]= useState(false); const [legend, setLegend] = useState(null); diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 741e1e3..7860a42 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -7,7 +7,7 @@ * - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol) * - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe) * - defaultChartType : 기본 차트 타입 - * - (테마는 DB 미사용 — localStorage, see localThemeStorage) + * - defaultTheme : 기본 테마 (로그인 시 DB 공유, 게스트는 localStorage) * - defaultLogScale : 기본 로그 스케일 * - defaultLayoutId : 기본 레이아웃 ID * - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE) @@ -20,7 +20,7 @@ * // 기본 심볼 사용 * const symbol = settings.defaultSymbol ?? 'KRW-BTC'; * - * // 테마 변경 시 saveLocalTheme() (App.tsx) + * // 테마 변경 시 saveAppDef({ defaultTheme }) + saveLocalTheme() (App.tsx) * ``` */ @@ -65,7 +65,7 @@ import { } from '../types/chartPaneSeparator'; import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate'; import { reconcilePendingUiPreferencesOnLoad } from '../utils/uiPreferencesDb'; -import { loadLocalTheme } from '../utils/localThemeStorage'; +import { loadLocalTheme, resolveThemeForSession } from '../utils/localThemeStorage'; // 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지 let _cache: AppSettingsDto | null = null; @@ -127,6 +127,21 @@ function ensureLoaded(): Promise { } } } + if (hasRegisteredUser() && _cache) { + const local = loadLocalTheme(); + const dbTheme = _cache.defaultTheme; + const dbIsDefault = !dbTheme || dbTheme === 'dark'; + if (dbIsDefault && local !== 'dark') { + try { + const saved = await saveAppSettings({ defaultTheme: local }); + if (generation === _loadGeneration && saved) { + _cache = { ..._cache, ...saved, defaultTheme: local }; + } + } catch (e) { + console.warn('[useAppSettings] 테마 localStorage→DB 이전 실패:', e); + } + } + } if (hasRegisteredUser()) { try { await loadStrategies(); @@ -161,11 +176,12 @@ export function reloadAppSettingsCache(): Promise { /** DB 또는 fallback 기본값 접근 헬퍼 */ export function resolveAppDefaults(s: AppSettingsDto) { + const accountTheme = resolveThemeForSession(s.defaultTheme, hasRegisteredUser()); return { symbol: (s.defaultSymbol ?? 'KRW-BTC') as string, timeframe: (s.defaultTimeframe ?? '1D') as Timeframe, chartType: (s.defaultChartType ?? 'candlestick') as ChartType, - theme: loadLocalTheme(), + theme: accountTheme, logScale: s.defaultLogScale ?? false, layoutId: s.defaultLayoutId ?? '1', mainChartStyle:(s.mainChartStyle as unknown as MainChartStyle | null) ?? DEFAULT_MAIN_CHART_STYLE, @@ -184,7 +200,7 @@ export function resolveAppDefaults(s: AppSettingsDto) { ), chartPaneSeparator: resolveChartPaneSeparatorOptions( s.chartPaneSeparator as Partial | null | undefined, - loadLocalTheme(), + accountTheme, ), tradeAlertPopup: s.tradeAlertPopup ?? true, tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true, @@ -288,9 +304,7 @@ export function useAppSettings(sessionKey = 0) { * 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영. */ const save = useCallback((patch: AppSettingsDto) => { - const { defaultTheme: _omitTheme, ...patchWithoutTheme } = patch; - const apiPatch = 'defaultTheme' in patch ? patchWithoutTheme : patch; - const merged = { ...(_cache ?? {}), ...apiPatch }; + const merged = { ...(_cache ?? {}), ...patch }; _cache = merged; notifyAppSettingsListeners(); setSettings(merged); @@ -303,7 +317,7 @@ export function useAppSettings(sessionKey = 0) { if (patch.tradeAlertTimeFormat != null) { setTradeAlertTimeFormat(normalizeChartTimeFormat(patch.tradeAlertTimeFormat)); } - saveAppSettings(apiPatch).then(updated => { + saveAppSettings(patch).then(updated => { if (updated) { _cache = { ...(_cache ?? {}), ...updated }; notifyAppSettingsListeners(); diff --git a/frontend/src/types/uiPreferences.ts b/frontend/src/types/uiPreferences.ts index f07e55d..770c466 100644 --- a/frontend/src/types/uiPreferences.ts +++ b/frontend/src/types/uiPreferences.ts @@ -28,6 +28,8 @@ export interface UiPreferences { chart?: { alertPrices?: number[]; legacyIndicators?: IndicatorConfig[]; + /** 차트 자석 모드 — 설정 화면·툴바 공유 */ + magnetMode?: 'off' | 'weak' | 'strong'; }; panels?: Record; virtual?: { diff --git a/frontend/src/utils/chartMagnetModeStorage.ts b/frontend/src/utils/chartMagnetModeStorage.ts new file mode 100644 index 0000000..b670ac6 --- /dev/null +++ b/frontend/src/utils/chartMagnetModeStorage.ts @@ -0,0 +1,16 @@ +import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb'; + +export type MagnetMode = 'off' | 'weak' | 'strong'; + +function normalizeMagnetMode(v: unknown): MagnetMode { + return v === 'weak' || v === 'strong' ? v : 'off'; +} + +/** DB ui_preferences.chart.magnetMode */ +export function loadMagnetMode(): MagnetMode { + return normalizeMagnetMode(getUiPreferences().chart?.magnetMode); +} + +export function saveMagnetMode(mode: MagnetMode): void { + patchUiPreferences({ chart: { magnetMode: mode } }); +} diff --git a/frontend/src/utils/localThemeStorage.ts b/frontend/src/utils/localThemeStorage.ts index 63abb4a..aabfb52 100644 --- a/frontend/src/utils/localThemeStorage.ts +++ b/frontend/src/utils/localThemeStorage.ts @@ -8,7 +8,7 @@ function isTheme(v: unknown): v is Theme { return typeof v === 'string' && (VALID as readonly string[]).includes(v); } -/** 이 브라우저(기기)에 저장된 UI 테마. 계정·DB와 무관 */ +/** 이 브라우저(기기)에 저장된 UI 테마 — 게스트·오프라인 폴백 */ export function loadLocalTheme(): Theme { try { const raw = localStorage.getItem(STORAGE_KEY); @@ -27,7 +27,13 @@ export function saveLocalTheme(theme: Theme): void { } } -/** localStorage에 없을 때만 DB defaultTheme을 이 기기에 1회 복사 */ +/** 로그인 사용자: DB defaultTheme 우선. 게스트: localStorage */ +export function resolveThemeForSession(dbTheme: string | undefined, useAccountScope: boolean): Theme { + if (useAccountScope && isTheme(dbTheme)) return dbTheme; + return loadLocalTheme(); +} + +/** localStorage에 없을 때만 DB defaultTheme을 이 기기에 1회 복사 (게스트) */ export function migrateDbThemeToLocalIfNeeded(dbTheme: string | undefined): Theme { try { const existing = localStorage.getItem(STORAGE_KEY); @@ -39,3 +45,5 @@ export function migrateDbThemeToLocalIfNeeded(dbTheme: string | undefined): Them saveLocalTheme(theme); return theme; } + +export { isTheme };