mobile download

This commit is contained in:
Macbook
2026-05-28 14:44:19 +09:00
parent e2816b037f
commit 3503ef33f5
152 changed files with 11021 additions and 687 deletions
@@ -0,0 +1,43 @@
package com.goldenchart.controller;
import com.goldenchart.dto.MobileAppReleaseInfoDto;
import com.goldenchart.service.MobileAppReleaseService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/mobile-app")
@RequiredArgsConstructor
public class MobileAppReleaseController {
private final MobileAppReleaseService releaseService;
@GetMapping("/info")
public MobileAppReleaseInfoDto info(HttpServletRequest request) {
return releaseService.getInfo(resolvePublicBaseUrl(request));
}
@GetMapping("/download/android")
public ResponseEntity<Resource> downloadAndroid() {
return releaseService.downloadAndroid();
}
private static String resolvePublicBaseUrl(HttpServletRequest request) {
String forwardedProto = request.getHeader("X-Forwarded-Proto");
String forwardedHost = request.getHeader("X-Forwarded-Host");
if (forwardedHost != null && !forwardedHost.isBlank()) {
String scheme = forwardedProto != null && !forwardedProto.isBlank() ? forwardedProto : "https";
return scheme + "://" + forwardedHost.split(",")[0].trim();
}
String scheme = request.getScheme();
int port = request.getServerPort();
String host = request.getServerName();
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
}
}
@@ -0,0 +1,18 @@
package com.goldenchart.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class MobileAppReleaseInfoDto {
private boolean available;
private String fileName;
private String version;
private Long sizeBytes;
private String updatedAt;
/** 브라우저·QR 스캔용 절대 다운로드 URL */
private String downloadUrl;
/** 모바일 설치 안내 페이지 URL */
private String installPageUrl;
}
@@ -23,6 +23,9 @@ public class StrategyDto {
/** 매도 조건 LogicNode 트리 (JSON 그대로 직렬화) */
private JsonNode sellCondition;
/** 편집기 flow layout { buy, sell } — START 분봉·노드 좌표 등 */
private JsonNode flowLayout;
private Boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -233,6 +233,11 @@ public class GcAppSettings {
@JdbcTypeCode(SqlTypes.JSON)
private String trendSearchSettingsJson;
/** 편집기·팔레트·패널 크기·가상투자 목록 등 UI 설정 통합 JSON */
@Column(name = "ui_preferences_json", columnDefinition = "JSON")
@JdbcTypeCode(SqlTypes.JSON)
private String uiPreferencesJson;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@@ -44,6 +44,11 @@ public class GcStrategy {
@JdbcTypeCode(SqlTypes.JSON)
private String sellConditionJson;
/** 편집기 flow layout — START 분봉·좌표·orphans (Logic Expression 표시·평가 분봉 동기화) */
@Column(name = "flow_layout_json", columnDefinition = "JSON")
@JdbcTypeCode(SqlTypes.JSON)
private String flowLayoutJson;
@Column(name = "enabled", nullable = false)
@Builder.Default
private Boolean enabled = true;
@@ -174,6 +174,9 @@ public class AppSettingsService {
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) {
@@ -226,6 +229,7 @@ public class AppSettingsService {
? 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;
}
@@ -0,0 +1,134 @@
package com.goldenchart.service;
import com.goldenchart.dto.MobileAppReleaseInfoDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.*;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.Optional;
import java.util.stream.Stream;
@Service
@Slf4j
public class MobileAppReleaseService {
private static final String DEFAULT_APK_NAME = "goldenchart-android.apk";
private static final DateTimeFormatter DT_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.of("Asia/Seoul"));
@Value("${goldenchart.mobile-app.release-dir:data/mobile-releases}")
private String releaseDir;
@Value("${goldenchart.mobile-app.version:}")
private String configuredVersion;
@Value("${goldenchart.mobile-app.public-base-url:}")
private String configuredPublicBaseUrl;
public MobileAppReleaseInfoDto getInfo(String requestBaseUrl) {
String publicBaseUrl = resolvePublicBaseUrl(requestBaseUrl);
Optional<Path> apk = resolveApkPath();
if (apk.isEmpty()) {
return MobileAppReleaseInfoDto.builder()
.available(false)
.installPageUrl(buildInstallPageUrl(publicBaseUrl))
.build();
}
Path file = apk.get();
try {
long size = Files.size(file);
Instant updated = Files.getLastModifiedTime(file).toInstant();
String base = normalizeBase(publicBaseUrl);
String downloadUrl = base + "/api/mobile-app/download/android";
return MobileAppReleaseInfoDto.builder()
.available(true)
.fileName(file.getFileName().toString())
.version(configuredVersion != null && !configuredVersion.isBlank()
? configuredVersion
: DT_FMT.format(updated))
.sizeBytes(size)
.updatedAt(DT_FMT.format(updated))
.downloadUrl(downloadUrl)
.installPageUrl(buildInstallPageUrl(publicBaseUrl))
.build();
} catch (IOException e) {
log.warn("[mobile-app] stat failed: {}", e.getMessage());
return MobileAppReleaseInfoDto.builder()
.available(false)
.installPageUrl(buildInstallPageUrl(publicBaseUrl))
.build();
}
}
public ResponseEntity<Resource> downloadAndroid() {
Optional<Path> apk = resolveApkPath();
if (apk.isEmpty()) {
return ResponseEntity.notFound().build();
}
Path file = apk.get();
FileSystemResource resource = new FileSystemResource(file);
String filename = file.getFileName().toString();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("application/vnd.android.package-archive"))
.body(resource);
}
private Optional<Path> resolveApkPath() {
Path dir = Paths.get(releaseDir).toAbsolutePath().normalize();
if (!Files.isDirectory(dir)) {
log.debug("[mobile-app] release dir missing: {}", dir);
return Optional.empty();
}
Path preferred = dir.resolve(DEFAULT_APK_NAME);
if (Files.isRegularFile(preferred)) {
return Optional.of(preferred);
}
try (Stream<Path> stream = Files.list(dir)) {
return stream
.filter(p -> Files.isRegularFile(p) && p.getFileName().toString().toLowerCase().endsWith(".apk"))
.max(Comparator.comparing(p -> {
try {
return Files.getLastModifiedTime(p).toMillis();
} catch (IOException e) {
return 0L;
}
}));
} catch (IOException e) {
log.warn("[mobile-app] list dir failed: {}", e.getMessage());
return Optional.empty();
}
}
private static String buildInstallPageUrl(String publicBaseUrl) {
return normalizeBase(publicBaseUrl) + "/install.html";
}
private String resolvePublicBaseUrl(String requestBaseUrl) {
if (configuredPublicBaseUrl != null && !configuredPublicBaseUrl.isBlank()) {
return configuredPublicBaseUrl.trim();
}
if (requestBaseUrl != null && !requestBaseUrl.isBlank()) {
return requestBaseUrl.trim();
}
return "http://exdev.co.kr";
}
private static String normalizeBase(String publicBaseUrl) {
if (publicBaseUrl == null || publicBaseUrl.isBlank()) {
return "http://exdev.co.kr";
}
return publicBaseUrl.replaceAll("/+$", "");
}
}
@@ -67,6 +67,11 @@ public class StrategyConditionTimeframeService {
GcStrategy strategy = opt.get();
ensureDslRepaired(strategy);
Set<String> fromFlowLayout = collectFromFlowLayoutJson(strategy.getFlowLayoutJson());
if (!fromFlowLayout.isEmpty()) {
return fromFlowLayout;
}
Set<String> buyOut = new LinkedHashSet<>();
Set<String> sellOut = new LinkedHashSet<>();
boolean buyScoped = collectStartScopeFromJson(strategy.getBuyConditionJson(), buyOut);
@@ -241,4 +246,33 @@ public class StrategyConditionTimeframeService {
out.add(ct);
}
}
/** flow_layout_json.startMeta — 편집기 Logic Expression·START 분봉 (DSL과 동기 저장) */
private Set<String> collectFromFlowLayoutJson(String flowLayoutJson) {
Set<String> out = new LinkedHashSet<>();
if (flowLayoutJson == null || flowLayoutJson.isBlank()) return out;
try {
JsonNode root = objectMapper.readTree(flowLayoutJson);
for (String side : new String[] { "buy", "sell" }) {
JsonNode startMeta = root.path(side).path("startMeta");
if (!startMeta.isObject()) continue;
startMeta.fields().forEachRemaining(entry -> {
JsonNode meta = entry.getValue();
JsonNode types = meta.path("candleTypes");
if (types.isArray() && !types.isEmpty()) {
for (JsonNode t : types) {
String ct = LiveStrategyTimeframeService.normalize(t.asText(""));
if (!ct.isBlank()) out.add(ct);
}
} else {
String ct = LiveStrategyTimeframeService.normalize(meta.path("candleType").asText(""));
if (!ct.isBlank()) out.add(ct);
}
});
}
} catch (Exception e) {
log.warn("[StrategyTimeframes] flow_layout_json 파싱 실패: {}", e.getMessage());
}
return out;
}
}
@@ -72,6 +72,9 @@ public class StrategyService {
sellJson != null
? dslTimeframeNormalizer.normalizeJson(sellJson, strategyName)
: null);
if (dto.getFlowLayout() != null && !dto.getFlowLayout().isNull()) {
entity.setFlowLayoutJson(objectMapper.writeValueAsString(dto.getFlowLayout()));
}
} catch (Exception e) {
log.warn("전략 DSL 직렬화 실패: {}", e.getMessage());
}
@@ -149,6 +152,8 @@ public class StrategyService {
dto.setBuyCondition(objectMapper.readTree(e.getBuyConditionJson()));
if (e.getSellConditionJson() != null)
dto.setSellCondition(objectMapper.readTree(e.getSellConditionJson()));
if (e.getFlowLayoutJson() != null && !e.getFlowLayoutJson().isBlank())
dto.setFlowLayout(objectMapper.readTree(e.getFlowLayoutJson()));
} catch (Exception ex) {
log.warn("전략 DSL 역직렬화 실패: {}", ex.getMessage());
}
@@ -104,12 +104,20 @@ goldenchart:
upload-dir: ${GC_VERIFICATION_UPLOAD_DIR:data/verification-images}
max-image-size-mb: 5
max-images-per-issue: 30
mobile-app:
release-dir: ${GC_MOBILE_APP_RELEASE_DIR:data/mobile-releases}
version: ${GC_MOBILE_APP_VERSION:}
# QR·APK 다운로드 절대 URL (로컬 개발 시에도 exdev 서버 APK 사용)
public-base-url: ${GC_MOBILE_APP_PUBLIC_BASE_URL:http://exdev.co.kr}
cors:
allowed-origins:
- http://localhost:5173
- http://localhost:5174
- http://localhost:3000
- http://localhost:80
- http://localhost
- capacitor://localhost
- https://localhost
# Firebase FCM (서비스 계정 JSON: classpath:firebase-service-account.json)
firebase:
@@ -0,0 +1,3 @@
-- 전략편집기 flow layout (START 분봉·노드 좌표·orphans 등) — localStorage 대신 DB 단일 소스
ALTER TABLE gc_strategy
ADD COLUMN flow_layout_json JSON NULL COMMENT '전략편집기 UI 상태 {buy,sell}' AFTER sell_condition_json;
@@ -0,0 +1,5 @@
-- UI·편집기·가상투자 등 클라이언트 설정 (localStorage 대체)
ALTER TABLE gc_app_settings
ADD COLUMN ui_preferences_json JSON NULL
COMMENT '프론트 UI 설정 통합 JSON'
AFTER trend_search_settings_json;