-
当前账号池(共 {{ 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">
捐赠您的网盘 Cookie/Token,解析时将从所有捐赠账号中随机选择使用,分摊请求压力。
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 532ba51..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;
@@ -484,12 +485,13 @@ public class ParserApi {
* 捐赠网盘账号
*/
@RouteMapping(value = "/donateAccount", method = RouteMethod.POST)
- public Future donateAccount(HttpServerRequest request, JsonObject body) {
+ 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 = request.remoteAddress().host();
+ String ip = ctx.request().remoteAddress().host();
body.put("ip", ip);
return dbService.saveDonatedAccount(body);
}
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 60c7cda..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
@@ -366,11 +366,21 @@ public class DbServiceImpl implements DbService {
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", usernameFuture.result());
- account.put("password", passwordFuture.result());
- account.put("token", tokenFuture.result());
+ account.put("username", username);
+ account.put("password", password);
+ account.put("token", token);
account.put("donatedAccountToken", failureTokenFuture.result());
return JsonResult.data(account).toJsonObject();
});
@@ -450,8 +460,10 @@ public class DbServiceImpl implements DbService {
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);
+ // value 看起来像密文但无法解密,通常是密钥轮换/不一致导致;
+ // 不应回退为明文,否则会把密文误当 token/cookie 返回给调用方
+ log.warn("decrypt donated account field failed, fallback to null to avoid ciphertext leakage", e);
+ return Future.succeededFuture((String) null);
});
}
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
index a7c6b44..b2fc3cc 100644
--- 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
@@ -30,10 +30,15 @@ public class CryptoUtil {
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.");
+ if (key != null) {
+ key = key.trim();
}
- return new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
+ 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 {