diff --git a/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java index 83b1649..d9e9ba1 100644 --- a/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java +++ b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java @@ -39,6 +39,7 @@ public class SecurityConfig { .requestMatchers(new AntPathRequestMatcher("/api/file/upload", "POST")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/file/*/thumbnail", "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("/error")).permitAll() .anyRequest().authenticated() diff --git a/file-service/src/main/java/com/xuqm/file/controller/FileController.java b/file-service/src/main/java/com/xuqm/file/controller/FileController.java index 41ba35e..19adc21 100644 --- a/file-service/src/main/java/com/xuqm/file/controller/FileController.java +++ b/file-service/src/main/java/com/xuqm/file/controller/FileController.java @@ -10,6 +10,8 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -44,6 +46,17 @@ public class FileController { 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 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. */ @@ -77,7 +90,12 @@ public class FileController { && !contentType.startsWith("image/") && !contentType.startsWith("video/") && !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 { response.setHeader("Content-Disposition", "inline"); } diff --git a/file-service/src/main/java/com/xuqm/file/controller/GlobalExceptionHandler.java b/file-service/src/main/java/com/xuqm/file/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..e528503 --- /dev/null +++ b/file-service/src/main/java/com/xuqm/file/controller/GlobalExceptionHandler.java @@ -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> 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> 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> 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> 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> 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; + }; + } +} diff --git a/file-service/src/main/java/com/xuqm/file/entity/FileEntity.java b/file-service/src/main/java/com/xuqm/file/entity/FileEntity.java index d42ba97..bd143f9 100644 --- a/file-service/src/main/java/com/xuqm/file/entity/FileEntity.java +++ b/file-service/src/main/java/com/xuqm/file/entity/FileEntity.java @@ -41,6 +41,14 @@ public class FileEntity { @Column(name = "last_accessed_at", nullable = false) 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 void setId(String id) { this.id = id; } @@ -73,4 +81,7 @@ public class FileEntity { public Instant getLastAccessedAt() { return lastAccessedAt; } public void setLastAccessedAt(Instant lastAccessedAt) { this.lastAccessedAt = lastAccessedAt; } + + public boolean isPinned() { return pinned; } + public void setPinned(boolean pinned) { this.pinned = pinned; } } diff --git a/file-service/src/main/java/com/xuqm/file/repository/FileRepository.java b/file-service/src/main/java/com/xuqm/file/repository/FileRepository.java index a3f4985..84af3df 100644 --- a/file-service/src/main/java/com/xuqm/file/repository/FileRepository.java +++ b/file-service/src/main/java/com/xuqm/file/repository/FileRepository.java @@ -13,6 +13,6 @@ public interface FileRepository extends JpaRepository { Optional 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 findExpired(@Param("cutoff") Instant cutoff); } diff --git a/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java b/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java index 12aea04..d5a1ba6 100644 --- a/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java +++ b/file-service/src/main/java/com/xuqm/file/service/FileStorageService.java @@ -167,6 +167,16 @@ public class FileStorageService { 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 * * *") @Transactional public void reclaimExpiredFiles() { diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 4fe4d5f..d623998 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -193,6 +193,9 @@ public class AppVersionController { if (platform == AppVersionEntity.Platform.ANDROID) { if (hasText(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 { UpdateAssetService.StoreResult stored = updateAssetService.storeAppPackage(apkFile); entity.setDownloadUrl(stored != null ? stored.url() : null); diff --git a/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java b/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java index c59be5f..1de98f6 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java @@ -11,7 +11,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -40,19 +42,24 @@ public class PublicDownloadController { return ResponseEntity.ok(ApiResponse.success(response)); } + private static final List EVER_PUBLISHED = List.of( + AppVersionEntity.PublishStatus.PUBLISHED, AppVersionEntity.PublishStatus.DEPRECATED); + private Map androidInfo(String appKey) { - Optional latest = versionRepository.findTopByAppKeyAndPlatformAndPublishStatusOrderByVersionCodeDesc( - appKey, AppVersionEntity.Platform.ANDROID, AppVersionEntity.PublishStatus.PUBLISHED); - if (latest.isEmpty()) { + // "Ever published" = currently PUBLISHED or since superseded (DEPRECATED). DRAFT + // versions were never actually released and must not appear on the public page. + List versions = versionRepository.findByAppKeyAndPlatformAndPublishStatusInOrderByVersionCodeDesc( + appKey, AppVersionEntity.Platform.ANDROID, EVER_PUBLISHED); + if (versions.isEmpty()) { return null; } - AppVersionEntity v = latest.get(); + AppVersionEntity latest = versions.get(0); Map info = new LinkedHashMap<>(); - info.put("versionName", v.getVersionName()); - info.put("versionCode", v.getVersionCode()); - info.put("downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : ""); - info.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : ""); - info.put("publishedAt", v.getCreatedAt() != null ? v.getCreatedAt().toString() : ""); + info.put("versionName", latest.getVersionName()); + info.put("versionCode", latest.getVersionCode()); + info.put("downloadUrl", latest.getDownloadUrl() != null ? latest.getDownloadUrl() : ""); + info.put("changeLog", latest.getChangeLog() != null ? latest.getChangeLog() : ""); + info.put("publishedAt", latest.getCreatedAt() != null ? latest.getCreatedAt().toString() : ""); Map storeLinks = new LinkedHashMap<>(); for (AppStoreConfigEntity.StoreType storeType : AppStoreConfigEntity.StoreType.values()) { @@ -63,6 +70,19 @@ public class PublicDownloadController { } } info.put("storeLinks", storeLinks); + + List> history = new ArrayList<>(); + for (AppVersionEntity v : versions) { + Map 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; } diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java index 7a42bb2..aa5c40b 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java @@ -56,4 +56,7 @@ public interface AppVersionRepository extends JpaRepository findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus( String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode, AppVersionEntity.PublishStatus publishStatus); + + List findByAppKeyAndPlatformAndPublishStatusInOrderByVersionCodeDesc( + String appKey, AppVersionEntity.Platform platform, List statuses); } diff --git a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java index 157742b..611e8db 100644 --- a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java @@ -386,17 +386,49 @@ public class UpdateAssetService { 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 - // hairpin NAT / HTTPS overhead when update-service and file-service are co-located. - String internalUrl = packageUrl; - if (packageUrl != null) { - if (hasText(fileBaseUrl) && packageUrl.startsWith(fileBaseUrl)) { - internalUrl = fileServiceInternalUrl + packageUrl.substring(fileBaseUrl.length()); - } else if (packageUrl.contains("file.dev.xuqinmin.com")) { - internalUrl = packageUrl.replace("https://file.dev.xuqinmin.com", fileServiceInternalUrl); - } + /** + * 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. + */ + private String rewriteToInternalUrl(String packageUrl) { + if (packageUrl == null) return null; + if (hasText(fileBaseUrl) && packageUrl.startsWith(fileBaseUrl)) { + return fileServiceInternalUrl + packageUrl.substring(fileBaseUrl.length()); + } 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(); connection.setConnectTimeout(15_000); connection.setReadTimeout(300_000);