feat(update-service,file-service): 下载页对已丢失的历史APK显示"无法下载"

新增 file-service GET /api/file/{hash}/exists 轻量存在性检查(不读取文件内容),
下载页为每个历史版本调用该接口确认文件是否真实存在;若已丢失(如本次修复之前
就已被 30 天回收任务误删),不再展示会 404 的下载按钮,改为显示"无法下载"提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-07-04 02:47:43 +08:00
父节点 25e39386d9
当前提交 e34ef26d73
共有 5 个文件被更改,包括 87 次插入14 次删除

查看文件

@ -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

查看文件

@ -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<Boolean> 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

查看文件

@ -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)

查看文件

@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> latestEntry = history.get(0);
Map<String, Object> 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<Map<String, Object>> history = new ArrayList<>();
for (AppVersionEntity v : versions) {
Map<String, Object> 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;
}

查看文件

@ -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();