diff --git a/.gitignore b/.gitignore index 264979c..e0e6875 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ test-filelist.java *.temp *.log *.bak +**/secret.yml *.swp *.swo *~ diff --git a/README.md b/README.md index 19b0c0b..e1e0e7d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,18 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数} > 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。 > [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html) + +#### 密钥作用说明 + +- `server.authEncryptKey` + - 作用:用于 `auth` 参数的 AES 加解密 + - 要求:16位(AES-128) + +- `server.donatedAccountFailureTokenSignKey` + - 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签 + - 目的:防止客户端伪造失败计数请求 + - 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同 + ### 特殊说明 - 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值 diff --git a/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java b/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java index cfb886b..1794a39 100644 --- a/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java +++ b/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java @@ -318,6 +318,7 @@ public class RouterHandlerFactory implements BaseHttpApi { // 只处理POST/PUT/PATCH等有body的请求方法,避免GET请求读取body导致"Request has already been read"错误 String httpMethod = ctx.request().method().name(); if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod)) + && ctx.parsedHeaders() != null && ctx.parsedHeaders().contentType() != null && HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value()) && ctx.body() != null && ctx.body().asJsonObject() != null) { JsonObject body = ctx.body().asJsonObject(); @@ -340,8 +341,12 @@ public class RouterHandlerFactory implements BaseHttpApi { }); } } else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod)) - && ctx.body() != null) { - queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString())); + && ctx.body() != null && ctx.body().length() > 0) { + try { + queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString())); + } catch (Exception e) { + LOGGER.debug("Failed to parse body as params: {}", e.getMessage()); + } } // 解析其他参数 @@ -360,6 +365,12 @@ public class RouterHandlerFactory implements BaseHttpApi { parameterValueList.put(k, ctx.request()); } else if (HttpServerResponse.class.getName().equals(v.getRight().getName())) { parameterValueList.put(k, ctx.response()); + } else if (JsonObject.class.getName().equals(v.getRight().getName())) { + if (ctx.body() != null && ctx.body().asJsonObject() != null) { + parameterValueList.put(k, ctx.body().asJsonObject()); + } else { + parameterValueList.put(k, new JsonObject()); + } } else if (parameterValueList.get(k) == null && CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) { // 绑定实体类 @@ -374,6 +385,17 @@ public class RouterHandlerFactory implements BaseHttpApi { }); // 调用handle 获取响应对象 Object[] parameterValueArray = parameterValueList.values().toArray(new Object[0]); + + // 打印调试信息,确认参数注入的情况 + if (LOGGER.isDebugEnabled() && method.getName().equals("donateAccount")) { + LOGGER.debug("donateAccount parameter list:"); + int i = 0; + for (Map.Entry entry : parameterValueList.entrySet()) { + LOGGER.debug("Param [{}]: {} = {}", i++, entry.getKey(), + entry.getValue() != null ? entry.getValue().toString() : "null"); + } + } + try { // 反射调用 Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray); diff --git a/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java b/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java index 4eefaf6..46d477d 100644 --- a/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java +++ b/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java @@ -48,6 +48,9 @@ public class RouterVerticle extends AbstractVerticle { } else { options = new HttpServerOptions(); } + + // 绑定到 0.0.0.0 以允许外部访问 + options.setHost("0.0.0.0"); options.setPort(port); server = vertx.createHttpServer(options); diff --git a/parser/doc/auth-param/AUTH_PARAM_GUIDE.md b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md index c3e3f20..39ebdaa 100644 --- a/parser/doc/auth-param/AUTH_PARAM_GUIDE.md +++ b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md @@ -36,6 +36,22 @@ URL解码 → Base64解码 → AES解密 → JSON对象 - **密钥长度**: 16位(128位) - **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置) +### 密钥作用说明(重要) + +当前系统中涉及两类不同用途的密钥: + +1. `server.authEncryptKey` + - 用途:加解密 `auth` 参数(前端/调用方传入的认证信息) + - 影响范围:`/parser`、`/json/parser`、`/v2/linkInfo` 等接口中的 `auth` 参数 + - 注意:这是 **AES 对称加密密钥**,要求 16 位 + +2. `server.donatedAccountFailureTokenSignKey` + - 用途:签名和验签“捐赠账号失败计数 token”(用于防伪造、失败计数) + - 影响范围:捐赠账号失败计数与自动失效逻辑 + - 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串 + +> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。 + ## JSON 模型定义 ### AuthParam 对象 @@ -301,14 +317,25 @@ if (auths != null) { ## 配置说明 -在 `app-dev.yml` 中配置加密密钥: +在 `app-dev.yml` 中配置密钥: ```yaml server: # auth参数加密密钥(16位AES密钥) authEncryptKey: 'your_custom_key16' + + # 捐赠账号失败计数token签名密钥(HMAC) + # 建议使用较长随机字符串,并与 authEncryptKey 不同 + donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key' ``` +### 密钥管理建议 + +- 不要在公开仓库提交生产密钥 +- 建议通过环境变量或私有配置注入 +- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性 +- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受) + ## 更新日志 - **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型 diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index 92fa027..3514759 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -2,7 +2,7 @@
+ + + + + + + +
+ + 当前账号池(活跃 {{ donateAccountCounts.active.total }} / 失效 {{ donateAccountCounts.inactive.total }}) + + +
+ 活跃账号 + + {{ getPanDisplayName(panType) }}: {{ count }} 个 + +
+ +
+ 失效账号 + + {{ getPanDisplayName(panType) }}: {{ count }} 个 + +
+
+
+ 暂无捐赠账号,成为第一个捐赠者吧! +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -436,7 +536,24 @@ export default { ext5: '' }, // 所有网盘的认证配置 { panType: config } - allAuthConfigs: {} + allAuthConfigs: {}, + + // 捐赠账号相关 + showDonateDialog: false, + donateSubmitting: false, + donateConfig: { + panType: '', + authType: 'cookie', + username: '', + password: '', + token: '', + remark: '' + }, + // 捐赠账号数量统计 + donateAccountCounts: { + active: { total: 0 }, + inactive: { total: 0 } + } } }, computed: { @@ -460,6 +577,7 @@ export default { if (url.includes('drive.uc.cn') || url.includes('fast.uc.cn')) return 'UC' if (url.includes('feijipan.com') || url.includes('feijihe.com') || url.includes('xiaofeiyang.com')) return 'FJ' if (url.includes('ilanzou.com') || url.includes('lanzouv.com')) return 'IZ' + if (url.includes('123pan.com') || url.includes('123684.com') || url.includes('123865.com')) return 'YE' return '' }, @@ -469,7 +587,8 @@ export default { 'QK': '夸克网盘', 'UC': 'UC网盘', 'FJ': '小飞机网盘', - 'IZ': '蓝奏优享' + 'IZ': '蓝奏优享', + 'YE': '123云盘' } return names[panType] || panType }, @@ -663,14 +782,36 @@ export default { } }, - // 生成加密的 auth 参数(根据当前链接的网盘类型) - generateAuthParam() { + // 生成加密的 auth 参数(优先使用个人配置,否则从后端随机获取捐赠账号) + async generateAuthParam() { const panType = this.getCurrentPanType() - if (!panType || !this.allAuthConfigs[panType]) { - return '' + if (!panType) return '' + + let config = null + + // 优先使用个人配置 + if (this.allAuthConfigs[panType]) { + config = this.allAuthConfigs[panType] + console.log(`[认证] 使用个人配置: ${this.getPanDisplayName(panType)}`) + } else { + // 从后端随机获取捐赠账号 + try { + const response = await axios.get(`${this.baseAPI}/v2/randomAuth`, { params: { panType } }) + // 解包 JsonResult 嵌套 + let data = response.data + while (data && data.data !== undefined && data.code !== undefined) { + data = data.data + } + if (data && (data.token || data.username)) { + config = data + console.log(`[认证] 使用捐赠账号: ${this.getPanDisplayName(panType)}`) + } + } catch (e) { + console.log(`[认证] 无可用捐赠账号: ${this.getPanDisplayName(panType)}`) + } } - const config = this.allAuthConfigs[panType] + if (!config) return '' // 构建 JSON 对象 const authObj = {} @@ -685,7 +826,8 @@ export default { if (config.ext3) authObj.ext3 = config.ext3 if (config.ext4) authObj.ext4 = config.ext4 if (config.ext5) authObj.ext5 = config.ext5 - + if (config.donatedAccountToken) authObj.donatedAccountToken = config.donatedAccountToken + // AES 加密 + Base64 + URL 编码 try { const jsonStr = JSON.stringify(authObj) @@ -710,9 +852,9 @@ export default { }, // 更新智能直链 - updateDirectLink() { + async updateDirectLink() { if (this.link) { - const authParam = this.generateAuthParam() + const authParam = await this.generateAuthParam() const authSuffix = authParam ? `&auth=${authParam}` : '' this.directLink = `${this.baseAPI}/parser?url=${this.link}${this.password ? `&pwd=${this.password}` : ''}${authSuffix}` } @@ -766,8 +908,8 @@ export default { this.errorButtonVisible = false try { this.isLoading = true - // 添加认证参数 - const authParam = this.generateAuthParam() + // 添加认证参数(异步获取) + const authParam = await this.generateAuthParam() if (authParam) { params.auth = authParam } @@ -1086,7 +1228,7 @@ export default { if (this.password) params.pwd = this.password // 添加认证参数 - const authParam = this.generateAuthParam() + const authParam = await this.generateAuthParam() if (authParam) params.auth = authParam const response = await axios.get(`${this.baseAPI}/v2/clientLinks`, { params }) @@ -1115,6 +1257,119 @@ export default { } finally { this.isLoading = false } + }, + + // ========== 捐赠账号相关方法 ========== + + // 捐赠弹窗中网盘类型变更 + onDonatePanTypeChange(panType) { + const types = this.getDonateAuthTypes() + this.donateConfig.authType = types.length > 0 ? types[0].value : 'cookie' + this.donateConfig.username = '' + this.donateConfig.password = '' + this.donateConfig.token = '' + this.donateConfig.remark = '' + }, + + // 获取捐赠弹窗支持的认证类型 + getDonateAuthTypes() { + const pt = (this.donateConfig.panType || '').toLowerCase() + const allTypes = { + cookie: { label: 'Cookie', value: 'cookie' }, + accesstoken: { label: 'AccessToken', value: 'accesstoken' }, + authorization: { label: 'Authorization', value: 'authorization' }, + password: { label: '用户名密码', value: 'password' }, + custom: { label: '自定义', value: 'custom' } + } + switch (pt) { + case 'qk': case 'uc': return [allTypes.cookie] + case 'fj': case 'iz': return [allTypes.password] + case 'ye': return [allTypes.password, allTypes.authorization] + default: return Object.values(allTypes) + } + }, + + // 提交捐赠账号(调用后端 API) + async submitDonateAccount() { + if (!this.donateConfig.panType) { + this.$message.warning('请选择网盘类型') + return + } + if (!this.donateConfig.token && !this.donateConfig.username) { + this.$message.warning('请填写认证信息(Cookie/Token 或 用户名密码)') + return + } + + this.donateSubmitting = true + try { + const payload = { + panType: this.donateConfig.panType, + authType: this.donateConfig.authType, + username: this.donateConfig.username || '', + password: this.donateConfig.password || '', + token: this.donateConfig.token || '', + remark: this.donateConfig.remark || '' + } + await axios.post(`${this.baseAPI}/v2/donateAccount`, payload) + this.$message.success(`已捐赠 ${this.getPanDisplayName(this.donateConfig.panType)} 账号,感谢您的贡献!`) + + // 重置表单 + this.donateConfig.username = '' + this.donateConfig.password = '' + this.donateConfig.token = '' + this.donateConfig.remark = '' + + // 刷新计数 + await this.loadDonateAccountCounts() + } catch (e) { + console.error('捐赠账号失败:', e) + this.$message.error('捐赠失败,请稍后重试') + } finally { + this.donateSubmitting = false + } + }, + + // 从后端加载捐赠账号数量统计 + async loadDonateAccountCounts() { + try { + const response = await axios.get(`${this.baseAPI}/v2/donateAccountCounts`) + // 解包可能的 JsonResult 嵌套 + let data = response.data + while (data && data.data !== undefined && data.code !== undefined) { + data = data.data + } + + if (data && typeof data === 'object') { + // 兼容新结构: { active: {...}, inactive: {...} } + if (data.active && data.inactive) { + if (data.active.total === undefined) { + data.active.total = Object.entries(data.active) + .filter(([k, v]) => k !== 'total' && typeof v === 'number') + .reduce((s, [, v]) => s + v, 0) + } + if (data.inactive.total === undefined) { + data.inactive.total = Object.entries(data.inactive) + .filter(([k, v]) => k !== 'total' && typeof v === 'number') + .reduce((s, [, v]) => s + v, 0) + } + this.donateAccountCounts = data + } else { + // 兼容旧结构: { QK: 3, total: 4 } + const active = { ...data } + if (active.total === undefined) { + active.total = Object.entries(active) + .filter(([k, v]) => k !== 'total' && typeof v === 'number') + .reduce((s, [, v]) => s + v, 0) + } + this.donateAccountCounts = { + active, + inactive: { total: 0 } + } + } + } + } catch (e) { + console.error('加载捐赠账号统计失败:', e) + } } }, @@ -1128,6 +1383,9 @@ export default { // 加载认证配置 this.loadAuthConfig() + // 加载捐赠账号统计 + this.loadDonateAccountCounts() + // 获取初始统计信息 this.getInfo() diff --git a/web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java b/web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java index 131cf06..0a9c1f2 100644 --- a/web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java +++ b/web-service/src/main/java/cn/qaiu/lz/common/util/AuthParamCodec.java @@ -78,12 +78,17 @@ public class AuthParamCodec { } try { - // Step 1: URL解码 - String urlDecoded = URLDecoder.decode(encryptedAuth, StandardCharsets.UTF_8); - log.debug("URL解码结果: {}", urlDecoded); + // Step 1: URL解码(兼容:有些框架已自动解码,此处避免再次把 '+' 变成空格) + String normalized = encryptedAuth; + if (normalized.contains("%")) { + normalized = URLDecoder.decode(normalized, StandardCharsets.UTF_8); + } + // 兼容 query 参数中 '+' 被还原为空格的情况 + normalized = normalized.replace(' ', '+'); + log.debug("认证参数规范化结果: {}", normalized); // Step 2: Base64解码 - byte[] base64Decoded = Base64.getDecoder().decode(urlDecoded); + byte[] base64Decoded = Base64.getDecoder().decode(normalized); log.debug("Base64解码成功,长度: {}", base64Decoded.length); // Step 3: AES解密 diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java index 5f6f458..7f2b024 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java @@ -28,6 +28,7 @@ import io.vertx.core.Promise; import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpServerResponse; import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -477,4 +478,37 @@ public class ParserApi { ClientLinkType type = ClientLinkType.valueOf(clientType.toUpperCase()); return clientLinks.get(type); } + + // ========== 捐赠账号 API ========== + + /** + * 捐赠网盘账号 + */ + @RouteMapping(value = "/donateAccount", method = RouteMethod.POST) + public Future donateAccount(RoutingContext ctx) { + JsonObject body = ctx.body().asJsonObject(); + if (body == null || StringUtils.isBlank(body.getString("panType")) + || StringUtils.isBlank(body.getString("authType"))) { + return Future.succeededFuture(JsonResult.error("panType and authType are required").toJsonObject()); + } + String ip = ctx.request().remoteAddress().host(); + body.put("ip", ip); + return dbService.saveDonatedAccount(body); + } + + /** + * 获取各网盘捐赠账号数量 + */ + @RouteMapping(value = "/donateAccountCounts", method = RouteMethod.GET) + public Future getDonateAccountCounts() { + return dbService.getDonatedAccountCounts(); + } + + /** + * 随机获取指定网盘类型的捐赠账号(内部使用,返回加密后的 auth 参数) + */ + @RouteMapping(value = "/randomAuth", method = RouteMethod.GET) + public Future getRandomAuth(String panType) { + return dbService.getRandomDonatedAccount(panType); + } } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java index 4cff5b8..baeae2e 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java @@ -5,6 +5,7 @@ import cn.qaiu.lz.common.util.URLParamUtil; import cn.qaiu.lz.web.model.AuthParam; import cn.qaiu.lz.web.model.CacheLinkInfo; import cn.qaiu.lz.web.service.CacheService; +import cn.qaiu.lz.web.service.DbService; import cn.qaiu.vx.core.annotaions.RouteHandler; import cn.qaiu.vx.core.annotaions.RouteMapping; import cn.qaiu.vx.core.enums.RouteMethod; @@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; public class ServerApi { private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class); + private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class); @RouteMapping(value = "/parser", method = RouteMethod.GET, order = 1) public Future parse(HttpServerResponse response, HttpServerRequest request, RoutingContext rcx, String pwd, String auth) { @@ -43,7 +45,10 @@ public class ServerApi { response.putHeader("nfd-cache-hit", res.getCacheHit().toString()) .putHeader("nfd-cache-expires", res.getExpires()), res.getDirectLink(), promise)) - .onFailure(t -> promise.fail(t.fillInStackTrace())); + .onFailure(t -> { + recordDonatedAccountFailureIfNeeded(otherParam, t); + promise.fail(t.fillInStackTrace()); + }); return promise.future(); } @@ -51,7 +56,8 @@ public class ServerApi { public Future parseJson(HttpServerRequest request, String pwd, String auth) { String url = URLParamUtil.parserParams(request); JsonObject otherParam = buildOtherParam(request, auth); - return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam); + return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam) + .onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t)); } @RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET) @@ -106,10 +112,48 @@ public class ServerApi { otherParam.put("authInfo3", authParam.getExt3()); otherParam.put("authInfo4", authParam.getExt4()); otherParam.put("authInfo5", authParam.getExt5()); + if (authParam.getDonatedAccountToken() != null && !authParam.getDonatedAccountToken().isBlank()) { + otherParam.put("donatedAccountToken", authParam.getDonatedAccountToken()); + } log.debug("已解码认证参数: authType={}", authParam.getAuthType()); } } return otherParam; } + + private void recordDonatedAccountFailureIfNeeded(JsonObject otherParam, Throwable cause) { + if (!isLikelyAuthFailure(cause)) { + return; + } + String donatedAccountToken = otherParam.getString("donatedAccountToken"); + if (donatedAccountToken == null || donatedAccountToken.isBlank()) { + return; + } + dbService.recordDonatedAccountFailureByToken(donatedAccountToken) + .onFailure(e -> log.warn("记录捐赠账号失败次数失败", e)); + } + + private boolean isLikelyAuthFailure(Throwable cause) { + if (cause == null) { + return false; + } + String msg = cause.getMessage(); + if (msg == null || msg.isBlank()) { + return false; + } + String lower = msg.toLowerCase(); + return lower.contains("auth") + || lower.contains("token") + || lower.contains("cookie") + || lower.contains("password") + || lower.contains("credential") + || lower.contains("401") + || lower.contains("403") + || lower.contains("unauthorized") + || lower.contains("forbidden") + || lower.contains("expired") + || lower.contains("登录") + || lower.contains("认证"); + } } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java b/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java index 4666911..6148b6a 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/AuthParam.java @@ -93,6 +93,11 @@ public class AuthParam implements ToJson { */ private String ext5; + /** + * 捐赠账号失败计数令牌(服务端签发,不可伪造) + */ + private String donatedAccountToken; + /** * 从 JsonObject 构造 */ @@ -111,6 +116,7 @@ public class AuthParam implements ToJson { this.ext3 = json.getString("ext3"); this.ext4 = json.getString("ext4"); this.ext5 = json.getString("ext5"); + this.donatedAccountToken = json.getString("donatedAccountToken"); } /** @@ -129,6 +135,7 @@ public class AuthParam implements ToJson { if (ext3 != null) json.put("ext3", ext3); if (ext4 != null) json.put("ext4", ext4); if (ext5 != null) json.put("ext5", ext5); + if (donatedAccountToken != null) json.put("donatedAccountToken", donatedAccountToken); return json; } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java b/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java new file mode 100644 index 0000000..6947eaa --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java @@ -0,0 +1,55 @@ +package cn.qaiu.lz.web.model; + +import cn.qaiu.db.ddl.Constraint; +import cn.qaiu.db.ddl.Length; +import cn.qaiu.db.ddl.Table; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 捐赠账号实体 + * 用户捐赠的网盘认证信息,解析时随机选择使用 + */ +@Data +@Table("donated_account") +public class DonatedAccount { + + private static final long serialVersionUID = 1L; + + @Constraint(autoIncrement = true, notNull = true) + private Long id; + + @Length(varcharSize = 16) + @Constraint(notNull = true) + private String panType; // 网盘类型: QK, UC, FJ, IZ, YE + + @Length(varcharSize = 32) + @Constraint(notNull = true) + private String authType; // 认证类型: cookie, accesstoken, authorization, password, custom + + @Length(varcharSize = 128) + private String username; // 用户名 + + @Length(varcharSize = 128) + private String password; // 密码 + + @Length(varcharSize = 4096) + private String token; // Cookie/Token + + @Length(varcharSize = 64) + private String remark; // 备注 + + @Length(varcharSize = 64) + private String ip; // 捐赠者IP + + @Constraint(notNull = true, defaultValue = "true") + private Boolean enabled = true; // 是否启用 + + @Constraint(notNull = true, defaultValue = "0") + private Integer failCount = 0; // 失败次数,达到阈值自动禁用 + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime = new Date(); +} diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java b/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java index 0256d2e..a996b4a 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java @@ -50,4 +50,30 @@ public interface DbService extends BaseAsyncService { */ Future getPlaygroundParserById(Long id); + // ========== 捐赠账号相关 ========== + + /** + * 保存捐赠账号 + */ + Future saveDonatedAccount(JsonObject account); + + /** + * 获取各网盘捐赠账号数量统计 + */ + Future getDonatedAccountCounts(); + + /** + * 随机获取指定网盘类型的一个启用账号 + */ + Future getRandomDonatedAccount(String panType); + + /** + * 签发捐赠账号失败计数令牌(服务端临时令牌) + */ + Future issueDonatedAccountFailureToken(Long accountId); + + /** + * 使用服务端失败计数令牌记录捐赠账号解析失败 + */ + Future recordDonatedAccountFailureByToken(String failureToken); } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java index 87aa228..0d1ee25 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java @@ -2,8 +2,9 @@ package cn.qaiu.lz.web.service.impl; import cn.qaiu.db.pool.JDBCPoolInit; import cn.qaiu.lz.common.model.UserInfo; -import cn.qaiu.lz.web.service.DbService; import cn.qaiu.lz.web.model.StatisticsInfo; +import cn.qaiu.lz.web.service.DbService; +import cn.qaiu.lz.web.util.CryptoUtil; import cn.qaiu.vx.core.annotaions.Service; import cn.qaiu.vx.core.model.JsonResult; import io.vertx.core.Future; @@ -14,8 +15,13 @@ import io.vertx.sqlclient.Row; import io.vertx.sqlclient.Tuple; import io.vertx.sqlclient.templates.SqlTemplate; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; @@ -28,6 +34,11 @@ import java.util.List; @Slf4j @Service public class DbServiceImpl implements DbService { + private static final int DONATED_ACCOUNT_DISABLE_THRESHOLD = 3; + private static final long FAILURE_TOKEN_TTL_MILLIS = 10 * 60 * 1000L; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG = "donatedAccountFailureTokenSignKey"; + private static final String DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK = "nfd_donate_fail_token_sign_2026"; @Override public Future sayOk(String data) { log.info("say ok1 -> wait..."); @@ -265,4 +276,249 @@ public class DbServiceImpl implements DbService { return promise.future(); } + + // ========== 捐赠账号相关 ========== + + @Override + public Future saveDonatedAccount(JsonObject account) { + JDBCPool client = JDBCPoolInit.instance().getPool(); + + Future encryptedUsername = CryptoUtil.encrypt(account.getString("username")); + Future encryptedPassword = CryptoUtil.encrypt(account.getString("password")); + Future encryptedToken = CryptoUtil.encrypt(account.getString("token")); + + return ensureFailCountColumn(client).compose(v -> + Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> { + String sql = """ + INSERT INTO donated_account + (pan_type, auth_type, username, password, token, remark, ip, enabled, fail_count, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, true, 0, NOW()) + """; + + return client.preparedQuery(sql) + .execute(Tuple.of( + account.getString("panType"), + account.getString("authType"), + encryptedUsername.result(), + encryptedPassword.result(), + encryptedToken.result(), + account.getString("remark"), + account.getString("ip") + )) + .map(res -> JsonResult.success("捐赠成功").toJsonObject()) + .onFailure(e -> log.error("saveDonatedAccount failed", e)); + })); + } + + @Override + public Future getDonatedAccountCounts() { + JDBCPool client = JDBCPoolInit.instance().getPool(); + + String sql = "SELECT pan_type, enabled, COUNT(*) as count FROM donated_account GROUP BY pan_type, enabled"; + + return client.query(sql).execute().map(rows -> { + JsonObject result = new JsonObject(); + JsonObject activeCounts = new JsonObject(); + JsonObject inactiveCounts = new JsonObject(); + int totalActive = 0; + int totalInactive = 0; + + for (Row row : rows) { + String panType = row.getString("pan_type"); + boolean enabled = row.getBoolean("enabled"); + int count = row.getInteger("count"); + + if (enabled) { + activeCounts.put(panType, count); + totalActive += count; + } else { + inactiveCounts.put(panType, count); + totalInactive += count; + } + } + + activeCounts.put("total", totalActive); + inactiveCounts.put("total", totalInactive); + + result.put("active", activeCounts); + result.put("inactive", inactiveCounts); + + return JsonResult.data(result).toJsonObject(); + }).onFailure(e -> log.error("getDonatedAccountCounts failed", e)); + } + + @Override + public Future getRandomDonatedAccount(String panType) { + JDBCPool client = JDBCPoolInit.instance().getPool(); + + String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1"; + + return client.preparedQuery(sql) + .execute(Tuple.of(panType)) + .compose(rows -> { + if (rows.size() > 0) { + Row row = rows.iterator().next(); + + Future usernameFuture = decryptOrPlain(row.getString("username")); + Future passwordFuture = decryptOrPlain(row.getString("password")); + Future tokenFuture = decryptOrPlain(row.getString("token")); + Future failureTokenFuture = issueDonatedAccountFailureToken(row.getLong("id")); + + return Future.all(usernameFuture, passwordFuture, tokenFuture, failureTokenFuture) + .map(compositeFuture -> { + String username = usernameFuture.result(); + String password = passwordFuture.result(); + String token = tokenFuture.result(); + + // 如果解密后没有任何可用凭证,返回空对象,避免把密文当作明文认证参数下发给前端 + if (StringUtils.isBlank(username) && StringUtils.isBlank(password) && StringUtils.isBlank(token)) { + log.warn("random donated account has no usable credential after decrypt, accountId={}", row.getLong("id")); + return JsonResult.data(new JsonObject()).toJsonObject(); + } + + JsonObject account = new JsonObject(); + account.put("authType", row.getString("auth_type")); + account.put("username", username); + account.put("password", password); + account.put("token", token); + account.put("donatedAccountToken", failureTokenFuture.result()); + return JsonResult.data(account).toJsonObject(); + }); + } else { + return Future.succeededFuture(JsonResult.data(new JsonObject()).toJsonObject()); + } + }) + .onFailure(e -> log.error("getRandomDonatedAccount failed", e)); + } + + @Override + public Future issueDonatedAccountFailureToken(Long accountId) { + if (accountId == null) { + return Future.failedFuture("accountId is null"); + } + try { + long issuedAt = System.currentTimeMillis(); + String payload = accountId + ":" + issuedAt; + String signature = hmacSha256(payload); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8)) + + "." + + Base64.getUrlEncoder().withoutPadding().encodeToString(signature.getBytes(StandardCharsets.UTF_8)); + return Future.succeededFuture(token); + } catch (Exception e) { + return Future.failedFuture(e); + } + } + + @Override + public Future recordDonatedAccountFailureByToken(String failureToken) { + JDBCPool client = JDBCPoolInit.instance().getPool(); + + Long accountId; + try { + accountId = parseAndVerifyFailureToken(failureToken); + } catch (Exception e) { + return Future.failedFuture(e); + } + + String updateSql = """ + UPDATE donated_account + SET fail_count = fail_count + 1, + enabled = CASE + WHEN fail_count + 1 >= ? THEN false + ELSE enabled + END + WHERE id = ? + """; + + return ensureFailCountColumn(client) + .compose(v -> client.preparedQuery(updateSql) + .execute(Tuple.of(DONATED_ACCOUNT_DISABLE_THRESHOLD, accountId))) + .map(rows -> (Void) null) + .onFailure(e -> log.error("recordDonatedAccountFailureByToken failed", e)); + } + + private Future ensureFailCountColumn(JDBCPool client) { + Promise promise = Promise.promise(); + String sql = "ALTER TABLE donated_account ADD COLUMN IF NOT EXISTS fail_count INT DEFAULT 0 NOT NULL"; + client.query(sql).execute() + .onSuccess(res -> promise.complete()) + .onFailure(e -> { + String msg = e.getMessage() == null ? "" : e.getMessage().toLowerCase(); + if (!(msg.contains("duplicate") || msg.contains("exists") || msg.contains("already"))) { + log.warn("ensure fail_count column failed, continue without schema migration", e); + } + promise.complete(); + }); + return promise.future(); + } + + private Future decryptOrPlain(String value) { + if (value == null) { + return Future.succeededFuture(null); + } + if (!isLikelyEncrypted(value)) { + return Future.succeededFuture(value); + } + return CryptoUtil.decrypt(value).recover(e -> { + // value 看起来像密文但无法解密,通常是密钥轮换/不一致导致; + // 不应回退为明文,否则会把密文误当 token/cookie 返回给调用方 + log.warn("decrypt donated account field failed, fallback to null to avoid ciphertext leakage", e); + return Future.succeededFuture((String) null); + }); + } + + private boolean isLikelyEncrypted(String value) { + try { + byte[] decoded = Base64.getDecoder().decode(value); + return decoded.length > 16; + } catch (Exception e) { + return false; + } + } + + private Long parseAndVerifyFailureToken(String token) throws Exception { + if (token == null || token.isBlank() || !token.contains(".")) { + throw new IllegalArgumentException("invalid donated account token"); + } + String[] parts = token.split("\\.", 2); + String payload = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8); + String signature = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); + String expected = hmacSha256(payload); + if (!expected.equals(signature)) { + throw new IllegalArgumentException("donated account token signature invalid"); + } + + String[] payloadParts = payload.split(":", 2); + if (payloadParts.length != 2) { + throw new IllegalArgumentException("invalid donated account token payload"); + } + Long accountId = Long.parseLong(payloadParts[0]); + long issuedAt = Long.parseLong(payloadParts[1]); + if (System.currentTimeMillis() - issuedAt > FAILURE_TOKEN_TTL_MILLIS) { + throw new IllegalArgumentException("donated account token expired"); + } + return accountId; + } + + private String hmacSha256(String payload) throws Exception { + String secret = getDonatedAccountFailureTokenSignKey(); + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(digest); + } + + private String getDonatedAccountFailureTokenSignKey() { + try { + String configKey = cn.qaiu.vx.core.util.SharedDataUtil + .getJsonStringForServerConfig(DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG); + if (StringUtils.isNotBlank(configKey)) { + return configKey; + } + } catch (Exception e) { + log.debug("读取捐赠账号失败计数签名密钥失败,使用默认值: {}", e.getMessage()); + } + return DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK; + } } + diff --git a/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java new file mode 100644 index 0000000..291aa6b --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java @@ -0,0 +1,7 @@ +package cn.qaiu.lz.web.util; + +public class CryptoException extends RuntimeException { + public CryptoException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java new file mode 100644 index 0000000..b2fc3cc --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java @@ -0,0 +1,105 @@ +package cn.qaiu.lz.web.util; + +import cn.qaiu.vx.core.util.ConfigUtil; +import cn.qaiu.vx.core.util.VertxHolder; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +public class CryptoUtil { + + private static final Logger logger = LoggerFactory.getLogger(CryptoUtil.class); + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; // 96 bits + private static final int GCM_TAG_LENGTH = 16; // 128 bits + + private static Future secretKeyFuture; + + static { + Vertx vertx = VertxHolder.getVertxInstance(); + if (vertx != null) { + secretKeyFuture = ConfigUtil.readYamlConfig("secret", vertx) + .map(config -> { + String key = config.getJsonObject("encrypt").getString("key"); + if (key != null) { + key = key.trim(); + } + byte[] keyBytes = key == null ? null : key.getBytes(StandardCharsets.UTF_8); + if (keyBytes == null || keyBytes.length != 32) { + int currentLen = keyBytes == null ? 0 : keyBytes.length; + throw new IllegalArgumentException("Invalid AES key length in secret.yml. Key must be 32 bytes. current=" + currentLen); + } + return new SecretKeySpec(keyBytes, "AES"); + }) + .onFailure(err -> logger.error("Failed to load encryption key from secret.yml", err)); + } else { + logger.error("Vertx instance is not available for CryptoUtil initialization."); + secretKeyFuture = Future.failedFuture("Vertx instance not available."); + } + } + + public static Future encrypt(String strToEncrypt) { + if (strToEncrypt == null) { + return Future.succeededFuture(null); + } + return secretKeyFuture.compose(secretKey -> { + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + SecureRandom random = new SecureRandom(); + random.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec); + + byte[] cipherText = cipher.doFinal(strToEncrypt.getBytes(StandardCharsets.UTF_8)); + + // Prepend IV to ciphertext + ByteBuffer byteBuffer = ByteBuffer.allocate(iv.length + cipherText.length); + byteBuffer.put(iv); + byteBuffer.put(cipherText); + + return Future.succeededFuture(Base64.getEncoder().encodeToString(byteBuffer.array())); + } catch (Exception e) { + return Future.failedFuture(new CryptoException("Encryption failed", e)); + } + }); + } + + public static Future decrypt(String strToDecrypt) { + if (strToDecrypt == null) { + return Future.succeededFuture(null); + } + return secretKeyFuture.compose(secretKey -> { + try { + byte[] decodedBytes = Base64.getDecoder().decode(strToDecrypt); + + // Extract IV from the beginning of the decoded bytes + ByteBuffer byteBuffer = ByteBuffer.wrap(decodedBytes); + byte[] iv = new byte[GCM_IV_LENGTH]; + byteBuffer.get(iv); + byte[] cipherText = new byte[byteBuffer.remaining()]; + byteBuffer.get(cipherText); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec); + + byte[] decryptedText = cipher.doFinal(cipherText); + + return Future.succeededFuture(new String(decryptedText, StandardCharsets.UTF_8)); + } catch (Exception e) { + return Future.failedFuture(new CryptoException("Decryption failed", e)); + } + }); + } +}