package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.dto.DesktopAppPlatformReleaseDto; import com.goldenchart.dto.DesktopAppReleaseInfoDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; 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.Locale; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @Service @Slf4j @RequiredArgsConstructor public class DesktopAppReleaseService { private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.of("Asia/Seoul")); private static final Pattern VERSION_IN_NAME = Pattern.compile("GoldenChart[_-](\\d+\\.\\d+\\.\\d+)", Pattern.CASE_INSENSITIVE); private final ObjectMapper objectMapper; @Value("${goldenchart.desktop-app.release-dir:data/desktop-releases}") private String releaseDir; @Value("${goldenchart.desktop-app.updates-dir:}") private String updatesDir; @Value("${goldenchart.desktop-app.version:}") private String configuredVersion; @Value("${goldenchart.desktop-app.public-base-url:}") private String configuredPublicBaseUrl; public DesktopAppReleaseInfoDto getInfo(String requestBaseUrl) { String publicBaseUrl = resolvePublicBaseUrl(requestBaseUrl); String base = normalizeBase(publicBaseUrl); String staticBase = base + "/desktop/updates"; Optional macFile = resolveMacInstaller(); Optional winFile = resolveWindowsInstaller(); String macVer = macFile.map(p -> extractVersionFromFileName(p.getFileName().toString())).orElse(null); String winVer = winFile.map(p -> extractVersionFromFileName(p.getFileName().toString())).orElse(null); String publishedVer = readLatestJsonVersion(); String buildInfoVer = readBuildInfoVersion(releaseDirectory()); // 헤더 = build-info(마지막 성공 Jenkins 빌드) → mac/win 설치 파일 → 설정값 // latest.json 은 업데이터용 — 설치 파일보다 높으면 헤더에 쓰지 않음 (UI 불일치 방지) String installerVer = maxSemver(macVer, winVer); String version = maxSemver( buildInfoVer, installerVer, nullIfBlank(configuredVersion) ); if (version == null) { version = publishedVer; } Instant latestUpdated = Stream.of(macFile, winFile) .flatMap(Optional::stream) .map(this::lastModified) .max(Comparator.naturalOrder()) .orElse(Instant.EPOCH); return DesktopAppReleaseInfoDto.builder() .version(version) .updatedAt(latestUpdated.equals(Instant.EPOCH) ? null : DT_FMT.format(latestUpdated)) .mac(buildPlatform(macFile, staticBase, macVer != null ? macVer : version)) .windows(buildPlatform(winFile, staticBase, winVer != null ? winVer : version)) .build(); } private DesktopAppPlatformReleaseDto buildPlatform( Optional file, String staticBase, String version ) { if (file.isEmpty()) { return DesktopAppPlatformReleaseDto.builder().available(false).build(); } Path path = file.get(); try { long size = Files.size(path); Instant updated = Files.getLastModifiedTime(path).toInstant(); String fileName = path.getFileName().toString(); return DesktopAppPlatformReleaseDto.builder() .available(true) .fileName(fileName) .version(version) .sizeBytes(size) .updatedAt(DT_FMT.format(updated)) .downloadUrl(staticBase + "/" + encodeFileName(fileName)) .build(); } catch (IOException e) { log.warn("[desktop-app] stat failed: {}", e.getMessage()); return DesktopAppPlatformReleaseDto.builder().available(false).build(); } } private Instant lastModified(Path path) { try { return Files.getLastModifiedTime(path).toInstant(); } catch (IOException e) { return Instant.EPOCH; } } private String readLatestJsonVersion() { Path latest = resolveUpdatesDirectory() != null ? resolveUpdatesDirectory().resolve("latest.json") : null; if (latest == null || !Files.isRegularFile(latest)) { return null; } try { JsonNode root = objectMapper.readTree(Files.readString(latest)); JsonNode ver = root.path("version"); return ver.isTextual() ? ver.asText().trim() : null; } catch (Exception e) { log.debug("[desktop-app] latest.json read failed: {}", e.getMessage()); return null; } } private String readBuildInfoVersion(Path release) { if (release == null) { return null; } Path info = release.resolve("build-info.json"); if (!Files.isRegularFile(info)) { return null; } try { JsonNode root = objectMapper.readTree(Files.readString(info)); JsonNode ver = root.path("version"); return ver.isTextual() ? ver.asText().trim() : null; } catch (Exception e) { log.debug("[desktop-app] build-info read failed: {}", e.getMessage()); return null; } } private String extractVersionFromFileName(String fileName) { Matcher m = VERSION_IN_NAME.matcher(fileName); return m.find() ? m.group(1) : null; } private String maxSemver(String... candidates) { String best = null; for (String c : candidates) { if (c == null || c.isBlank()) { continue; } if (best == null || compareSemver(c, best) > 0) { best = c.trim(); } } return best; } private int compareSemver(String a, String b) { int[] pa = parseSemverParts(a); int[] pb = parseSemverParts(b); for (int i = 0; i < 3; i++) { int d = pa[i] - pb[i]; if (d != 0) { return d; } } return 0; } private int[] parseSemverParts(String v) { String[] parts = v.replaceAll("^[vV]", "").split("\\."); int[] out = new int[3]; for (int i = 0; i < 3; i++) { out[i] = i < parts.length ? parseIntSafe(parts[i]) : 0; } return out; } private int parseIntSafe(String s) { try { return Integer.parseInt(s.replaceAll("[^0-9].*", "")); } catch (NumberFormatException e) { return 0; } } private static String nullIfBlank(String s) { return s == null || s.isBlank() ? null : s.trim(); } private Optional resolveMacInstaller() { Path dir = releaseDirectory(); if (dir == null) { return Optional.empty(); } try (Stream stream = Files.list(dir)) { return stream .filter(p -> Files.isRegularFile(p) && p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".dmg")) .max(macComparator()); } catch (IOException e) { log.warn("[desktop-app] list dir failed: {}", e.getMessage()); return Optional.empty(); } } private Optional resolveWindowsInstaller() { Path dir = releaseDirectory(); if (dir == null) { return Optional.empty(); } try (Stream stream = Files.list(dir)) { return stream .filter(p -> { String n = p.getFileName().toString().toLowerCase(Locale.ROOT); return Files.isRegularFile(p) && n.endsWith(".exe") && (n.contains("setup") || n.contains("nsis") || n.startsWith("goldenchart")); }) .max(windowsComparator()); } catch (IOException e) { log.warn("[desktop-app] list dir failed: {}", e.getMessage()); return Optional.empty(); } } private Comparator macComparator() { return Comparator .comparingInt((Path p) -> scoreMac(p.getFileName().toString())) .thenComparing((Path p) -> semverScore(extractVersionFromFileName(p.getFileName().toString()))) .thenComparing(p -> lastModified(p).toEpochMilli()); } private Comparator windowsComparator() { return Comparator .comparingInt((Path p) -> scoreWindows(p.getFileName().toString())) .thenComparing((Path p) -> semverScore(extractVersionFromFileName(p.getFileName().toString()))) .thenComparing(p -> lastModified(p).toEpochMilli()); } private long semverScore(String version) { if (version == null) { return 0; } int[] p = parseSemverParts(version); return p[0] * 1_000_000L + p[1] * 1_000L + p[2]; } private static int scoreMac(String name) { String lower = name.toLowerCase(Locale.ROOT); if (lower.contains("aarch64") || lower.contains("arm64")) { return 3; } if (lower.contains("universal")) { return 2; } if (lower.contains("x64") || lower.contains("x86_64")) { return 1; } return 0; } private static int scoreWindows(String name) { String lower = name.toLowerCase(Locale.ROOT); if (lower.contains("setup")) { return 2; } return 1; } private Path releaseDirectory() { Path dir = Paths.get(releaseDir).toAbsolutePath().normalize(); if (!Files.isDirectory(dir)) { log.debug("[desktop-app] release dir missing: {}", dir); return null; } return dir; } private Path resolveUpdatesDirectory() { if (updatesDir != null && !updatesDir.isBlank()) { Path dir = Paths.get(updatesDir).toAbsolutePath().normalize(); if (Files.isDirectory(dir)) { return dir; } } Path fallback = releaseDirectory() != null ? releaseDirectory().getParent().resolve("desktop-updates") : null; if (fallback != null && Files.isDirectory(fallback)) { return fallback; } return null; } private static String encodeFileName(String fileName) { return fileName.replace(" ", "%20"); } private String resolvePublicBaseUrl(String requestBaseUrl) { if (configuredPublicBaseUrl != null && !configuredPublicBaseUrl.isBlank()) { return configuredPublicBaseUrl.trim(); } if (requestBaseUrl != null && !requestBaseUrl.isBlank()) { return requestBaseUrl.trim(); } return "https://stock.exdev.co.kr"; } private static String normalizeBase(String publicBaseUrl) { if (publicBaseUrl == null || publicBaseUrl.isBlank()) { return "https://stock.exdev.co.kr"; } return publicBaseUrl.replaceAll("/+$", ""); } }