333 lines
12 KiB
Java
333 lines
12 KiB
Java
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<Path> macFile = resolveMacInstaller();
|
|
Optional<Path> 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<Path> 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<Path> resolveMacInstaller() {
|
|
Path dir = releaseDirectory();
|
|
if (dir == null) {
|
|
return Optional.empty();
|
|
}
|
|
try (Stream<Path> 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<Path> resolveWindowsInstaller() {
|
|
Path dir = releaseDirectory();
|
|
if (dir == null) {
|
|
return Optional.empty();
|
|
}
|
|
try (Stream<Path> 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<Path> 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<Path> 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("/+$", "");
|
|
}
|
|
}
|