From 04443bcb5ee5ab457d3d8bdc41fce30c1b9e9fb5 Mon Sep 17 00:00:00 2001 From: rensumo <15206641+rensumo@user.noreply.gitee.com> Date: Thu, 19 Feb 2026 12:59:47 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=8D=90?= =?UTF-8?q?=E8=B5=A0=E8=B4=A6=E5=8F=B7=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=98=E5=82=A8=E5=92=8C?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E9=80=89=E6=8B=A9=E8=B4=A6=E5=8F=B7=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-front/src/views/Home.vue | 243 +++++++++++++++++- .../cn/qaiu/lz/web/controller/ParserApi.java | 28 ++ .../cn/qaiu/lz/web/model/DonatedAccount.java | 52 ++++ .../cn/qaiu/lz/web/service/DbService.java | 17 ++ .../lz/web/service/impl/DbServiceImpl.java | 90 +++++++ 5 files changed, 417 insertions(+), 13 deletions(-) create mode 100644 web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index 92fa027..e6857f1 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -2,7 +2,7 @@
+ + + + + + + +
+ 当前账号池(共 {{ donateAccountCounts.total }} 个) + + {{ getPanDisplayName(panType) }}: {{ count }} 个 + +
+
+ 暂无捐赠账号,成为第一个捐赠者吧! +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
@@ -436,7 +517,21 @@ export default { ext5: '' }, // 所有网盘的认证配置 { panType: config } - allAuthConfigs: {} + allAuthConfigs: {}, + + // 捐赠账号相关 + showDonateDialog: false, + donateSubmitting: false, + donateConfig: { + panType: '', + authType: 'cookie', + username: '', + password: '', + token: '', + remark: '' + }, + // 捐赠账号数量统计 { panType: count, total: N } + donateAccountCounts: { total: 0 } } }, computed: { @@ -460,6 +555,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 +565,8 @@ export default { 'QK': '夸克网盘', 'UC': 'UC网盘', 'FJ': '小飞机网盘', - 'IZ': '蓝奏优享' + 'IZ': '蓝奏优享', + 'YE': '123云盘' } return names[panType] || panType }, @@ -663,14 +760,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 = {} @@ -710,9 +829,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 +885,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 +1205,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 +1234,101 @@ 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 嵌套: { code, data: { code, data: { QK: 3, total: 4 } } } + let data = response.data + while (data && data.data !== undefined && data.code !== undefined) { + data = data.data + } + if (data && typeof data === 'object') { + // 确保有 total 字段 + if (data.total === undefined) { + let total = 0 + for (const [key, val] of Object.entries(data)) { + if (typeof val === 'number') total += val + } + data.total = total + } + this.donateAccountCounts = data + } + } catch (e) { + console.error('加载捐赠账号统计失败:', e) + } } }, @@ -1128,6 +1342,9 @@ export default { // 加载认证配置 this.loadAuthConfig() + // 加载捐赠账号统计 + this.loadDonateAccountCounts() + // 获取初始统计信息 this.getInfo() 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..29dc5e5 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 @@ -477,4 +477,32 @@ public class ParserApi { ClientLinkType type = ClientLinkType.valueOf(clientType.toUpperCase()); return clientLinks.get(type); } + + // ========== 捐赠账号 API ========== + + /** + * 捐赠网盘账号 + */ + @RouteMapping(value = "/donateAccount", method = RouteMethod.POST) + public Future donateAccount(HttpServerRequest request, JsonObject body) { + String ip = 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/model/DonatedAccount.java b/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java new file mode 100644 index 0000000..f374a04 --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/model/DonatedAccount.java @@ -0,0 +1,52 @@ +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; // 是否启用 + + @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..8dcc8d6 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,21 @@ public interface DbService extends BaseAsyncService { */ Future getPlaygroundParserById(Long id); + // ========== 捐赠账号相关 ========== + + /** + * 保存捐赠账号 + */ + Future saveDonatedAccount(JsonObject account); + + /** + * 获取各网盘捐赠账号数量统计 + */ + Future getDonatedAccountCounts(); + + /** + * 随机获取指定网盘类型的一个启用账号 + */ + Future getRandomDonatedAccount(String panType); + } 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..7ebee36 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 @@ -265,4 +265,94 @@ public class DbServiceImpl implements DbService { return promise.future(); } + + // ========== 捐赠账号相关 ========== + + @Override + public Future saveDonatedAccount(JsonObject account) { + JDBCPool client = JDBCPoolInit.instance().getPool(); + Promise promise = Promise.promise(); + + String sql = """ + INSERT INTO donated_account + (pan_type, auth_type, username, password, token, remark, ip, enabled, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, true, NOW()) + """; + + client.preparedQuery(sql) + .execute(Tuple.of( + account.getString("panType"), + account.getString("authType"), + account.getString("username"), + account.getString("password"), + account.getString("token"), + account.getString("remark"), + account.getString("ip") + )) + .onSuccess(res -> { + promise.complete(JsonResult.success("捐赠成功").toJsonObject()); + }) + .onFailure(e -> { + log.error("saveDonatedAccount failed", e); + promise.fail(e); + }); + + return promise.future(); + } + + @Override + public Future getDonatedAccountCounts() { + JDBCPool client = JDBCPoolInit.instance().getPool(); + Promise promise = Promise.promise(); + + String sql = "SELECT pan_type, COUNT(*) as count FROM donated_account WHERE enabled = true GROUP BY pan_type"; + + client.query(sql).execute().onSuccess(rows -> { + JsonObject counts = new JsonObject(); + int total = 0; + for (Row row : rows) { + String panType = row.getString("pan_type"); + Integer count = row.getInteger("count"); + counts.put(panType, count); + total += count; + } + counts.put("total", total); + promise.complete(JsonResult.data(counts).toJsonObject()); + }).onFailure(e -> { + log.error("getDonatedAccountCounts failed", e); + promise.fail(e); + }); + + return promise.future(); + } + + @Override + public Future getRandomDonatedAccount(String panType) { + JDBCPool client = JDBCPoolInit.instance().getPool(); + Promise promise = Promise.promise(); + + String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1"; + + client.preparedQuery(sql) + .execute(Tuple.of(panType)) + .onSuccess(rows -> { + if (rows.size() > 0) { + Row row = rows.iterator().next(); + JsonObject account = new JsonObject(); + account.put("authType", row.getString("auth_type")); + account.put("username", row.getString("username")); + account.put("password", row.getString("password")); + account.put("token", row.getString("token")); + promise.complete(JsonResult.data(account).toJsonObject()); + } else { + promise.complete(JsonResult.data(new JsonObject()).toJsonObject()); + } + }) + .onFailure(e -> { + log.error("getRandomDonatedAccount failed", e); + promise.fail(e); + }); + + return promise.future(); + } } From 07c650a474f44ed6dd50f826671c1894c7e99ea2 Mon Sep 17 00:00:00 2001 From: rensumo <15206641+rensumo@user.noreply.gitee.com> Date: Sun, 22 Feb 2026 11:12:35 +0800 Subject: [PATCH 2/5] feat: Add validation for donateAccount endpoint --- .../src/main/java/cn/qaiu/lz/web/controller/ParserApi.java | 4 ++++ 1 file changed, 4 insertions(+) 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 29dc5e5..532ba51 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 @@ -485,6 +485,10 @@ public class ParserApi { */ @RouteMapping(value = "/donateAccount", method = RouteMethod.POST) public Future donateAccount(HttpServerRequest request, JsonObject body) { + 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 = request.remoteAddress().host(); body.put("ip", ip); return dbService.saveDonatedAccount(body); From 81ffbbd6b1f3650b00c9e9ce7b70029b7098ec23 Mon Sep 17 00:00:00 2001 From: rensumo <15206641+rensumo@user.noreply.gitee.com> Date: Sun, 22 Feb 2026 12:24:47 +0800 Subject: [PATCH 3/5] feat: harden donated-account failure token and document key usage --- .gitignore | 1 + README.md | 12 + parser/doc/auth-param/AUTH_PARAM_GUIDE.md | 29 +- web-front/src/views/Home.vue | 82 ++++-- .../cn/qaiu/lz/web/controller/ServerApi.java | 48 ++- .../java/cn/qaiu/lz/web/model/AuthParam.java | 7 + .../cn/qaiu/lz/web/model/DonatedAccount.java | 3 + .../cn/qaiu/lz/web/service/DbService.java | 9 + .../lz/web/service/impl/DbServiceImpl.java | 276 ++++++++++++++---- .../cn/qaiu/lz/web/util/CryptoException.java | 7 + .../java/cn/qaiu/lz/web/util/CryptoUtil.java | 100 +++++++ 11 files changed, 489 insertions(+), 85 deletions(-) create mode 100644 web-service/src/main/java/cn/qaiu/lz/web/util/CryptoException.java create mode 100644 web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java 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/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 e6857f1..d34a070 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -369,16 +369,34 @@ -
- 当前账号池(共 {{ donateAccountCounts.total }} 个) - - {{ getPanDisplayName(panType) }}: {{ count }} 个 - +
+ + 当前账号池(活跃 {{ donateAccountCounts.active.total }} / 失效 {{ donateAccountCounts.inactive.total }}) + + +
+ 活跃账号 + + {{ getPanDisplayName(panType) }}: {{ count }} 个 + +
+ +
+ 失效账号 + + {{ getPanDisplayName(panType) }}: {{ count }} 个 + +
暂无捐赠账号,成为第一个捐赠者吧! @@ -530,8 +548,11 @@ export default { token: '', remark: '' }, - // 捐赠账号数量统计 { panType: count, total: N } - donateAccountCounts: { total: 0 } + // 捐赠账号数量统计 + donateAccountCounts: { + active: { total: 0 }, + inactive: { total: 0 } + } } }, computed: { @@ -804,7 +825,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) @@ -1310,21 +1332,39 @@ export default { async loadDonateAccountCounts() { try { const response = await axios.get(`${this.baseAPI}/v2/donateAccountCounts`) - // 解包可能的 JsonResult 嵌套: { code, data: { code, data: { QK: 3, total: 4 } } } + // 解包可能的 JsonResult 嵌套 let data = response.data while (data && data.data !== undefined && data.code !== undefined) { data = data.data } + if (data && typeof data === 'object') { - // 确保有 total 字段 - if (data.total === undefined) { - let total = 0 - for (const [key, val] of Object.entries(data)) { - if (typeof val === 'number') total += val + // 兼容新结构: { 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 } } - data.total = total } - this.donateAccountCounts = data } } catch (e) { console.error('加载捐赠账号统计失败:', e) 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 index f374a04..6947eaa 100644 --- 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 @@ -47,6 +47,9 @@ public class DonatedAccount { @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 8dcc8d6..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 @@ -67,4 +67,13 @@ public interface DbService extends BaseAsyncService { */ 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 7ebee36..01037f7 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..."); @@ -271,88 +282,231 @@ public class DbServiceImpl implements DbService { @Override public Future saveDonatedAccount(JsonObject account) { JDBCPool client = JDBCPoolInit.instance().getPool(); - Promise promise = Promise.promise(); - String sql = """ - INSERT INTO donated_account - (pan_type, auth_type, username, password, token, remark, ip, enabled, create_time) - VALUES (?, ?, ?, ?, ?, ?, ?, true, NOW()) - """; + Future encryptedUsername = CryptoUtil.encrypt(account.getString("username")); + Future encryptedPassword = CryptoUtil.encrypt(account.getString("password")); + Future encryptedToken = CryptoUtil.encrypt(account.getString("token")); - client.preparedQuery(sql) - .execute(Tuple.of( - account.getString("panType"), - account.getString("authType"), - account.getString("username"), - account.getString("password"), - account.getString("token"), - account.getString("remark"), - account.getString("ip") - )) - .onSuccess(res -> { - promise.complete(JsonResult.success("捐赠成功").toJsonObject()); - }) - .onFailure(e -> { - log.error("saveDonatedAccount failed", e); - promise.fail(e); - }); + 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 promise.future(); + 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(); - Promise promise = Promise.promise(); - String sql = "SELECT pan_type, COUNT(*) as count FROM donated_account WHERE enabled = true GROUP BY pan_type"; + 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; - client.query(sql).execute().onSuccess(rows -> { - JsonObject counts = new JsonObject(); - int total = 0; for (Row row : rows) { String panType = row.getString("pan_type"); - Integer count = row.getInteger("count"); - counts.put(panType, count); - total += count; - } - counts.put("total", total); - promise.complete(JsonResult.data(counts).toJsonObject()); - }).onFailure(e -> { - log.error("getDonatedAccountCounts failed", e); - promise.fail(e); - }); + boolean enabled = row.getBoolean("enabled"); + int count = row.getInteger("count"); - return promise.future(); + 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(); - Promise promise = Promise.promise(); String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1"; - client.preparedQuery(sql) - .execute(Tuple.of(panType)) - .onSuccess(rows -> { - if (rows.size() > 0) { - Row row = rows.iterator().next(); - JsonObject account = new JsonObject(); - account.put("authType", row.getString("auth_type")); - account.put("username", row.getString("username")); - account.put("password", row.getString("password")); - account.put("token", row.getString("token")); - promise.complete(JsonResult.data(account).toJsonObject()); - } else { - promise.complete(JsonResult.data(new JsonObject()).toJsonObject()); - } - }) - .onFailure(e -> { - log.error("getRandomDonatedAccount failed", e); - promise.fail(e); - }); + 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 -> { + JsonObject account = new JsonObject(); + account.put("authType", row.getString("auth_type")); + account.put("username", usernameFuture.result()); + account.put("password", passwordFuture.result()); + account.put("token", tokenFuture.result()); + 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)) + .mapEmpty()) + .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 -> { + log.warn("decrypt donated account field failed, fallback to plaintext", e); + return Future.succeededFuture(value); + }); + } + + 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..a7c6b44 --- /dev/null +++ b/web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java @@ -0,0 +1,100 @@ +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.length() != 32) { + throw new IllegalArgumentException("Invalid AES key length in secret.yml. Key must be 32 bytes."); + } + return new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "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)); + } + }); + } +} From 6355c35452431c317361610d7a25737e28c38d2b Mon Sep 17 00:00:00 2001 From: rensumo <15206641+rensumo@user.noreply.gitee.com> Date: Sun, 22 Feb 2026 12:36:20 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=8D=90=E8=B5=A0?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=A4=B1=E8=B4=A5=E8=AE=A1=E6=95=B0=E4=B8=8E?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=A4=96=E9=83=A8=E8=AE=BF=E9=97=AE=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java | 3 +++ .../main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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/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 01037f7..60c7cda 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 @@ -422,8 +422,8 @@ public class DbServiceImpl implements DbService { return ensureFailCountColumn(client) .compose(v -> client.preparedQuery(updateSql) - .execute(Tuple.of(DONATED_ACCOUNT_DISABLE_THRESHOLD, accountId)) - .mapEmpty()) + .execute(Tuple.of(DONATED_ACCOUNT_DISABLE_THRESHOLD, accountId))) + .map(rows -> (Void) null) .onFailure(e -> log.error("recordDonatedAccountFailureByToken failed", e)); } From b150641e3b8276e6af47768e9857febc0a134ebb Mon Sep 17 00:00:00 2001 From: rensumo <15206641+rensumo@user.noreply.gitee.com> Date: Sun, 22 Feb 2026 16:06:22 +0800 Subject: [PATCH 5/5] fix: stabilize auth/decrypt flow and refresh donate account counts --- .../handlerfactory/RouterHandlerFactory.java | 26 +++++++++++++++++-- web-front/src/views/Home.vue | 3 ++- .../qaiu/lz/common/util/AuthParamCodec.java | 13 +++++++--- .../cn/qaiu/lz/web/controller/ParserApi.java | 6 +++-- .../lz/web/service/impl/DbServiceImpl.java | 22 ++++++++++++---- .../java/cn/qaiu/lz/web/util/CryptoUtil.java | 11 +++++--- 6 files changed, 64 insertions(+), 17 deletions(-) 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/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index d34a070..3514759 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -361,7 +361,8 @@ v-model="showDonateDialog" title="🎁 捐赠网盘账号" width="550px" - :close-on-click-modal="false"> + :close-on-click-modal="false" + @open="loadDonateAccountCounts">