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>
这个提交包含在:
父节点
d9b87237a2
当前提交
eb0e3a1877
@ -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);
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户