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;
}
}
}