mobile download
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user