From e34ef26d734eb49b76adf2b7dc5ab5afaa2b5884 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sat, 4 Jul 2026 02:47:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(update-service,file-service):=20=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E9=A1=B5=E5=AF=B9=E5=B7=B2=E4=B8=A2=E5=A4=B1=E7=9A=84?= =?UTF-8?q?=E5=8E=86=E5=8F=B2APK=E6=98=BE=E7=A4=BA"=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 file-service GET /api/file/{hash}/exists 轻量存在性检查(不读取文件内容), 下载页为每个历史版本调用该接口确认文件是否真实存在;若已丢失(如本次修复之前 就已被 30 天回收任务误删),不再展示会 404 的下载按钮,改为显示"无法下载"提示。 Co-Authored-By: Claude Sonnet 5 --- .../com/xuqm/file/config/SecurityConfig.java | 1 + .../xuqm/file/controller/FileController.java | 10 +++++ .../xuqm/file/service/FileStorageService.java | 8 ++++ .../controller/PublicDownloadController.java | 40 +++++++++++------- .../update/service/UpdateAssetService.java | 42 +++++++++++++++++++ 5 files changed, 87 insertions(+), 14 deletions(-) diff --git a/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java index cfb571e..989009f 100644 --- a/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java +++ b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java @@ -38,6 +38,7 @@ public class SecurityConfig { .requestMatchers(new AntPathRequestMatcher("/**", "OPTIONS")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/file/upload", "POST")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/file/*/thumbnail", "GET")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/api/file/*/exists", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/file/*", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/file/*/pin", "POST")).permitAll() // DELETE is permitAll at the Spring Security layer but enforces its own diff --git a/file-service/src/main/java/com/xuqm/file/controller/FileController.java b/file-service/src/main/java/com/xuqm/file/controller/FileController.java index 693624a..d2e3ba2 100644 --- a/file-service/src/main/java/com/xuqm/file/controller/FileController.java +++ b/file-service/src/main/java/com/xuqm/file/controller/FileController.java @@ -51,6 +51,16 @@ public class FileController { serveFromPath(entity.getStoragePath(), entity.getMimeType(), entity.getOriginalName(), response); } + /** + * Lightweight existence check — does not read/stream file content. Used by other + * services (e.g. update-service's public download page) to avoid exposing dead + * download links for files already lost to reclaim. + */ + @GetMapping("/{hash}/exists") + public ApiResponse exists(@PathVariable String hash) { + return ApiResponse.success(fileStorageService.exists(hash)); + } + /** * Mark a file as permanently retained, excluding it from the 30-day unused-file reclaim job. * Called by other services (e.g. update-service) for files that must stay downloadable diff --git a/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java b/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java index 9974b18..3885fab 100644 --- a/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java +++ b/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java @@ -167,6 +167,14 @@ public class FileStorageService { return entity; } + /** Lightweight existence check — no file content read, used by other services to avoid dead download links. */ + @Transactional(readOnly = true) + public boolean exists(String hash) { + return fileRepository.findByHash(hash) + .map(e -> Files.exists(Paths.get(e.getStoragePath()))) + .orElse(false); + } + @Transactional public void pin(String hash) { FileEntity entity = fileRepository.findByHash(hash) diff --git a/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java b/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java index d0a4415..6cfeadc 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java @@ -6,6 +6,7 @@ import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.service.AppStoreService; +import com.xuqm.update.service.UpdateAssetService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -28,12 +29,14 @@ public class PublicDownloadController { private final AppVersionRepository versionRepository; private final AppStoreService appStoreService; private final ObjectMapper objectMapper; + private final UpdateAssetService updateAssetService; public PublicDownloadController(AppVersionRepository versionRepository, AppStoreService appStoreService, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, UpdateAssetService updateAssetService) { this.versionRepository = versionRepository; this.appStoreService = appStoreService; this.objectMapper = objectMapper; + this.updateAssetService = updateAssetService; } @GetMapping("/download-info") @@ -84,10 +87,31 @@ public class PublicDownloadController { return null; } AppVersionEntity latest = versions.get(0); + + List> history = new ArrayList<>(); + for (AppVersionEntity v : versions) { + // Retention cleanup already clears downloadUrl for evicted versions; for the + // rest, confirm the file is still actually there — some historical APKs were + // reclaimed by file-service before the pin-on-upload fix shipped. + boolean hasUrl = v.getDownloadUrl() != null && !v.getDownloadUrl().isBlank(); + boolean available = hasUrl && updateAssetService.downloadUrlExists(v.getDownloadUrl()); + Map h = new LinkedHashMap<>(); + h.put("versionName", v.getVersionName()); + h.put("versionCode", v.getVersionCode()); + h.put("downloadUrl", available ? v.getDownloadUrl() : ""); + h.put("available", available); + h.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : ""); + h.put("publishedAt", v.getCreatedAt() != null ? v.getCreatedAt().toString() : ""); + h.put("current", v.getId().equals(latest.getId())); + history.add(h); + } + Map latestEntry = history.get(0); + Map info = new LinkedHashMap<>(); info.put("versionName", latest.getVersionName()); info.put("versionCode", latest.getVersionCode()); - info.put("downloadUrl", latest.getDownloadUrl() != null ? latest.getDownloadUrl() : ""); + info.put("downloadUrl", latestEntry.get("downloadUrl")); + info.put("available", latestEntry.get("available")); info.put("changeLog", latest.getChangeLog() != null ? latest.getChangeLog() : ""); info.put("publishedAt", latest.getCreatedAt() != null ? latest.getCreatedAt().toString() : ""); @@ -100,18 +124,6 @@ public class PublicDownloadController { } } info.put("storeLinks", storeLinks); - - List> history = new ArrayList<>(); - for (AppVersionEntity v : versions) { - Map h = new LinkedHashMap<>(); - h.put("versionName", v.getVersionName()); - h.put("versionCode", v.getVersionCode()); - h.put("downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : ""); - h.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : ""); - h.put("publishedAt", v.getCreatedAt() != null ? v.getCreatedAt().toString() : ""); - h.put("current", v.getId().equals(latest.getId())); - history.add(h); - } info.put("history", history); return info; } diff --git a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java index a02d20a..f5b8237 100644 --- a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java @@ -486,6 +486,48 @@ public class UpdateAssetService { } } + /** + * Whether a version's downloadUrl still resolves to an actual file, without downloading + * its content. Used by the public download page so it can show "无法下载" instead of a + * dead link for historical versions whose APK was already lost (e.g. reclaimed by + * file-service before the pin-on-upload fix shipped). Fails open (assumes it still + * exists) on transient check errors, so a network blip never hides a good link. + */ + public boolean downloadUrlExists(String downloadUrl) { + if (downloadUrl == null || downloadUrl.isBlank()) { + return false; + } + if (isFileServiceUrl(downloadUrl)) { + try { + String existsUrl = rewriteToInternalUrl(downloadUrl) + "/exists"; + HttpURLConnection connection = (HttpURLConnection) new URL(existsUrl).openConnection(); + connection.setConnectTimeout(8_000); + connection.setReadTimeout(10_000); + int status = connection.getResponseCode(); + if (status >= 400) { + return true; + } + try (InputStream in = connection.getInputStream()) { + JsonNode root = objectMapper.readTree(in); + return root.path("data").asBoolean(true); + } + } catch (Exception e) { + log.warn("Failed to check existence for {}: {}", downloadUrl, e.getMessage()); + return true; + } + } + if (downloadUrl.startsWith(baseUrl)) { + String prefix = "/files/apk/"; + int idx = downloadUrl.indexOf(prefix); + if (idx >= 0) { + String filename = downloadUrl.substring(idx + prefix.length()); + return Files.exists(Paths.get(uploadDir, "apk", filename)); + } + } + // Externally-hosted URL of unknown origin — can't check, assume it's fine. + return true; + } + private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException { String internalUrl = rewriteToInternalUrl(packageUrl); HttpURLConnection connection = (HttpURLConnection) new URL(internalUrl).openConnection();