|
| 1 | +package com.github.balloonupdate.mcpatch.client.network; |
| 2 | + |
| 3 | +import com.github.balloonupdate.mcpatch.client.exceptions.McpatchBusinessException; |
| 4 | +import com.github.balloonupdate.mcpatch.client.logging.Log; |
| 5 | +import okhttp3.OkHttpClient; |
| 6 | +import okhttp3.Request; |
| 7 | +import okhttp3.Response; |
| 8 | + |
| 9 | +import java.net.URI; |
| 10 | +import java.net.URLEncoder; |
| 11 | +import java.nio.charset.StandardCharsets; |
| 12 | +import java.util.Map; |
| 13 | +import java.util.concurrent.ConcurrentHashMap; |
| 14 | +import java.util.concurrent.TimeUnit; |
| 15 | + |
| 16 | +/** |
| 17 | + * 阿里云ESA A方案鉴权凭据服务。 |
| 18 | + * <p> |
| 19 | + * 在下载每个文件之前,向鉴权 API 请求 auth_key,然后拼接到下载 URL 中, |
| 20 | + * 实现防盗链功能,防止恶意反复下载。 |
| 21 | + * <p> |
| 22 | + * 特性: |
| 23 | + * - 线程安全的凭据缓存,同一文件路径在有效期内复用 auth_key |
| 24 | + * - 提前刷新机制,在 auth_key 过期前 5 分钟主动刷新 |
| 25 | + * - OkHttp 连接池复用,减少握手开销 |
| 26 | + */ |
| 27 | +public class AuthKeyService { |
| 28 | + |
| 29 | + /** |
| 30 | + * 鉴权 API 地址 |
| 31 | + */ |
| 32 | + private final String authApiUrl; |
| 33 | + |
| 34 | + /** |
| 35 | + * 鉴权 URL 有效时长,单位秒 |
| 36 | + */ |
| 37 | + private final int expireTime; |
| 38 | + |
| 39 | + /** |
| 40 | + * 鉴权用户ID |
| 41 | + */ |
| 42 | + private final String uid; |
| 43 | + |
| 44 | + /** |
| 45 | + * OkHttp 客户端(复用连接池) |
| 46 | + */ |
| 47 | + private final OkHttpClient client; |
| 48 | + |
| 49 | + /** |
| 50 | + * 自定义 HTTP headers,与配置文件中的 http-headers 保持一致 |
| 51 | + */ |
| 52 | + private final Map<String, String> httpHeaders; |
| 53 | + |
| 54 | + /** |
| 55 | + * 缓存:filePath → AuthKeyEntry |
| 56 | + * 使用 ConcurrentHashMap 保证多线程并行下载时的线程安全 |
| 57 | + */ |
| 58 | + private final ConcurrentHashMap<String, AuthKeyEntry> cache = new ConcurrentHashMap<>(); |
| 59 | + |
| 60 | + /** |
| 61 | + * 提前刷新时间:5分钟(毫秒) |
| 62 | + * 在 auth_key 过期前 5 分钟主动刷新,避免下载过程中凭据过期 |
| 63 | + */ |
| 64 | + private static final long REFRESH_AHEAD_MILLIS = 5 * 60 * 1000; |
| 65 | + |
| 66 | + /** |
| 67 | + * 缓存条目,保存 auth_key 及其过期时间 |
| 68 | + */ |
| 69 | + static class AuthKeyEntry { |
| 70 | + final String authKey; |
| 71 | + final long expireTimestampMillis; |
| 72 | + |
| 73 | + AuthKeyEntry(String authKey, long expireTimestampMillis) { |
| 74 | + this.authKey = authKey; |
| 75 | + this.expireTimestampMillis = expireTimestampMillis; |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * 创建鉴权凭据服务 |
| 81 | + * |
| 82 | + * @param authApiUrl 鉴权 API 地址,例如 https://auth-api.mxzysoa.com/generate-auth-url |
| 83 | + * @param expireTime 鉴权 URL 有效时长,单位秒 |
| 84 | + * @param uid 鉴权用户ID |
| 85 | + * @param httpTimeout HTTP 超时时间,单位毫秒 |
| 86 | + * @param httpHeaders 自定义 HTTP headers,与配置文件中的 http-headers 保持一致 |
| 87 | + */ |
| 88 | + public AuthKeyService(String authApiUrl, int expireTime, String uid, int httpTimeout, Map<String, String> httpHeaders) { |
| 89 | + this.authApiUrl = authApiUrl; |
| 90 | + this.expireTime = expireTime; |
| 91 | + this.uid = uid; |
| 92 | + this.httpHeaders = httpHeaders; |
| 93 | + |
| 94 | + this.client = new OkHttpClient.Builder() |
| 95 | + .connectTimeout(httpTimeout, TimeUnit.MILLISECONDS) |
| 96 | + .readTimeout(httpTimeout, TimeUnit.MILLISECONDS) |
| 97 | + .writeTimeout(httpTimeout, TimeUnit.MILLISECONDS) |
| 98 | + .build(); |
| 99 | + } |
| 100 | + |
| 101 | + /** |
| 102 | + * 根据原始下载 URL 构建带鉴权参数的 URL。 |
| 103 | + * <p> |
| 104 | + * 流程: |
| 105 | + * 1. 从 originalUrl 提取路径部分作为 filePath(例如 /index.json) |
| 106 | + * 2. 向鉴权 API 请求 auth_key(带缓存) |
| 107 | + * 3. 在 originalUrl 后追加 ?auth_key=xxx 或 &auth_key=xxx |
| 108 | + * |
| 109 | + * @param originalUrl 原始下载 URL |
| 110 | + * @return 带 auth_key 参数的 URL |
| 111 | + * @throws McpatchBusinessException 鉴权请求失败时抛出 |
| 112 | + */ |
| 113 | + public String buildAuthUrl(String originalUrl) throws McpatchBusinessException { |
| 114 | + String filePath = extractFilePath(originalUrl); |
| 115 | + String authKey = getAuthKey(filePath); |
| 116 | + String separator = originalUrl.contains("?") ? "&" : "?"; |
| 117 | + return originalUrl + separator + "auth_key=" + authKey; |
| 118 | + } |
| 119 | + |
| 120 | + /** |
| 121 | + * 从完整 URL 中提取文件路径部分。 |
| 122 | + * <p> |
| 123 | + * 例如: |
| 124 | + * - https://dl1.mxzysoa.com/index.json → /index.json |
| 125 | + * - https://dl1.mxzysoa.com/sub/v1.pack → /sub/v1.pack |
| 126 | + * - https://dl1.mxzysoa.com:8443/a/b/c.dat?q=1 → /a/b/c.dat |
| 127 | + * |
| 128 | + * @param url 完整的 URL |
| 129 | + * @return 以 / 开头的文件路径 |
| 130 | + */ |
| 131 | + private String extractFilePath(String url) throws McpatchBusinessException { |
| 132 | + try { |
| 133 | + URI uri = new URI(url); |
| 134 | + String path = uri.getPath(); |
| 135 | + if (path == null || path.isEmpty()) { |
| 136 | + path = "/"; |
| 137 | + } |
| 138 | + return path; |
| 139 | + } catch (Exception e) { |
| 140 | + throw new McpatchBusinessException("无法从 URL 中提取文件路径: " + url, e); |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + /** |
| 145 | + * 获取 auth_key(带缓存,线程安全)。 |
| 146 | + * <p> |
| 147 | + * 缓存命中且未过期时直接返回;缓存未命中或已过期时向鉴权 API 请求新的 auth_key。 |
| 148 | + * 使用 ConcurrentHashMap 的原子操作保证同一路径在并发场景下不会重复请求。 |
| 149 | + * |
| 150 | + * @param filePath 文件路径,以 / 开头 |
| 151 | + * @return auth_key 字符串 |
| 152 | + * @throws McpatchBusinessException 鉴权请求失败时抛出 |
| 153 | + */ |
| 154 | + private String getAuthKey(String filePath) throws McpatchBusinessException { |
| 155 | + AuthKeyEntry entry = cache.get(filePath); |
| 156 | + |
| 157 | + // 缓存命中且未过期 |
| 158 | + if (entry != null && System.currentTimeMillis() < entry.expireTimestampMillis - REFRESH_AHEAD_MILLIS) { |
| 159 | + return entry.authKey; |
| 160 | + } |
| 161 | + |
| 162 | + // 缓存未命中或已过期,请求新的 auth_key |
| 163 | + AuthKeyEntry newEntry = requestAuthKey(filePath); |
| 164 | + cache.put(filePath, newEntry); |
| 165 | + return newEntry.authKey; |
| 166 | + } |
| 167 | + |
| 168 | + /** |
| 169 | + * 向鉴权 API 请求 auth_key。 |
| 170 | + * <p> |
| 171 | + * 调用方式:GET {authApiUrl}?filePath={filePath}&expireTime={expireTime}&uid={uid} |
| 172 | + * 响应格式(rawKey=true,GET 默认):纯文本,格式为 {timestamp}-{rand}-{uid}-{md5hash} |
| 173 | + * |
| 174 | + * @param filePath 文件路径,以 / 开头 |
| 175 | + * @return AuthKeyEntry 包含 auth_key 和过期时间 |
| 176 | + * @throws McpatchBusinessException 请求失败时抛出 |
| 177 | + */ |
| 178 | + private AuthKeyEntry requestAuthKey(String filePath) throws McpatchBusinessException { |
| 179 | + String encodedFilePath = URLEncoder.encode(filePath, StandardCharsets.UTF_8); |
| 180 | + |
| 181 | + String url = authApiUrl |
| 182 | + + "?filePath=" + encodedFilePath |
| 183 | + + "&expireTime=" + expireTime |
| 184 | + + "&uid=" + URLEncoder.encode(uid, StandardCharsets.UTF_8); |
| 185 | + |
| 186 | + Log.info("正在获取下载凭据: " + filePath); |
| 187 | + |
| 188 | + Request.Builder reqBuilder = new Request.Builder() |
| 189 | + .url(url) |
| 190 | + .get(); |
| 191 | + |
| 192 | + // 使用 header()(替换模式)而非 addHeader()(追加模式), |
| 193 | + // 确保配置文件中的 User-Agent 等自定义 headers 是唯一的、权威的值, |
| 194 | + // 不会与 OkHttp 或系统默认值产生重复 |
| 195 | + if (httpHeaders != null) { |
| 196 | + for (Map.Entry<String, String> e : httpHeaders.entrySet()) { |
| 197 | + reqBuilder.header(e.getKey(), e.getValue()); |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + Request request = reqBuilder.build(); |
| 202 | + |
| 203 | + try (Response response = client.newCall(request).execute()) { |
| 204 | + if (!response.isSuccessful()) { |
| 205 | + String body = response.peekBody(300).string(); |
| 206 | + throw new McpatchBusinessException( |
| 207 | + String.format("鉴权API返回错误 %d: %s (filePath=%s)\n%s", |
| 208 | + response.code(), response.message(), filePath, body)); |
| 209 | + } |
| 210 | + |
| 211 | + String authKey = response.body().string().trim(); |
| 212 | + |
| 213 | + if (authKey.isEmpty()) { |
| 214 | + throw new McpatchBusinessException("鉴权API返回了空的auth_key (filePath=" + filePath + ")"); |
| 215 | + } |
| 216 | + |
| 217 | + // 解析 auth_key 中的时间戳,计算过期时间 |
| 218 | + // auth_key 格式:{timestamp}-{rand}-{uid}-{md5hash} |
| 219 | + long expireTimestampMillis = parseExpireTimestamp(authKey); |
| 220 | + |
| 221 | + Log.info("下载凭据获取成功: " + filePath); |
| 222 | + |
| 223 | + return new AuthKeyEntry(authKey, expireTimestampMillis); |
| 224 | + } catch (McpatchBusinessException e) { |
| 225 | + Log.warn("获取下载凭据失败: " + filePath); |
| 226 | + throw e; |
| 227 | + } catch (Exception e) { |
| 228 | + Log.warn("获取下载凭据失败: " + filePath); |
| 229 | + throw new McpatchBusinessException("请求鉴权凭据失败: filePath=" + filePath, e); |
| 230 | + } |
| 231 | + } |
| 232 | + |
| 233 | + /** |
| 234 | + * 从 auth_key 字符串中解析过期时间戳。 |
| 235 | + * <p> |
| 236 | + * auth_key 格式:{timestamp}-{rand}-{uid}-{md5hash} |
| 237 | + * timestamp 是十进制 Unix 时间戳(秒),表示 auth_key 失效时间。 |
| 238 | + * |
| 239 | + * @param authKey auth_key 字符串 |
| 240 | + * @return 过期时间戳(毫秒) |
| 241 | + */ |
| 242 | + private long parseExpireTimestamp(String authKey) { |
| 243 | + try { |
| 244 | + int dashIndex = authKey.indexOf('-'); |
| 245 | + if (dashIndex > 0) { |
| 246 | + long timestampSeconds = Long.parseLong(authKey.substring(0, dashIndex)); |
| 247 | + return timestampSeconds * 1000; |
| 248 | + } |
| 249 | + } catch (NumberFormatException e) { |
| 250 | + Log.warn("无法解析auth_key中的时间戳,使用默认过期时间"); |
| 251 | + } |
| 252 | + |
| 253 | + // 解析失败时,使用当前时间 + expireTime 作为过期时间 |
| 254 | + return System.currentTimeMillis() + (long) expireTime * 1000; |
| 255 | + } |
| 256 | + |
| 257 | + /** |
| 258 | + * 清除所有缓存。 |
| 259 | + * 通常在关闭时调用。 |
| 260 | + */ |
| 261 | + public void clearCache() { |
| 262 | + cache.clear(); |
| 263 | + } |
| 264 | + |
| 265 | + /** |
| 266 | + * 关闭鉴权服务,释放资源。 |
| 267 | + * 关闭 OkHttp 连接池和调度器,清除缓存。 |
| 268 | + */ |
| 269 | + public void shutdown() { |
| 270 | + cache.clear(); |
| 271 | + client.dispatcher().executorService().shutdown(); |
| 272 | + client.connectionPool().evictAll(); |
| 273 | + } |
| 274 | + |
| 275 | + /** |
| 276 | + * 使指定文件路径的缓存失效。 |
| 277 | + * <p> |
| 278 | + * 当下载请求因鉴权失败(如 CDN 返回 403)时调用此方法, |
| 279 | + * 强制下次请求时重新获取 auth_key,避免用过期的凭据反复重试。 |
| 280 | + * |
| 281 | + * @param filePath 文件路径,以 / 开头 |
| 282 | + */ |
| 283 | + public void invalidateCache(String filePath) { |
| 284 | + cache.remove(filePath); |
| 285 | + Log.info("下载凭据已失效,将重新获取: " + filePath); |
| 286 | + } |
| 287 | + |
| 288 | + /** |
| 289 | + * 从完整 URL 中提取文件路径并使对应的缓存失效。 |
| 290 | + * <p> |
| 291 | + * 便捷方法,等价于 {@code invalidateCache(extractFilePath(url))} |
| 292 | + * |
| 293 | + * @param url 完整的下载 URL |
| 294 | + */ |
| 295 | + public void invalidateCacheByUrl(String url) throws McpatchBusinessException { |
| 296 | + invalidateCache(extractFilePath(url)); |
| 297 | + } |
| 298 | +} |
0 commit comments