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("/**", "OPTIONS")).permitAll()
|
||||||
.requestMatchers(new AntPathRequestMatcher("/api/file/upload", "POST")).permitAll()
|
.requestMatchers(new AntPathRequestMatcher("/api/file/upload", "POST")).permitAll()
|
||||||
.requestMatchers(new AntPathRequestMatcher("/api/file/*/thumbnail", "GET")).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/*", "GET")).permitAll()
|
||||||
.requestMatchers(new AntPathRequestMatcher("/api/file/*/pin", "POST")).permitAll()
|
.requestMatchers(new AntPathRequestMatcher("/api/file/*/pin", "POST")).permitAll()
|
||||||
// DELETE is permitAll at the Spring Security layer but enforces its own
|
// 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);
|
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.
|
* 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
|
* Called by other services (e.g. update-service) for files that must stay downloadable
|
||||||
|
|||||||
@ -167,6 +167,14 @@ public class FileStorageService {
|
|||||||
return entity;
|
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
|
@Transactional
|
||||||
public void pin(String hash) {
|
public void pin(String hash) {
|
||||||
FileEntity entity = fileRepository.findByHash(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.entity.AppVersionEntity;
|
||||||
import com.xuqm.update.repository.AppVersionRepository;
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
import com.xuqm.update.service.AppStoreService;
|
import com.xuqm.update.service.AppStoreService;
|
||||||
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@ -28,12 +29,14 @@ public class PublicDownloadController {
|
|||||||
private final AppVersionRepository versionRepository;
|
private final AppVersionRepository versionRepository;
|
||||||
private final AppStoreService appStoreService;
|
private final AppStoreService appStoreService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final UpdateAssetService updateAssetService;
|
||||||
|
|
||||||
public PublicDownloadController(AppVersionRepository versionRepository, AppStoreService appStoreService,
|
public PublicDownloadController(AppVersionRepository versionRepository, AppStoreService appStoreService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper, UpdateAssetService updateAssetService) {
|
||||||
this.versionRepository = versionRepository;
|
this.versionRepository = versionRepository;
|
||||||
this.appStoreService = appStoreService;
|
this.appStoreService = appStoreService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.updateAssetService = updateAssetService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/download-info")
|
@GetMapping("/download-info")
|
||||||
@ -84,10 +87,31 @@ public class PublicDownloadController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
AppVersionEntity latest = versions.get(0);
|
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<>();
|
Map<String, Object> info = new LinkedHashMap<>();
|
||||||
info.put("versionName", latest.getVersionName());
|
info.put("versionName", latest.getVersionName());
|
||||||
info.put("versionCode", latest.getVersionCode());
|
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("changeLog", latest.getChangeLog() != null ? latest.getChangeLog() : "");
|
||||||
info.put("publishedAt", latest.getCreatedAt() != null ? latest.getCreatedAt().toString() : "");
|
info.put("publishedAt", latest.getCreatedAt() != null ? latest.getCreatedAt().toString() : "");
|
||||||
|
|
||||||
@ -100,18 +124,6 @@ public class PublicDownloadController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
info.put("storeLinks", storeLinks);
|
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);
|
info.put("history", history);
|
||||||
return info;
|
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 {
|
private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException {
|
||||||
String internalUrl = rewriteToInternalUrl(packageUrl);
|
String internalUrl = rewriteToInternalUrl(packageUrl);
|
||||||
HttpURLConnection connection = (HttpURLConnection) new URL(internalUrl).openConnection();
|
HttpURLConnection connection = (HttpURLConnection) new URL(internalUrl).openConnection();
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户