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; @Value("${goldenchart.mobile-app.ios-testflight-url:}") private String iosTestFlightUrl; public MobileAppReleaseInfoDto getInfo(String requestBaseUrl) { String publicBaseUrl = resolvePublicBaseUrl(requestBaseUrl); boolean iosAvailable = iosTestFlightUrl != null && !iosTestFlightUrl.isBlank(); Optional apk = resolveApkPath(); if (apk.isEmpty()) { return MobileAppReleaseInfoDto.builder() .available(false) .installPageUrl(buildInstallPageUrl(publicBaseUrl)) .iosAvailable(iosAvailable) .iosTestFlightUrl(iosAvailable ? iosTestFlightUrl.trim() : null) .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"; String version = resolveApkVersion(file); return MobileAppReleaseInfoDto.builder() .available(true) .fileName(file.getFileName().toString()) .version(version) .sizeBytes(size) .updatedAt(DT_FMT.format(updated)) .downloadUrl(downloadUrl) .installPageUrl(buildInstallPageUrl(publicBaseUrl)) .iosAvailable(iosAvailable) .iosTestFlightUrl(iosAvailable ? iosTestFlightUrl.trim() : null) .build(); } catch (IOException e) { log.warn("[mobile-app] stat failed: {}", e.getMessage()); return MobileAppReleaseInfoDto.builder() .available(false) .installPageUrl(buildInstallPageUrl(publicBaseUrl)) .iosAvailable(iosAvailable) .iosTestFlightUrl(iosAvailable ? iosTestFlightUrl.trim() : null) .build(); } } public ResponseEntity downloadAndroid() { Optional 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 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 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("/+$", ""); } /** APK와 함께 기록된 .version sidecar → .env configuredVersion → 파일 수정 시각 */ private String resolveApkVersion(Path apkFile) { String apkName = apkFile.getFileName().toString(); Path sidecar = apkFile.getParent().resolve( apkName.replaceAll("(?i)\\.apk$", "") + ".version"); try { if (Files.isRegularFile(sidecar)) { String fromFile = Files.readString(sidecar).trim(); if (!fromFile.isBlank()) { return fromFile; } } } catch (IOException e) { log.warn("[mobile-app] read version sidecar failed: {}", e.getMessage()); } if (configuredVersion != null && !configuredVersion.isBlank()) { return configuredVersion.trim(); } try { return DT_FMT.format(Files.getLastModifiedTime(apkFile).toInstant()); } catch (IOException e) { return "unknown"; } } }