mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-11 19:36:54 +00:00
Compare commits
7 Commits
d06974d556
...
fdf067c25e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf067c25e | ||
|
|
5f9da47513 | ||
|
|
b150641e3b | ||
|
|
6355c35452 | ||
|
|
81ffbbd6b1 | ||
|
|
07c650a474 | ||
|
|
04443bcb5e |
12
README.md
12
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` 参数值
|
||||
|
||||
@@ -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<String, Object> entry : parameterValueList.entrySet()) {
|
||||
LOGGER.debug("Param [{}]: {} = {}", i++, entry.getKey(),
|
||||
entry.getValue() != null ? entry.getValue().toString() : "null");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 反射调用
|
||||
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 认证类型
|
||||
|
||||
55
parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java
Normal file
55
parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java
Normal file
@@ -0,0 +1,55 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.IPanTool;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 蓝奏云优享解析器选择器
|
||||
* 根据配置的鉴权方式选择不同的解析器:
|
||||
* - 如果配置了 username 和 password,则使用 IzToolWithAuth (支持大文件)
|
||||
* - 否则使用 IzTool (免登录,仅支持小文件)
|
||||
*/
|
||||
public class IzSelectorTool implements IPanTool {
|
||||
private final IPanTool selectedTool;
|
||||
|
||||
public IzSelectorTool(ShareLinkInfo shareLinkInfo) {
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
|
||||
// 检查是否配置了账号密码
|
||||
if (auths.contains("username") && auths.contains("password")) {
|
||||
String username = auths.get("username");
|
||||
String password = auths.get("password");
|
||||
if (StringUtils.isNotEmpty(username) && StringUtils.isNotEmpty(password)) {
|
||||
// 使用 IzToolWithAuth (账密登录,支持大文件)
|
||||
this.selectedTool = new IzToolWithAuth(shareLinkInfo);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 无认证信息或认证信息无效,使用免登录版本(仅支持小文件)
|
||||
this.selectedTool = new IzTool(shareLinkInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
return selectedTool.parse();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
return selectedTool.parseFileList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
return selectedTool.parseById();
|
||||
}
|
||||
}
|
||||
@@ -5,35 +5,50 @@ import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.AESUtils;
|
||||
import cn.qaiu.util.AcwScV2Generator;
|
||||
import cn.qaiu.util.CommonUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import io.netty.handler.codec.http.cookie.DefaultCookie;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import io.vertx.ext.web.client.WebClientSession;
|
||||
import io.vertx.uritemplate.UriTemplate;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 蓝奏云优享
|
||||
* v019b22
|
||||
*
|
||||
*/
|
||||
public class IzTool extends PanBase {
|
||||
|
||||
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
|
||||
private static final String API_URL0 = "https://api.ilanzou.com/";
|
||||
private static final String API_URL_PREFIX = "https://api.ilanzou.com/unproved/";
|
||||
|
||||
private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" +
|
||||
"&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60";
|
||||
|
||||
private static final String LOGIN_URL = API_URL_PREFIX +
|
||||
"login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken=&extra=2";
|
||||
|
||||
// https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2×tamp=EC2C6E7F45EB21338A17A7621E0BB437
|
||||
private static final String TOKEN_VERIFY_URL = API_URL0 +
|
||||
"proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}";
|
||||
|
||||
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" +
|
||||
"&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}";
|
||||
// downloadId=x&enable=1&devType=6&uuid=x×tamp=x&auth=x&shareId=lGFndCM
|
||||
|
||||
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + "file/redirect?uuid={uuid}&devType=6&devCode={uuid}" +
|
||||
"&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken={appToken}&enable=1&downloadId={fidEncode}&auth={auth}";
|
||||
|
||||
|
||||
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
|
||||
"={uuid}&extra=2×tamp={ts}";
|
||||
@@ -42,16 +57,15 @@ public class IzTool extends PanBase {
|
||||
"={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" +
|
||||
"={folderId}&offset=1&limit=60";
|
||||
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
|
||||
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
|
||||
private static final MultiMap header;
|
||||
|
||||
static {
|
||||
header = MultiMap.caseInsensitiveMultiMap();
|
||||
header.set("Accept", "application/json, text/plain, */*");
|
||||
header.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
header.set("Accept-Encoding", "gzip, deflate");
|
||||
header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
|
||||
header.set("Cache-Control", "no-cache");
|
||||
header.set("Connection", "keep-alive");
|
||||
@@ -69,38 +83,59 @@ public class IzTool extends PanBase {
|
||||
header.set("sec-ch-ua-mobile", "?0");
|
||||
header.set("sec-ch-ua-platform", "\"Windows\"");
|
||||
}
|
||||
|
||||
public IzTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
|
||||
private void setCookie(String html) {
|
||||
int beginIndex = html.indexOf("arg1='") + 6;
|
||||
String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex));
|
||||
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
||||
// 创建一个 Cookie 并放入 CookieStore
|
||||
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
||||
nettyCookie.setDomain(".ilanzou.com"); // 设置域名
|
||||
nettyCookie.setPath("/"); // 设置路径
|
||||
nettyCookie.setSecure(false);
|
||||
nettyCookie.setHttpOnly(false);
|
||||
webClientSession.cookieStore().put(nettyCookie);
|
||||
}
|
||||
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||
|
||||
public static String token = null;
|
||||
public static boolean authFlag = true;
|
||||
|
||||
public Future<String> parse() {
|
||||
String shareId = shareLinkInfo.getShareKey();
|
||||
|
||||
// 24.5.12 ilanzou改规则无需计算shareId
|
||||
// String shareId = String.valueOf(AESUtils.idEncryptIz(dataKey));
|
||||
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
|
||||
|
||||
// 第一次请求 获取文件信息
|
||||
// POST https://api.ilanzou.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
|
||||
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
|
||||
: (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword());
|
||||
// 检查并输出认证状态
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
|
||||
isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(10, token.length())) + "...)" : "未登录");
|
||||
|
||||
// 如果需要认证但还没有token,先执行登录
|
||||
if ((isTempAuth || authFlag) && token == null) {
|
||||
log.info("文件解析需要登录,开始执行登录流程...");
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
return login(tsEncode, auths)
|
||||
.compose(v -> {
|
||||
log.info("文件解析预登录成功,继续解析流程");
|
||||
return parseWithAuth(shareId, tsEncode);
|
||||
})
|
||||
.onFailure(err -> {
|
||||
log.warn("文件解析预登录失败: {},尝试使用免登录模式", err.getMessage());
|
||||
// 登录失败,继续使用免登录模式
|
||||
});
|
||||
} else if (token != null) {
|
||||
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(10, token.length())));
|
||||
}
|
||||
} else {
|
||||
log.debug("文件解析无认证信息,使用免登录模式");
|
||||
}
|
||||
|
||||
return parseWithAuth(shareId, tsEncode);
|
||||
}
|
||||
|
||||
private Future<String> parseWithAuth(String shareId, String tsEncode) {
|
||||
// 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口
|
||||
webClientSession.postAbs(UriTemplate.of(VIP_REQUEST_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(r0 -> { // 忽略res
|
||||
|
||||
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
|
||||
: (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword());
|
||||
// 第一次请求 获取文件信息
|
||||
// POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
|
||||
webClientSession.postAbs(UriTemplate.of(url))
|
||||
@@ -121,36 +156,43 @@ public class IzTool extends PanBase {
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(res2 -> {
|
||||
handleParseResponse(asText(res2), shareId);
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
processFirstResponse(res2);
|
||||
}).onFailure(handleFail("请求1-重试"));
|
||||
return;
|
||||
}
|
||||
handleParseResponse(resBody, shareId);
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
});
|
||||
processFirstResponse(res);
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void handleParseResponse(String resBody, String shareId) {
|
||||
JsonObject resJson;
|
||||
try {
|
||||
resJson = new JsonObject(resBody);
|
||||
} catch (Exception e) {
|
||||
fail(FIRST_REQUEST_URL + " 解析JSON失败: " + resBody);
|
||||
return;
|
||||
}
|
||||
if (resJson.isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 返回内容为空");
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* 设置 cookie
|
||||
*/
|
||||
private void setCookie(String html) {
|
||||
int beginIndex = html.indexOf("arg1='") + 6;
|
||||
String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex));
|
||||
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
||||
// 创建一个 Cookie 并放入 CookieStore
|
||||
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
||||
nettyCookie.setDomain(".ilanzou.com"); // 设置域名
|
||||
nettyCookie.setPath("/"); // 设置路径
|
||||
nettyCookie.setSecure(false);
|
||||
nettyCookie.setHttpOnly(false);
|
||||
webClientSession.cookieStore().put(nettyCookie);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理第一次请求的响应
|
||||
*/
|
||||
private void processFirstResponse(HttpResponse<Buffer> res) {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 200) {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
if (resJson.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
return;
|
||||
}
|
||||
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
return;
|
||||
@@ -167,30 +209,251 @@ public class IzTool extends PanBase {
|
||||
promise.complete(fileList.getInteger("folderId").toString());
|
||||
return;
|
||||
}
|
||||
// 提取文件信息
|
||||
extractFileInfo(fileList, fileInfo);
|
||||
getDownURL(resJson);
|
||||
}
|
||||
|
||||
private void getDownURL(JsonObject resJson) {
|
||||
String dataKey = shareLinkInfo.getShareKey();
|
||||
// 文件Id
|
||||
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
|
||||
String fileId = fileInfo.getString("fileIds");
|
||||
String userId = fileInfo.getString("userId");
|
||||
// 其他参数
|
||||
// String fidEncode = AESUtils.encrypt2HexIz(fileId + "|");
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs);
|
||||
// 第二次请求
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.putHeaders(header).send().onSuccess(res2 -> {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location")) {
|
||||
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + headers);
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
|
||||
|
||||
// 检查是否有认证信息
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
// 检查是否为临时认证(临时认证每次都尝试登录)
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
|
||||
if (isTempAuth || authFlag) {
|
||||
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
|
||||
HttpRequest<Buffer> httpRequest =
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey);
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
if (token == null) {
|
||||
// 执行登录
|
||||
login(tsEncode2, auths).onFailure(failRes-> {
|
||||
log.warn("登录失败: {}", failRes.getMessage());
|
||||
fail(failRes.getMessage());
|
||||
}).onSuccess(r-> {
|
||||
httpRequest.setTemplateParam("appToken", header.get("appToken"))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
// 验证token
|
||||
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header).send().onSuccess(res -> {
|
||||
// log.info("res: {}",asJson(res));
|
||||
if (asJson(res).getInteger("code") != 200) {
|
||||
login(tsEncode2, auths).onFailure(failRes -> {
|
||||
log.warn("重新登录失败: {}", failRes.getMessage());
|
||||
fail(failRes.getMessage());
|
||||
}).onSuccess(r-> {
|
||||
httpRequest.setTemplateParam("appToken", header.get("appToken"))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
httpRequest.setTemplateParam("appToken", header.get("appToken"))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
}).onFailure(handleFail("Token验证"));
|
||||
}
|
||||
} else {
|
||||
// authFlag 为 false,使用免登录解析
|
||||
log.debug("authFlag=false,使用免登录解析");
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey).send()
|
||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
} else {
|
||||
// 没有认证信息,使用免登录解析
|
||||
log.debug("无认证信息,使用免登录解析");
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey).send()
|
||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
}
|
||||
|
||||
private Future<Void> login(String tsEncode2, MultiMap auths) {
|
||||
Promise<Void> promise1 = Promise.promise();
|
||||
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
|
||||
.setTemplateParam("uuid",uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password")))
|
||||
.onSuccess(res2->{
|
||||
JsonObject json = asJson(res2);
|
||||
if (json.getInteger("code") == 200) {
|
||||
token = json.getJsonObject("data").getString("appToken");
|
||||
header.set("appToken", token);
|
||||
log.info("登录成功 token: {}", token);
|
||||
promise1.complete();
|
||||
} else {
|
||||
// 检查是否为临时认证
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
if (isTempAuth) {
|
||||
// 临时认证失败,直接返回错误,不影响后台配置的认证
|
||||
log.warn("临时认证失败: {}", json.getString("msg"));
|
||||
promise1.fail("临时认证失败: " + json.getString("msg"));
|
||||
} else {
|
||||
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
|
||||
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
|
||||
authFlag = false;
|
||||
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
|
||||
}
|
||||
}
|
||||
}).onFailure(err -> {
|
||||
log.error("登录请求异常: {}", err.getMessage());
|
||||
promise1.fail("登录请求异常: " + err.getMessage());
|
||||
});
|
||||
return promise1.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从接口返回数据中提取文件信息
|
||||
*/
|
||||
private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) {
|
||||
try {
|
||||
// 文件名
|
||||
String fileName = fileList.getString("fileName");
|
||||
shareLinkInfo.getOtherParam().put("fileName", fileName);
|
||||
|
||||
// 文件大小 (KB -> Bytes)
|
||||
Long fileSize = fileList.getLong("fileSize", 0L) * 1024;
|
||||
shareLinkInfo.getOtherParam().put("fileSize", fileSize);
|
||||
shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize));
|
||||
|
||||
// 文件图标
|
||||
String fileIcon = fileList.getString("fileIcon");
|
||||
if (StringUtils.isNotBlank(fileIcon)) {
|
||||
shareLinkInfo.getOtherParam().put("fileIcon", fileIcon);
|
||||
}
|
||||
|
||||
// 文件ID
|
||||
Long fileId = fileList.getLong("fileId");
|
||||
if (fileId != null) {
|
||||
shareLinkInfo.getOtherParam().put("fileId", fileId.toString());
|
||||
}
|
||||
|
||||
// 文件类型 (1=文件, 2=目录)
|
||||
Integer fileType = fileList.getInteger("fileType", 1);
|
||||
shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder");
|
||||
|
||||
// 下载次数
|
||||
Integer downloads = fileList.getInteger("fileDownloads", 0);
|
||||
shareLinkInfo.getOtherParam().put("downloadCount", downloads);
|
||||
|
||||
// 点赞数
|
||||
Integer likes = fileList.getInteger("fileLikes", 0);
|
||||
shareLinkInfo.getOtherParam().put("likeCount", likes);
|
||||
|
||||
// 评论数
|
||||
Integer comments = fileList.getInteger("fileComments", 0);
|
||||
shareLinkInfo.getOtherParam().put("commentCount", comments);
|
||||
|
||||
// 评分
|
||||
Double stars = fileList.getDouble("fileStars", 0.0);
|
||||
shareLinkInfo.getOtherParam().put("stars", stars);
|
||||
|
||||
// 更新时间
|
||||
String updateTime = fileList.getString("updTime");
|
||||
if (StringUtils.isNotBlank(updateTime)) {
|
||||
shareLinkInfo.getOtherParam().put("updateTime", updateTime);
|
||||
}
|
||||
|
||||
// 创建时间
|
||||
String createTime = null;
|
||||
|
||||
// 分享信息
|
||||
if (shareInfo != null) {
|
||||
// 分享ID
|
||||
Integer shareId = shareInfo.getInteger("shareId");
|
||||
if (shareId != null) {
|
||||
shareLinkInfo.getOtherParam().put("shareId", shareId.toString());
|
||||
}
|
||||
|
||||
// 上传时间
|
||||
String addTime = shareInfo.getString("addTime");
|
||||
if (StringUtils.isNotBlank(addTime)) {
|
||||
shareLinkInfo.getOtherParam().put("createTime", addTime);
|
||||
createTime = addTime;
|
||||
}
|
||||
|
||||
// 预览次数
|
||||
Integer previewNum = shareInfo.getInteger("previewNum", 0);
|
||||
shareLinkInfo.getOtherParam().put("previewCount", previewNum);
|
||||
|
||||
// 用户信息
|
||||
JsonObject userMap = shareInfo.getJsonObject("map");
|
||||
if (userMap != null) {
|
||||
String userName = userMap.getString("userName");
|
||||
if (StringUtils.isNotBlank(userName)) {
|
||||
shareLinkInfo.getOtherParam().put("userName", userName);
|
||||
}
|
||||
|
||||
// VIP信息
|
||||
Integer isVip = userMap.getInteger("isVip", 0);
|
||||
shareLinkInfo.getOtherParam().put("isVip", isVip == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 FileInfo 对象并存入 otherParam
|
||||
FileInfo fileInfoObj = new FileInfo()
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setFileName(fileName)
|
||||
.setFileId(fileList.getLong("fileId") != null ? fileList.getLong("fileId").toString() : null)
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setFileType(fileType == 1 ? "file" : "folder")
|
||||
.setFileIcon(fileList.getString("fileIcon"))
|
||||
.setDownloadCount(downloads)
|
||||
.setCreateTime(createTime)
|
||||
.setUpdateTime(updateTime);
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj);
|
||||
|
||||
log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}",
|
||||
fileName, fileSize, downloads);
|
||||
} catch (Exception e) {
|
||||
log.warn("提取文件信息失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void down(HttpResponse<Buffer> res2) {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) {
|
||||
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误");
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
@@ -205,11 +468,7 @@ public class IzTool extends PanBase {
|
||||
return promise.future();
|
||||
}
|
||||
parse().onSuccess(id -> {
|
||||
if (id != null && id.matches("^[a-zA-Z0-9]+$")) {
|
||||
parserDir(id, shareId, promise);
|
||||
} else {
|
||||
promise.fail("解析目录ID失败");
|
||||
}
|
||||
parserDir(id, shareId, promise);
|
||||
}).onFailure(failRes -> {
|
||||
log.error("解析目录失败: {}", failRes.getMessage());
|
||||
promise.fail(failRes);
|
||||
@@ -218,6 +477,22 @@ public class IzTool extends PanBase {
|
||||
}
|
||||
|
||||
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
|
||||
if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileName(id)
|
||||
.setFileId(id)
|
||||
.setFileType("file")
|
||||
.setParserUrl(id)
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
result.add(fileInfo);
|
||||
promise.complete(result);
|
||||
return;
|
||||
}
|
||||
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
|
||||
|
||||
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
|
||||
// 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860
|
||||
// 拿到目录ID
|
||||
@@ -228,103 +503,134 @@ public class IzTool extends PanBase {
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.setTemplateParam("folderId", id)
|
||||
.send().onSuccess(res -> {
|
||||
JsonObject jsonObject;
|
||||
try {
|
||||
jsonObject = asJson(res);
|
||||
} catch (Exception e) {
|
||||
promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString());
|
||||
String resBody = asText(res);
|
||||
// 检查是否包含 cookie 验证
|
||||
if (resBody.contains("var arg1='")) {
|
||||
log.debug("目录解析需要 cookie 验证,重新创建 session");
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
setCookie(resBody);
|
||||
// 重新请求目录列表
|
||||
webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.setTemplateParam("folderId", id)
|
||||
.send().onSuccess(res2 -> {
|
||||
processDirResponse(res2, shareId, promise);
|
||||
}).onFailure(err -> {
|
||||
log.error("目录解析重试失败: {}", err.getMessage());
|
||||
promise.fail("目录解析失败: " + err.getMessage());
|
||||
});
|
||||
return;
|
||||
}
|
||||
// System.out.println(jsonObject.encodePrettily());
|
||||
JsonArray list = jsonObject.getJsonArray("list");
|
||||
ArrayList<FileInfo> result = new ArrayList<>();
|
||||
list.forEach(item->{
|
||||
JsonObject fileJson = (JsonObject) item;
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
// 映射已知字段
|
||||
String fileId = fileJson.getString("fileId");
|
||||
String userId = fileJson.getString("userId");
|
||||
|
||||
// 回传用到的参数
|
||||
//"fidEncode", paramJson.getString("fidEncode"))
|
||||
//"uuid", paramJson.getString("uuid"))
|
||||
//"ts", paramJson.getString("ts"))
|
||||
//"auth", paramJson.getString("auth"))
|
||||
//"shareId", paramJson.getString("shareId"))
|
||||
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs);
|
||||
JsonObject entries = JsonObject.of(
|
||||
"fidEncode", fidEncode,
|
||||
"uuid", uuid,
|
||||
"ts", tsEncode,
|
||||
"auth", auth,
|
||||
"shareId", shareId);
|
||||
byte[] encode = Base64.getEncoder().encode(entries.encode().getBytes());
|
||||
String param = new String(encode);
|
||||
|
||||
if (fileJson.getInteger("fileType") == 2) {
|
||||
// 如果是目录
|
||||
fileInfo.setFileName(fileJson.getString("name"))
|
||||
.setFileId(fileJson.getString("folderId"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileType("folder")
|
||||
.setSize(0L)
|
||||
.setSizeStr("0B")
|
||||
.setCreateBy(fileJson.getLong("userId").toString())
|
||||
.setDownloadCount(fileJson.getInteger("fileDownloads"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileIcon(fileJson.getString("fileIcon"))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
// 设置目录解析的URL
|
||||
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(),
|
||||
shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid));
|
||||
result.add(fileInfo);
|
||||
return;
|
||||
}
|
||||
long fileSize = fileJson.getLong("fileSize") * 1024;
|
||||
fileInfo.setFileName(fileJson.getString("fileName"))
|
||||
.setFileId(fileId)
|
||||
.setCreateTime(fileJson.getString("createTime"))
|
||||
.setFileType("file")
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setCreateBy(fileJson.getLong("userId").toString())
|
||||
.setDownloadCount(fileJson.getInteger("fileDownloads"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileIcon(fileJson.getString("fileIcon"))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(),
|
||||
shareLinkInfo.getType(), param))
|
||||
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(),
|
||||
shareLinkInfo.getType(), param));
|
||||
result.add(fileInfo);
|
||||
});
|
||||
promise.complete(result);
|
||||
}).onFailure(failRes -> {
|
||||
log.error("解析目录请求失败: {}", failRes.getMessage());
|
||||
promise.fail(failRes);
|
||||
processDirResponse(res, shareId, promise);
|
||||
}).onFailure(err -> {
|
||||
log.error("目录解析请求失败: {}", err.getMessage());
|
||||
promise.fail("目录解析失败: " + err.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理目录解析响应
|
||||
*/
|
||||
private void processDirResponse(HttpResponse<Buffer> res, String shareId, Promise<List<FileInfo>> promise) {
|
||||
try {
|
||||
JsonObject jsonObject = asJson(res);
|
||||
log.debug("目录解析响应: {}", jsonObject.encodePrettily());
|
||||
|
||||
if (!jsonObject.containsKey("list")) {
|
||||
log.error("目录解析响应缺少 list 字段: {}", jsonObject);
|
||||
promise.fail("目录解析失败: 响应格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray list = jsonObject.getJsonArray("list");
|
||||
ArrayList<FileInfo> result = new ArrayList<>();
|
||||
list.forEach(item->{
|
||||
JsonObject fileJson = (JsonObject) item;
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
// 映射已知字段
|
||||
String fileId = fileJson.getString("fileId");
|
||||
String userId = fileJson.getString("userId");
|
||||
|
||||
// 其他参数 - 每个文件使用新的时间戳
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
|
||||
|
||||
// 回传用到的参数
|
||||
JsonObject entries = JsonObject.of(
|
||||
"fidEncode", fidEncode,
|
||||
"uuid", uuid,
|
||||
"ts", tsEncode2,
|
||||
"auth", auth,
|
||||
"shareId", shareId);
|
||||
String param = CommonUtils.urlBase64Encode(entries.encode());
|
||||
|
||||
if (fileJson.getInteger("fileType") == 2) {
|
||||
// 如果是目录
|
||||
fileInfo.setFileName(fileJson.getString("name"))
|
||||
.setFileId(fileJson.getString("folderId"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileType("folder")
|
||||
.setSize(0L)
|
||||
.setSizeStr("0B")
|
||||
.setCreateBy(fileJson.getLong("userId").toString())
|
||||
.setDownloadCount(fileJson.getInteger("fileDownloads"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileIcon(fileJson.getString("fileIcon"))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
// 设置目录解析的URL
|
||||
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(),
|
||||
shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid));
|
||||
result.add(fileInfo);
|
||||
return;
|
||||
}
|
||||
long fileSize = fileJson.getLong("fileSize") * 1024;
|
||||
fileInfo.setFileName(fileJson.getString("fileName"))
|
||||
.setFileId(fileId)
|
||||
.setCreateTime(fileJson.getString("createTime"))
|
||||
.setFileType("file")
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setCreateBy(fileJson.getLong("userId").toString())
|
||||
.setDownloadCount(fileJson.getInteger("fileDownloads"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileIcon(fileJson.getString("fileIcon"))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(),
|
||||
shareLinkInfo.getType(), param))
|
||||
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(),
|
||||
shareLinkInfo.getType(), param));
|
||||
result.add(fileInfo);
|
||||
});
|
||||
promise.complete(result);
|
||||
} catch (Exception e) {
|
||||
log.error("处理目录响应异常: {}", e.getMessage(), e);
|
||||
promise.fail("目录解析失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
// 第二次请求
|
||||
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
|
||||
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
|
||||
// 使用免登录接口
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
||||
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
||||
.setTemplateParam("ts", paramJson.getString("ts"))
|
||||
.setTemplateParam("auth", paramJson.getString("auth"))
|
||||
.setTemplateParam("shareId", paramJson.getString("shareId"))
|
||||
.putHeaders(header).send().onSuccess(res2 -> {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location")) {
|
||||
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res2.headers());
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
||||
.setTemplateParam("dataKey", paramJson.getString("shareId"))
|
||||
.send().onSuccess(this::down).onFailure(handleFail("parseById"));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
public static void resetToken() {
|
||||
token = null;
|
||||
authFlag = true;
|
||||
}
|
||||
}
|
||||
|
||||
658
parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java
Normal file
658
parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java
Normal file
@@ -0,0 +1,658 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.AESUtils;
|
||||
import cn.qaiu.util.AcwScV2Generator;
|
||||
import cn.qaiu.util.CommonUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import io.netty.handler.codec.http.cookie.DefaultCookie;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.client.HttpRequest;
|
||||
import io.vertx.ext.web.client.HttpResponse;
|
||||
import io.vertx.ext.web.client.WebClientSession;
|
||||
import io.vertx.uritemplate.UriTemplate;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 蓝奏云优享 - 需要登录版本(支持大文件)
|
||||
*/
|
||||
public class IzToolWithAuth extends PanBase {
|
||||
|
||||
private static final String API_URL0 = "https://api.ilanzou.com/";
|
||||
private static final String API_URL_PREFIX = "https://api.ilanzou.com/unproved/";
|
||||
|
||||
private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" +
|
||||
"&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60";
|
||||
|
||||
private static final String LOGIN_URL = API_URL_PREFIX +
|
||||
"login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken=&extra=2";
|
||||
|
||||
// https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2×tamp=EC2C6E7F45EB21338A17A7621E0BB437
|
||||
private static final String TOKEN_VERIFY_URL = API_URL0 +
|
||||
"proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}";
|
||||
|
||||
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" +
|
||||
"&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}";
|
||||
|
||||
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + "file/redirect?uuid={uuid}&devType=6&devCode={uuid}" +
|
||||
"&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken={appToken}&enable=1&downloadId={fidEncode}&auth={auth}";
|
||||
|
||||
|
||||
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
|
||||
"={uuid}&extra=2×tamp={ts}";
|
||||
|
||||
private static final String FILE_LIST_URL = API_URL_PREFIX + "/share/list?devType=6&devModel=Chrome&uuid" +
|
||||
"={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" +
|
||||
"={folderId}&offset=1&limit=60";
|
||||
|
||||
|
||||
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
|
||||
private static final MultiMap header;
|
||||
|
||||
static {
|
||||
header = MultiMap.caseInsensitiveMultiMap();
|
||||
header.set("Accept", "application/json, text/plain, */*");
|
||||
header.set("Accept-Encoding", "gzip, deflate");
|
||||
header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
|
||||
header.set("Cache-Control", "no-cache");
|
||||
header.set("Connection", "keep-alive");
|
||||
header.set("Content-Length", "0");
|
||||
header.set("DNT", "1");
|
||||
header.set("Host", "api.ilanzou.com");
|
||||
header.set("Origin", "https://www.ilanzou.com/");
|
||||
header.set("Pragma", "no-cache");
|
||||
header.set("Referer", "https://www.ilanzou.com/");
|
||||
header.set("Sec-Fetch-Dest", "empty");
|
||||
header.set("Sec-Fetch-Mode", "cors");
|
||||
header.set("Sec-Fetch-Site", "cross-site");
|
||||
header.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
|
||||
header.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"");
|
||||
header.set("sec-ch-ua-mobile", "?0");
|
||||
header.set("sec-ch-ua-platform", "\"Windows\"");
|
||||
}
|
||||
public IzToolWithAuth(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
|
||||
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||
|
||||
public static String token = null;
|
||||
public static boolean authFlag = true;
|
||||
|
||||
public Future<String> parse() {
|
||||
|
||||
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
|
||||
|
||||
// 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口
|
||||
webClientSession.postAbs(UriTemplate.of(VIP_REQUEST_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(r0 -> { // 忽略res
|
||||
|
||||
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
|
||||
: (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword());
|
||||
// 第一次请求 获取文件信息
|
||||
// POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
|
||||
webClientSession.postAbs(UriTemplate.of(url))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(res -> {
|
||||
String resBody = asText(res);
|
||||
// 检查是否包含 cookie 验证
|
||||
if (resBody.contains("var arg1='")) {
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
setCookie(resBody);
|
||||
// 重新请求
|
||||
webClientSession.postAbs(UriTemplate.of(url))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(res2 -> {
|
||||
processFirstResponse(res2);
|
||||
}).onFailure(handleFail("请求1-重试"));
|
||||
return;
|
||||
}
|
||||
processFirstResponse(res);
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 cookie
|
||||
*/
|
||||
private void setCookie(String html) {
|
||||
int beginIndex = html.indexOf("arg1='") + 6;
|
||||
String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex));
|
||||
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
|
||||
// 创建一个 Cookie 并放入 CookieStore
|
||||
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
|
||||
nettyCookie.setDomain(".ilanzou.com"); // 设置域名
|
||||
nettyCookie.setPath("/"); // 设置路径
|
||||
nettyCookie.setSecure(false);
|
||||
nettyCookie.setHttpOnly(false);
|
||||
webClientSession.cookieStore().put(nettyCookie);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理第一次请求的响应
|
||||
*/
|
||||
private void processFirstResponse(HttpResponse<Buffer> res) {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 200) {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
return;
|
||||
}
|
||||
// 文件Id
|
||||
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
|
||||
// 如果是目录返回目录ID
|
||||
if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
|
||||
return;
|
||||
}
|
||||
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
|
||||
if (fileList.getInteger("fileType") == 2) {
|
||||
promise.complete(fileList.getInteger("folderId").toString());
|
||||
return;
|
||||
}
|
||||
// 提取文件信息
|
||||
extractFileInfo(fileList, fileInfo);
|
||||
getDownURL(resJson);
|
||||
}
|
||||
|
||||
private void getDownURL(JsonObject resJson) {
|
||||
String dataKey = shareLinkInfo.getShareKey();
|
||||
// 文件Id
|
||||
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
|
||||
String fileId = fileInfo.getString("fileIds");
|
||||
String userId = fileInfo.getString("userId");
|
||||
// 其他参数
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
|
||||
|
||||
// 检查是否有认证信息
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
// 检查是否为临时认证(临时认证每次都尝试登录)
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
|
||||
if (isTempAuth || authFlag) {
|
||||
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
|
||||
HttpRequest<Buffer> httpRequest =
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey);
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
if (token == null) {
|
||||
// 执行登录
|
||||
login(tsEncode2, auths).onFailure(failRes-> {
|
||||
log.warn("登录失败: {}", failRes.getMessage());
|
||||
fail(failRes.getMessage());
|
||||
}).onSuccess(r-> {
|
||||
httpRequest.setTemplateParam("appToken", header.get("appToken"))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
// 验证token
|
||||
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header).send().onSuccess(res -> {
|
||||
// log.info("res: {}",asJson(res));
|
||||
if (asJson(res).getInteger("code") != 200) {
|
||||
login(tsEncode2, auths).onFailure(failRes -> {
|
||||
log.warn("重新登录失败: {}", failRes.getMessage());
|
||||
fail(failRes.getMessage());
|
||||
}).onSuccess(r-> {
|
||||
httpRequest.setTemplateParam("appToken", header.get("appToken"))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
httpRequest.setTemplateParam("appToken", header.get("appToken"))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
}).onFailure(handleFail("Token验证"));
|
||||
}
|
||||
} else {
|
||||
// authFlag 为 false,使用免登录解析
|
||||
log.debug("authFlag=false,使用免登录解析");
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey).send()
|
||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
} else {
|
||||
// 没有认证信息,使用免登录解析
|
||||
log.debug("无认证信息,使用免登录解析");
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", dataKey).send()
|
||||
.onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
}
|
||||
|
||||
private Future<Void> login(String tsEncode2, MultiMap auths) {
|
||||
Promise<Void> promise1 = Promise.promise();
|
||||
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
|
||||
.setTemplateParam("uuid",uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password")))
|
||||
.onSuccess(res2->{
|
||||
JsonObject json = asJson(res2);
|
||||
if (json.getInteger("code") == 200) {
|
||||
token = json.getJsonObject("data").getString("appToken");
|
||||
header.set("appToken", token);
|
||||
log.info("登录成功 token: {}", token);
|
||||
promise1.complete();
|
||||
} else {
|
||||
// 检查是否为临时认证
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
if (isTempAuth) {
|
||||
// 临时认证失败,直接返回错误,不影响后台配置的认证
|
||||
log.warn("临时认证失败: {}", json.getString("msg"));
|
||||
promise1.fail("临时认证失败: " + json.getString("msg"));
|
||||
} else {
|
||||
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
|
||||
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
|
||||
authFlag = false;
|
||||
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
|
||||
}
|
||||
}
|
||||
}).onFailure(err -> {
|
||||
log.error("登录请求异常: {}", err.getMessage());
|
||||
promise1.fail("登录请求异常: " + err.getMessage());
|
||||
});
|
||||
return promise1.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从接口返回数据中提取文件信息
|
||||
*/
|
||||
private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) {
|
||||
try {
|
||||
// 文件名
|
||||
String fileName = fileList.getString("fileName");
|
||||
shareLinkInfo.getOtherParam().put("fileName", fileName);
|
||||
|
||||
// 文件大小 (KB -> Bytes)
|
||||
Long fileSize = fileList.getLong("fileSize", 0L) * 1024;
|
||||
shareLinkInfo.getOtherParam().put("fileSize", fileSize);
|
||||
shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize));
|
||||
|
||||
// 文件图标
|
||||
String fileIcon = fileList.getString("fileIcon");
|
||||
if (StringUtils.isNotBlank(fileIcon)) {
|
||||
shareLinkInfo.getOtherParam().put("fileIcon", fileIcon);
|
||||
}
|
||||
|
||||
// 文件ID
|
||||
Long fileId = fileList.getLong("fileId");
|
||||
if (fileId != null) {
|
||||
shareLinkInfo.getOtherParam().put("fileId", fileId.toString());
|
||||
}
|
||||
|
||||
// 文件类型 (1=文件, 2=目录)
|
||||
Integer fileType = fileList.getInteger("fileType", 1);
|
||||
shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder");
|
||||
|
||||
// 下载次数
|
||||
Integer downloads = fileList.getInteger("fileDownloads", 0);
|
||||
shareLinkInfo.getOtherParam().put("downloadCount", downloads);
|
||||
|
||||
// 点赞数
|
||||
Integer likes = fileList.getInteger("fileLikes", 0);
|
||||
shareLinkInfo.getOtherParam().put("likeCount", likes);
|
||||
|
||||
// 评论数
|
||||
Integer comments = fileList.getInteger("fileComments", 0);
|
||||
shareLinkInfo.getOtherParam().put("commentCount", comments);
|
||||
|
||||
// 评分
|
||||
Double stars = fileList.getDouble("fileStars", 0.0);
|
||||
shareLinkInfo.getOtherParam().put("stars", stars);
|
||||
|
||||
// 更新时间
|
||||
String updateTime = fileList.getString("updTime");
|
||||
if (StringUtils.isNotBlank(updateTime)) {
|
||||
shareLinkInfo.getOtherParam().put("updateTime", updateTime);
|
||||
}
|
||||
|
||||
// 创建时间
|
||||
String createTime = null;
|
||||
|
||||
// 分享信息
|
||||
if (shareInfo != null) {
|
||||
// 分享ID
|
||||
Integer shareId = shareInfo.getInteger("shareId");
|
||||
if (shareId != null) {
|
||||
shareLinkInfo.getOtherParam().put("shareId", shareId.toString());
|
||||
}
|
||||
|
||||
// 上传时间
|
||||
String addTime = shareInfo.getString("addTime");
|
||||
if (StringUtils.isNotBlank(addTime)) {
|
||||
shareLinkInfo.getOtherParam().put("createTime", addTime);
|
||||
createTime = addTime;
|
||||
}
|
||||
|
||||
// 预览次数
|
||||
Integer previewNum = shareInfo.getInteger("previewNum", 0);
|
||||
shareLinkInfo.getOtherParam().put("previewCount", previewNum);
|
||||
|
||||
// 用户信息
|
||||
JsonObject userMap = shareInfo.getJsonObject("map");
|
||||
if (userMap != null) {
|
||||
String userName = userMap.getString("userName");
|
||||
if (StringUtils.isNotBlank(userName)) {
|
||||
shareLinkInfo.getOtherParam().put("userName", userName);
|
||||
}
|
||||
|
||||
// VIP信息
|
||||
Integer isVip = userMap.getInteger("isVip", 0);
|
||||
shareLinkInfo.getOtherParam().put("isVip", isVip == 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 FileInfo 对象并存入 otherParam
|
||||
FileInfo fileInfoObj = new FileInfo()
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setFileName(fileName)
|
||||
.setFileId(fileList.getLong("fileId") != null ? fileList.getLong("fileId").toString() : null)
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setFileType(fileType == 1 ? "file" : "folder")
|
||||
.setFileIcon(fileList.getString("fileIcon"))
|
||||
.setDownloadCount(downloads)
|
||||
.setCreateTime(createTime)
|
||||
.setUpdateTime(updateTime);
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj);
|
||||
|
||||
log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}",
|
||||
fileName, fileSize, downloads);
|
||||
} catch (Exception e) {
|
||||
log.warn("提取文件信息失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void down(HttpResponse<Buffer> res2) {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) {
|
||||
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误");
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
|
||||
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
|
||||
// 如果参数里的目录ID不为空,则直接解析目录
|
||||
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
||||
if (dirId != null && !dirId.isEmpty()) {
|
||||
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
|
||||
parserDir(dirId, shareId, promise);
|
||||
return promise.future();
|
||||
}
|
||||
parse().onSuccess(id -> {
|
||||
parserDir(id, shareId, promise);
|
||||
}).onFailure(failRes -> {
|
||||
log.error("解析目录失败: {}", failRes.getMessage());
|
||||
promise.fail(failRes);
|
||||
});
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
|
||||
if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileName(id)
|
||||
.setFileId(id)
|
||||
.setFileType("file")
|
||||
.setParserUrl(id)
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
result.add(fileInfo);
|
||||
promise.complete(result);
|
||||
return;
|
||||
}
|
||||
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs));
|
||||
|
||||
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
|
||||
|
||||
// 检查是否需要登录(有认证信息且需要使用认证)
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
|
||||
log.debug("目录解析检查认证: isTempAuth={}, authFlag={}, token={}", isTempAuth, authFlag, token != null ? "已有" : "null");
|
||||
|
||||
if ((isTempAuth || authFlag) && token == null) {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
log.info("目录解析需要登录,开始执行登录...");
|
||||
// 先登录获取 token
|
||||
login(tsEncode, auths)
|
||||
.onFailure(err -> {
|
||||
log.warn("目录解析登录失败,使用免登录模式: {}", err.getMessage());
|
||||
// 登录失败,继续使用免登录
|
||||
requestDirList(id, shareId, tsEncode, promise);
|
||||
})
|
||||
.onSuccess(r -> {
|
||||
log.info("目录解析登录成功,token={}, 使用 VIP 模式", token != null ? token.substring(0, 10) + "..." : "null");
|
||||
requestDirList(id, shareId, tsEncode, promise);
|
||||
});
|
||||
return;
|
||||
} else if (token != null) {
|
||||
log.debug("目录解析已有 token,直接使用 VIP 模式");
|
||||
} else {
|
||||
log.debug("目录解析: authFlag=false 或为临时认证但已失败,使用免登录模式");
|
||||
}
|
||||
} else {
|
||||
log.debug("目录解析无认证信息,使用免登录模式");
|
||||
}
|
||||
|
||||
// 无需登录或已登录,直接请求
|
||||
requestDirList(id, shareId, tsEncode, promise);
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求目录列表
|
||||
*/
|
||||
private void requestDirList(String id, String shareId, String tsEncode, Promise<List<FileInfo>> promise) {
|
||||
webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.setTemplateParam("folderId", id)
|
||||
.send().onSuccess(res -> {
|
||||
String resBody = asText(res);
|
||||
// 检查是否包含 cookie 验证
|
||||
if (resBody.contains("var arg1='")) {
|
||||
log.debug("目录解析需要 cookie 验证,重新创建 session");
|
||||
webClientSession = WebClientSession.create(clientNoRedirects);
|
||||
setCookie(resBody);
|
||||
// 重新请求目录列表
|
||||
webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.setTemplateParam("folderId", id)
|
||||
.send().onSuccess(res2 -> {
|
||||
processDirResponse(res2, shareId, promise);
|
||||
}).onFailure(err -> {
|
||||
log.error("目录解析重试失败: {}", err.getMessage());
|
||||
promise.fail("目录解析失败: " + err.getMessage());
|
||||
});
|
||||
return;
|
||||
}
|
||||
processDirResponse(res, shareId, promise);
|
||||
}).onFailure(err -> {
|
||||
log.error("目录解析请求失败: {}", err.getMessage());
|
||||
promise.fail("目录解析失败: " + err.getMessage());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理目录解析响应
|
||||
*/
|
||||
private void processDirResponse(HttpResponse<Buffer> res, String shareId, Promise<List<FileInfo>> promise) {
|
||||
try {
|
||||
JsonObject jsonObject = asJson(res);
|
||||
log.debug("目录解析响应: {}", jsonObject.encodePrettily());
|
||||
|
||||
if (!jsonObject.containsKey("list")) {
|
||||
log.error("目录解析响应缺少 list 字段: {}", jsonObject);
|
||||
promise.fail("目录解析失败: 响应格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray list = jsonObject.getJsonArray("list");
|
||||
ArrayList<FileInfo> result = new ArrayList<>();
|
||||
list.forEach(item->{
|
||||
JsonObject fileJson = (JsonObject) item;
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
// 映射已知字段
|
||||
String fileId = fileJson.getString("fileId");
|
||||
String userId = fileJson.getString("userId");
|
||||
|
||||
// 其他参数 - 每个文件使用新的时间戳
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2);
|
||||
|
||||
// 回传用到的参数(包含 token)
|
||||
JsonObject entries = JsonObject.of(
|
||||
"fidEncode", fidEncode,
|
||||
"uuid", uuid,
|
||||
"ts", tsEncode2,
|
||||
"auth", auth,
|
||||
"shareId", shareId,
|
||||
"appToken", token != null ? token : "");
|
||||
String param = CommonUtils.urlBase64Encode(entries.encode());
|
||||
|
||||
if (fileJson.getInteger("fileType") == 2) {
|
||||
// 如果是目录
|
||||
fileInfo.setFileName(fileJson.getString("name"))
|
||||
.setFileId(fileJson.getString("folderId"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileType("folder")
|
||||
.setSize(0L)
|
||||
.setSizeStr("0B")
|
||||
.setCreateBy(fileJson.getLong("userId").toString())
|
||||
.setDownloadCount(fileJson.getInteger("fileDownloads"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileIcon(fileJson.getString("fileIcon"))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
// 设置目录解析的URL
|
||||
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(),
|
||||
shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid));
|
||||
result.add(fileInfo);
|
||||
return;
|
||||
}
|
||||
long fileSize = fileJson.getLong("fileSize") * 1024;
|
||||
fileInfo.setFileName(fileJson.getString("fileName"))
|
||||
.setFileId(fileId)
|
||||
.setCreateTime(fileJson.getString("createTime"))
|
||||
.setFileType("file")
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setCreateBy(fileJson.getLong("userId").toString())
|
||||
.setDownloadCount(fileJson.getInteger("fileDownloads"))
|
||||
.setCreateTime(fileJson.getString("updTime"))
|
||||
.setFileIcon(fileJson.getString("fileIcon"))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(),
|
||||
shareLinkInfo.getType(), param))
|
||||
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(),
|
||||
shareLinkInfo.getType(), param));
|
||||
result.add(fileInfo);
|
||||
});
|
||||
promise.complete(result);
|
||||
} catch (Exception e) {
|
||||
log.error("处理目录响应异常: {}", e.getMessage(), e);
|
||||
promise.fail("目录解析失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
|
||||
String appToken = paramJson.getString("appToken", "");
|
||||
|
||||
// 如果有 token,使用 VIP 接口
|
||||
if (StringUtils.isNotBlank(appToken)) {
|
||||
log.debug("parseById 使用 VIP 接口, appToken={}", appToken.substring(0, Math.min(10, appToken.length())) + "...");
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
||||
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
||||
.setTemplateParam("ts", paramJson.getString("ts"))
|
||||
.setTemplateParam("auth", paramJson.getString("auth"))
|
||||
.setTemplateParam("appToken", appToken)
|
||||
.send().onSuccess(this::down).onFailure(handleFail("parseById-VIP"));
|
||||
} else {
|
||||
// 无 token,使用免登录接口
|
||||
log.debug("parseById 使用免登录接口");
|
||||
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
||||
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
||||
.setTemplateParam("ts", paramJson.getString("ts"))
|
||||
.setTemplateParam("auth", paramJson.getString("auth"))
|
||||
.setTemplateParam("dataKey", paramJson.getString("shareId"))
|
||||
.send().onSuccess(this::down).onFailure(handleFail("parseById"));
|
||||
}
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
public static void resetToken() {
|
||||
token = null;
|
||||
authFlag = true;
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ public class QkTool extends PanBase {
|
||||
JsonObject item = fileList.getJsonObject(i);
|
||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
||||
if (item.getBoolean("file", false) ||
|
||||
(item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) {
|
||||
(item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) {
|
||||
files.add(item);
|
||||
}
|
||||
}
|
||||
@@ -259,32 +259,14 @@ public class QkTool extends PanBase {
|
||||
|
||||
log.debug("找到 {} 个文件", files.size());
|
||||
|
||||
// 提取第一个文件的信息并保存到 otherParam
|
||||
try {
|
||||
JsonObject firstFile = files.get(0);
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileId(firstFile.getString("fid"))
|
||||
.setFileName(firstFile.getString("file_name"))
|
||||
.setSize(firstFile.getLong("size", 0L))
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(firstFile.getLong("size", 0L)))
|
||||
.setFileType(firstFile.getBoolean("file", true) ? "file" : "folder")
|
||||
.setCreateTime(firstFile.getString("updated_at"))
|
||||
.setUpdateTime(firstFile.getString("updated_at"))
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
|
||||
// 保存到 otherParam,供 CacheServiceImpl 使用
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
log.debug("夸克提取文件信息: {}", fileInfo.getFileName());
|
||||
} catch (Exception e) {
|
||||
log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 提取文件ID列表
|
||||
// 构建文件映射和文件ID列表(参考 kuake.py:下载结果通过 fid 回填文件信息)
|
||||
List<String> fileIds = new ArrayList<>();
|
||||
Map<String, JsonObject> fileMap = new HashMap<>();
|
||||
for (JsonObject file : files) {
|
||||
String fid = file.getString("fid");
|
||||
if (fid != null && !fid.isEmpty()) {
|
||||
fileIds.add(fid);
|
||||
fileMap.put(fid, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,20 +276,55 @@ public class QkTool extends PanBase {
|
||||
}
|
||||
|
||||
// 第三步:批量获取下载链接
|
||||
getDownloadLinksBatch(fileIds, stoken)
|
||||
getDownloadLinksBatch(fileIds)
|
||||
.onSuccess(downloadData -> {
|
||||
if (downloadData.isEmpty()) {
|
||||
fail("未能获取到下载链接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一个文件的下载链接
|
||||
String downloadUrl = downloadData.get(0).getString("download_url");
|
||||
// 按 fid 对齐下载结果和文件信息,取首个有效下载链接
|
||||
String downloadUrl = null;
|
||||
JsonObject matchedFile = null;
|
||||
for (JsonObject item : downloadData) {
|
||||
String fid = item.getString("fid");
|
||||
String currentUrl = item.getString("download_url");
|
||||
if (currentUrl != null && !currentUrl.isEmpty() && fid != null) {
|
||||
JsonObject fileMeta = fileMap.get(fid);
|
||||
if (fileMeta != null) {
|
||||
downloadUrl = currentUrl;
|
||||
matchedFile = fileMeta;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
fail("下载链接为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 提取匹配文件的信息并保存到 otherParam
|
||||
if (matchedFile != null) {
|
||||
try {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileId(matchedFile.getString("fid"))
|
||||
.setFileName(matchedFile.getString("file_name"))
|
||||
.setSize(matchedFile.getLong("size", 0L))
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(matchedFile.getLong("size", 0L)))
|
||||
.setFileType(matchedFile.getBoolean("file", true) ? "file" : "folder")
|
||||
.setCreateTime(matchedFile.getString("updated_at"))
|
||||
.setUpdateTime(matchedFile.getString("updated_at"))
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
|
||||
// 保存到 otherParam,供 CacheServiceImpl 使用
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
log.debug("夸克提取文件信息: {}", fileInfo.getFileName());
|
||||
} catch (Exception e) {
|
||||
log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 夸克网盘需要配合下载请求头,保存下载请求头
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
|
||||
@@ -329,17 +346,17 @@ public class QkTool extends PanBase {
|
||||
/**
|
||||
* 批量获取下载链接(分批处理)
|
||||
*/
|
||||
private Future<List<JsonObject>> getDownloadLinksBatch(List<String> fileIds, String stoken) {
|
||||
private Future<List<JsonObject>> getDownloadLinksBatch(List<String> fileIds) {
|
||||
List<JsonObject> allResults = new ArrayList<>();
|
||||
Promise<List<JsonObject>> promise = Promise.promise();
|
||||
|
||||
// 同步处理每个批次
|
||||
processBatch(fileIds, stoken, 0, allResults, promise);
|
||||
processBatch(fileIds, 0, allResults, promise);
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void processBatch(List<String> fileIds, String stoken, int startIndex, List<JsonObject> allResults, Promise<List<JsonObject>> promise) {
|
||||
private void processBatch(List<String> fileIds, int startIndex, List<JsonObject> allResults, Promise<List<JsonObject>> promise) {
|
||||
if (startIndex >= fileIds.size()) {
|
||||
// 所有批次处理完成
|
||||
promise.complete(allResults);
|
||||
@@ -382,7 +399,7 @@ public class QkTool extends PanBase {
|
||||
}
|
||||
|
||||
// 处理下一批次
|
||||
processBatch(fileIds, stoken, endIndex, allResults, promise);
|
||||
processBatch(fileIds, endIndex, allResults, promise);
|
||||
})
|
||||
.onFailure(t -> promise.fail("获取下载链接失败: " + t.getMessage()));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="app" v-cloak :class="{ 'dark-theme': isDarkMode }">
|
||||
<!-- <el-dialog
|
||||
v-model="showRiskDialog"
|
||||
title="使用本网站您应改同意"
|
||||
title="使用本网站您应该同意"
|
||||
width="300px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
@@ -35,6 +35,10 @@
|
||||
<i class="fas fa-server feedback-icon"></i>
|
||||
部署
|
||||
</a>
|
||||
<a href="javascript:void(0)" class="feedback-link mini donate-link" @click="showDonateDialog = true">
|
||||
<i class="fas fa-gift feedback-icon" style="color: #e74c3c;"></i>
|
||||
捐赠账号
|
||||
</a>
|
||||
</div>
|
||||
<el-row :gutter="20" style="margin-left: 0; margin-right: 0;">
|
||||
<el-card class="box-card">
|
||||
@@ -60,7 +64,7 @@
|
||||
</div>
|
||||
<!-- 项目简介移到卡片内 -->
|
||||
<div class="project-intro">
|
||||
<div class="intro-title">NFD网盘直链解析0.2.1</div>
|
||||
<div class="intro-title">NFD网盘直链解析0.2.1b2</div>
|
||||
<div class="intro-desc">
|
||||
<div>支持网盘:蓝奏云、蓝奏云优享、小飞机盘、123云盘、奶牛快传、移动云空间、QQ邮箱云盘、QQ闪传等 <el-link style="color:#606cf5" href="https://github.com/qaiu/netdisk-fast-download?tab=readme-ov-file#%E7%BD%91%E7%9B%98%E6%94%AF%E6%8C%81%E6%83%85%E5%86%B5" target="_blank"> >> </el-link></div>
|
||||
<div>文件夹解析支持:蓝奏云、蓝奏云优享、小飞机盘、123云盘</div>
|
||||
@@ -352,6 +356,102 @@
|
||||
<!-- </el-input>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- 捐赠账号弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showDonateDialog"
|
||||
title="🎁 捐赠网盘账号"
|
||||
width="550px"
|
||||
:close-on-click-modal="false"
|
||||
@open="loadDonateAccountCounts">
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 15px;">
|
||||
<template #title>
|
||||
捐赠您的网盘 Cookie/Token,解析时将从所有捐赠账号中随机选择使用,分摊请求压力。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 已捐赠账号数量统计 -->
|
||||
<div v-if="donateAccountCounts.active.total + donateAccountCounts.inactive.total > 0" style="margin-bottom: 16px;">
|
||||
<el-divider content-position="left">
|
||||
当前账号池(活跃 {{ donateAccountCounts.active.total }} / 失效 {{ donateAccountCounts.inactive.total }})
|
||||
</el-divider>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<el-tag type="success" style="margin-right: 8px;">活跃账号</el-tag>
|
||||
<el-tag
|
||||
v-for="(count, panType) in donateAccountCounts.active"
|
||||
:key="`active-${panType}`"
|
||||
v-show="panType !== 'total'"
|
||||
type="success"
|
||||
style="margin-right: 6px; margin-bottom: 4px;">
|
||||
{{ getPanDisplayName(panType) }}: {{ count }} 个
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<el-tag type="danger" style="margin-right: 8px;">失效账号</el-tag>
|
||||
<el-tag
|
||||
v-for="(count, panType) in donateAccountCounts.inactive"
|
||||
:key="`inactive-${panType}`"
|
||||
v-show="panType !== 'total'"
|
||||
type="danger"
|
||||
style="margin-right: 6px; margin-bottom: 4px;">
|
||||
{{ getPanDisplayName(panType) }}: {{ count }} 个
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="margin-bottom: 16px; text-align: center; color: #999;">
|
||||
暂无捐赠账号,成为第一个捐赠者吧!
|
||||
</div>
|
||||
|
||||
<el-form :model="donateConfig" label-width="100px" size="default">
|
||||
<el-form-item label="网盘类型" required>
|
||||
<el-select v-model="donateConfig.panType" placeholder="请选择网盘类型" style="width: 100%" @change="onDonatePanTypeChange">
|
||||
<el-option-group label="必须认证">
|
||||
<el-option label="夸克网盘 (QK)" value="QK" />
|
||||
<el-option label="UC网盘 (UC)" value="UC" />
|
||||
</el-option-group>
|
||||
<el-option-group label="大文件需认证">
|
||||
<el-option label="小飞机网盘 (FJ)" value="FJ" />
|
||||
<el-option label="蓝奏优享 (IZ)" value="IZ" />
|
||||
<el-option label="123云盘 (YE)" value="YE" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="认证类型">
|
||||
<el-select v-model="donateConfig.authType" placeholder="请选择认证类型" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in getDonateAuthTypes()"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="donateConfig.authType === 'password'" label="用户名">
|
||||
<el-input v-model="donateConfig.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="donateConfig.authType === 'password'" label="密码">
|
||||
<el-input v-model="donateConfig.password" type="password" show-password placeholder="请输入密码" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="donateConfig.authType && donateConfig.authType !== 'password'" label="Token/Cookie">
|
||||
<el-input
|
||||
v-model="donateConfig.token"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="粘贴 Cookie 或 Token(从浏览器开发者工具获取)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注(可选)">
|
||||
<el-input v-model="donateConfig.remark" placeholder="如:我的夸克小号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showDonateDialog = false">关闭</el-button>
|
||||
<el-button type="primary" @click="submitDonateAccount" :loading="donateSubmitting">
|
||||
<el-icon><Plus /></el-icon> 捐赠此账号
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
@@ -436,7 +536,24 @@ export default {
|
||||
ext5: ''
|
||||
},
|
||||
// 所有网盘的认证配置 { panType: config }
|
||||
allAuthConfigs: {}
|
||||
allAuthConfigs: {},
|
||||
|
||||
// 捐赠账号相关
|
||||
showDonateDialog: false,
|
||||
donateSubmitting: false,
|
||||
donateConfig: {
|
||||
panType: '',
|
||||
authType: 'cookie',
|
||||
username: '',
|
||||
password: '',
|
||||
token: '',
|
||||
remark: ''
|
||||
},
|
||||
// 捐赠账号数量统计
|
||||
donateAccountCounts: {
|
||||
active: { total: 0 },
|
||||
inactive: { total: 0 }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -460,6 +577,7 @@ export default {
|
||||
if (url.includes('drive.uc.cn') || url.includes('fast.uc.cn')) return 'UC'
|
||||
if (url.includes('feijipan.com') || url.includes('feijihe.com') || url.includes('xiaofeiyang.com')) return 'FJ'
|
||||
if (url.includes('ilanzou.com') || url.includes('lanzouv.com')) return 'IZ'
|
||||
if (url.includes('123pan.com') || url.includes('123684.com') || url.includes('123865.com')) return 'YE'
|
||||
return ''
|
||||
},
|
||||
|
||||
@@ -469,7 +587,8 @@ export default {
|
||||
'QK': '夸克网盘',
|
||||
'UC': 'UC网盘',
|
||||
'FJ': '小飞机网盘',
|
||||
'IZ': '蓝奏优享'
|
||||
'IZ': '蓝奏优享',
|
||||
'YE': '123云盘'
|
||||
}
|
||||
return names[panType] || panType
|
||||
},
|
||||
@@ -663,14 +782,36 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 生成加密的 auth 参数(根据当前链接的网盘类型)
|
||||
generateAuthParam() {
|
||||
// 生成加密的 auth 参数(优先使用个人配置,否则从后端随机获取捐赠账号)
|
||||
async generateAuthParam() {
|
||||
const panType = this.getCurrentPanType()
|
||||
if (!panType || !this.allAuthConfigs[panType]) {
|
||||
return ''
|
||||
if (!panType) return ''
|
||||
|
||||
let config = null
|
||||
|
||||
// 优先使用个人配置
|
||||
if (this.allAuthConfigs[panType]) {
|
||||
config = this.allAuthConfigs[panType]
|
||||
console.log(`[认证] 使用个人配置: ${this.getPanDisplayName(panType)}`)
|
||||
} else {
|
||||
// 从后端随机获取捐赠账号
|
||||
try {
|
||||
const response = await axios.get(`${this.baseAPI}/v2/randomAuth`, { params: { panType } })
|
||||
// 解包 JsonResult 嵌套
|
||||
let data = response.data
|
||||
while (data && data.data !== undefined && data.code !== undefined) {
|
||||
data = data.data
|
||||
}
|
||||
if (data && (data.token || data.username)) {
|
||||
config = data
|
||||
console.log(`[认证] 使用捐赠账号: ${this.getPanDisplayName(panType)}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[认证] 无可用捐赠账号: ${this.getPanDisplayName(panType)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const config = this.allAuthConfigs[panType]
|
||||
if (!config) return ''
|
||||
|
||||
// 构建 JSON 对象
|
||||
const authObj = {}
|
||||
@@ -685,6 +826,7 @@ 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 {
|
||||
@@ -710,9 +852,9 @@ export default {
|
||||
},
|
||||
|
||||
// 更新智能直链
|
||||
updateDirectLink() {
|
||||
async updateDirectLink() {
|
||||
if (this.link) {
|
||||
const authParam = this.generateAuthParam()
|
||||
const authParam = await this.generateAuthParam()
|
||||
const authSuffix = authParam ? `&auth=${authParam}` : ''
|
||||
this.directLink = `${this.baseAPI}/parser?url=${this.link}${this.password ? `&pwd=${this.password}` : ''}${authSuffix}`
|
||||
}
|
||||
@@ -766,8 +908,8 @@ export default {
|
||||
this.errorButtonVisible = false
|
||||
try {
|
||||
this.isLoading = true
|
||||
// 添加认证参数
|
||||
const authParam = this.generateAuthParam()
|
||||
// 添加认证参数(异步获取)
|
||||
const authParam = await this.generateAuthParam()
|
||||
if (authParam) {
|
||||
params.auth = authParam
|
||||
}
|
||||
@@ -815,7 +957,8 @@ export default {
|
||||
} else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le') {
|
||||
// 小飞机、蓝奏、优享、联想乐云:提示大文件需要认证
|
||||
const hasAuth = this.allAuthConfigs[panType]?.cookie ||
|
||||
this.allAuthConfigs[panType]?.username
|
||||
this.allAuthConfigs[panType]?.username ||
|
||||
(this.donateAccountCounts.active[panType.toUpperCase()] || 0) > 0
|
||||
if (!hasAuth) {
|
||||
this.$message.info({
|
||||
message: `${panName}的大文件解析需要配置认证信息,请在"配置认证"中添加`,
|
||||
@@ -1086,7 +1229,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 +1258,119 @@ export default {
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
// ========== 捐赠账号相关方法 ==========
|
||||
|
||||
// 捐赠弹窗中网盘类型变更
|
||||
onDonatePanTypeChange(panType) {
|
||||
const types = this.getDonateAuthTypes()
|
||||
this.donateConfig.authType = types.length > 0 ? types[0].value : 'cookie'
|
||||
this.donateConfig.username = ''
|
||||
this.donateConfig.password = ''
|
||||
this.donateConfig.token = ''
|
||||
this.donateConfig.remark = ''
|
||||
},
|
||||
|
||||
// 获取捐赠弹窗支持的认证类型
|
||||
getDonateAuthTypes() {
|
||||
const pt = (this.donateConfig.panType || '').toLowerCase()
|
||||
const allTypes = {
|
||||
cookie: { label: 'Cookie', value: 'cookie' },
|
||||
accesstoken: { label: 'AccessToken', value: 'accesstoken' },
|
||||
authorization: { label: 'Authorization', value: 'authorization' },
|
||||
password: { label: '用户名密码', value: 'password' },
|
||||
custom: { label: '自定义', value: 'custom' }
|
||||
}
|
||||
switch (pt) {
|
||||
case 'qk': case 'uc': return [allTypes.cookie]
|
||||
case 'fj': case 'iz': return [allTypes.password]
|
||||
case 'ye': return [allTypes.password, allTypes.authorization]
|
||||
default: return Object.values(allTypes)
|
||||
}
|
||||
},
|
||||
|
||||
// 提交捐赠账号(调用后端 API)
|
||||
async submitDonateAccount() {
|
||||
if (!this.donateConfig.panType) {
|
||||
this.$message.warning('请选择网盘类型')
|
||||
return
|
||||
}
|
||||
if (!this.donateConfig.token && !this.donateConfig.username) {
|
||||
this.$message.warning('请填写认证信息(Cookie/Token 或 用户名密码)')
|
||||
return
|
||||
}
|
||||
|
||||
this.donateSubmitting = true
|
||||
try {
|
||||
const payload = {
|
||||
panType: this.donateConfig.panType,
|
||||
authType: this.donateConfig.authType,
|
||||
username: this.donateConfig.username || '',
|
||||
password: this.donateConfig.password || '',
|
||||
token: this.donateConfig.token || '',
|
||||
remark: this.donateConfig.remark || ''
|
||||
}
|
||||
await axios.post(`${this.baseAPI}/v2/donateAccount`, payload)
|
||||
this.$message.success(`已捐赠 ${this.getPanDisplayName(this.donateConfig.panType)} 账号,感谢您的贡献!`)
|
||||
|
||||
// 重置表单
|
||||
this.donateConfig.username = ''
|
||||
this.donateConfig.password = ''
|
||||
this.donateConfig.token = ''
|
||||
this.donateConfig.remark = ''
|
||||
|
||||
// 刷新计数
|
||||
await this.loadDonateAccountCounts()
|
||||
} catch (e) {
|
||||
console.error('捐赠账号失败:', e)
|
||||
this.$message.error('捐赠失败,请稍后重试')
|
||||
} finally {
|
||||
this.donateSubmitting = false
|
||||
}
|
||||
},
|
||||
|
||||
// 从后端加载捐赠账号数量统计
|
||||
async loadDonateAccountCounts() {
|
||||
try {
|
||||
const response = await axios.get(`${this.baseAPI}/v2/donateAccountCounts`)
|
||||
// 解包可能的 JsonResult 嵌套
|
||||
let data = response.data
|
||||
while (data && data.data !== undefined && data.code !== undefined) {
|
||||
data = data.data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
// 兼容新结构: { active: {...}, inactive: {...} }
|
||||
if (data.active && data.inactive) {
|
||||
if (data.active.total === undefined) {
|
||||
data.active.total = Object.entries(data.active)
|
||||
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
|
||||
.reduce((s, [, v]) => s + v, 0)
|
||||
}
|
||||
if (data.inactive.total === undefined) {
|
||||
data.inactive.total = Object.entries(data.inactive)
|
||||
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
|
||||
.reduce((s, [, v]) => s + v, 0)
|
||||
}
|
||||
this.donateAccountCounts = data
|
||||
} else {
|
||||
// 兼容旧结构: { QK: 3, total: 4 }
|
||||
const active = { ...data }
|
||||
if (active.total === undefined) {
|
||||
active.total = Object.entries(active)
|
||||
.filter(([k, v]) => k !== 'total' && typeof v === 'number')
|
||||
.reduce((s, [, v]) => s + v, 0)
|
||||
}
|
||||
this.donateAccountCounts = {
|
||||
active,
|
||||
inactive: { total: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载捐赠账号统计失败:', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1128,6 +1384,9 @@ export default {
|
||||
// 加载认证配置
|
||||
this.loadAuthConfig()
|
||||
|
||||
// 加载捐赠账号统计
|
||||
this.loadDonateAccountCounts()
|
||||
|
||||
// 获取初始统计信息
|
||||
this.getInfo()
|
||||
|
||||
|
||||
@@ -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解密
|
||||
|
||||
@@ -28,6 +28,7 @@ import io.vertx.core.Promise;
|
||||
import io.vertx.core.http.HttpServerRequest;
|
||||
import io.vertx.core.http.HttpServerResponse;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
@@ -477,4 +478,37 @@ public class ParserApi {
|
||||
ClientLinkType type = ClientLinkType.valueOf(clientType.toUpperCase());
|
||||
return clientLinks.get(type);
|
||||
}
|
||||
|
||||
// ========== 捐赠账号 API ==========
|
||||
|
||||
/**
|
||||
* 捐赠网盘账号
|
||||
*/
|
||||
@RouteMapping(value = "/donateAccount", method = RouteMethod.POST)
|
||||
public Future<JsonObject> donateAccount(RoutingContext ctx) {
|
||||
JsonObject body = ctx.body().asJsonObject();
|
||||
if (body == null || StringUtils.isBlank(body.getString("panType"))
|
||||
|| StringUtils.isBlank(body.getString("authType"))) {
|
||||
return Future.succeededFuture(JsonResult.error("panType and authType are required").toJsonObject());
|
||||
}
|
||||
String ip = ctx.request().remoteAddress().host();
|
||||
body.put("ip", ip);
|
||||
return dbService.saveDonatedAccount(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取各网盘捐赠账号数量
|
||||
*/
|
||||
@RouteMapping(value = "/donateAccountCounts", method = RouteMethod.GET)
|
||||
public Future<JsonObject> getDonateAccountCounts() {
|
||||
return dbService.getDonatedAccountCounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* 随机获取指定网盘类型的捐赠账号(内部使用,返回加密后的 auth 参数)
|
||||
*/
|
||||
@RouteMapping(value = "/randomAuth", method = RouteMethod.GET)
|
||||
public Future<JsonObject> getRandomAuth(String panType) {
|
||||
return dbService.getRandomDonatedAccount(panType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void> 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<CacheLinkInfo> 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("认证");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.qaiu.lz.web.model;
|
||||
|
||||
import cn.qaiu.db.ddl.Constraint;
|
||||
import cn.qaiu.db.ddl.Length;
|
||||
import cn.qaiu.db.ddl.Table;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 捐赠账号实体
|
||||
* 用户捐赠的网盘认证信息,解析时随机选择使用
|
||||
*/
|
||||
@Data
|
||||
@Table("donated_account")
|
||||
public class DonatedAccount {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Constraint(autoIncrement = true, notNull = true)
|
||||
private Long id;
|
||||
|
||||
@Length(varcharSize = 16)
|
||||
@Constraint(notNull = true)
|
||||
private String panType; // 网盘类型: QK, UC, FJ, IZ, YE
|
||||
|
||||
@Length(varcharSize = 32)
|
||||
@Constraint(notNull = true)
|
||||
private String authType; // 认证类型: cookie, accesstoken, authorization, password, custom
|
||||
|
||||
@Length(varcharSize = 128)
|
||||
private String username; // 用户名
|
||||
|
||||
@Length(varcharSize = 128)
|
||||
private String password; // 密码
|
||||
|
||||
@Length(varcharSize = 4096)
|
||||
private String token; // Cookie/Token
|
||||
|
||||
@Length(varcharSize = 64)
|
||||
private String remark; // 备注
|
||||
|
||||
@Length(varcharSize = 64)
|
||||
private String ip; // 捐赠者IP
|
||||
|
||||
@Constraint(notNull = true, defaultValue = "true")
|
||||
private Boolean enabled = true; // 是否启用
|
||||
|
||||
@Constraint(notNull = true, defaultValue = "0")
|
||||
private Integer failCount = 0; // 失败次数,达到阈值自动禁用
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createTime = new Date();
|
||||
}
|
||||
@@ -50,4 +50,30 @@ public interface DbService extends BaseAsyncService {
|
||||
*/
|
||||
Future<JsonObject> getPlaygroundParserById(Long id);
|
||||
|
||||
// ========== 捐赠账号相关 ==========
|
||||
|
||||
/**
|
||||
* 保存捐赠账号
|
||||
*/
|
||||
Future<JsonObject> saveDonatedAccount(JsonObject account);
|
||||
|
||||
/**
|
||||
* 获取各网盘捐赠账号数量统计
|
||||
*/
|
||||
Future<JsonObject> getDonatedAccountCounts();
|
||||
|
||||
/**
|
||||
* 随机获取指定网盘类型的一个启用账号
|
||||
*/
|
||||
Future<JsonObject> getRandomDonatedAccount(String panType);
|
||||
|
||||
/**
|
||||
* 签发捐赠账号失败计数令牌(服务端临时令牌)
|
||||
*/
|
||||
Future<String> issueDonatedAccountFailureToken(Long accountId);
|
||||
|
||||
/**
|
||||
* 使用服务端失败计数令牌记录捐赠账号解析失败
|
||||
*/
|
||||
Future<Void> recordDonatedAccountFailureByToken(String failureToken);
|
||||
}
|
||||
|
||||
@@ -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<JsonObject> sayOk(String data) {
|
||||
log.info("say ok1 -> wait...");
|
||||
@@ -265,4 +276,249 @@ public class DbServiceImpl implements DbService {
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
// ========== 捐赠账号相关 ==========
|
||||
|
||||
@Override
|
||||
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"));
|
||||
|
||||
return ensureFailCountColumn(client).compose(v ->
|
||||
Future.all(encryptedUsername, encryptedPassword, encryptedToken).compose(compositeFuture -> {
|
||||
String sql = """
|
||||
INSERT INTO donated_account
|
||||
(pan_type, auth_type, username, password, token, remark, ip, enabled, fail_count, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, true, 0, NOW())
|
||||
""";
|
||||
|
||||
return client.preparedQuery(sql)
|
||||
.execute(Tuple.of(
|
||||
account.getString("panType"),
|
||||
account.getString("authType"),
|
||||
encryptedUsername.result(),
|
||||
encryptedPassword.result(),
|
||||
encryptedToken.result(),
|
||||
account.getString("remark"),
|
||||
account.getString("ip")
|
||||
))
|
||||
.map(res -> JsonResult.success("捐赠成功").toJsonObject())
|
||||
.onFailure(e -> log.error("saveDonatedAccount failed", e));
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<JsonObject> getDonatedAccountCounts() {
|
||||
JDBCPool client = JDBCPoolInit.instance().getPool();
|
||||
|
||||
String sql = "SELECT pan_type, enabled, COUNT(*) as count FROM donated_account GROUP BY pan_type, enabled";
|
||||
|
||||
return client.query(sql).execute().map(rows -> {
|
||||
JsonObject result = new JsonObject();
|
||||
JsonObject activeCounts = new JsonObject();
|
||||
JsonObject inactiveCounts = new JsonObject();
|
||||
int totalActive = 0;
|
||||
int totalInactive = 0;
|
||||
|
||||
for (Row row : rows) {
|
||||
String panType = row.getString("pan_type");
|
||||
boolean enabled = row.getBoolean("enabled");
|
||||
int count = row.getInteger("count");
|
||||
|
||||
if (enabled) {
|
||||
activeCounts.put(panType, count);
|
||||
totalActive += count;
|
||||
} else {
|
||||
inactiveCounts.put(panType, count);
|
||||
totalInactive += count;
|
||||
}
|
||||
}
|
||||
|
||||
activeCounts.put("total", totalActive);
|
||||
inactiveCounts.put("total", totalInactive);
|
||||
|
||||
result.put("active", activeCounts);
|
||||
result.put("inactive", inactiveCounts);
|
||||
|
||||
return JsonResult.data(result).toJsonObject();
|
||||
}).onFailure(e -> log.error("getDonatedAccountCounts failed", e));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<JsonObject> getRandomDonatedAccount(String panType) {
|
||||
JDBCPool client = JDBCPoolInit.instance().getPool();
|
||||
|
||||
String sql = "SELECT * FROM donated_account WHERE pan_type = ? AND enabled = true ORDER BY RAND() LIMIT 1";
|
||||
|
||||
return client.preparedQuery(sql)
|
||||
.execute(Tuple.of(panType))
|
||||
.compose(rows -> {
|
||||
if (rows.size() > 0) {
|
||||
Row row = rows.iterator().next();
|
||||
|
||||
Future<String> usernameFuture = decryptOrPlain(row.getString("username"));
|
||||
Future<String> passwordFuture = decryptOrPlain(row.getString("password"));
|
||||
Future<String> tokenFuture = decryptOrPlain(row.getString("token"));
|
||||
Future<String> failureTokenFuture = issueDonatedAccountFailureToken(row.getLong("id"));
|
||||
|
||||
return Future.all(usernameFuture, passwordFuture, tokenFuture, failureTokenFuture)
|
||||
.map(compositeFuture -> {
|
||||
String username = usernameFuture.result();
|
||||
String password = passwordFuture.result();
|
||||
String token = tokenFuture.result();
|
||||
|
||||
// 如果解密后没有任何可用凭证,返回空对象,避免把密文当作明文认证参数下发给前端
|
||||
if (StringUtils.isBlank(username) && StringUtils.isBlank(password) && StringUtils.isBlank(token)) {
|
||||
log.warn("random donated account has no usable credential after decrypt, accountId={}", row.getLong("id"));
|
||||
return JsonResult.data(new JsonObject()).toJsonObject();
|
||||
}
|
||||
|
||||
JsonObject account = new JsonObject();
|
||||
account.put("authType", row.getString("auth_type"));
|
||||
account.put("username", username);
|
||||
account.put("password", password);
|
||||
account.put("token", token);
|
||||
account.put("donatedAccountToken", failureTokenFuture.result());
|
||||
return JsonResult.data(account).toJsonObject();
|
||||
});
|
||||
} else {
|
||||
return Future.succeededFuture(JsonResult.data(new JsonObject()).toJsonObject());
|
||||
}
|
||||
})
|
||||
.onFailure(e -> log.error("getRandomDonatedAccount failed", e));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> 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<Void> recordDonatedAccountFailureByToken(String failureToken) {
|
||||
JDBCPool client = JDBCPoolInit.instance().getPool();
|
||||
|
||||
Long accountId;
|
||||
try {
|
||||
accountId = parseAndVerifyFailureToken(failureToken);
|
||||
} catch (Exception e) {
|
||||
return Future.failedFuture(e);
|
||||
}
|
||||
|
||||
String updateSql = """
|
||||
UPDATE donated_account
|
||||
SET fail_count = fail_count + 1,
|
||||
enabled = CASE
|
||||
WHEN fail_count + 1 >= ? THEN false
|
||||
ELSE enabled
|
||||
END
|
||||
WHERE id = ?
|
||||
""";
|
||||
|
||||
return ensureFailCountColumn(client)
|
||||
.compose(v -> client.preparedQuery(updateSql)
|
||||
.execute(Tuple.of(DONATED_ACCOUNT_DISABLE_THRESHOLD, accountId)))
|
||||
.map(rows -> (Void) null)
|
||||
.onFailure(e -> log.error("recordDonatedAccountFailureByToken failed", e));
|
||||
}
|
||||
|
||||
private Future<Void> ensureFailCountColumn(JDBCPool client) {
|
||||
Promise<Void> 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<String> decryptOrPlain(String value) {
|
||||
if (value == null) {
|
||||
return Future.succeededFuture(null);
|
||||
}
|
||||
if (!isLikelyEncrypted(value)) {
|
||||
return Future.succeededFuture(value);
|
||||
}
|
||||
return CryptoUtil.decrypt(value).recover(e -> {
|
||||
// value 看起来像密文但无法解密,通常是密钥轮换/不一致导致;
|
||||
// 不应回退为明文,否则会把密文误当 token/cookie 返回给调用方
|
||||
log.warn("decrypt donated account field failed, fallback to null to avoid ciphertext leakage", e);
|
||||
return Future.succeededFuture((String) null);
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isLikelyEncrypted(String value) {
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(value);
|
||||
return decoded.length > 16;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Long parseAndVerifyFailureToken(String token) throws Exception {
|
||||
if (token == null || token.isBlank() || !token.contains(".")) {
|
||||
throw new IllegalArgumentException("invalid donated account token");
|
||||
}
|
||||
String[] parts = token.split("\\.", 2);
|
||||
String payload = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8);
|
||||
String signature = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
|
||||
String expected = hmacSha256(payload);
|
||||
if (!expected.equals(signature)) {
|
||||
throw new IllegalArgumentException("donated account token signature invalid");
|
||||
}
|
||||
|
||||
String[] payloadParts = payload.split(":", 2);
|
||||
if (payloadParts.length != 2) {
|
||||
throw new IllegalArgumentException("invalid donated account token payload");
|
||||
}
|
||||
Long accountId = Long.parseLong(payloadParts[0]);
|
||||
long issuedAt = Long.parseLong(payloadParts[1]);
|
||||
if (System.currentTimeMillis() - issuedAt > FAILURE_TOKEN_TTL_MILLIS) {
|
||||
throw new IllegalArgumentException("donated account token expired");
|
||||
}
|
||||
return accountId;
|
||||
}
|
||||
|
||||
private String hmacSha256(String payload) throws Exception {
|
||||
String secret = getDonatedAccountFailureTokenSignKey();
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
|
||||
byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(digest);
|
||||
}
|
||||
|
||||
private String getDonatedAccountFailureTokenSignKey() {
|
||||
try {
|
||||
String configKey = cn.qaiu.vx.core.util.SharedDataUtil
|
||||
.getJsonStringForServerConfig(DONATED_ACCOUNT_TOKEN_SIGN_KEY_CONFIG);
|
||||
if (StringUtils.isNotBlank(configKey)) {
|
||||
return configKey;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("读取捐赠账号失败计数签名密钥失败,使用默认值: {}", e.getMessage());
|
||||
}
|
||||
return DONATED_ACCOUNT_TOKEN_SIGN_KEY_FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package cn.qaiu.lz.web.util;
|
||||
|
||||
public class CryptoException extends RuntimeException {
|
||||
public CryptoException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
105
web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java
Normal file
105
web-service/src/main/java/cn/qaiu/lz/web/util/CryptoUtil.java
Normal file
@@ -0,0 +1,105 @@
|
||||
package cn.qaiu.lz.web.util;
|
||||
|
||||
import cn.qaiu.vx.core.util.ConfigUtil;
|
||||
import cn.qaiu.vx.core.util.VertxHolder;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Vertx;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
public class CryptoUtil {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CryptoUtil.class);
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
private static final int GCM_IV_LENGTH = 12; // 96 bits
|
||||
private static final int GCM_TAG_LENGTH = 16; // 128 bits
|
||||
|
||||
private static Future<SecretKeySpec> secretKeyFuture;
|
||||
|
||||
static {
|
||||
Vertx vertx = VertxHolder.getVertxInstance();
|
||||
if (vertx != null) {
|
||||
secretKeyFuture = ConfigUtil.readYamlConfig("secret", vertx)
|
||||
.map(config -> {
|
||||
String key = config.getJsonObject("encrypt").getString("key");
|
||||
if (key != null) {
|
||||
key = key.trim();
|
||||
}
|
||||
byte[] keyBytes = key == null ? null : key.getBytes(StandardCharsets.UTF_8);
|
||||
if (keyBytes == null || keyBytes.length != 32) {
|
||||
int currentLen = keyBytes == null ? 0 : keyBytes.length;
|
||||
throw new IllegalArgumentException("Invalid AES key length in secret.yml. Key must be 32 bytes. current=" + currentLen);
|
||||
}
|
||||
return new SecretKeySpec(keyBytes, "AES");
|
||||
})
|
||||
.onFailure(err -> logger.error("Failed to load encryption key from secret.yml", err));
|
||||
} else {
|
||||
logger.error("Vertx instance is not available for CryptoUtil initialization.");
|
||||
secretKeyFuture = Future.failedFuture("Vertx instance not available.");
|
||||
}
|
||||
}
|
||||
|
||||
public static Future<String> 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<String> 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
4
web-service/src/main/resources/secret.yml
Normal file
4
web-service/src/main/resources/secret.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
# This file contains sensitive information and should not be committed to version control.
|
||||
# It is used to store the encryption key for the application.
|
||||
encrypt:
|
||||
key: "nfd_secret_key_32bytes_2026_abcd"
|
||||
Reference in New Issue
Block a user