318 lines
19 KiB
Java
318 lines
19 KiB
Java
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;
|
|
|
|
/**
|
|
* 앱 전역 차트 기본 설정 서비스.
|
|
*
|
|
* <p>프론트엔드에서 하드코딩된 기본값들(DEFAULT_STATE, DEFAULT_MAIN_CHART_STYLE,
|
|
* DEFAULT_SYNC 등)을 DB 로 대체하여 사용자별 맞춤 기본값을 관리한다.</p>
|
|
*/
|
|
@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<String, Object> get(Long userId, String deviceId) {
|
|
GcAppSettings s = findEntity(userId, deviceId)
|
|
.orElse(new GcAppSettings());
|
|
return toMap(s);
|
|
}
|
|
|
|
/**
|
|
* 설정값을 Map 으로 받아 저장.
|
|
* DB 에 레코드가 없으면 신규 생성.
|
|
*/
|
|
public Map<String, Object> save(Long userId, String deviceId,
|
|
Map<String, Object> 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<GcAppSettings> 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<String, Object> 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("chartTimeFormat")) s.setChartTimeFormat(normalizeChartTimeFormat((String) d.get("chartTimeFormat")));
|
|
if (d.containsKey("tradeAlertTimeFormat")) s.setTradeAlertTimeFormat(normalizeChartTimeFormat((String) d.get("tradeAlertTimeFormat")));
|
|
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("chartCandleAreaPriceLabels")) s.setChartCandleAreaPriceLabels(
|
|
Boolean.parseBoolean(d.get("chartCandleAreaPriceLabels").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("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight(
|
|
Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString()));
|
|
if (d.containsKey("chartCrosshairInfoVisible")) s.setChartCrosshairInfoVisible(
|
|
Boolean.parseBoolean(d.get("chartCrosshairInfoVisible").toString()));
|
|
if (d.containsKey("chartHoverToolbarVisible")) s.setChartHoverToolbarVisible(
|
|
Boolean.parseBoolean(d.get("chartHoverToolbarVisible").toString()));
|
|
if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions")));
|
|
if (d.containsKey("chartPaneSeparator")) s.setChartPaneSeparatorJson(toJson(d.get("chartPaneSeparator")));
|
|
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("verificationIssueNotify")) s.setVerificationIssueNotify(
|
|
Boolean.parseBoolean(d.get("verificationIssueNotify").toString()));
|
|
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("virtualTargetMaxCount")) {
|
|
int max = Integer.parseInt(d.get("virtualTargetMaxCount").toString());
|
|
s.setVirtualTargetMaxCount(Math.min(100, Math.max(1, max)));
|
|
}
|
|
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("paperOrderFillMode")) {
|
|
String mode = d.get("paperOrderFillMode").toString().toUpperCase();
|
|
s.setPaperOrderFillMode(
|
|
"NEXT_TICK".equals(mode) || "AUTO_CANCEL".equals(mode) ? mode : "LIMIT_ONLY");
|
|
}
|
|
if (d.containsKey("paperOrderAutoCancelPct")) {
|
|
double v = Double.parseDouble(d.get("paperOrderAutoCancelPct").toString());
|
|
s.setPaperOrderAutoCancelPct(new java.math.BigDecimal(Math.max(0.1, Math.min(50.0, 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()));
|
|
if (d.containsKey("trendSearchSettings")) {
|
|
s.setTrendSearchSettingsJson(toJson(d.get("trendSearchSettings")));
|
|
}
|
|
if (d.containsKey("uiPreferences")) {
|
|
s.setUiPreferencesJson(toJson(d.get("uiPreferences")));
|
|
}
|
|
}
|
|
|
|
private Map<String, Object> toMap(GcAppSettings s) {
|
|
Map<String, Object> 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("chartTimeFormat", s.getChartTimeFormat() != null ? s.getChartTimeFormat() : "MM-dd HH:mm");
|
|
m.put("tradeAlertTimeFormat", s.getTradeAlertTimeFormat() != null ? s.getTradeAlertTimeFormat() : "MM-dd HH:mm");
|
|
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("chartCandleAreaPriceLabels", s.getChartCandleAreaPriceLabels() != null ? s.getChartCandleAreaPriceLabels() : true);
|
|
m.put("chartSeriesPriceLabels", s.getChartSeriesPriceLabels() != null ? s.getChartSeriesPriceLabels() : true);
|
|
m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true);
|
|
m.put("chartLiveReceiveHighlight", s.getChartLiveReceiveHighlight() != null ? s.getChartLiveReceiveHighlight() : true);
|
|
m.put("chartCrosshairInfoVisible", s.getChartCrosshairInfoVisible() != null ? s.getChartCrosshairInfoVisible() : true);
|
|
m.put("chartHoverToolbarVisible", s.getChartHoverToolbarVisible() != null ? s.getChartHoverToolbarVisible() : true);
|
|
m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson()));
|
|
m.put("chartPaneSeparator", parseJson(s.getChartPaneSeparatorJson()));
|
|
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("verificationIssueNotify", s.getVerificationIssueNotify() != null ? s.getVerificationIssueNotify() : true);
|
|
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("virtualTargetMaxCount", s.getVirtualTargetMaxCount() != null
|
|
? s.getVirtualTargetMaxCount() : 20);
|
|
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("paperOrderFillMode", s.getPaperOrderFillMode() != null ? s.getPaperOrderFillMode() : "LIMIT_ONLY");
|
|
m.put("paperOrderAutoCancelPct", s.getPaperOrderAutoCancelPct() != null
|
|
? s.getPaperOrderAutoCancelPct().doubleValue() : 1.0);
|
|
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);
|
|
m.put("trendSearchSettings", parseJson(s.getTrendSearchSettingsJson()));
|
|
m.put("uiPreferences", parseJson(s.getUiPreferencesJson()));
|
|
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;
|
|
}
|
|
}
|
|
|
|
private static String normalizeChartTimeFormat(String raw) {
|
|
if (raw == null) return "MM-dd HH:mm";
|
|
String t = raw.trim();
|
|
if (t.isEmpty() || !t.matches(".*[yMdHms].*")) return "MM-dd HH:mm";
|
|
return t.length() > 64 ? t.substring(0, 64) : t;
|
|
}
|
|
}
|