설정 db 저장
This commit is contained in:
@@ -16,14 +16,25 @@ public class BacktestSettingsController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<BacktestSettingsDto> get(
|
public ResponseEntity<BacktestSettingsDto> get(
|
||||||
|
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
|
||||||
@RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId) {
|
@RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId) {
|
||||||
return ResponseEntity.ok(service.get(deviceId));
|
return ResponseEntity.ok(service.get(parseUserId(userIdHeader), deviceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<BacktestSettingsDto> save(
|
public ResponseEntity<BacktestSettingsDto> save(
|
||||||
|
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
|
||||||
@RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId,
|
@RequestHeader(value = "X-Device-Id", defaultValue = "default") String deviceId,
|
||||||
@RequestBody BacktestSettingsDto dto) {
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public class GcBacktestSettings {
|
|||||||
@Column(name = "device_id", length = 100)
|
@Column(name = "device_id", length = 100)
|
||||||
private String deviceId;
|
private String deviceId;
|
||||||
|
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
// ── 자본 설정 ──────────────────────────────────────────────────────────────
|
// ── 자본 설정 ──────────────────────────────────────────────────────────────
|
||||||
@Column(name = "initial_capital", nullable = false, precision = 20, scale = 2)
|
@Column(name = "initial_capital", nullable = false, precision = 20, scale = 2)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public interface GcBacktestSettingsRepository extends JpaRepository<GcBacktestSettings, Long> {
|
public interface GcBacktestSettingsRepository extends JpaRepository<GcBacktestSettings, Long> {
|
||||||
Optional<GcBacktestSettings> findFirstByDeviceIdOrderByUpdatedAtDesc(String deviceId);
|
Optional<GcBacktestSettings> findFirstByDeviceIdOrderByUpdatedAtDesc(String deviceId);
|
||||||
|
|
||||||
|
Optional<GcBacktestSettings> findFirstByUserIdOrderByUpdatedAtDesc(Long userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,32 +3,99 @@ package com.goldenchart.service;
|
|||||||
import com.goldenchart.dto.BacktestSettingsDto;
|
import com.goldenchart.dto.BacktestSettingsDto;
|
||||||
import com.goldenchart.entity.GcBacktestSettings;
|
import com.goldenchart.entity.GcBacktestSettings;
|
||||||
import com.goldenchart.repository.GcBacktestSettingsRepository;
|
import com.goldenchart.repository.GcBacktestSettingsRepository;
|
||||||
|
import com.goldenchart.trading.TradingAccess;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class BacktestSettingsService {
|
public class BacktestSettingsService {
|
||||||
|
|
||||||
private final GcBacktestSettingsRepository repo;
|
private final GcBacktestSettingsRepository repo;
|
||||||
|
|
||||||
/** device_id 기준 설정 조회 — 없으면 기본값 반환 */
|
/** userId 우선, 없으면 deviceId — 레거시 user:{id}·기기별 행 자동 승격 */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public BacktestSettingsDto get(String deviceId) {
|
public BacktestSettingsDto get(Long userId, String deviceId) {
|
||||||
return repo.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId)
|
return findEntity(userId, deviceId)
|
||||||
.map(this::toDto)
|
.map(this::toDto)
|
||||||
.orElseGet(BacktestSettingsDto::new);
|
.orElseGet(BacktestSettingsDto::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** upsert — 기존 설정이 있으면 덮어쓰기, 없으면 새 행 삽입 */
|
/**
|
||||||
@Transactional
|
* 레거시 호출 — scopeKey가 {@code user:{userId}} 이면 userId 조회로 위임.
|
||||||
public BacktestSettingsDto save(String deviceId, BacktestSettingsDto dto) {
|
*/
|
||||||
GcBacktestSettings entity = repo
|
@Transactional(readOnly = true)
|
||||||
.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId)
|
public BacktestSettingsDto get(String scopeKey) {
|
||||||
.orElseGet(GcBacktestSettings::new);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<GcBacktestSettings> findEntity(Long userId, String deviceId) {
|
||||||
|
if (userId != null && userId > 0) {
|
||||||
|
Optional<GcBacktestSettings> byUser = repo.findFirstByUserIdOrderByUpdatedAtDesc(userId);
|
||||||
|
if (byUser.isPresent()) return byUser;
|
||||||
|
|
||||||
|
if (deviceId != null && !deviceId.isBlank()) {
|
||||||
|
Optional<GcBacktestSettings> byDevice = repo.findFirstByDeviceIdOrderByUpdatedAtDesc(deviceId);
|
||||||
|
if (byDevice.isPresent()) {
|
||||||
|
return Optional.of(promoteToUser(byDevice.get(), userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<GcBacktestSettings> 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.setDeviceId(deviceId);
|
||||||
|
}
|
||||||
entity.setInitialCapital(dto.getInitialCapital());
|
entity.setInitialCapital(dto.getInitialCapital());
|
||||||
entity.setCommissionType(dto.getCommissionType());
|
entity.setCommissionType(dto.getCommissionType());
|
||||||
entity.setCommissionRate(dto.getCommissionRate());
|
entity.setCommissionRate(dto.getCommissionRate());
|
||||||
@@ -58,8 +125,6 @@ public class BacktestSettingsService {
|
|||||||
"SCAN_SIGNALS".equals(dto.getTradeExecutionMode())
|
"SCAN_SIGNALS".equals(dto.getTradeExecutionMode())
|
||||||
? "SCAN_SIGNALS"
|
? "SCAN_SIGNALS"
|
||||||
: "BACKTEST_ENGINE");
|
: "BACKTEST_ENGINE");
|
||||||
|
|
||||||
return toDto(repo.save(entity));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 변환 헬퍼 ─────────────────────────────────────────────────────────────
|
// ── 변환 헬퍼 ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public class LiveRiskMonitorService {
|
|||||||
GcAppSettings app = appSettingsRepo.findByUserId(userId).orElse(null);
|
GcAppSettings app = appSettingsRepo.findByUserId(userId).orElse(null);
|
||||||
if (app == null) continue;
|
if (app == null) continue;
|
||||||
|
|
||||||
BacktestSettingsDto risk = backtestSettingsService.get(cacheKey);
|
BacktestSettingsDto risk = backtestSettingsService.get(userId, null);
|
||||||
double avg = pos[1];
|
double avg = pos[1];
|
||||||
double pnlPct = (tradePrice - avg) / avg * 100.0;
|
double pnlPct = (tradePrice - avg) / avg * 100.0;
|
||||||
|
|
||||||
|
|||||||
@@ -389,11 +389,8 @@ public class LiveStrategyEvaluator {
|
|||||||
if (!globalLive && Boolean.TRUE.equals(s.getIsLiveCheck())) {
|
if (!globalLive && Boolean.TRUE.equals(s.getIsLiveCheck())) {
|
||||||
return s.getPositionMode() != null ? s.getPositionMode() : "LONG_ONLY";
|
return s.getPositionMode() != null ? s.getPositionMode() : "LONG_ONLY";
|
||||||
}
|
}
|
||||||
String deviceKey = s.getUserId() != null
|
if (s.getUserId() != null || (s.getDeviceId() != null && !s.getDeviceId().isBlank())) {
|
||||||
? TradingAccess.accountDeviceKey(s.getUserId())
|
BacktestSettingsDto bt = backtestSettingsService.get(s.getUserId(), s.getDeviceId());
|
||||||
: s.getDeviceId();
|
|
||||||
if (deviceKey != null && !deviceKey.isBlank()) {
|
|
||||||
BacktestSettingsDto bt = backtestSettingsService.get(deviceKey);
|
|
||||||
if (bt.getPositionMode() != null) {
|
if (bt.getPositionMode() != null) {
|
||||||
return "SIGNAL_ONLY".equals(bt.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY";
|
return "SIGNAL_ONLY".equals(bt.getPositionMode()) ? "SIGNAL_ONLY" : "LONG_ONLY";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ public class RealtimePositionMonitor {
|
|||||||
double avg = pos.getAvgPrice().doubleValue();
|
double avg = pos.getAvgPrice().doubleValue();
|
||||||
if (avg <= 0) continue;
|
if (avg <= 0) continue;
|
||||||
|
|
||||||
BacktestSettingsDto risk = backtestSettingsService.get(TradingAccess.accountDeviceKey(userId));
|
BacktestSettingsDto risk = backtestSettingsService.get(userId, null);
|
||||||
double pnlPct = (tradePrice - avg) / avg * 100.0;
|
double pnlPct = (tradePrice - avg) / avg * 100.0;
|
||||||
|
|
||||||
if (Boolean.TRUE.equals(risk.getStopLossEnabled())
|
if (Boolean.TRUE.equals(risk.getStopLossEnabled())
|
||||||
|
|||||||
@@ -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);
|
||||||
+12
-2
@@ -28,8 +28,10 @@ import { syncDocumentTheme } from './utils/documentTheme';
|
|||||||
import {
|
import {
|
||||||
loadLocalTheme,
|
loadLocalTheme,
|
||||||
migrateDbThemeToLocalIfNeeded,
|
migrateDbThemeToLocalIfNeeded,
|
||||||
|
resolveThemeForSession,
|
||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
} from './utils/localThemeStorage';
|
} from './utils/localThemeStorage';
|
||||||
|
import { hasRegisteredUser } from './utils/backendApi';
|
||||||
import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions';
|
import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions';
|
||||||
import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions';
|
import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions';
|
||||||
import { clearAdminUnlock } from './utils/adminUnlock';
|
import { clearAdminUnlock } from './utils/adminUnlock';
|
||||||
@@ -694,9 +696,12 @@ function App() {
|
|||||||
setTheme(t => {
|
setTheme(t => {
|
||||||
const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark';
|
const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark';
|
||||||
saveLocalTheme(next);
|
saveLocalTheme(next);
|
||||||
|
if (hasRegisteredUser()) {
|
||||||
|
saveAppDef({ defaultTheme: next });
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [saveAppDef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncDocumentTheme(theme);
|
syncDocumentTheme(theme);
|
||||||
@@ -704,7 +709,12 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appSettingsLoaded) return;
|
if (!appSettingsLoaded) return;
|
||||||
setTheme(migrateDbThemeToLocalIfNeeded(getAppSettingsCache().defaultTheme));
|
const dbTheme = getAppSettingsCache().defaultTheme;
|
||||||
|
if (hasRegisteredUser()) {
|
||||||
|
setTheme(resolveThemeForSession(dbTheme, true));
|
||||||
|
} else {
|
||||||
|
setTheme(migrateDbThemeToLocalIfNeeded(dbTheme));
|
||||||
|
}
|
||||||
}, [appSettingsLoaded]);
|
}, [appSettingsLoaded]);
|
||||||
|
|
||||||
const openNotificationsPage = useCallback(
|
const openNotificationsPage = useCallback(
|
||||||
|
|||||||
@@ -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 DrawingToolbar from '../components/DrawingToolbar';
|
||||||
import BottomBar from '../components/BottomBar';
|
import BottomBar from '../components/BottomBar';
|
||||||
import TradingChart from '../components/TradingChart';
|
import TradingChart from '../components/TradingChart';
|
||||||
@@ -90,7 +90,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
sortIndicatorsByTypeOrder,
|
sortIndicatorsByTypeOrder,
|
||||||
} from '../utils/indicatorListOrder';
|
} 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 type { AppSettingsDto } from '../utils/backendApi';
|
||||||
import MarketPanel from '../components/MarketPanel';
|
import MarketPanel from '../components/MarketPanel';
|
||||||
import { type MenuPage } from '../components/TopMenuBar';
|
import { type MenuPage } from '../components/TopMenuBar';
|
||||||
@@ -245,7 +246,22 @@ export function useChartWorkspace({
|
|||||||
const [drawingsLocked, setDrawingsLocked] = useState(false);
|
const [drawingsLocked, setDrawingsLocked] = useState(false);
|
||||||
const [drawingsVisible, setDrawingsVisible] = useState(true);
|
const [drawingsVisible, setDrawingsVisible] = useState(true);
|
||||||
const [indicatorsVisible, setIndicatorsVisible] = useState(true);
|
const [indicatorsVisible, setIndicatorsVisible] = useState(true);
|
||||||
const [magnetMode, setMagnetMode] = useState<'off'|'weak'|'strong'>('off');
|
const [magnetMode, setMagnetModeState] = useState<MagnetMode>('off');
|
||||||
|
const setMagnetMode = useCallback((value: SetStateAction<MagnetMode>) => {
|
||||||
|
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 [snapToIndicator, setSnapToIndicator] = useState(false);
|
||||||
const [keepLockedOnDelete,setKeepLockedOnDelete]= useState(false);
|
const [keepLockedOnDelete,setKeepLockedOnDelete]= useState(false);
|
||||||
const [legend, setLegend] = useState<LegendData | null>(null);
|
const [legend, setLegend] = useState<LegendData | null>(null);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol)
|
* - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol)
|
||||||
* - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe)
|
* - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe)
|
||||||
* - defaultChartType : 기본 차트 타입
|
* - defaultChartType : 기본 차트 타입
|
||||||
* - (테마는 DB 미사용 — localStorage, see localThemeStorage)
|
* - defaultTheme : 기본 테마 (로그인 시 DB 공유, 게스트는 localStorage)
|
||||||
* - defaultLogScale : 기본 로그 스케일
|
* - defaultLogScale : 기본 로그 스케일
|
||||||
* - defaultLayoutId : 기본 레이아웃 ID
|
* - defaultLayoutId : 기본 레이아웃 ID
|
||||||
* - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE)
|
* - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE)
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
* // 기본 심볼 사용
|
* // 기본 심볼 사용
|
||||||
* const symbol = settings.defaultSymbol ?? 'KRW-BTC';
|
* const symbol = settings.defaultSymbol ?? 'KRW-BTC';
|
||||||
*
|
*
|
||||||
* // 테마 변경 시 saveLocalTheme() (App.tsx)
|
* // 테마 변경 시 saveAppDef({ defaultTheme }) + saveLocalTheme() (App.tsx)
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ import {
|
|||||||
} from '../types/chartPaneSeparator';
|
} from '../types/chartPaneSeparator';
|
||||||
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
|
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
|
||||||
import { reconcilePendingUiPreferencesOnLoad } from '../utils/uiPreferencesDb';
|
import { reconcilePendingUiPreferencesOnLoad } from '../utils/uiPreferencesDb';
|
||||||
import { loadLocalTheme } from '../utils/localThemeStorage';
|
import { loadLocalTheme, resolveThemeForSession } from '../utils/localThemeStorage';
|
||||||
|
|
||||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||||
let _cache: AppSettingsDto | null = null;
|
let _cache: AppSettingsDto | null = null;
|
||||||
@@ -127,6 +127,21 @@ function ensureLoaded(): Promise<AppSettingsDto> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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()) {
|
if (hasRegisteredUser()) {
|
||||||
try {
|
try {
|
||||||
await loadStrategies();
|
await loadStrategies();
|
||||||
@@ -161,11 +176,12 @@ export function reloadAppSettingsCache(): Promise<AppSettingsDto> {
|
|||||||
|
|
||||||
/** DB 또는 fallback 기본값 접근 헬퍼 */
|
/** DB 또는 fallback 기본값 접근 헬퍼 */
|
||||||
export function resolveAppDefaults(s: AppSettingsDto) {
|
export function resolveAppDefaults(s: AppSettingsDto) {
|
||||||
|
const accountTheme = resolveThemeForSession(s.defaultTheme, hasRegisteredUser());
|
||||||
return {
|
return {
|
||||||
symbol: (s.defaultSymbol ?? 'KRW-BTC') as string,
|
symbol: (s.defaultSymbol ?? 'KRW-BTC') as string,
|
||||||
timeframe: (s.defaultTimeframe ?? '1D') as Timeframe,
|
timeframe: (s.defaultTimeframe ?? '1D') as Timeframe,
|
||||||
chartType: (s.defaultChartType ?? 'candlestick') as ChartType,
|
chartType: (s.defaultChartType ?? 'candlestick') as ChartType,
|
||||||
theme: loadLocalTheme(),
|
theme: accountTheme,
|
||||||
logScale: s.defaultLogScale ?? false,
|
logScale: s.defaultLogScale ?? false,
|
||||||
layoutId: s.defaultLayoutId ?? '1',
|
layoutId: s.defaultLayoutId ?? '1',
|
||||||
mainChartStyle:(s.mainChartStyle as unknown as MainChartStyle | null) ?? DEFAULT_MAIN_CHART_STYLE,
|
mainChartStyle:(s.mainChartStyle as unknown as MainChartStyle | null) ?? DEFAULT_MAIN_CHART_STYLE,
|
||||||
@@ -184,7 +200,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
|||||||
),
|
),
|
||||||
chartPaneSeparator: resolveChartPaneSeparatorOptions(
|
chartPaneSeparator: resolveChartPaneSeparatorOptions(
|
||||||
s.chartPaneSeparator as Partial<ChartPaneSeparatorOptions> | null | undefined,
|
s.chartPaneSeparator as Partial<ChartPaneSeparatorOptions> | null | undefined,
|
||||||
loadLocalTheme(),
|
accountTheme,
|
||||||
),
|
),
|
||||||
tradeAlertPopup: s.tradeAlertPopup ?? true,
|
tradeAlertPopup: s.tradeAlertPopup ?? true,
|
||||||
tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true,
|
tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true,
|
||||||
@@ -288,9 +304,7 @@ export function useAppSettings(sessionKey = 0) {
|
|||||||
* 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영.
|
* 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영.
|
||||||
*/
|
*/
|
||||||
const save = useCallback((patch: AppSettingsDto) => {
|
const save = useCallback((patch: AppSettingsDto) => {
|
||||||
const { defaultTheme: _omitTheme, ...patchWithoutTheme } = patch;
|
const merged = { ...(_cache ?? {}), ...patch };
|
||||||
const apiPatch = 'defaultTheme' in patch ? patchWithoutTheme : patch;
|
|
||||||
const merged = { ...(_cache ?? {}), ...apiPatch };
|
|
||||||
_cache = merged;
|
_cache = merged;
|
||||||
notifyAppSettingsListeners();
|
notifyAppSettingsListeners();
|
||||||
setSettings(merged);
|
setSettings(merged);
|
||||||
@@ -303,7 +317,7 @@ export function useAppSettings(sessionKey = 0) {
|
|||||||
if (patch.tradeAlertTimeFormat != null) {
|
if (patch.tradeAlertTimeFormat != null) {
|
||||||
setTradeAlertTimeFormat(normalizeChartTimeFormat(patch.tradeAlertTimeFormat));
|
setTradeAlertTimeFormat(normalizeChartTimeFormat(patch.tradeAlertTimeFormat));
|
||||||
}
|
}
|
||||||
saveAppSettings(apiPatch).then(updated => {
|
saveAppSettings(patch).then(updated => {
|
||||||
if (updated) {
|
if (updated) {
|
||||||
_cache = { ...(_cache ?? {}), ...updated };
|
_cache = { ...(_cache ?? {}), ...updated };
|
||||||
notifyAppSettingsListeners();
|
notifyAppSettingsListeners();
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface UiPreferences {
|
|||||||
chart?: {
|
chart?: {
|
||||||
alertPrices?: number[];
|
alertPrices?: number[];
|
||||||
legacyIndicators?: IndicatorConfig[];
|
legacyIndicators?: IndicatorConfig[];
|
||||||
|
/** 차트 자석 모드 — 설정 화면·툴바 공유 */
|
||||||
|
magnetMode?: 'off' | 'weak' | 'strong';
|
||||||
};
|
};
|
||||||
panels?: Record<string, number | boolean | string>;
|
panels?: Record<string, number | boolean | string>;
|
||||||
virtual?: {
|
virtual?: {
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ function isTheme(v: unknown): v is Theme {
|
|||||||
return typeof v === 'string' && (VALID as readonly string[]).includes(v);
|
return typeof v === 'string' && (VALID as readonly string[]).includes(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 이 브라우저(기기)에 저장된 UI 테마. 계정·DB와 무관 */
|
/** 이 브라우저(기기)에 저장된 UI 테마 — 게스트·오프라인 폴백 */
|
||||||
export function loadLocalTheme(): Theme {
|
export function loadLocalTheme(): Theme {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
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 {
|
export function migrateDbThemeToLocalIfNeeded(dbTheme: string | undefined): Theme {
|
||||||
try {
|
try {
|
||||||
const existing = localStorage.getItem(STORAGE_KEY);
|
const existing = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -39,3 +45,5 @@ export function migrateDbThemeToLocalIfNeeded(dbTheme: string | undefined): Them
|
|||||||
saveLocalTheme(theme);
|
saveLocalTheme(theme);
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { isTheme };
|
||||||
|
|||||||
Reference in New Issue
Block a user