Skip to content

Commit 7e98714

Browse files
committed
feat: implement multi-source download with fallback between GitHub and Gitee
1 parent 956cd7f commit 7e98714

2 files changed

Lines changed: 208 additions & 31 deletions

File tree

src/main/java/com/laker/postman/service/update/UpdateDownloader.java

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
@Slf4j
1818
public class UpdateDownloader {
1919

20+
private static final String GITHUB_COM = "github.com";
21+
private static final String GITEE_COM = "gitee.com";
22+
2023
/**
2124
* 下载进度回调接口
2225
*/
@@ -33,22 +36,75 @@ public interface DownloadProgressCallback {
3336
private volatile boolean cancelled = false;
3437

3538
/**
36-
* 异步下载文件
39+
* 异步下载文件(支持多源切换)
3740
*/
3841
public CompletableFuture<File> downloadAsync(String downloadUrl, DownloadProgressCallback callback) {
3942
return CompletableFuture.supplyAsync(() -> {
4043
try {
44+
// 尝试从原始 URL 下载
45+
log.info("Attempting to download from: {}", downloadUrl);
4146
return downloadFile(downloadUrl, callback);
4247
} catch (Exception e) {
4348
if (!cancelled) {
44-
String friendlyError = getFriendlyErrorMessage(e);
45-
callback.onError(friendlyError);
49+
log.warn("Download failed from primary source: {}", e.getMessage());
50+
51+
// 尝试切换到备用源
52+
String alternativeUrl = getAlternativeDownloadUrl(downloadUrl);
53+
if (alternativeUrl != null && !alternativeUrl.equals(downloadUrl)) {
54+
log.info("Trying alternative source: {}", alternativeUrl);
55+
callback.onProgress(0, 0, 0, 0); // 重置进度
56+
57+
try {
58+
return downloadFile(alternativeUrl, callback);
59+
} catch (Exception e2) {
60+
log.error("Download failed from alternative source: {}", e2.getMessage());
61+
String friendlyError = getFriendlyErrorMessage(e2);
62+
callback.onError(friendlyError);
63+
}
64+
} else {
65+
String friendlyError = getFriendlyErrorMessage(e);
66+
callback.onError(friendlyError);
67+
}
4668
}
4769
return null;
4870
}
4971
});
5072
}
5173

74+
/**
75+
* 获取备用下载源
76+
* GitHub <-> Gitee 互为备用
77+
*/
78+
private String getAlternativeDownloadUrl(String originalUrl) {
79+
if (originalUrl == null) {
80+
return null;
81+
}
82+
83+
// GitHub -> Gitee
84+
if (originalUrl.contains(GITHUB_COM) || originalUrl.contains("githubusercontent.com")) {
85+
// GitHub Release 下载链接格式:
86+
// https://github.com/lakernote/easy-postman/releases/download/v1.0.0/EasyPostman-1.0.0.msi
87+
// Gitee Release 下载链接格式:
88+
// https://gitee.com/lakernote/easy-postman/releases/download/v1.0.0/EasyPostman-1.0.0.msi
89+
90+
String giteeUrl = originalUrl
91+
.replace(GITHUB_COM, GITEE_COM)
92+
.replace("githubusercontent.com", GITEE_COM);
93+
94+
log.debug("Switched from GitHub to Gitee: {} -> {}", originalUrl, giteeUrl);
95+
return giteeUrl;
96+
}
97+
98+
// Gitee -> GitHub
99+
if (originalUrl.contains(GITEE_COM)) {
100+
String githubUrl = originalUrl.replace(GITEE_COM, GITHUB_COM);
101+
log.debug("Switched from Gitee to GitHub: {} -> {}", originalUrl, githubUrl);
102+
return githubUrl;
103+
}
104+
105+
return null;
106+
}
107+
52108
/**
53109
* 取消下载
54110
*/
@@ -98,7 +154,11 @@ private File downloadFile(String downloadUrl, DownloadProgressCallback callback)
98154
}
99155

100156
if (cancelled) {
101-
tempFile.delete();
157+
try {
158+
java.nio.file.Files.delete(tempFile.toPath());
159+
} catch (IOException e) {
160+
log.warn("Failed to delete temporary file: {}", e.getMessage());
161+
}
102162
callback.onCancelled();
103163
return null;
104164
}

src/main/java/com/laker/postman/service/update/VersionChecker.java

Lines changed: 144 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,57 @@
1313
import java.net.URL;
1414
import java.nio.charset.StandardCharsets;
1515
import java.util.Scanner;
16+
import java.util.concurrent.CompletableFuture;
1617

1718
/**
1819
* 版本检查器 - 负责检查远程版本信息
1920
*/
2021
@Slf4j
2122
public class VersionChecker {
2223

24+
private static final String GITHUB_API_URL = "https://api.github.com/repos/lakernote/easy-postman/releases/latest";
2325
private static final String GITEE_API_URL = "https://gitee.com/api/v5/repos/lakernote/easy-postman/releases/latest";
2426

27+
private static final String SOURCE_GITHUB = "GitHub";
28+
private static final String SOURCE_GITEE = "Gitee";
29+
private static final String SOURCE_KEY = "_source";
30+
private static final String BROWSER_DOWNLOAD_URL = "browser_download_url";
31+
private static final String MACOS_ARM64_DMG = "-macos-arm64.dmg";
32+
private static final String PORTABLE_ZIP = "-portable.zip";
33+
34+
// 缓存最佳源,避免重复检测
35+
private static volatile String cachedBestSource = null;
36+
2537
/**
2638
* 检查更新信息
2739
*/
2840
public UpdateInfo checkForUpdate() {
2941
try {
3042
String currentVersion = SystemUtil.getCurrentVersion();
31-
JSONObject releaseInfo = fetchLatestReleaseInfo();
43+
44+
// 并行检测最快的源
45+
String bestSource = detectBestSource();
46+
JSONObject releaseInfo = fetchLatestReleaseInfo(bestSource);
47+
48+
if (releaseInfo == null) {
49+
// 如果最佳源失败,尝试备用源
50+
String fallbackSource = bestSource.equals(GITHUB_API_URL) ? GITEE_API_URL : GITHUB_API_URL;
51+
log.info("Primary source failed, trying fallback source");
52+
releaseInfo = fetchLatestReleaseInfo(fallbackSource);
53+
54+
if (releaseInfo != null) {
55+
cachedBestSource = fallbackSource; // 更新缓存
56+
}
57+
}
3258

3359
if (releaseInfo == null) {
3460
return UpdateInfo.noUpdateAvailable(I18nUtil.getMessage(MessageKeys.UPDATE_FETCH_RELEASE_FAILED));
3561
}
3662

3763
String latestVersion = releaseInfo.getStr("tag_name");
38-
log.info("Current version: {}, Gitee latest version: {}", currentVersion, latestVersion);
64+
String sourceName = releaseInfo.getStr(SOURCE_KEY, "unknown");
65+
log.info("Current version: {}, Latest version: {} (from {})", currentVersion, latestVersion, sourceName);
66+
3967
if (latestVersion == null) {
4068
return UpdateInfo.noUpdateAvailable(I18nUtil.getMessage(MessageKeys.UPDATE_NO_VERSION_INFO));
4169
}
@@ -52,12 +80,93 @@ public UpdateInfo checkForUpdate() {
5280
}
5381
}
5482

83+
/**
84+
* 检测最佳源(GitHub 或 Gitee)
85+
* 策略:并行测试连接速度,选择响应最快的源
86+
*/
87+
private static String detectBestSource() {
88+
// 如果已有缓存,直接使用
89+
if (cachedBestSource != null) {
90+
log.info("Using cached best source: {}", cachedBestSource.contains("github") ? SOURCE_GITHUB : SOURCE_GITEE);
91+
return cachedBestSource;
92+
}
93+
94+
log.info("Detecting best update source...");
95+
96+
// 并行测试两个源的连接速度
97+
CompletableFuture<Long> githubTest = CompletableFuture.supplyAsync(() -> testSourceSpeed(GITHUB_API_URL, SOURCE_GITHUB));
98+
CompletableFuture<Long> giteeTest = CompletableFuture.supplyAsync(() -> testSourceSpeed(GITEE_API_URL, SOURCE_GITEE));
99+
100+
try {
101+
// 等待两个测试完成,最多等待 5 秒
102+
CompletableFuture.allOf(githubTest, giteeTest).get(5, java.util.concurrent.TimeUnit.SECONDS);
103+
104+
long githubTime = githubTest.getNow(Long.MAX_VALUE);
105+
long giteeTime = giteeTest.getNow(Long.MAX_VALUE);
106+
107+
// 选择响应时间更短的源
108+
if (githubTime < giteeTime && githubTime < 5000) {
109+
log.info("GitHub is faster ({} ms vs {} ms), using GitHub", githubTime, giteeTime);
110+
cachedBestSource = GITHUB_API_URL;
111+
return GITHUB_API_URL;
112+
} else if (giteeTime < Long.MAX_VALUE) {
113+
log.info("Gitee is faster or preferred ({} ms vs {} ms), using Gitee", giteeTime, githubTime);
114+
cachedBestSource = GITEE_API_URL;
115+
return GITEE_API_URL;
116+
}
117+
118+
} catch (java.util.concurrent.TimeoutException e) {
119+
log.debug("Source detection timeout: {}", e.getMessage());
120+
Thread.currentThread().interrupt();
121+
} catch (java.util.concurrent.ExecutionException e) {
122+
log.debug("Source detection execution error: {}", e.getMessage());
123+
} catch (InterruptedException e) {
124+
log.debug("Source detection interrupted: {}", e.getMessage());
125+
Thread.currentThread().interrupt();
126+
}
127+
128+
// 默认使用 Gitee(国内用户居多)
129+
log.info("Using default source: Gitee");
130+
cachedBestSource = GITEE_API_URL;
131+
return GITEE_API_URL;
132+
}
133+
134+
/**
135+
* 测试源的连接速度(返回响应时间,单位:毫秒)
136+
*/
137+
private static long testSourceSpeed(String apiUrl, String sourceName) {
138+
long startTime = System.currentTimeMillis();
139+
try {
140+
URL url = new URL(apiUrl);
141+
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
142+
conn.setConnectTimeout(3000);
143+
conn.setReadTimeout(3000);
144+
conn.setRequestMethod("HEAD"); // 只测试连接,不下载内容
145+
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; EasyPostman/" + SystemUtil.getCurrentVersion() + ")");
146+
147+
int code = conn.getResponseCode();
148+
long responseTime = System.currentTimeMillis() - startTime;
149+
150+
if (code == 200 || code == 301 || code == 302) {
151+
log.debug("{} connection test successful: {} ms", sourceName, responseTime);
152+
return responseTime;
153+
} else {
154+
log.debug("{} connection test failed with code: {}", sourceName, code);
155+
return Long.MAX_VALUE;
156+
}
157+
} catch (Exception e) {
158+
log.debug("{} connection test failed: {}", sourceName, e.getMessage());
159+
return Long.MAX_VALUE;
160+
}
161+
}
162+
55163
/**
56164
* 获取最新版本发布信息
57165
*/
58-
private JSONObject fetchLatestReleaseInfo() {
166+
private JSONObject fetchLatestReleaseInfo(String apiUrl) {
167+
String sourceName = apiUrl.contains("github") ? SOURCE_GITHUB : SOURCE_GITEE;
59168
try {
60-
URL url = new URL(GITEE_API_URL);
169+
URL url = new URL(apiUrl);
61170
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
62171
conn.setConnectTimeout(10000);
63172
conn.setReadTimeout(10000);
@@ -75,7 +184,11 @@ private JSONObject fetchLatestReleaseInfo() {
75184
try (InputStream is = conn.getInputStream();
76185
Scanner scanner = new Scanner(is, StandardCharsets.UTF_8)) {
77186
String json = scanner.useDelimiter("\\A").next();
78-
return new JSONObject(json);
187+
JSONObject releaseInfo = new JSONObject(json);
188+
// 标记数据来源
189+
releaseInfo.set(SOURCE_KEY, sourceName);
190+
log.info("Successfully fetched release info from {}", sourceName);
191+
return releaseInfo;
79192
}
80193
} else {
81194
// 尝试读取错误响应
@@ -90,12 +203,12 @@ private JSONObject fetchLatestReleaseInfo() {
90203
// 忽略读取错误响应的异常
91204
}
92205

93-
log.warn("Failed to fetch release info, HTTP code: {}, response: {}", code, errorResponse);
206+
log.warn("Failed to fetch release info from {}, HTTP code: {}, response: {}", sourceName, code, errorResponse);
94207

95208
return null;
96209
}
97210
} catch (Exception e) {
98-
log.debug("Error fetching release info: {}", e.getMessage());
211+
log.debug("Error fetching release info from {}: {}", sourceName, e.getMessage());
99212
}
100213
return null;
101214
}
@@ -115,19 +228,27 @@ public String getDownloadUrl(JSONObject releaseInfo) {
115228
}
116229

117230
String osName = System.getProperty("os.name").toLowerCase();
231+
String source = releaseInfo.getStr(SOURCE_KEY, "unknown");
118232

233+
String downloadUrl;
119234
if (osName.contains("win")) {
120235
// Windows 特殊处理:区分便携版和安装版
121-
return getWindowsDownloadUrl(assets);
236+
downloadUrl = getWindowsDownloadUrl(assets);
122237
} else if (osName.contains("mac")) {
123238
// macOS 特殊处理:支持新版本的架构特定 DMG 和旧版本的通用 DMG
124-
return getMacDownloadUrl(assets);
239+
downloadUrl = getMacDownloadUrl(assets);
125240
} else if (osName.contains("linux")) {
126-
return findAssetByExtension(assets, ".deb");
241+
downloadUrl = findAssetByExtension(assets, ".deb");
127242
} else {
128243
log.warn("Unsupported OS: {}", osName);
129244
return null;
130245
}
246+
247+
if (downloadUrl != null) {
248+
log.info("Found download URL from {}: {}", source, downloadUrl);
249+
}
250+
251+
return downloadUrl;
131252
}
132253

133254
/**
@@ -139,8 +260,8 @@ private String getWindowsDownloadUrl(JSONArray assets) {
139260

140261
if (isPortable) {
141262
// 便携版:优先下载便携版 ZIP
142-
log.info("Detected portable version, looking for -portable.zip");
143-
String portableUrl = findAssetByPattern(assets, "-portable.zip");
263+
log.info("Detected portable version, looking for portable.zip");
264+
String portableUrl = findAssetByPattern(assets, PORTABLE_ZIP);
144265

145266
if (portableUrl != null) {
146267
log.info("Found portable ZIP for update");
@@ -225,7 +346,7 @@ private String findAssetByPattern(JSONArray assets, String pattern) {
225346
JSONObject asset = assets.getJSONObject(i);
226347
String name = asset.getStr("name");
227348
if (name != null && name.contains(pattern)) {
228-
String url = asset.getStr("browser_download_url");
349+
String url = asset.getStr(BROWSER_DOWNLOAD_URL);
229350
log.debug("Found asset by pattern '{}': {} -> {}", pattern, name, url);
230351
return url;
231352
}
@@ -293,7 +414,7 @@ private String findAssetByExtension(JSONArray assets, String extension) {
293414
JSONObject asset = assets.getJSONObject(i);
294415
String name = asset.getStr("name");
295416
if (name != null && name.endsWith(extension)) {
296-
String url = asset.getStr("browser_download_url");
417+
String url = asset.getStr(BROWSER_DOWNLOAD_URL);
297418
log.debug("Found asset: {} -> {}", name, url);
298419
return url;
299420
}
@@ -310,18 +431,14 @@ private String findGenericDmg(JSONArray assets) {
310431
for (int i = 0; i < assets.size(); i++) {
311432
JSONObject asset = assets.getJSONObject(i);
312433
String name = asset.getStr("name");
313-
if (name != null && name.endsWith(".dmg")) {
314-
// 排除带有架构后缀的 DMG(支持新旧两种命名格式)
315-
// 新格式: -macos-x86_64.dmg, -macos-arm64.dmg
316-
// 旧格式: -intel.dmg, -arm64.dmg
317-
if (!name.endsWith("-intel.dmg") &&
434+
if (name != null && name.endsWith(".dmg") &&
435+
!name.endsWith("-intel.dmg") &&
318436
!name.endsWith("-arm64.dmg") &&
319437
!name.endsWith("-macos-x86_64.dmg") &&
320-
!name.endsWith("-macos-arm64.dmg")) {
321-
String url = asset.getStr("browser_download_url");
322-
log.debug("Found generic DMG (without architecture suffix): {} -> {}", name, url);
323-
return url;
324-
}
438+
!name.endsWith(MACOS_ARM64_DMG)) {
439+
String url = asset.getStr(BROWSER_DOWNLOAD_URL);
440+
log.debug("Found generic DMG (without architecture suffix): {} -> {}", name, url);
441+
return url;
325442
}
326443
}
327444
return null;
@@ -341,7 +458,7 @@ private String getMacPackageSuffix() {
341458
// 检测 Apple Silicon (ARM64)
342459
if (arch.contains("aarch64") || arch.equals("arm64")) {
343460
log.info("Detected Apple Silicon (ARM64), using -macos-arm64.dmg");
344-
return "-macos-arm64.dmg";
461+
return MACOS_ARM64_DMG;
345462
}
346463

347464
// 检测 Intel (x86_64)
@@ -356,7 +473,7 @@ private String getMacPackageSuffix() {
356473

357474
// 默认返回 ARM64 版本(因为新 Mac 都是 Apple Silicon)
358475
log.info("Unable to detect architecture, defaulting to -macos-arm64.dmg");
359-
return "-macos-arm64.dmg";
476+
return MACOS_ARM64_DMG;
360477
}
361478

362479

@@ -385,7 +502,7 @@ private int compareVersion(String v1, String v2) {
385502

386503
private int parseIntSafe(String s) {
387504
try {
388-
return Integer.parseInt(s.replaceAll("[^0-9]", ""));
505+
return Integer.parseInt(s.replaceAll("\\D", ""));
389506
} catch (Exception e) {
390507
return 0;
391508
}

0 commit comments

Comments
 (0)