fix(file-service,update-service): APK 下载 500、文件被误回收;下载页支持历史版本

file-service:
- 新增 GlobalExceptionHandler(此前完全缺失,任何异常都变成裸 500,包括文件不存在)
- 修复 Content-Disposition 文件名未按 RFC 5987 编码,中文应用名会导致下载响应异常
- 新增 pinned 标记 + POST /api/file/{hash}/pin,30 天未访问自动回收任务不再误删仍被引用的文件

update-service:
- 上传 APK 到 file-service 后自动调用 pin,防止已发布版本的安装包被回收站清理
- PublicDownloadController 返回 Android 全部曾发布版本(PUBLISHED+DEPRECATED)历史列表,而非仅最新版本

tenant-platform:
- 下载页展示历史版本列表,支持下载任意历史 APK
- 版本管理页新增"复制对外下载页链接"入口,便于管理员查找/分享

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-07-04 02:12:45 +08:00
父节点 d9b87237a2
当前提交 eb0e3a1877
共有 10 个文件被更改,包括 187 次插入21 次删除

查看文件

@ -39,6 +39,7 @@ public class SecurityConfig {
.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/*", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/file/*", "GET")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/file/*/pin", "POST")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/actuator/**", "GET")).permitAll() .requestMatchers(new AntPathRequestMatcher("/actuator/**", "GET")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/error")).permitAll() .requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()

查看文件

@ -10,6 +10,8 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@ -44,6 +46,17 @@ public class FileController {
serveFromPath(entity.getStoragePath(), entity.getMimeType(), entity.getOriginalName(), response); serveFromPath(entity.getStoragePath(), entity.getMimeType(), entity.getOriginalName(), response);
} }
/**
* 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
* indefinitely, such as APK packages, regardless of how often they're re-downloaded.
*/
@PostMapping("/{hash}/pin")
public ApiResponse<Void> pin(@PathVariable String hash) {
fileStorageService.pin(hash);
return ApiResponse.success(null);
}
/** /**
* Stream the thumbnail for a given hash. Falls back to the original if no thumbnail exists. * Stream the thumbnail for a given hash. Falls back to the original if no thumbnail exists.
*/ */
@ -77,7 +90,12 @@ public class FileController {
&& !contentType.startsWith("image/") && !contentType.startsWith("image/")
&& !contentType.startsWith("video/") && !contentType.startsWith("video/")
&& !contentType.startsWith("audio/")) { && !contentType.startsWith("audio/")) {
response.setHeader("Content-Disposition", "attachment; filename=\"" + originalName + "\""); // Non-ASCII filenames (e.g. Chinese app names) are not valid in a raw quoted-string
// Content-Disposition value encode per RFC 5987 and keep an ASCII fallback.
String asciiFallback = originalName.replaceAll("[^\\x20-\\x7E]", "_");
String encoded = URLEncoder.encode(originalName, StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + asciiFallback + "\"; filename*=UTF-8''" + encoded);
} else { } else {
response.setHeader("Content-Disposition", "inline"); response.setHeader("Content-Disposition", "inline");
} }

查看文件

@ -0,0 +1,68 @@
package com.xuqm.file.controller;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.model.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handle(BusinessException ex, HttpServletRequest request) {
if (ex.getCode() >= 500) {
log.error("[{}] {} code={} msg={}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage(), ex);
} else {
log.warn("[{}] {} code={} msg={}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage());
}
return ResponseEntity.status(resolveStatus(ex.getCode()))
.body(ApiResponse.error(ex.getCode(), ex.getMessage()));
}
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handle(MissingServletRequestParameterException ex, HttpServletRequest request) {
log.warn("[{}] {} missing param: {}", request.getMethod(), request.getRequestURI(), ex.getParameterName());
return ResponseEntity.badRequest()
.body(ApiResponse.badRequest("缺少必填参数: " + ex.getParameterName()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handle(IllegalArgumentException ex, HttpServletRequest request) {
log.warn("[{}] {} illegal argument: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.badRequest(ex.getMessage() == null ? "参数错误" : ex.getMessage()));
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handle(MaxUploadSizeExceededException ex, HttpServletRequest request) {
log.warn("[{}] {} upload too large: {}", request.getMethod(), request.getRequestURI(), ex.getMessage());
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(ApiResponse.error(413, "文件过大"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handle(Exception ex, HttpServletRequest request) {
log.error("[{}] {} unhandled exception: {}", request.getMethod(), request.getRequestURI(), ex.getMessage(), ex);
return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, "服务器内部错误"));
}
private HttpStatus resolveStatus(int code) {
return switch (code) {
case 400 -> HttpStatus.BAD_REQUEST;
case 401 -> HttpStatus.UNAUTHORIZED;
case 403 -> HttpStatus.FORBIDDEN;
case 404 -> HttpStatus.NOT_FOUND;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
}
}

查看文件

@ -41,6 +41,14 @@ public class FileEntity {
@Column(name = "last_accessed_at", nullable = false) @Column(name = "last_accessed_at", nullable = false)
private Instant lastAccessedAt; private Instant lastAccessedAt;
/**
* Pinned files are excluded from the 30-day unused-file reclaim job. Set by other
* services (e.g. update-service) for files that must stay downloadable indefinitely,
* such as APK packages, even if nobody re-downloads them for a long time.
*/
@Column(name = "pinned", nullable = false)
private boolean pinned;
public String getId() { return id; } public String getId() { return id; }
public void setId(String id) { this.id = id; } public void setId(String id) { this.id = id; }
@ -73,4 +81,7 @@ public class FileEntity {
public Instant getLastAccessedAt() { return lastAccessedAt; } public Instant getLastAccessedAt() { return lastAccessedAt; }
public void setLastAccessedAt(Instant lastAccessedAt) { this.lastAccessedAt = lastAccessedAt; } public void setLastAccessedAt(Instant lastAccessedAt) { this.lastAccessedAt = lastAccessedAt; }
public boolean isPinned() { return pinned; }
public void setPinned(boolean pinned) { this.pinned = pinned; }
} }

查看文件

@ -13,6 +13,6 @@ public interface FileRepository extends JpaRepository<FileEntity, String> {
Optional<FileEntity> findByHash(String hash); Optional<FileEntity> findByHash(String hash);
@Query("SELECT f FROM FileEntity f WHERE f.lastAccessedAt < :cutoff") @Query("SELECT f FROM FileEntity f WHERE f.lastAccessedAt < :cutoff AND f.pinned = false")
List<FileEntity> findExpired(@Param("cutoff") Instant cutoff); List<FileEntity> findExpired(@Param("cutoff") Instant cutoff);
} }

查看文件

@ -167,6 +167,16 @@ public class FileStorageService {
return entity; return entity;
} }
@Transactional
public void pin(String hash) {
FileEntity entity = fileRepository.findByHash(hash)
.orElseThrow(() -> new BusinessException(404, "File not found: " + hash));
if (!entity.isPinned()) {
entity.setPinned(true);
fileRepository.save(entity);
}
}
@Scheduled(cron = "0 0 3 * * *") @Scheduled(cron = "0 0 3 * * *")
@Transactional @Transactional
public void reclaimExpiredFiles() { public void reclaimExpiredFiles() {

查看文件

@ -193,6 +193,9 @@ public class AppVersionController {
if (platform == AppVersionEntity.Platform.ANDROID) { if (platform == AppVersionEntity.Platform.ANDROID) {
if (hasText(apkUrl)) { if (hasText(apkUrl)) {
entity.setDownloadUrl(apkUrl); entity.setDownloadUrl(apkUrl);
// apkUrl was uploaded to file-service directly by the client; pin it so the
// shared 30-day unused-file reclaim job never deletes a published app version's APK.
updateAssetService.pinRemoteFile(apkUrl);
} else { } else {
UpdateAssetService.StoreResult stored = updateAssetService.storeAppPackage(apkFile); UpdateAssetService.StoreResult stored = updateAssetService.storeAppPackage(apkFile);
entity.setDownloadUrl(stored != null ? stored.url() : null); entity.setDownloadUrl(stored != null ? stored.url() : null);

查看文件

@ -11,7 +11,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -40,19 +42,24 @@ public class PublicDownloadController {
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
private static final List<AppVersionEntity.PublishStatus> EVER_PUBLISHED = List.of(
AppVersionEntity.PublishStatus.PUBLISHED, AppVersionEntity.PublishStatus.DEPRECATED);
private Map<String, Object> androidInfo(String appKey) { private Map<String, Object> androidInfo(String appKey) {
Optional<AppVersionEntity> latest = versionRepository.findTopByAppKeyAndPlatformAndPublishStatusOrderByVersionCodeDesc( // "Ever published" = currently PUBLISHED or since superseded (DEPRECATED). DRAFT
appKey, AppVersionEntity.Platform.ANDROID, AppVersionEntity.PublishStatus.PUBLISHED); // versions were never actually released and must not appear on the public page.
if (latest.isEmpty()) { List<AppVersionEntity> versions = versionRepository.findByAppKeyAndPlatformAndPublishStatusInOrderByVersionCodeDesc(
appKey, AppVersionEntity.Platform.ANDROID, EVER_PUBLISHED);
if (versions.isEmpty()) {
return null; return null;
} }
AppVersionEntity v = latest.get(); AppVersionEntity latest = versions.get(0);
Map<String, Object> info = new LinkedHashMap<>(); Map<String, Object> info = new LinkedHashMap<>();
info.put("versionName", v.getVersionName()); info.put("versionName", latest.getVersionName());
info.put("versionCode", v.getVersionCode()); info.put("versionCode", latest.getVersionCode());
info.put("downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : ""); info.put("downloadUrl", latest.getDownloadUrl() != null ? latest.getDownloadUrl() : "");
info.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : ""); info.put("changeLog", latest.getChangeLog() != null ? latest.getChangeLog() : "");
info.put("publishedAt", v.getCreatedAt() != null ? v.getCreatedAt().toString() : ""); info.put("publishedAt", latest.getCreatedAt() != null ? latest.getCreatedAt().toString() : "");
Map<String, String> storeLinks = new LinkedHashMap<>(); Map<String, String> storeLinks = new LinkedHashMap<>();
for (AppStoreConfigEntity.StoreType storeType : AppStoreConfigEntity.StoreType.values()) { for (AppStoreConfigEntity.StoreType storeType : AppStoreConfigEntity.StoreType.values()) {
@ -63,6 +70,19 @@ 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);
return info; return info;
} }

查看文件

@ -56,4 +56,7 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
List<AppVersionEntity> findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus( List<AppVersionEntity> findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus(
String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode, String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode,
AppVersionEntity.PublishStatus publishStatus); AppVersionEntity.PublishStatus publishStatus);
List<AppVersionEntity> findByAppKeyAndPlatformAndPublishStatusInOrderByVersionCodeDesc(
String appKey, AppVersionEntity.Platform platform, List<AppVersionEntity.PublishStatus> statuses);
} }

查看文件

@ -386,17 +386,49 @@ public class UpdateAssetService {
return moduleId + "." + platform.toLowerCase(Locale.ROOT) + ext; return moduleId + "." + platform.toLowerCase(Locale.ROOT) + ext;
} }
private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException { /**
// Rewrite external file-service URLs to the internal Docker network address to avoid * Rewrite external file-service URLs to the internal Docker network address to avoid
// hairpin NAT / HTTPS overhead when update-service and file-service are co-located. * hairpin NAT / HTTPS overhead when update-service and file-service are co-located.
String internalUrl = packageUrl; */
if (packageUrl != null) { private String rewriteToInternalUrl(String packageUrl) {
if (hasText(fileBaseUrl) && packageUrl.startsWith(fileBaseUrl)) { if (packageUrl == null) return null;
internalUrl = fileServiceInternalUrl + packageUrl.substring(fileBaseUrl.length()); if (hasText(fileBaseUrl) && packageUrl.startsWith(fileBaseUrl)) {
} else if (packageUrl.contains("file.dev.xuqinmin.com")) { return fileServiceInternalUrl + packageUrl.substring(fileBaseUrl.length());
internalUrl = packageUrl.replace("https://file.dev.xuqinmin.com", fileServiceInternalUrl); } else if (packageUrl.contains("file.dev.xuqinmin.com")) {
} return packageUrl.replace("https://file.dev.xuqinmin.com", fileServiceInternalUrl);
} }
return packageUrl;
}
/**
* Mark an APK hosted on file-service as permanently retained so it's excluded from
* file-service's 30-day unused-file reclaim job. Best-effort failures are logged and
* swallowed so they never block version creation/publishing.
*/
public void pinRemoteFile(String packageUrl) {
boolean isFileServiceUrl = packageUrl != null && (
packageUrl.contains("file.dev.xuqinmin.com")
|| (hasText(fileBaseUrl) && packageUrl.startsWith(fileBaseUrl)));
if (!isFileServiceUrl) {
return;
}
try {
String pinUrl = rewriteToInternalUrl(packageUrl) + "/pin";
HttpURLConnection connection = (HttpURLConnection) new URL(pinUrl).openConnection();
connection.setRequestMethod("POST");
connection.setConnectTimeout(10_000);
connection.setReadTimeout(15_000);
int status = connection.getResponseCode();
if (status >= 400) {
log.warn("Failed to pin file-service asset {}: HTTP {}", packageUrl, status);
}
} catch (Exception e) {
log.warn("Failed to pin file-service asset {}: {}", packageUrl, e.getMessage());
}
}
private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException {
String internalUrl = rewriteToInternalUrl(packageUrl);
HttpURLConnection connection = (HttpURLConnection) new URL(internalUrl).openConnection(); HttpURLConnection connection = (HttpURLConnection) new URL(internalUrl).openConnection();
connection.setConnectTimeout(15_000); connection.setConnectTimeout(15_000);
connection.setReadTimeout(300_000); connection.setReadTimeout(300_000);