feat(update-service,file-service): 下载页对已丢失的历史APK显示"无法下载"
新增 file-service GET /api/file/{hash}/exists 轻量存在性检查(不读取文件内容),
下载页为每个历史版本调用该接口确认文件是否真实存在;若已丢失(如本次修复之前
就已被 30 天回收任务误删),不再展示会 404 的下载按钮,改为显示"无法下载"提示。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
这个提交包含在:
父节点
25e39386d9
当前提交
e34ef26d73
@ -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();
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户