From 9be2e71b82286381284f7ab49a590448e8f6f778 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sat, 4 Jul 2026 02:38:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(update-service,file-service):=20=E6=8C=89?= =?UTF-8?q?=E5=8D=A0=E7=94=A8=E7=A9=BA=E9=97=B4=E9=98=88=E5=80=BC=E6=B8=85?= =?UTF-8?q?=E7=90=86=E5=8E=86=E5=8F=B2APK=EF=BC=8C=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=A1=B5=E5=85=BC=E5=AE=B9"=E4=BB=8E=E6=9C=AA=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=8F=91=E5=B8=83"=E7=9A=84=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 下载页/公开接口重新定义"曾上架":不再要求内部 PublishStatus=PUBLISHED, 实际生产数据显示大多数租户只提交应用商店从不点击"发布",现改为 PublishStatus 已发布 或 任一厂商 storeReviewStatus 显示 liveOnStore - 新增 file-service DELETE /api/file/{hash}(X-Internal-Token 保护,销毁性操作不能像 pin/GET 一样完全公开) - 新增 ApkRetentionService:按 appKey 分组,从最新版本往回累加安装包大小, 超过阈值(默认 5GB,可通过发布配置 JSON 里 apkRetentionMaxTotalSizeMb 按应用覆盖) 即清理更旧版本的安装包,但永远保留最新版本 - AppVersionEntity 新增 fileSize 字段,上传时记录(本地存储路径直接取 MultipartFile.getSize(),file-service 路径由前端一并上传时的 size 回传) - 新增每日 02:00 自动重新 pin 所有历史 APK 的兜底任务,保护本次修复之前已上传、 尚未被 pin 保护的历史版本 Co-Authored-By: Claude Sonnet 5 --- .../com/xuqm/file/config/SecurityConfig.java | 3 + .../xuqm/file/controller/FileController.java | 22 ++++ .../xuqm/file/service/FileStorageService.java | 13 ++ .../controller/AppVersionController.java | 3 + .../controller/PublicDownloadController.java | 59 ++++++--- .../xuqm/update/entity/AppVersionEntity.java | 7 ++ .../repository/AppVersionRepository.java | 3 + .../update/service/ApkRetentionService.java | 118 ++++++++++++++++++ .../update/service/UpdateAssetService.java | 69 +++++++++- 9 files changed, 277 insertions(+), 20 deletions(-) create mode 100644 update-service/src/main/java/com/xuqm/update/service/ApkRetentionService.java 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 d9e9ba1..cfb571e 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 @@ -40,6 +40,9 @@ public class SecurityConfig { .requestMatchers(new AntPathRequestMatcher("/api/file/*/thumbnail", "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 + // X-Internal-Token shared-secret check in FileController — see delete(). + .requestMatchers(new AntPathRequestMatcher("/api/file/*", "DELETE")).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 19adc21..693624a 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 @@ -4,7 +4,9 @@ import com.xuqm.common.model.ApiResponse; import com.xuqm.file.entity.FileEntity; import com.xuqm.file.service.FileStorageService; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,6 +24,9 @@ public class FileController { private final FileStorageService fileStorageService; + @Value("${file.internal-token:xuqm-internal-token}") + private String internalToken; + public FileController(FileStorageService fileStorageService) { this.fileStorageService = fileStorageService; } @@ -57,6 +62,23 @@ public class FileController { return ApiResponse.success(null); } + /** + * Permanently delete a file (DB record + disk file + thumbnail), regardless of pinned + * status. Destructive and shared across every service that uses file-service (IM + * attachments, avatars, APK packages, ...) — gated by an internal shared-secret header + * rather than left open like GET/upload/pin, since a wrong call here is unrecoverable. + * Called by update-service's APK space-based retention cleanup. + */ + @DeleteMapping("/{hash}") + public ResponseEntity> delete(@PathVariable String hash, + @RequestHeader(value = "X-Internal-Token", required = false) String token) { + if (token == null || !internalToken.equals(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + fileStorageService.delete(hash); + return ResponseEntity.ok(ApiResponse.success(null)); + } + /** * Stream the thumbnail for a given hash. Falls back to the original if no thumbnail exists. */ 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 d5a1ba6..9974b18 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 @@ -177,6 +177,19 @@ public class FileStorageService { } } + @Transactional + public void delete(String hash) { + FileEntity entity = fileRepository.findByHash(hash).orElse(null); + if (entity == null) { + return; + } + deleteDiskFile(entity.getStoragePath()); + if (entity.getThumbnailPath() != null) { + deleteDiskFile(entity.getThumbnailPath()); + } + fileRepository.delete(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 d623998..5773ff3 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 @@ -152,6 +152,7 @@ public class AppVersionController { @RequestParam(required = false) String changeLog, @RequestParam(defaultValue = "false") boolean forceUpdate, @RequestParam(required = false) String apkUrl, + @RequestParam(required = false) Long apkSize, @RequestParam(required = false) MultipartFile apkFile, @RequestParam(required = false) String scheduledPublishAt, @RequestParam(required = false) String webhookUrl, @@ -193,6 +194,7 @@ public class AppVersionController { if (platform == AppVersionEntity.Platform.ANDROID) { if (hasText(apkUrl)) { entity.setDownloadUrl(apkUrl); + entity.setFileSize(apkSize != null ? apkSize : 0L); // 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); @@ -200,6 +202,7 @@ public class AppVersionController { UpdateAssetService.StoreResult stored = updateAssetService.storeAppPackage(apkFile); entity.setDownloadUrl(stored != null ? stored.url() : null); entity.setApkHash(stored != null ? stored.hash() : null); + entity.setFileSize(apkFile.getSize()); } } entity.setChangeLog(changeLog); 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 1de98f6..d0a4415 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 @@ -1,5 +1,6 @@ package com.xuqm.update.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppVersionEntity; @@ -15,7 +16,6 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; /** * Public, unauthenticated endpoint backing the per-app download page. Returns only what a @@ -27,10 +27,13 @@ public class PublicDownloadController { private final AppVersionRepository versionRepository; private final AppStoreService appStoreService; + private final ObjectMapper objectMapper; - public PublicDownloadController(AppVersionRepository versionRepository, AppStoreService appStoreService) { + public PublicDownloadController(AppVersionRepository versionRepository, AppStoreService appStoreService, + ObjectMapper objectMapper) { this.versionRepository = versionRepository; this.appStoreService = appStoreService; + this.objectMapper = objectMapper; } @GetMapping("/download-info") @@ -42,14 +45,41 @@ public class PublicDownloadController { return ResponseEntity.ok(ApiResponse.success(response)); } - private static final List EVER_PUBLISHED = List.of( - AppVersionEntity.PublishStatus.PUBLISHED, AppVersionEntity.PublishStatus.DEPRECATED); + /** + * "曾上架" (ever released) means either update-service's own publish workflow was used + * (PublishStatus PUBLISHED/DEPRECATED), OR — the far more common real-world case — the + * tenant submits straight to vendor app stores and never touches the internal "发布" + * button at all, leaving publishStatus stuck at DRAFT forever even though the version is + * fully live on vivo/华为/小米/etc. Treat a version confirmed live on any vendor store + * (storeReviewStatus liveOnStore=true or state=APPROVED) as released too. + */ + private boolean everReleased(AppVersionEntity v) { + if (v.getPublishStatus() == AppVersionEntity.PublishStatus.PUBLISHED + || v.getPublishStatus() == AppVersionEntity.PublishStatus.DEPRECATED) { + return true; + } + String reviewJson = v.getStoreReviewStatus(); + if (reviewJson == null || reviewJson.isBlank()) { + return false; + } + try { + Map reviewMap = objectMapper.readValue(reviewJson, + new com.fasterxml.jackson.core.type.TypeReference>() {}); + for (Object entry : reviewMap.values()) { + if (!(entry instanceof Map m)) continue; + if (Boolean.TRUE.equals(m.get("liveOnStore"))) return true; + if ("APPROVED".equals(m.get("state"))) return true; + } + } catch (Exception ignored) { + // malformed JSON — treat as not released + } + return false; + } private Map androidInfo(String appKey) { - // "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); + List all = versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc( + appKey, AppVersionEntity.Platform.ANDROID); + List versions = all.stream().filter(this::everReleased).toList(); if (versions.isEmpty()) { return null; } @@ -92,17 +122,16 @@ public class PublicDownloadController { if (storeUrl == null || storeUrl.isBlank()) { return null; } - Optional latest = versionRepository.findTopByAppKeyAndPlatformAndPublishStatusOrderByVersionCodeDesc( - appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED); - if (latest.isEmpty()) { + List all = versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform); + AppVersionEntity latest = all.stream().filter(this::everReleased).findFirst().orElse(null); + if (latest == null) { return null; } - AppVersionEntity v = latest.get(); Map info = new LinkedHashMap<>(); - info.put("versionName", v.getVersionName()); - info.put("versionCode", v.getVersionCode()); + info.put("versionName", latest.getVersionName()); + info.put("versionCode", latest.getVersionCode()); info.put("storeUrl", storeUrl); - info.put("publishedAt", v.getCreatedAt() != null ? v.getCreatedAt().toString() : ""); + info.put("publishedAt", latest.getCreatedAt() != null ? latest.getCreatedAt().toString() : ""); return info; } } diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index 69bc5c2..7598bb3 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -132,6 +132,10 @@ public class AppVersionEntity { @Column(length = 64) private String apkHash; + /** APK file size in bytes. Used by the size-based retention cleanup to evict old versions. */ + @Column(nullable = false, columnDefinition = "BIGINT NOT NULL DEFAULT 0") + private long fileSize; + @Column(nullable = false) private LocalDateTime createdAt; @@ -186,6 +190,9 @@ public class AppVersionEntity { public String getApkHash() { return apkHash; } public void setApkHash(String apkHash) { this.apkHash = apkHash; } + public long getFileSize() { return fileSize; } + public void setFileSize(long fileSize) { this.fileSize = fileSize; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } 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 aa5c40b..c4ee6e2 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 @@ -59,4 +59,7 @@ public interface AppVersionRepository extends JpaRepository findByAppKeyAndPlatformAndPublishStatusInOrderByVersionCodeDesc( String appKey, AppVersionEntity.Platform platform, List statuses); + + @Query(value = "SELECT * FROM update_app_version WHERE download_url LIKE 'https://file.dev.xuqinmin.com%'", nativeQuery = true) + List findAllWithFileServiceDownloadUrl(); } diff --git a/update-service/src/main/java/com/xuqm/update/service/ApkRetentionService.java b/update-service/src/main/java/com/xuqm/update/service/ApkRetentionService.java new file mode 100644 index 0000000..f01e74d --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/ApkRetentionService.java @@ -0,0 +1,118 @@ +package com.xuqm.update.service; + +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.repository.AppVersionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Evicts old Android APK packages once an app's total install-package storage exceeds a + * configured budget, instead of relying on file-service's generic 30-day-unused reclaim + * (which has no notion of "keep every historical release downloadable" and, worse, has no + * notion of "app" at all). The currently-latest version per app is never evicted regardless + * of total size, so the app always stays installable/updatable. + */ +@Service +public class ApkRetentionService { + + private static final Logger log = LoggerFactory.getLogger(ApkRetentionService.class); + + private final AppVersionRepository versionRepository; + private final UpdateAssetService updateAssetService; + private final PublishConfigService publishConfigService; + private final UpdateOperationLogService operationLogService; + + @Value("${update.apk-retention.default-max-total-size-mb:5120}") + private long defaultMaxTotalSizeMb; + + public ApkRetentionService(AppVersionRepository versionRepository, + UpdateAssetService updateAssetService, + PublishConfigService publishConfigService, + UpdateOperationLogService operationLogService) { + this.versionRepository = versionRepository; + this.updateAssetService = updateAssetService; + this.publishConfigService = publishConfigService; + this.operationLogService = operationLogService; + } + + /** Runs after the daily re-pin (02:00) and before file-service's reclaim job (03:00). */ + @Scheduled(cron = "0 30 2 * * *") + public void enforceRetention() { + List all = versionRepository.findAllWithFileServiceDownloadUrl(); + Map> byApp = all.stream() + .collect(Collectors.groupingBy(AppVersionEntity::getAppKey)); + int evictedTotal = 0; + for (Map.Entry> entry : byApp.entrySet()) { + evictedTotal += enforceForApp(entry.getKey(), entry.getValue()); + } + if (evictedTotal > 0) { + log.info("APK retention: evicted {} old version(s) across {} app(s) over their size budget", + evictedTotal, byApp.size()); + } + } + + private int enforceForApp(String appKey, List unsorted) { + List versions = new ArrayList<>(unsorted); + versions.sort(Comparator.comparingInt(AppVersionEntity::getVersionCode).reversed()); + + long maxBytes = resolveMaxTotalBytes(appKey); + long running = 0; + int evicted = 0; + for (int i = 0; i < versions.size(); i++) { + AppVersionEntity v = versions.get(i); + running += v.getFileSize(); + if (i == 0) { + // Always keep the latest version downloadable, regardless of total size. + continue; + } + if (running > maxBytes) { + if (evict(v)) { + evicted++; + } + } + } + return evicted; + } + + private long resolveMaxTotalBytes(String appKey) { + long mb = defaultMaxTotalSizeMb; + try { + long override = publishConfigService.getConfigNode(appKey) + .path("apkRetentionMaxTotalSizeMb").asLong(0); + if (override > 0) { + mb = override; + } + } catch (Exception ignored) { + // fall back to the default + } + return mb * 1024 * 1024; + } + + private boolean evict(AppVersionEntity v) { + String downloadUrl = v.getDownloadUrl(); + if (!updateAssetService.deleteRemoteFile(downloadUrl)) { + return false; + } + v.setDownloadUrl(null); + v.setApkHash(null); + long freedBytes = v.getFileSize(); + v.setFileSize(0); + versionRepository.save(v); + operationLogService.record(v.getAppKey(), "APP_VERSION", v.getId(), "APK_RETENTION_EVICT", + "安装包占用空间超过阈值,自动清理旧版本安装包", + Map.of("versionName", v.getVersionName(), "versionCode", v.getVersionCode(), + "freedBytes", freedBytes)); + log.info("APK retention: evicted {}/{} versionCode={} freedBytes={}", + v.getAppKey(), v.getPlatform(), v.getVersionCode(), freedBytes); + return true; + } +} 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 611e8db..a02d20a 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 @@ -2,12 +2,15 @@ package com.xuqm.update.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.model.AppPackageInspectResult; import com.xuqm.update.model.RnBundleInspectResult; +import com.xuqm.update.repository.AppVersionRepository; import net.dongliu.apk.parser.ApkFile; import net.dongliu.apk.parser.bean.ApkMeta; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ContentDisposition; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.slf4j.Logger; @@ -25,6 +28,7 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.security.DigestInputStream; import java.security.MessageDigest; +import java.util.List; import java.util.Map; import java.util.Locale; import java.util.HexFormat; @@ -43,6 +47,7 @@ public class UpdateAssetService { private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class); private final ObjectMapper objectMapper; + private final AppVersionRepository versionRepository; @Value("${update.upload-dir:/tmp/xuqm-update}") private String uploadDir; @@ -56,8 +61,29 @@ public class UpdateAssetService { @Value("${FILE_BASE_URL:}") private String fileBaseUrl; - public UpdateAssetService(ObjectMapper objectMapper) { + @Value("${file.internal-token:xuqm-internal-token}") + private String internalToken; + + public UpdateAssetService(ObjectMapper objectMapper, AppVersionRepository versionRepository) { this.objectMapper = objectMapper; + this.versionRepository = versionRepository; + } + + /** + * Daily safety net: re-pin every app version whose APK lives on file-service, so + * historical versions already uploaded before the pin-on-upload fix (or any that + * transiently failed to pin) don't get silently deleted by file-service's 30-day + * unused-file reclaim job. Runs well before that job's 03:00 cron. + */ + @Scheduled(cron = "0 0 2 * * *") + public void rePinAllFileServiceAssets() { + List versions = versionRepository.findAllWithFileServiceDownloadUrl(); + for (AppVersionEntity v : versions) { + pinRemoteFile(v.getDownloadUrl()); + } + if (!versions.isEmpty()) { + log.info("Re-pinned {} file-service-hosted APK(s) against 30-day reclaim", versions.size()); + } } /** 存储结果:下载 URL 和文件 SHA-256 哈希 */ @@ -400,16 +426,19 @@ public class UpdateAssetService { return packageUrl; } + private boolean isFileServiceUrl(String packageUrl) { + return packageUrl != null && ( + packageUrl.contains("file.dev.xuqinmin.com") + || (hasText(fileBaseUrl) && packageUrl.startsWith(fileBaseUrl))); + } + /** * 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) { + if (!isFileServiceUrl(packageUrl)) { return; } try { @@ -427,6 +456,36 @@ public class UpdateAssetService { } } + /** + * Permanently delete an APK hosted on file-service. Used by the size-based retention + * cleanup when evicting old versions past the configured total-size budget. Returns + * true only on a confirmed successful delete — callers should not clear their own + * downloadUrl reference unless this returns true, to avoid orphaning a version whose + * file deletion actually failed. + */ + public boolean deleteRemoteFile(String packageUrl) { + if (!isFileServiceUrl(packageUrl)) { + return false; + } + try { + String deleteUrl = rewriteToInternalUrl(packageUrl); + HttpURLConnection connection = (HttpURLConnection) new URL(deleteUrl).openConnection(); + connection.setRequestMethod("DELETE"); + connection.setRequestProperty("X-Internal-Token", internalToken); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(15_000); + int status = connection.getResponseCode(); + if (status >= 400) { + log.warn("Failed to delete file-service asset {}: HTTP {}", packageUrl, status); + return false; + } + return true; + } catch (Exception e) { + log.warn("Failed to delete file-service asset {}: {}", packageUrl, e.getMessage()); + return false; + } + } + private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException { String internalUrl = rewriteToInternalUrl(packageUrl); HttpURLConnection connection = (HttpURLConnection) new URL(internalUrl).openConnection();