fix: 123网盘捐赠账号无需临时auth参数即可自动生效,修复token前缀与脏数据问题

- 前端: getCurrentPanType() 补全123网盘域名匹配正则(share.123pan.cn等子域名)
- 前端: 临时认证弹窗补充123云盘(YE)选项及提示文案
- 前端: submitDonateAccount() 按当前authType只提交相关字段,避免切换认证方式后
  残留的用户名/密码脏数据被一并提交
- 后端: URLParamUtil.addParam() 不再无条件用静态yml配置覆盖已生效的临时认证/
  自动捐赠账号回退(增加 __TEMP_AUTH_ADDED 判断)
- 后端: CacheServiceImpl 新增 applyDonatedAccountFallback(),请求未带auth参数
  且无静态配置时自动从捐赠账号池随机取一个可用账号使用,并在解析失败时记录/
  自动禁用失败账号
- 后端: DbServiceImpl.getRandomDonatedAccount() 对非password类型账号强制丢弃
  历史遗留的username/password字段,避免被误当账号密码登录
- 解析器: Ye2Tool 清理token/authorization前缀中的"Bearer ",避免拼接成
  'Bearer Bearer xxx' 导致123接口返回 tokenstring should not contain bearer

已通过本地实测验证: 捐赠123账号后,不带auth参数直接请求
/json/parser?url=https://1815268665.share.123pan.cn/123pan/iaKtVv-r4aCd
可正常返回200及直链
This commit is contained in:
q
2026-07-18 23:21:56 +08:00
parent 5e20ed5050
commit 675d930f86
5 changed files with 211 additions and 30 deletions
@@ -99,10 +99,26 @@ public class Ye2Tool extends PanBase {
return null;
}
String token = auths.get("token");
if (StringUtils.isNotEmpty(token)) {
if (StringUtils.isEmpty(token)) {
token = auths.get("authorization");
}
return stripBearerPrefix(token);
}
/**
* 用户/捐赠账号提供的 token 经常是从浏览器开发者工具里连同 "Bearer " 前缀一起复制过来的,
* 如果不做清理,后续 header.set("Authorization", "Bearer " + token) 会拼出
* "Bearer Bearer xxx",导致123网盘接口报错 tokenstring should not contain 'bearer '。
*/
private String stripBearerPrefix(String token) {
if (StringUtils.isBlank(token)) {
return token;
}
return auths.get("authorization");
String trimmed = token.trim();
if (trimmed.regionMatches(true, 0, "Bearer ", 0, 7)) {
trimmed = trimmed.substring(7).trim();
}
return trimmed;
}
private boolean isTokenExpired() {
+16 -7
View File
@@ -326,6 +326,10 @@
<span>蓝奏优享 (IZ)</span>
<el-tag size="small" type="warning" style="margin-left: 8px">大文件</el-tag>
</el-option>
<el-option label="123云盘 (YE)" value="YE">
<span>123云盘 (YE)</span>
<el-tag size="small" type="warning" style="margin-left: 8px">部分分享需登录</el-tag>
</el-option>
</el-option-group>
</el-select>
</el-form-item>
@@ -767,7 +771,8 @@ 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'
// 123网盘域名较多(如 123pan.com/123pan.cn/share.123pan.cn/123684.com/123865.com 等数字域名),使用规则匹配代替枚举
if (/123\d{3}\.com|123panpay\.com|123pan\.(?:com|cn)/.test(url)) return 'YE'
return ''
},
@@ -789,7 +794,8 @@ export default {
'QK': '夸克网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)',
'UC': 'UC网盘必须配置 Cookie 才能解析和下载(登录后从浏览器开发者工具获取)',
'FJ': '小飞机网盘大文件(>100MB)需要配置认证信息',
'IZ': '蓝奏优享大文件需要配置认证信息'
'IZ': '蓝奏优享大文件需要配置认证信息',
'YE': '123云盘部分分享(需要登录才能查看/下载)需要配置账号密码或 Authorization Token'
}
return hints[this.authConfig.panType] || '请选择网盘类型后配置认证信息'
},
@@ -1230,10 +1236,11 @@ export default {
duration: 5000,
showClose: true
})
} else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le') {
// 小飞机、蓝奏、优享、联想乐云:提示大文件需要认证
} else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le' || panType === 'ye') {
// 小飞机、蓝奏、优享、联想乐云、123云盘:提示大文件/需登录分享需要认证
const hasAuth = this.allAuthConfigs[panType]?.cookie ||
this.allAuthConfigs[panType]?.username ||
this.allAuthConfigs[panType]?.token ||
(this.donateAccountCounts.active[panType.toUpperCase()] || 0) > 0
if (!hasAuth) {
this.$message.info({
@@ -1612,12 +1619,14 @@ export default {
this.donateSubmitting = true
try {
// 只提交当前认证方式实际用到的字段,避免切换认证类型后遗留的用户名/密码脏数据被一起提交
const isPasswordAuth = this.donateConfig.authType === 'password'
const payload = {
panType: this.donateConfig.panType,
authType: this.donateConfig.authType,
username: this.donateConfig.username || '',
password: this.donateConfig.password || '',
token: this.donateConfig.token || '',
username: isPasswordAuth ? (this.donateConfig.username || '') : '',
password: isPasswordAuth ? (this.donateConfig.password || '') : '',
token: isPasswordAuth ? '' : (this.donateConfig.token || ''),
remark: this.donateConfig.remark || ''
}
await axios.post(`${this.baseAPI}/v2/donateAccount`, payload)
@@ -100,21 +100,27 @@ public class URLParamUtil {
}
}
if (localMap.containsKey(ConfigConstant.AUTHS)) {
JsonObject auths = (JsonObject) localMap.get(ConfigConstant.AUTHS);
if (auths.containsKey(type)) {
// 需要处理引号
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
JsonObject jsonObject = auths.getJsonObject(type);
if (jsonObject != null) {
jsonObject.forEach(entity -> {
if (entity == null || entity.getValue() == null) {
return;
}
entries.set(entity.getKey(), entity.getValue().toString());
});
}
// 如果本次请求已经通过 auth 临时参数(个人配置/捐赠账号)设置过认证信息,
// 则不要再用后台 app-dev.yml 的静态配置覆盖,否则临时认证会被静默清空/替换为空配置。
boolean tempAuthAdded = Boolean.TRUE.equals(
parserCreate.getShareLinkInfo().getOtherParam().get("__TEMP_AUTH_ADDED"));
if (!tempAuthAdded) {
JsonObject auths = (JsonObject) localMap.get(ConfigConstant.AUTHS);
if (auths.containsKey(type)) {
// 需要处理引号
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
JsonObject jsonObject = auths.getJsonObject(type);
if (jsonObject != null) {
jsonObject.forEach(entity -> {
if (entity == null || entity.getValue() == null) {
return;
}
entries.set(entity.getKey(), entity.getValue().toString());
});
}
parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.AUTHS, entries);
parserCreate.getShareLinkInfo().getOtherParam().put(ConfigConstant.AUTHS, entries);
}
}
}
@@ -8,15 +8,22 @@ import cn.qaiu.lz.common.cache.CacheTotalField;
import cn.qaiu.lz.common.util.URLParamUtil;
import cn.qaiu.lz.web.model.CacheLinkInfo;
import cn.qaiu.lz.web.service.CacheService;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.IPanTool;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.vx.core.annotaions.Service;
import cn.qaiu.vx.core.util.AsyncServiceUtil;
import cn.qaiu.vx.core.util.ConfigConstant;
import cn.qaiu.vx.core.util.VertxHolder;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import io.vertx.core.shareddata.LocalMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateFormatUtils;
import java.util.Date;
@@ -28,8 +35,11 @@ import java.util.Map;
public class CacheServiceImpl implements CacheService {
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
private static final String TEMP_AUTH_ADDED = "__TEMP_AUTH_ADDED";
private static final String DONATED_ACCOUNT_TOKEN = "__AUTO_DONATED_ACCOUNT_TOKEN";
private final CacheManager cacheManager = new CacheManager();
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
static {
// 服务类加载时注册缓存定时清理任务
@@ -71,7 +81,9 @@ public class CacheServiceImpl implements CacheService {
promise.fail(cause);
return;
}
IPanTool.closeAfter(tool, tool::parse).onSuccess(redirectUrl -> {
IPanTool.closeAfter(tool, tool::parse).onFailure(err -> {
recordAutoDonatedFailureIfNeeded(shareLinkInfo, err);
}).onSuccess(redirectUrl -> {
// 使用 effectiveCacheDuration
long expires = System.currentTimeMillis() + effectiveCacheDuration * 60 * 1000L;
result.setDirectLink(redirectUrl);
@@ -141,6 +153,124 @@ public class CacheServiceImpl implements CacheService {
return DateFormatUtils.format(new Date(ts), "yyyy-MM-dd HH:mm:ss");
}
/**
* 当本次请求没有携带临时认证参数(auth=xxx),且该网盘类型在 app-dev.yml 中也没有配置可用的
* 静态账号密码/token 时,自动从捐赠账号池中随机挑选一个可用账号使用,
* 这样捐赠者捐赠账号后,普通请求(不带 auth 参数)也能直接受益,无需前端额外拼接参数。
*/
private Future<Void> applyDonatedAccountFallback(ParserCreate parserCreate) {
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
Map<String, Object> otherParam = shareLinkInfo.getOtherParam();
if (Boolean.TRUE.equals(otherParam.get(TEMP_AUTH_ADDED))) {
// 请求已经携带了个人配置/捐赠账号的临时认证参数,无需再自动回退
return Future.succeededFuture();
}
String type = shareLinkInfo.getType();
if (StringUtils.isBlank(type) || hasUsableStaticAuthConfig(type)) {
return Future.succeededFuture();
}
return dbService.getRandomDonatedAccount(type.toUpperCase())
.compose(res -> {
if (!Integer.valueOf(200).equals(res.getInteger("code"))) {
return Future.succeededFuture();
}
JsonObject data = res.getJsonObject("data");
if (data == null || data.isEmpty()) {
return Future.succeededFuture();
}
String username = data.getString("username");
String password = data.getString("password");
String token = data.getString("token");
if (StringUtils.isBlank(username) && StringUtils.isBlank(password) && StringUtils.isBlank(token)) {
return Future.succeededFuture();
}
MultiMap tempAuth = MultiMap.caseInsensitiveMultiMap();
if (StringUtils.isNotBlank(username)) {
tempAuth.set("username", username);
}
if (StringUtils.isNotBlank(password)) {
tempAuth.set("password", password);
}
if (StringUtils.isNotBlank(token)) {
tempAuth.set("token", token);
}
otherParam.put(ConfigConstant.AUTHS, tempAuth);
otherParam.put(TEMP_AUTH_ADDED, true);
String donatedAccountToken = data.getString("donatedAccountToken");
if (StringUtils.isNotBlank(donatedAccountToken)) {
otherParam.put(DONATED_ACCOUNT_TOKEN, donatedAccountToken);
}
log.debug("已自动应用捐赠账号: type={}", type);
return Future.<Void>succeededFuture();
})
.recover(err -> {
log.warn("自动获取捐赠账号失败: type={}", type, err);
return Future.succeededFuture();
});
}
/**
* 检查 app-dev.yml 中该网盘类型是否已配置了可用的静态认证信息(非空的 username/password/token/authorization 等)。
* 如果已配置,则不需要再自动回退到捐赠账号池。
*/
private boolean hasUsableStaticAuthConfig(String type) {
LocalMap<Object, Object> localMap = VertxHolder.getVertxInstance().sharedData()
.getLocalMap(ConfigConstant.LOCAL);
if (!localMap.containsKey(ConfigConstant.AUTHS)) {
return false;
}
JsonObject auths = (JsonObject) localMap.get(ConfigConstant.AUTHS);
JsonObject cfg = auths.getJsonObject(type);
if (cfg == null) {
return false;
}
for (String key : cfg.fieldNames()) {
Object value = cfg.getValue(key);
if (value != null && StringUtils.isNotBlank(value.toString())) {
return true;
}
}
return false;
}
/**
* 自动回退使用的捐赠账号解析失败时,记录一次失败次数(达到阈值后台账号会被自动禁用)。
*/
private void recordAutoDonatedFailureIfNeeded(ShareLinkInfo shareLinkInfo, Throwable cause) {
Object tokenObj = shareLinkInfo.getOtherParam().get(DONATED_ACCOUNT_TOKEN);
if (!(tokenObj instanceof String) || StringUtils.isBlank((String) tokenObj)) {
return;
}
if (!isLikelyAuthFailure(cause)) {
return;
}
dbService.recordDonatedAccountFailureByToken((String) tokenObj)
.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("认证");
}
private boolean shouldGenerateClientLinks(ShareLinkInfo shareLinkInfo) {
if (shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
return true;
@@ -279,7 +409,10 @@ public class CacheServiceImpl implements CacheService {
return Future.failedFuture(e);
}
parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap());
return getAndSaveCachedShareLink(parserCreate);
ParserCreate finalParserCreate = parserCreate;
return applyDonatedAccountFallback(finalParserCreate)
.compose(v -> getAndSaveCachedShareLink(finalParserCreate));
}
@Override
@@ -305,7 +438,9 @@ public class CacheServiceImpl implements CacheService {
otherParam.getString("authInfo4"),
otherParam.getString("authInfo5"));
}
return getAndSaveCachedShareLink(parserCreate);
ParserCreate finalParserCreate = parserCreate;
return applyDonatedAccountFallback(finalParserCreate)
.compose(v -> getAndSaveCachedShareLink(finalParserCreate));
}
}
@@ -325,9 +325,16 @@ public class DbServiceImpl implements DbService {
public Future<JsonObject> saveDonatedAccount(JsonObject account) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Future<String> encryptedUsername = CryptoUtil.encrypt(account.getString("username"));
Future<String> encryptedPassword = CryptoUtil.encrypt(account.getString("password"));
Future<String> encryptedToken = CryptoUtil.encrypt(account.getString("token"));
// 只保留当前认证方式实际用到的字段,避免调用方切换认证类型后遗留的用户名/密码脏数据
// 被一并存入库中,导致后续解析时被误当作真实凭证使用(例如把废弃的用户名当手机号登录)。
boolean isPasswordAuth = "password".equalsIgnoreCase(account.getString("authType"));
String usernameToStore = isPasswordAuth ? account.getString("username") : null;
String passwordToStore = isPasswordAuth ? account.getString("password") : null;
String tokenToStore = isPasswordAuth ? null : account.getString("token");
Future<String> encryptedUsername = CryptoUtil.encrypt(usernameToStore);
Future<String> encryptedPassword = CryptoUtil.encrypt(passwordToStore);
Future<String> encryptedToken = CryptoUtil.encrypt(tokenToStore);
return ensureFailCountColumn(client).compose(v ->
Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> {
@@ -412,6 +419,14 @@ public class DbServiceImpl implements DbService {
String password = passwordFuture.result();
String token = tokenFuture.result();
// 历史脏数据兜底:非 password 认证类型的账号不应该带用户名/密码
// (例如切换认证类型前遗留的表单数据),否则会被解析器误当作真实账号密码去登录。
boolean isPasswordAuth = "password".equalsIgnoreCase(row.getString("auth_type"));
if (!isPasswordAuth) {
username = null;
password = null;
}
// 如果解密后没有任何可用凭证,返回空对象,避免把密文当作明文认证参数下发给前端
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"));