package com.goldenchart.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.dto.UpbitApiCredentials; import com.goldenchart.entity.GcAppSettings; import com.goldenchart.repository.GcAppSettingsRepository; import com.goldenchart.security.SecretCryptoService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.HashMap; import java.util.Map; import java.util.Optional; /** * 앱 전역 차트 기본 설정 서비스. * *

프론트엔드에서 하드코딩된 기본값들(DEFAULT_STATE, DEFAULT_MAIN_CHART_STYLE, * DEFAULT_SYNC 등)을 DB 로 대체하여 사용자별 맞춤 기본값을 관리한다.

*/ @Service @RequiredArgsConstructor @Slf4j @Transactional public class AppSettingsService { private final GcAppSettingsRepository repo; private final ObjectMapper mapper; private final SecretCryptoService secretCrypto; // ── 공개 API ───────────────────────────────────────────────────────────── /** * 현재 설정값을 Map 으로 반환. * DB 에 없으면 엔티티 기본값이 반영된 빈 엔티티를 기반으로 Map 생성. */ @Transactional(readOnly = true) public Map get(Long userId, String deviceId) { GcAppSettings s = findEntity(userId, deviceId) .orElse(new GcAppSettings()); return toMap(s); } /** * 설정값을 Map 으로 받아 저장. * DB 에 레코드가 없으면 신규 생성. */ public Map save(Long userId, String deviceId, Map data) { GcAppSettings s = findOrCreate(userId, deviceId); apply(s, data); repo.save(s); log.debug("[AppSettings] saved for device={}", deviceId); return toMap(s); } // ── Private helpers ─────────────────────────────────────────────────────── private Optional findEntity(Long userId, String deviceId) { if (userId != null) return repo.findByUserId(userId); if (deviceId != null && !deviceId.isBlank()) return repo.findByDeviceId(deviceId); return Optional.empty(); } private GcAppSettings findOrCreate(Long userId, String deviceId) { return findEntity(userId, deviceId) .orElseGet(() -> GcAppSettings.builder() .userId(userId) .deviceId(deviceId) .build()); } private void apply(GcAppSettings s, Map d) { if (d.containsKey("defaultSymbol")) s.setDefaultSymbol((String) d.get("defaultSymbol")); if (d.containsKey("defaultTimeframe")) s.setDefaultTimeframe((String) d.get("defaultTimeframe")); if (d.containsKey("defaultChartType")) s.setDefaultChartType((String) d.get("defaultChartType")); if (d.containsKey("defaultTheme")) s.setDefaultTheme((String) d.get("defaultTheme")); if (d.containsKey("defaultLogScale")) s.setDefaultLogScale( Boolean.parseBoolean(d.get("defaultLogScale").toString())); if (d.containsKey("defaultLayoutId")) s.setDefaultLayoutId((String) d.get("defaultLayoutId")); if (d.containsKey("displayTimezone")) s.setDisplayTimezone((String) d.get("displayTimezone")); if (d.containsKey("mainChartStyle")) s.setMainChartStyleJson(toJson(d.get("mainChartStyle"))); if (d.containsKey("syncOptions")) s.setSyncOptionsJson(toJson(d.get("syncOptions"))); if (d.containsKey("btAutoPopup")) s.setBtAutoPopup( Boolean.parseBoolean(d.get("btAutoPopup").toString())); if (d.containsKey("btShowPrice")) s.setBtShowPrice( Boolean.parseBoolean(d.get("btShowPrice").toString())); if (d.containsKey("chartSeriesPriceLabels")) s.setChartSeriesPriceLabels( Boolean.parseBoolean(d.get("chartSeriesPriceLabels").toString())); if (d.containsKey("chartVolumeVisible")) s.setChartVolumeVisible( Boolean.parseBoolean(d.get("chartVolumeVisible").toString())); if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions"))); if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup( Boolean.parseBoolean(d.get("tradeAlertPopup").toString())); if (d.containsKey("tradeAlertSoundEnabled")) s.setTradeAlertSoundEnabled( Boolean.parseBoolean(d.get("tradeAlertSoundEnabled").toString())); if (d.containsKey("tradeAlertSound")) s.setTradeAlertSound( d.get("tradeAlertSound").toString()); if (d.containsKey("tradeAlertPopupPosition")) { String pos = d.get("tradeAlertPopupPosition").toString().toLowerCase(); s.setTradeAlertPopupPosition("left".equals(pos) || "bottom".equals(pos) ? pos : "right"); } if (d.containsKey("tradeAlertPopupLayout")) { String lay = d.get("tradeAlertPopupLayout").toString().toLowerCase(); s.setTradeAlertPopupLayout( "grid".equals(lay) || "strip".equals(lay) || "single".equals(lay) ? lay : "stack"); } if (d.containsKey("tradeAlertPopupGridCols")) { int cols = Integer.parseInt(d.get("tradeAlertPopupGridCols").toString()); s.setTradeAlertPopupGridCols(Math.min(4, Math.max(2, cols))); } if (d.containsKey("liveStrategyCheck")) s.setLiveStrategyCheck( Boolean.parseBoolean(d.get("liveStrategyCheck").toString())); if (d.containsKey("liveStrategyId")) { Object v = d.get("liveStrategyId"); s.setLiveStrategyId(v == null || "".equals(v.toString()) ? null : Long.parseLong(v.toString())); } if (d.containsKey("liveExecutionType")) s.setLiveExecutionType( "REALTIME_TICK".equals(d.get("liveExecutionType")) ? "REALTIME_TICK" : "CANDLE_CLOSE"); if (d.containsKey("livePositionMode")) s.setLivePositionMode( "SIGNAL_ONLY".equals(d.get("livePositionMode")) ? "SIGNAL_ONLY" : "LONG_ONLY"); if (d.containsKey("paperTradingEnabled")) s.setPaperTradingEnabled( Boolean.parseBoolean(d.get("paperTradingEnabled").toString())); if (d.containsKey("paperInitialCapital")) s.setPaperInitialCapital( new java.math.BigDecimal(d.get("paperInitialCapital").toString())); if (d.containsKey("paperFeeRatePct")) s.setPaperFeeRatePct( new java.math.BigDecimal(d.get("paperFeeRatePct").toString())); if (d.containsKey("paperSlippagePct")) s.setPaperSlippagePct( new java.math.BigDecimal(d.get("paperSlippagePct").toString())); if (d.containsKey("paperMinOrderKrw")) s.setPaperMinOrderKrw( new java.math.BigDecimal(d.get("paperMinOrderKrw").toString())); if (d.containsKey("paperAutoTradeEnabled")) s.setPaperAutoTradeEnabled( Boolean.parseBoolean(d.get("paperAutoTradeEnabled").toString())); if (d.containsKey("paperAutoTradeBudgetPct")) s.setPaperAutoTradeBudgetPct( new java.math.BigDecimal(d.get("paperAutoTradeBudgetPct").toString())); if (d.containsKey("tradingMode")) { String mode = d.get("tradingMode").toString().toUpperCase(); s.setTradingMode("LIVE".equals(mode) || "BOTH".equals(mode) ? mode : "PAPER"); } if (d.containsKey("liveAutoTradeEnabled")) s.setLiveAutoTradeEnabled( Boolean.parseBoolean(d.get("liveAutoTradeEnabled").toString())); if (d.containsKey("upbitAccessKey")) { String v = d.get("upbitAccessKey").toString().trim(); if (!v.isEmpty() && !v.startsWith("····") && !v.startsWith("****")) { s.setUpbitAccessKey(secretCrypto.encrypt(v)); } } if (d.containsKey("upbitSecretKey")) { String v = d.get("upbitSecretKey").toString().trim(); if (!v.isEmpty() && !"__UNCHANGED__".equals(v)) { s.setUpbitSecretKey(secretCrypto.encrypt(v)); } } if (d.containsKey("chartRealtimeSource")) { String src = d.get("chartRealtimeSource").toString().toUpperCase(); s.setChartRealtimeSource("UPBIT_DIRECT".equals(src) ? "UPBIT_DIRECT" : "BACKEND_STOMP"); } if (d.containsKey("liveAutoTradeBudgetPct")) s.setLiveAutoTradeBudgetPct( new java.math.BigDecimal(d.get("liveAutoTradeBudgetPct").toString())); if (d.containsKey("fcmPushEnabled")) s.setFcmPushEnabled( Boolean.parseBoolean(d.get("fcmPushEnabled").toString())); } private Map toMap(GcAppSettings s) { Map m = new HashMap<>(); m.put("defaultSymbol", s.getDefaultSymbol() != null ? s.getDefaultSymbol() : "KRW-BTC"); m.put("defaultTimeframe", s.getDefaultTimeframe() != null ? s.getDefaultTimeframe() : "1D"); m.put("defaultChartType", s.getDefaultChartType() != null ? s.getDefaultChartType() : "candlestick"); m.put("defaultTheme", s.getDefaultTheme() != null ? s.getDefaultTheme() : "dark"); m.put("defaultLogScale", s.getDefaultLogScale() != null ? s.getDefaultLogScale() : false); m.put("defaultLayoutId", s.getDefaultLayoutId() != null ? s.getDefaultLayoutId() : "1"); m.put("displayTimezone", s.getDisplayTimezone() != null ? s.getDisplayTimezone() : "Asia/Seoul"); m.put("mainChartStyle", parseJson(s.getMainChartStyleJson())); m.put("syncOptions", parseJson(s.getSyncOptionsJson())); m.put("btAutoPopup", s.getBtAutoPopup() != null ? s.getBtAutoPopup() : true); m.put("btShowPrice", s.getBtShowPrice() != null ? s.getBtShowPrice() : true); m.put("chartSeriesPriceLabels", s.getChartSeriesPriceLabels() != null ? s.getChartSeriesPriceLabels() : true); m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true); m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson())); m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true); m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true); m.put("tradeAlertSound", s.getTradeAlertSound() != null ? s.getTradeAlertSound() : "bell"); m.put("tradeAlertPopupPosition", s.getTradeAlertPopupPosition() != null ? s.getTradeAlertPopupPosition() : "right"); m.put("tradeAlertPopupLayout", s.getTradeAlertPopupLayout() != null ? s.getTradeAlertPopupLayout() : "stack"); m.put("tradeAlertPopupGridCols", s.getTradeAlertPopupGridCols() != null ? s.getTradeAlertPopupGridCols() : 2); m.put("liveStrategyCheck", s.getLiveStrategyCheck() != null ? s.getLiveStrategyCheck() : false); m.put("liveStrategyId", s.getLiveStrategyId()); m.put("liveExecutionType", s.getLiveExecutionType() != null ? s.getLiveExecutionType() : "CANDLE_CLOSE"); m.put("livePositionMode", s.getLivePositionMode() != null ? s.getLivePositionMode() : "LONG_ONLY"); m.put("paperTradingEnabled", s.getPaperTradingEnabled() != null ? s.getPaperTradingEnabled() : true); m.put("paperInitialCapital", s.getPaperInitialCapital() != null ? s.getPaperInitialCapital().doubleValue() : 10_000_000); m.put("paperFeeRatePct", s.getPaperFeeRatePct() != null ? s.getPaperFeeRatePct().doubleValue() : 0.05); m.put("paperSlippagePct", s.getPaperSlippagePct() != null ? s.getPaperSlippagePct().doubleValue() : 0); m.put("paperMinOrderKrw", s.getPaperMinOrderKrw() != null ? s.getPaperMinOrderKrw().doubleValue() : 5000); m.put("paperAutoTradeEnabled", s.getPaperAutoTradeEnabled() != null ? s.getPaperAutoTradeEnabled() : false); m.put("paperAutoTradeBudgetPct", s.getPaperAutoTradeBudgetPct() != null ? s.getPaperAutoTradeBudgetPct().doubleValue() : 95); m.put("tradingMode", s.getTradingMode() != null ? s.getTradingMode() : "PAPER"); m.put("liveAutoTradeEnabled", s.getLiveAutoTradeEnabled() != null ? s.getLiveAutoTradeEnabled() : false); UpbitApiCredentials creds = resolveUpbitCredentials(s); m.put("upbitAccessKeyMasked", SecretCryptoService.maskForDisplay(creds.accessKey())); m.put("hasUpbitKeys", creds.isComplete()); m.put("chartRealtimeSource", s.getChartRealtimeSource() != null ? s.getChartRealtimeSource() : "BACKEND_STOMP"); m.put("liveAutoTradeBudgetPct", s.getLiveAutoTradeBudgetPct() != null ? s.getLiveAutoTradeBudgetPct().doubleValue() : 95); m.put("fcmPushEnabled", s.getFcmPushEnabled() != null ? s.getFcmPushEnabled() : false); return m; } /** 업비트 API 키 복호화 (실거래 API 호출 전용, 외부 노출 금지) */ @Transactional(readOnly = true) public UpbitApiCredentials resolveUpbitCredentials(GcAppSettings s) { if (s == null) { return UpbitApiCredentials.builder().accessKey("").secretKey("").build(); } String access = safeDecrypt(s.getUpbitAccessKey()); String secret = safeDecrypt(s.getUpbitSecretKey()); return UpbitApiCredentials.builder() .accessKey(access != null ? access : "") .secretKey(secret != null ? secret : "") .build(); } /** encryption-key 불일치 시 API 500 대신 키 미등록으로 처리 */ private String safeDecrypt(String stored) { try { return secretCrypto.decrypt(stored); } catch (Exception e) { log.warn("[AppSettings] 업비트 키 복호화 실패 — hasUpbitKeys=false 로 처리: {}", e.getMessage()); return ""; } } @Transactional(readOnly = true) public boolean hasUpbitApiKeys(GcAppSettings s) { return resolveUpbitCredentials(s).isComplete(); } /** 실시간 전략 전역 템플릿 (관심종목 자동 연동용) */ @Transactional(readOnly = true) public GcAppSettings getEntity(Long userId, String deviceId) { return findEntity(userId, deviceId).orElse(new GcAppSettings()); } private Object parseJson(String json) { if (json == null || json.isBlank()) return null; try { return mapper.readValue(json, Object.class); } catch (Exception e) { return null; } } private String toJson(Object obj) { if (obj == null) return null; try { return mapper.writeValueAsString(obj); } catch (JsonProcessingException e) { log.warn("[AppSettings] JSON 직렬화 실패", e); return null; } } }