mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-22 08:36:54 +00:00
Compare commits
1 Commits
v3.0.1
...
copilot/vs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbe16a07d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,9 +41,7 @@ gradlew.bat
|
|||||||
unused.txt
|
unused.txt
|
||||||
/web-service/src/main/generated/
|
/web-service/src/main/generated/
|
||||||
/db
|
/db
|
||||||
/netdisk-fast-download/
|
|
||||||
/webroot/nfd-front/
|
/webroot/nfd-front/
|
||||||
/netdisk-fast-download/webroot/nfd-front/
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
# Maven generated files
|
# Maven generated files
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"java.compile.nullAnalysis.mode": "automatic",
|
"java.compile.nullAnalysis.mode": "automatic",
|
||||||
"java.configuration.updateBuildConfiguration": "interactive",
|
"java.configuration.updateBuildConfiguration": "interactive"
|
||||||
"java.debug.settings.onBuildFailureProceed": true
|
|
||||||
}
|
}
|
||||||
50
README.md
50
README.md
@@ -29,8 +29,7 @@ QQ交流群:1017480890
|
|||||||
[API接入](https://nfdparser.apifox.cn/)
|
[API接入](https://nfdparser.apifox.cn/)
|
||||||
[公益解析,lz站](https://lz.qaiu.top)
|
[公益解析,lz站](https://lz.qaiu.top)
|
||||||
[公益解析,lz0站](https://lz0.qaiu.top)
|
[公益解析,lz0站](https://lz0.qaiu.top)
|
||||||
[专业版](https://189.qaiu.top)
|
[专业版189站,注册体验](https://189.qaiu.top)
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
命令行下载分享文件:
|
命令行下载分享文件:
|
||||||
@@ -59,7 +58,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
|
|
||||||
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
||||||
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
|
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
|
||||||
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效。**
|
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效,** [lz站](https://lz.qaiu.top) 和 [lz0](https://lz0.qaiu.top) 不支持大文件,请使用 [189站](https://189.qaiu.top) 注册体验。
|
||||||
|
|
||||||
## 网盘支持情况:
|
## 网盘支持情况:
|
||||||
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
||||||
@@ -87,14 +86,20 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
|
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
|
||||||
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
|
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
|
||||||
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
|
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
|
||||||
- [飞书云盘-fs](https://www.feishu.cn/)
|
|
||||||
- [WPS云文档-pwps](https://www.kdocs.cn/)
|
- [WPS云文档-pwps](https://www.kdocs.cn/)
|
||||||
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
|
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
|
||||||
- [咪咕音乐-migu](https://music.migu.cn/)
|
- [咪咕音乐-migu](https://music.migu.cn/)
|
||||||
|
- [一刻相册-baidu_photo](https://photo.baidu.com/)
|
||||||
- Google云盘-pgd
|
- Google云盘-pgd
|
||||||
- Onedrive-pod
|
- Onedrive-pod
|
||||||
- Dropbox-pdp
|
- Dropbox-pdp
|
||||||
- iCloud-pic
|
- iCloud-pic
|
||||||
|
### 专属版提供
|
||||||
|
- [夸克云盘-qk](https://pan.quark.cn/)
|
||||||
|
- [UC云盘-uc](https://fast.uc.cn/)
|
||||||
|
- [移动云盘-p139](https://yun.139.com/)
|
||||||
|
- [联通云盘-pwo](https://pan.wo.cn/)
|
||||||
|
- [天翼云盘-p189](https://cloud.189.cn/)
|
||||||
|
|
||||||
## API接口
|
## API接口
|
||||||
|
|
||||||
@@ -326,15 +331,15 @@ json返回数据格式示例:
|
|||||||
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|
||||||
|-------------|---------|----------|-----------|-----------------|
|
|-------------|---------|----------|-----------|-----------------|
|
||||||
| 蓝奏云 | √ | √ | 不限空间 | 100M |
|
| 蓝奏云 | √ | √ | 不限空间 | 100M |
|
||||||
|
| 奶牛快传 | √ | X | 10G | 不限大小 |
|
||||||
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
|
||||||
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
|
| 小飞机网盘 | √ | √(密码可忽略) | 10G | 不限大小 |
|
||||||
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
|
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
|
||||||
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
|
||||||
| 文叔叔 | √ | √ | 10G | 5GB |
|
| 文叔叔 | √ | √ | 10G | 5GB |
|
||||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
||||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||||
| 飞书云盘 | √ | X | 15G | 不限大小 |
|
|
||||||
|
|
||||||
# 打包部署
|
# 打包部署
|
||||||
|
|
||||||
@@ -487,6 +492,23 @@ auths:
|
|||||||
|
|
||||||
**注意:** 目前仅支持 123(ye)的认证配置。
|
**注意:** 目前仅支持 123(ye)的认证配置。
|
||||||
|
|
||||||
|
## 开发计划
|
||||||
|
### v0.1.8~v0.1.9 ✓
|
||||||
|
- API添加文件信息(专属版/开源版)
|
||||||
|
- 目录解析(专属版/开源版)
|
||||||
|
- 文件预览功能(专属版/开源版)
|
||||||
|
- 文件夹预览功能(开源版)
|
||||||
|
- 友好的错误提示和一键反馈功能(开源版)
|
||||||
|
- 带cookie/token/username/pwd参数解析大文件(专属版)
|
||||||
|
### v0.2.x
|
||||||
|
- web后台管理--认证配置/分享链接管理(开源版/专属版)
|
||||||
|
- 123/小飞机/蓝奏优享等大文件解析(开源版)
|
||||||
|
- 直链分享(开源版/专属版)
|
||||||
|
- aria2/idm+/curl/wget链接生成(开源版/专属版)
|
||||||
|
- IP限流配置(开源版/专属版)
|
||||||
|
- refere防盗链,API鉴权防盗链(专属版)
|
||||||
|
- 123/小飞机/蓝奏优享/蓝奏文件夹解析API,天翼云盘/移动云盘文件夹解析API(专属版)
|
||||||
|
- 用户管理面板--营销推广系统(专属版)
|
||||||
|
|
||||||
**技术栈:**
|
**技术栈:**
|
||||||
Jdk17+Vert.x4
|
Jdk17+Vert.x4
|
||||||
@@ -511,6 +533,20 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
### 关于赞助定制专属版
|
||||||
|
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
|
||||||
|
2. 可提供托管服务:包含部署服务和云服务器环境。
|
||||||
|
3. 可提供功能定制开发。
|
||||||
|
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
|
||||||
|
<p>qq: 197575894</p>
|
||||||
|
<p>wechat: imcoding_</p>
|
||||||
|
|
||||||
|
<!--
|
||||||
|

|
||||||
|
|
||||||
|
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
|
||||||
|
-->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,6 @@
|
|||||||
<version>${jackson.version}</version>
|
<version>${jackson.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>junit</groupId>
|
|
||||||
<artifactId>junit</artifactId>
|
|
||||||
<version>${junit.version}</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ package cn.qaiu.vx.core;
|
|||||||
import cn.qaiu.vx.core.util.CommonUtil;
|
import cn.qaiu.vx.core.util.CommonUtil;
|
||||||
import cn.qaiu.vx.core.util.ConfigUtil;
|
import cn.qaiu.vx.core.util.ConfigUtil;
|
||||||
import cn.qaiu.vx.core.util.VertxHolder;
|
import cn.qaiu.vx.core.util.VertxHolder;
|
||||||
import cn.qaiu.vx.core.verticle.HttpProxyVerticle;
|
|
||||||
import cn.qaiu.vx.core.verticle.PostExecVerticle;
|
|
||||||
import cn.qaiu.vx.core.verticle.ReverseProxyVerticle;
|
import cn.qaiu.vx.core.verticle.ReverseProxyVerticle;
|
||||||
import cn.qaiu.vx.core.verticle.RouterVerticle;
|
import cn.qaiu.vx.core.verticle.RouterVerticle;
|
||||||
import cn.qaiu.vx.core.verticle.ServiceVerticle;
|
import cn.qaiu.vx.core.verticle.ServiceVerticle;
|
||||||
import io.vertx.core.*;
|
import io.vertx.core.*;
|
||||||
import io.vertx.core.dns.AddressResolverOptions;
|
import io.vertx.core.dns.AddressResolverOptions;
|
||||||
|
import io.vertx.core.impl.launcher.commands.VersionCommand;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.core.shareddata.LocalMap;
|
import io.vertx.core.shareddata.LocalMap;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -18,7 +17,6 @@ import org.slf4j.LoggerFactory;
|
|||||||
import java.lang.management.ManagementFactory;
|
import java.lang.management.ManagementFactory;
|
||||||
import java.util.Calendar;
|
import java.util.Calendar;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.locks.LockSupport;
|
import java.util.concurrent.locks.LockSupport;
|
||||||
|
|
||||||
import static cn.qaiu.vx.core.util.ConfigConstant.*;
|
import static cn.qaiu.vx.core.util.ConfigConstant.*;
|
||||||
@@ -56,7 +54,6 @@ public final class Deploy {
|
|||||||
public void start(String[] args, Handler<JsonObject> handle) {
|
public void start(String[] args, Handler<JsonObject> handle) {
|
||||||
this.mainThread = Thread.currentThread();
|
this.mainThread = Thread.currentThread();
|
||||||
this.handle = handle;
|
this.handle = handle;
|
||||||
|
|
||||||
if (args.length > 0 && args[0].startsWith("app-")) {
|
if (args.length > 0 && args[0].startsWith("app-")) {
|
||||||
// 启动参数dev或者prod
|
// 启动参数dev或者prod
|
||||||
path.append("-").append(args[0].replace("app-",""));
|
path.append("-").append(args[0].replace("app-",""));
|
||||||
@@ -107,7 +104,7 @@ public final class Deploy {
|
|||||||
|
|
||||||
System.out.printf(logoTemplate,
|
System.out.printf(logoTemplate,
|
||||||
CommonUtil.getAppVersion(),
|
CommonUtil.getAppVersion(),
|
||||||
"4x",
|
VersionCommand.getVersion(),
|
||||||
conf.getString("copyright"),
|
conf.getString("copyright"),
|
||||||
year
|
year
|
||||||
);
|
);
|
||||||
@@ -126,12 +123,12 @@ public final class Deploy {
|
|||||||
var vertxOptions = vertxConfigELPS == 0 ?
|
var vertxOptions = vertxConfigELPS == 0 ?
|
||||||
new VertxOptions() : new VertxOptions(vertxConfig);
|
new VertxOptions() : new VertxOptions(vertxConfig);
|
||||||
|
|
||||||
// vertxOptions.setAddressResolverOptions(
|
vertxOptions.setAddressResolverOptions(
|
||||||
// new AddressResolverOptions().
|
new AddressResolverOptions().
|
||||||
// addServer("114.114.114.114").
|
addServer("114.114.114.114").
|
||||||
// addServer("114.114.115.115").
|
addServer("114.114.115.115").
|
||||||
// addServer("8.8.8.8").
|
addServer("8.8.8.8").
|
||||||
// addServer("8.8.4.4"));
|
addServer("8.8.4.4"));
|
||||||
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
|
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
|
||||||
vertxOptions.getEventLoopPoolSize(),
|
vertxOptions.getEventLoopPoolSize(),
|
||||||
vertxOptions.getWorkerPoolSize());
|
vertxOptions.getWorkerPoolSize());
|
||||||
@@ -156,39 +153,12 @@ public final class Deploy {
|
|||||||
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
|
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
|
||||||
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
|
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
|
||||||
|
|
||||||
|
|
||||||
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
|
|
||||||
if (jsonObject != null) {
|
|
||||||
genPwd(jsonObject);
|
|
||||||
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy"));
|
|
||||||
future4.onSuccess(LOGGER::info);
|
|
||||||
future4.onFailure(e -> LOGGER.error("Other handle error", e));
|
|
||||||
Future.all(future1, future2, future3, future4)
|
|
||||||
.onSuccess(this::deployWorkVerticalSuccess)
|
|
||||||
.onFailure(this::deployVerticalFailed);
|
|
||||||
} else {
|
|
||||||
Future.all(future1, future2, future3)
|
Future.all(future1, future2, future3)
|
||||||
.onSuccess(this::deployWorkVerticalSuccess)
|
.onSuccess(this::deployWorkVerticalSuccess)
|
||||||
.onFailure(this::deployVerticalFailed);
|
.onFailure(this::deployVerticalFailed);
|
||||||
}
|
|
||||||
|
|
||||||
}).onFailure(e -> LOGGER.error("Other handle error", e));
|
}).onFailure(e -> LOGGER.error("Other handle error", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void genPwd(JsonObject jsonObject) {
|
|
||||||
if (jsonObject.getBoolean("randUserPwd")) {
|
|
||||||
var username = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
|
||||||
var password = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
|
||||||
jsonObject.put("username", username);
|
|
||||||
jsonObject.put("password", password);
|
|
||||||
}
|
|
||||||
LOGGER.info("=============server info=================");
|
|
||||||
LOGGER.info("\nport: {}\nusername: {}\npassword: {}",
|
|
||||||
jsonObject.getString("port"),
|
|
||||||
jsonObject.getString("username"),
|
|
||||||
jsonObject.getString("password"));
|
|
||||||
LOGGER.info("==============server info================");
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* 部署失败
|
* 部署失败
|
||||||
*
|
*
|
||||||
@@ -208,42 +178,6 @@ public final class Deploy {
|
|||||||
var t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
|
var t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
|
||||||
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
|
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
|
||||||
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
|
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
|
||||||
|
|
||||||
// 检查是否处于安装引导模式(数据库未配置)
|
|
||||||
Object installMode = VertxHolder.getVertxInstance().sharedData()
|
|
||||||
.getLocalMap(LOCAL).get("installMode");
|
|
||||||
if (Boolean.TRUE.equals(installMode)) {
|
|
||||||
LOGGER.info("系统处于安装引导模式,等待用户完成数据库配置后再启动后置初始化...");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正常模式:部署 PostExecVerticle 执行 AppRun 实现
|
|
||||||
deployPostExec();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 部署 PostExecVerticle(执行所有 AppRun 实现)
|
|
||||||
* 安装引导完成后也可手动调用此方法触发后置初始化
|
|
||||||
*/
|
|
||||||
public void deployPostExec() {
|
|
||||||
var vertx = VertxHolder.getVertxInstance();
|
|
||||||
var postExecFuture = vertx.deployVerticle(PostExecVerticle.class, getWorkDeploymentOptions("postExec", 2));
|
|
||||||
postExecFuture.onSuccess(id -> {
|
|
||||||
LOGGER.info("PostExecVerticle 部署成功,AppRun 实现执行完成");
|
|
||||||
}).onFailure(e -> {
|
|
||||||
LOGGER.error("PostExecVerticle 部署失败", e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重新部署 ServiceVerticle,重新注册因 DB 未就绪而失败的服务到 EventBus
|
|
||||||
* 安装引导完成、DB 初始化后调用
|
|
||||||
*/
|
|
||||||
public void redeployServices() {
|
|
||||||
var vertx = VertxHolder.getVertxInstance();
|
|
||||||
vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"))
|
|
||||||
.onSuccess(id -> LOGGER.info("ServiceVerticle 重新部署成功,DB 相关服务已注册"))
|
|
||||||
.onFailure(e -> LOGGER.error("ServiceVerticle 重新部署失败", e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import java.lang.annotation.*;
|
|||||||
public @interface HandleSortFilter {
|
public @interface HandleSortFilter {
|
||||||
/**
|
/**
|
||||||
* 注册顺序,数字越大越先注册<br>
|
* 注册顺序,数字越大越先注册<br>
|
||||||
* 前置拦截器会先执行后注册即数字小的, 后置拦截器会先执行先注册的即数字大的<br>
|
|
||||||
* 值<0时会过滤掉该处理器
|
* 值<0时会过滤掉该处理器
|
||||||
*/
|
*/
|
||||||
int value() default 0;
|
int value() default 0;
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.base;
|
|
||||||
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
|
|
||||||
public interface AppRun {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行方法
|
|
||||||
* @param config 启动配置文件
|
|
||||||
*/
|
|
||||||
void execute(JsonObject config);
|
|
||||||
}
|
|
||||||
@@ -38,20 +38,6 @@ public interface BaseHttpApi {
|
|||||||
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
||||||
}
|
}
|
||||||
|
|
||||||
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
|
|
||||||
if (!ctx.response().ended()) {
|
|
||||||
fireJsonObjectResponse(ctx, jsonObject, statusCode);
|
|
||||||
}
|
|
||||||
handleAfterInterceptor(ctx, jsonObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
|
|
||||||
if (!ctx.response().ended()) {
|
|
||||||
fireJsonResultResponse(ctx, jsonResult, statusCode);
|
|
||||||
}
|
|
||||||
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
|
|
||||||
}
|
|
||||||
|
|
||||||
default Set<AfterInterceptor> getAfterInterceptor() {
|
default Set<AfterInterceptor> getAfterInterceptor() {
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.base;
|
|
||||||
|
|
||||||
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认的AppRun实现示例
|
|
||||||
* <br>Create date 2024-01-01 00:00:00
|
|
||||||
*
|
|
||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
|
||||||
*/
|
|
||||||
@HandleSortFilter
|
|
||||||
public class DefaultAppRun implements AppRun {
|
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAppRun.class);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void execute(JsonObject config) {
|
|
||||||
LOGGER.info("======> AppRun实现类开始执行,配置数: {}", config.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,8 @@ import io.vertx.ext.web.RoutingContext;
|
|||||||
import io.vertx.ext.web.handler.*;
|
import io.vertx.ext.web.handler.*;
|
||||||
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
|
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
|
||||||
import io.vertx.ext.web.handler.sockjs.SockJSHandlerOptions;
|
import io.vertx.ext.web.handler.sockjs.SockJSHandlerOptions;
|
||||||
|
import io.vertx.ext.web.sstore.LocalSessionStore;
|
||||||
|
import io.vertx.ext.web.sstore.SessionStore;
|
||||||
import javassist.CtClass;
|
import javassist.CtClass;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.commons.lang3.tuple.Pair;
|
import org.apache.commons.lang3.tuple.Pair;
|
||||||
@@ -74,15 +76,15 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
// 主路由
|
// 主路由
|
||||||
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
|
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
|
||||||
mainRouter.route().handler(ctx -> {
|
mainRouter.route().handler(ctx -> {
|
||||||
String realPath = ctx.request().uri();
|
String realPath = ctx.request().uri();;
|
||||||
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
|
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
|
||||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
||||||
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
|
String rePath = realPath.substring(REROUTE_PATH_PREFIX.length());
|
||||||
ctx.reroute(rePath);
|
ctx.reroute(rePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.debug("New request:{}, {}, {}",
|
LOGGER.debug("The HTTP service request address information ===>path:{}, uri:{}, method:{}",
|
||||||
ctx.request().path(), ctx.request().absoluteURI(), ctx.request().method());
|
ctx.request().path(), ctx.request().absoluteURI(), ctx.request().method());
|
||||||
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
|
||||||
ctx.response().headers().add(DATE, LocalDateTime.now().format(ISO_LOCAL_DATE_TIME));
|
ctx.response().headers().add(DATE, LocalDateTime.now().format(ISO_LOCAL_DATE_TIME));
|
||||||
@@ -98,6 +100,16 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
// 配置文件上传路径
|
// 配置文件上传路径
|
||||||
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
|
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
|
||||||
|
|
||||||
|
// 配置Session管理 - 用于演练场登录状态持久化
|
||||||
|
// 30天过期时间(毫秒)
|
||||||
|
SessionStore sessionStore = LocalSessionStore.create(VertxHolder.getVertxInstance());
|
||||||
|
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||||
|
.setSessionTimeout(30L * 24 * 60 * 60 * 1000) // 30天
|
||||||
|
.setSessionCookieName("SESSIONID") // Cookie名称
|
||||||
|
.setCookieHttpOnlyFlag(true) // 防止XSS攻击
|
||||||
|
.setCookieSecureFlag(false); // 非HTTPS环境设置为false
|
||||||
|
mainRouter.route().handler(sessionHandler);
|
||||||
|
|
||||||
// 拦截器
|
// 拦截器
|
||||||
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
|
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
|
||||||
Route route0 = mainRouter.route("/*");
|
Route route0 = mainRouter.route("/*");
|
||||||
@@ -177,10 +189,10 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
if (ctx.response().ended()) return;
|
if (ctx.response().ended()) return;
|
||||||
// 超时处理器状态码503
|
// 超时处理器状态码503
|
||||||
if (ctx.statusCode() == 503 || ctx.failure() == null) {
|
if (ctx.statusCode() == 503 || ctx.failure() == null) {
|
||||||
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员"), 503);
|
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员", 500));
|
||||||
} else {
|
} else {
|
||||||
ctx.failure().printStackTrace();
|
ctx.failure().printStackTrace();
|
||||||
doFireJsonResultResponse(ctx, JsonResult.error(ctx.failure().getMessage()), 500);
|
doFireJsonResultResponse(ctx, JsonResult.error(ctx.failure().getMessage(), 500));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
|
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
|
||||||
@@ -234,7 +246,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
*/
|
*/
|
||||||
private Set<Handler<RoutingContext>> getInterceptorSet() {
|
private Set<Handler<RoutingContext>> getInterceptorSet() {
|
||||||
// 配置拦截
|
// 配置拦截
|
||||||
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toCollection(LinkedHashSet::new));
|
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,19 +315,19 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
|
|
||||||
final MultiMap queryParams = ctx.queryParams();
|
final MultiMap queryParams = ctx.queryParams();
|
||||||
// 解析body-json参数
|
// 解析body-json参数
|
||||||
if (HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())) {
|
// 只处理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();
|
JsonObject body = ctx.body().asJsonObject();
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
methodParametersTemp.forEach((k, v) -> {
|
methodParametersTemp.forEach((k, v) -> {
|
||||||
String typeName = v.getRight().getName();
|
|
||||||
// 直接绑定 JsonObject 类型参数
|
|
||||||
if (JsonObject.class.getName().equals(typeName)) {
|
|
||||||
parameterValueList.put(k, body);
|
|
||||||
}
|
|
||||||
// 只解析已配置包名前缀的实体类
|
// 只解析已配置包名前缀的实体类
|
||||||
else if (CommonUtil.matchRegList(entityPackagesReg.getList(), typeName)) {
|
if (CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
|
||||||
try {
|
try {
|
||||||
Class<?> aClass = Class.forName(typeName);
|
Class<?> aClass = Class.forName(v.getRight().getName());
|
||||||
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
|
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
|
||||||
if (!data.isEmpty()) {
|
if (!data.isEmpty()) {
|
||||||
Object entity = data.mapTo(aClass);
|
Object entity = data.mapTo(aClass);
|
||||||
@@ -324,21 +336,17 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
} catch (ClassNotFoundException e) {
|
} catch (ClassNotFoundException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// body 可能是 JsonArray
|
|
||||||
JsonArray bodyArray = ctx.body().asJsonArray();
|
|
||||||
if (bodyArray != null) {
|
|
||||||
methodParametersTemp.forEach((k, v) -> {
|
|
||||||
if (JsonArray.class.getName().equals(v.getRight().getName())) {
|
|
||||||
parameterValueList.put(k, bodyArray);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
} else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
|
||||||
} else if (ctx.body() != null) {
|
&& ctx.body() != null && ctx.body().length() > 0) {
|
||||||
|
try {
|
||||||
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
|
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.debug("Failed to parse body as params: {}", e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析其他参数
|
// 解析其他参数
|
||||||
@@ -357,6 +365,12 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
parameterValueList.put(k, ctx.request());
|
parameterValueList.put(k, ctx.request());
|
||||||
} else if (HttpServerResponse.class.getName().equals(v.getRight().getName())) {
|
} else if (HttpServerResponse.class.getName().equals(v.getRight().getName())) {
|
||||||
parameterValueList.put(k, ctx.response());
|
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
|
} else if (parameterValueList.get(k) == null
|
||||||
&& CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
|
&& CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
|
||||||
// 绑定实体类
|
// 绑定实体类
|
||||||
@@ -367,48 +381,45 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
} else if (parameterValueList.get(k) == null
|
|
||||||
&& JsonObject.class.getName().equals(v.getRight().getName())) {
|
|
||||||
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonObject
|
|
||||||
if (ctx.body() != null) {
|
|
||||||
JsonObject jo = ctx.body().asJsonObject();
|
|
||||||
if (jo != null) parameterValueList.put(k, jo);
|
|
||||||
}
|
|
||||||
} else if (parameterValueList.get(k) == null
|
|
||||||
&& JsonArray.class.getName().equals(v.getRight().getName())) {
|
|
||||||
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonArray
|
|
||||||
if (ctx.body() != null) {
|
|
||||||
JsonArray ja = ctx.body().asJsonArray();
|
|
||||||
if (ja != null) parameterValueList.put(k, ja);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 调用handle 获取响应对象
|
// 调用handle 获取响应对象
|
||||||
Object[] parameterValueArray = parameterValueList.values().toArray(new Object[0]);
|
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 {
|
try {
|
||||||
// 反射调用
|
// 反射调用
|
||||||
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
|
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
|
|
||||||
if (data instanceof JsonResult jsonResult) {
|
if (data instanceof JsonResult) {
|
||||||
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
|
doFireJsonResultResponse(ctx, (JsonResult<?>) data);
|
||||||
}
|
}
|
||||||
if (data instanceof JsonObject) {
|
if (data instanceof JsonObject) {
|
||||||
doFireJsonObjectResponse(ctx, ((JsonObject) data));
|
doFireJsonObjectResponse(ctx, ((JsonObject) data));
|
||||||
} else if (data instanceof Future) { // 处理异步响应
|
} else if (data instanceof Future) { // 处理异步响应
|
||||||
((Future<?>) data).onSuccess(res -> {
|
((Future<?>) data).onSuccess(res -> {
|
||||||
if (res instanceof JsonResult jsonResult) {
|
if (res instanceof JsonResult) {
|
||||||
doFireJsonResultResponse(ctx, jsonResult, jsonResult.getCode());
|
doFireJsonResultResponse(ctx, (JsonResult<?>) res);
|
||||||
}
|
}
|
||||||
if (res instanceof JsonObject) {
|
if (res instanceof JsonObject) {
|
||||||
doFireJsonObjectResponse(ctx, ((JsonObject) res));
|
doFireJsonObjectResponse(ctx, ((JsonObject) res));
|
||||||
} else if (res != null) {
|
} else if (res != null) {
|
||||||
doFireJsonResultResponse(ctx, JsonResult.data(res));
|
doFireJsonResultResponse(ctx, JsonResult.data(res));
|
||||||
} else {
|
} else {
|
||||||
doFireJsonResultResponse(ctx, JsonResult.data(null));
|
handleAfterInterceptor(ctx, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
}).onFailure(e -> doFireJsonResultResponse(ctx, JsonResult.error(e.getMessage()), 500));
|
}).onFailure(e -> doFireJsonResultResponse(ctx, JsonResult.error(e.getMessage())));
|
||||||
} else {
|
} else {
|
||||||
doFireJsonResultResponse(ctx, JsonResult.data(data));
|
doFireJsonResultResponse(ctx, JsonResult.data(data));
|
||||||
}
|
}
|
||||||
@@ -423,7 +434,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
|
|||||||
err = e.getCause().getMessage();
|
err = e.getCause().getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
doFireJsonResultResponse(ctx, JsonResult.error(err), 500);
|
doFireJsonResultResponse(ctx, JsonResult.error(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ package cn.qaiu.vx.core.interceptor;
|
|||||||
import io.vertx.core.Handler;
|
import io.vertx.core.Handler;
|
||||||
import io.vertx.ext.web.RoutingContext;
|
import io.vertx.ext.web.RoutingContext;
|
||||||
|
|
||||||
|
import static cn.qaiu.vx.core.util.ResponseUtil.sendError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 前置拦截器接口
|
* 前置拦截器接口
|
||||||
* <p>
|
|
||||||
* 注意:Vert.x是异步非阻塞框架,不能在Event Loop中使用synchronized等阻塞操作!
|
|
||||||
* 所有操作都应该是非阻塞的,使用Vert.x的上下文数据存储机制保证线程安全。
|
|
||||||
* </p>
|
|
||||||
*
|
*
|
||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
*/
|
*/
|
||||||
@@ -16,25 +14,28 @@ public interface BeforeInterceptor extends Handler<RoutingContext> {
|
|||||||
String IS_NEXT = "RoutingContextIsNext";
|
String IS_NEXT = "RoutingContextIsNext";
|
||||||
|
|
||||||
default Handler<RoutingContext> doHandle() {
|
default Handler<RoutingContext> doHandle() {
|
||||||
|
|
||||||
return ctx -> {
|
return ctx -> {
|
||||||
// 【优化】移除synchronized锁,Vert.x的RoutingContext本身就是线程安全的
|
// 加同步锁
|
||||||
// 每个请求都有独立的RoutingContext,不需要额外加锁
|
synchronized (BeforeInterceptor.class) {
|
||||||
ctx.put(IS_NEXT, false);
|
ctx.put(IS_NEXT, false);
|
||||||
handle(ctx); // 调用具体的处理逻辑
|
BeforeInterceptor.this.handle(ctx);
|
||||||
// 确保如果没有调用doNext()并且响应未结束,则返回错误
|
if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
|
||||||
// if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
|
sendError(ctx, 403);
|
||||||
// sendError(ctx, 403);
|
}
|
||||||
// }
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
default void doNext(RoutingContext context) {
|
default void doNext(RoutingContext context) {
|
||||||
// 【优化】移除synchronized锁
|
// 设置上下文状态为可以继续执行
|
||||||
// RoutingContext的put和next操作是线程安全的,不需要额外同步
|
// 添加同步锁保障多线程下执行时序
|
||||||
|
synchronized (BeforeInterceptor.class) {
|
||||||
context.put(IS_NEXT, true);
|
context.put(IS_NEXT, true);
|
||||||
context.next(); // 继续执行下一个处理器
|
context.next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void handle(RoutingContext context); // 实现具体的拦截处理逻辑
|
void handle(RoutingContext context);
|
||||||
}
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* ModuleGen cn.qaiu.vx.core
|
* ModuleGen cn.qaiu.vx.core
|
||||||
*/
|
*/
|
||||||
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core")
|
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core", useFutures = true)
|
||||||
package cn.qaiu.vx.core;
|
package cn.qaiu.vx.core;
|
||||||
|
|
||||||
import io.vertx.codegen.annotations.ModuleGen;
|
import io.vertx.codegen.annotations.ModuleGen;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import io.vertx.serviceproxy.ServiceProxyBuilder;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Xu Haidong
|
* @author Xu Haidong
|
||||||
* @date 2018/8/15
|
* Create at 2018/8/15
|
||||||
*/
|
*/
|
||||||
public final class AsyncServiceUtil {
|
public final class AsyncServiceUtil {
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import java.net.Socket;
|
|||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -118,7 +117,7 @@ public class CommonUtil {
|
|||||||
return set.stream().filter(c1 -> {
|
return set.stream().filter(c1 -> {
|
||||||
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
|
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
|
||||||
if (s1 != null) {
|
if (s1 != null) {
|
||||||
return s1.value() >= 0;
|
return s1.value() > 0;
|
||||||
} else {
|
} else {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -139,7 +138,7 @@ public class CommonUtil {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}).collect(Collectors.toCollection(LinkedHashSet::new));
|
}).collect(Collectors.toSet());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String appVersion;
|
private static String appVersion;
|
||||||
|
|||||||
@@ -4,13 +4,9 @@ import io.vertx.config.ConfigRetriever;
|
|||||||
import io.vertx.config.ConfigRetrieverOptions;
|
import io.vertx.config.ConfigRetrieverOptions;
|
||||||
import io.vertx.config.ConfigStoreOptions;
|
import io.vertx.config.ConfigStoreOptions;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.Promise;
|
|
||||||
import io.vertx.core.Vertx;
|
import io.vertx.core.Vertx;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步读取配置工具类
|
* 异步读取配置工具类
|
||||||
* <br>Create date 2021/9/2 1:23
|
* <br>Create date 2021/9/2 1:23
|
||||||
@@ -28,29 +24,7 @@ public class ConfigUtil {
|
|||||||
* @return JsonObject的Future
|
* @return JsonObject的Future
|
||||||
*/
|
*/
|
||||||
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
|
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
|
||||||
// 支持 classpath: 前缀从类路径读取,否则从文件系统读取
|
// 读取yml配置
|
||||||
if (path != null && path.startsWith("classpath:")) {
|
|
||||||
String resource = path.substring("classpath:".length());
|
|
||||||
// 使用 executeBlocking(Callable) 直接返回 Future<JsonObject>
|
|
||||||
return vertx.executeBlocking(() -> {
|
|
||||||
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
|
|
||||||
if (is == null) {
|
|
||||||
throw new RuntimeException("classpath resource not found: " + resource);
|
|
||||||
}
|
|
||||||
try (InputStream in = is) {
|
|
||||||
byte[] bytes = in.readAllBytes();
|
|
||||||
String content = new String(bytes, StandardCharsets.UTF_8);
|
|
||||||
if ("json".equalsIgnoreCase(format)) {
|
|
||||||
return new JsonObject(content);
|
|
||||||
} else {
|
|
||||||
throw new RuntimeException("unsupported classpath format: " + format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise<JsonObject> promise = Promise.promise();
|
|
||||||
|
|
||||||
ConfigStoreOptions store = new ConfigStoreOptions()
|
ConfigStoreOptions store = new ConfigStoreOptions()
|
||||||
.setType("file")
|
.setType("file")
|
||||||
.setFormat(format)
|
.setFormat(format)
|
||||||
@@ -59,22 +33,10 @@ public class ConfigUtil {
|
|||||||
ConfigRetriever retriever = ConfigRetriever
|
ConfigRetriever retriever = ConfigRetriever
|
||||||
.create(vertx, new ConfigRetrieverOptions().addStore(store));
|
.create(vertx, new ConfigRetrieverOptions().addStore(store));
|
||||||
|
|
||||||
// 异步获取配置
|
return retriever.getConfig();
|
||||||
// 成功直接完成 promise
|
|
||||||
retriever.getConfig()
|
|
||||||
.onSuccess(promise::complete)
|
|
||||||
.onFailure(err -> {
|
|
||||||
// 配置读取失败,直接返回失败 Future
|
|
||||||
promise.fail(new RuntimeException(
|
|
||||||
"读取配置文件失败: " + path, err));
|
|
||||||
retriever.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return promise.future();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步读取Yaml配置文件
|
* 异步读取Yaml配置文件
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.util;
|
|
||||||
|
|
||||||
import io.vertx.core.Future;
|
|
||||||
import io.vertx.core.Promise;
|
|
||||||
|
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
|
|
||||||
public class FutureUtils {
|
|
||||||
|
|
||||||
public static <T> T getResult(Future<T> future) {
|
|
||||||
try {
|
|
||||||
return future.toCompletionStage().toCompletableFuture().get();
|
|
||||||
} catch (InterruptedException | ExecutionException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public static <T> T getResult(Promise<T> promise) {
|
|
||||||
return promise.future().toCompletionStage().toCompletableFuture().join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
* @date 2023/10/14 9:07
|
* Create at 2023/10/14 9:07
|
||||||
*/
|
*/
|
||||||
public class JacksonConfig {
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
|
|||||||
*/
|
*/
|
||||||
public final class ReflectionUtil {
|
public final class ReflectionUtil {
|
||||||
|
|
||||||
// 缓存Reflections实例,避免重复扫描(每次扫描约35K+值,耗时1-3秒,占用大量内存)
|
|
||||||
private static final Map<String, Reflections> REFLECTIONS_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 以默认配置的基础包路径获取反射器
|
* 以默认配置的基础包路径获取反射器
|
||||||
@@ -49,41 +47,43 @@ public final class ReflectionUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取反射器(带缓存)
|
* 获取反射器
|
||||||
*
|
*
|
||||||
* @param packageAddress Package address String
|
* @param packageAddress Package address String
|
||||||
* @return Reflections object
|
* @return Reflections object
|
||||||
*/
|
*/
|
||||||
public static Reflections getReflections(String packageAddress) {
|
public static Reflections getReflections(String packageAddress) {
|
||||||
return REFLECTIONS_CACHE.computeIfAbsent(packageAddress, key -> {
|
|
||||||
List<String> packageAddressList;
|
List<String> packageAddressList;
|
||||||
if (key.contains(",")) {
|
if (packageAddress.contains(",")) {
|
||||||
packageAddressList = Arrays.asList(key.split(","));
|
packageAddressList = Arrays.asList(packageAddress.split(","));
|
||||||
} else if (key.contains(";")) {
|
} else if (packageAddress.contains(";")) {
|
||||||
packageAddressList = Arrays.asList(key.split(";"));
|
packageAddressList = Arrays.asList(packageAddress.split(";"));
|
||||||
} else {
|
} else {
|
||||||
packageAddressList = Collections.singletonList(key);
|
packageAddressList = Collections.singletonList(packageAddress);
|
||||||
}
|
}
|
||||||
return createReflections(packageAddressList);
|
|
||||||
});
|
return getReflections(packageAddressList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取反射器(带缓存)
|
* 获取反射器
|
||||||
*
|
*
|
||||||
* @param packageAddresses Package address List
|
* @param packageAddresses Package address List
|
||||||
* @return Reflections object
|
* @return Reflections object
|
||||||
*/
|
*/
|
||||||
public static Reflections getReflections(List<String> packageAddresses) {
|
public static Reflections getReflections(List<String> packageAddresses) {
|
||||||
String cacheKey = String.join(",", packageAddresses);
|
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
|
||||||
return REFLECTIONS_CACHE.computeIfAbsent(cacheKey, key -> createReflections(packageAddresses));
|
FilterBuilder filterBuilder = new FilterBuilder();
|
||||||
}
|
packageAddresses.forEach(str -> {
|
||||||
|
Collection<URL> urls = ClasspathHelper.forPackage(str.trim());
|
||||||
|
configurationBuilder.addUrls(urls);
|
||||||
|
filterBuilder.includePackage(str.trim());
|
||||||
|
});
|
||||||
|
|
||||||
private static Reflections createReflections(List<String> packageAddresses) {
|
// 采坑记录 2021-05-08
|
||||||
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
|
// 发现注解api层 没有继承父类时 这里反射一直有问题(Scanner SubTypesScanner was not configured)
|
||||||
.addClassLoaders(Thread.currentThread().getContextClassLoader())
|
// 因此这里需要手动配置各种Scanner扫描器 -- https://blog.csdn.net/qq_29499107/article/details/106889781
|
||||||
.forPackages(packageAddresses.toArray(new String[0]))
|
configurationBuilder.setScanners(
|
||||||
.setScanners(
|
|
||||||
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
|
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
|
||||||
// 会报错.默认为true.
|
// 会报错.默认为true.
|
||||||
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
|
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
|
||||||
@@ -91,6 +91,8 @@ public final class ReflectionUtil {
|
|||||||
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
|
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
|
||||||
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
|
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
|
||||||
);
|
);
|
||||||
|
|
||||||
|
configurationBuilder.filterInputsBy(filterBuilder);
|
||||||
return new Reflections(configurationBuilder);
|
return new Reflections(configurationBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ public class ResponseUtil {
|
|||||||
|
|
||||||
public static void redirect(HttpServerResponse response, String url) {
|
public static void redirect(HttpServerResponse response, String url) {
|
||||||
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
|
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
|
||||||
.putHeader("Referrer-Policy", "no-referrer")
|
|
||||||
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
|
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,22 +22,14 @@ public class ResponseUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
|
||||||
fireJsonObjectResponse(ctx, jsonObject, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
|
|
||||||
fireJsonObjectResponse(ctx, jsonObject, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
|
|
||||||
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||||
.setStatusCode(statusCode)
|
.setStatusCode(200)
|
||||||
.end(jsonObject.encode());
|
.end(jsonObject.encode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject, int statusCode) {
|
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
|
||||||
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
|
||||||
.setStatusCode(statusCode)
|
.setStatusCode(200)
|
||||||
.end(jsonObject.encode());
|
.end(jsonObject.encode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,10 +37,6 @@ public class ResponseUtil {
|
|||||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static <T> void fireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
|
|
||||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject(), statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static <T> void fireJsonResultResponse(HttpServerResponse ctx, JsonResult<T> jsonResult) {
|
public static <T> void fireJsonResultResponse(HttpServerResponse ctx, JsonResult<T> jsonResult) {
|
||||||
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,50 @@
|
|||||||
package cn.qaiu.vx.core.verticle;
|
package cn.qaiu.vx.core.verticle;
|
||||||
|
|
||||||
import io.vertx.core.AbstractVerticle;
|
import io.vertx.core.AbstractVerticle;
|
||||||
|
import io.vertx.core.Vertx;
|
||||||
|
import io.vertx.core.VertxOptions;
|
||||||
|
import io.vertx.core.dns.AddressResolverOptions;
|
||||||
import io.vertx.core.http.*;
|
import io.vertx.core.http.*;
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
import io.vertx.core.net.NetClient;
|
import io.vertx.core.net.NetClient;
|
||||||
import io.vertx.core.net.NetClientOptions;
|
import io.vertx.core.net.NetClientOptions;
|
||||||
|
import io.vertx.core.net.NetSocket;
|
||||||
import io.vertx.core.net.ProxyOptions;
|
import io.vertx.core.net.ProxyOptions;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
import static cn.qaiu.vx.core.util.ConfigConstant.GLOBAL_CONFIG;
|
|
||||||
import static cn.qaiu.vx.core.util.ConfigConstant.LOCAL;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
public class HttpProxyVerticle extends AbstractVerticle {
|
public class HttpProxyVerticle extends AbstractVerticle {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(HttpProxyVerticle.class);
|
|
||||||
|
|
||||||
private HttpClient httpClient;
|
private HttpClient httpClient;
|
||||||
private NetClient netClient;
|
private NetClient netClient;
|
||||||
|
|
||||||
private JsonObject proxyPreConf;
|
|
||||||
private JsonObject proxyServerConf;
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start() {
|
public void start() {
|
||||||
proxyServerConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
|
ProxyOptions proxyOptions = new ProxyOptions().setHost("127.0.0.1").setPort(7890);
|
||||||
proxyPreConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-pre");
|
|
||||||
Integer serverPort = proxyServerConf.getInteger("port");
|
|
||||||
|
|
||||||
ProxyOptions proxyOptions = null;
|
|
||||||
if (proxyPreConf != null && StringUtils.isNotBlank(proxyPreConf.getString("ip"))) {
|
|
||||||
proxyOptions = new ProxyOptions(proxyPreConf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
|
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
|
||||||
HttpClientOptions httpClientOptions = new HttpClientOptions();
|
HttpClientOptions httpClientOptions = new HttpClientOptions();
|
||||||
if (proxyOptions != null) {
|
httpClient = vertx.createHttpClient(httpClientOptions.setProxyOptions(proxyOptions));
|
||||||
httpClientOptions.setProxyOptions(proxyOptions);
|
|
||||||
}
|
|
||||||
httpClient = vertx.createHttpClient(httpClientOptions);
|
|
||||||
|
|
||||||
// 创建并启动 HTTP 代理服务器,监听指定端口
|
// 创建并启动 HTTP 代理服务器,监听指定端口
|
||||||
HttpServerOptions httpServerOptions = new HttpServerOptions();
|
HttpServer server = vertx.createHttpServer(new HttpServerOptions().setClientAuth(ClientAuth.REQUIRED));
|
||||||
if (proxyServerConf.containsKey("username") &&
|
|
||||||
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
|
|
||||||
httpServerOptions.setClientAuth(ClientAuth.REQUIRED);
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpServer server = vertx.createHttpServer();
|
|
||||||
server.requestHandler(this::handleClientRequest);
|
server.requestHandler(this::handleClientRequest);
|
||||||
|
|
||||||
// 初始化 NetClient,用于在 CONNECT 请求中建立 TCP 连接隧道
|
// 初始化 NetClient,用于在 CONNECT 请求中建立 TCP 连接隧道
|
||||||
NetClientOptions netClientOptions = new NetClientOptions();
|
netClient = vertx.createNetClient(new NetClientOptions()
|
||||||
|
.setProxyOptions(proxyOptions)
|
||||||
if (proxyOptions != null) {
|
|
||||||
httpClientOptions.setProxyOptions(proxyOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
netClient = vertx.createNetClient(netClientOptions
|
|
||||||
.setConnectTimeout(15000)
|
.setConnectTimeout(15000)
|
||||||
.setTrustAll(true));
|
.setTrustAll(true));
|
||||||
|
|
||||||
// 启动 HTTP 代理服务器
|
// 启动 HTTP 代理服务器
|
||||||
server.listen(serverPort)
|
server.listen(7891, ar -> {
|
||||||
.onSuccess(res-> LOGGER.info("HTTP Proxy server started on port {}", serverPort))
|
if (ar.succeeded()) {
|
||||||
.onFailure(err-> LOGGER.error("Failed to start HTTP Proxy server: " + err.getMessage()));
|
System.out.println("HTTP Proxy server started on port 7891");
|
||||||
|
} else {
|
||||||
|
System.err.println("Failed to start HTTP Proxy server: " + ar.cause());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
|
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
|
||||||
@@ -93,37 +66,37 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
clientRequest.pause();
|
clientRequest.pause();
|
||||||
// 通过 NetClient 连接目标服务器并创建隧道
|
// 通过 NetClient 连接目标服务器并创建隧道
|
||||||
netClient.connect(targetPort, targetHost)
|
netClient.connect(targetPort, targetHost, connectionAttempt -> {
|
||||||
.onSuccess(targetSocket -> {
|
if (connectionAttempt.succeeded()) {
|
||||||
// Upgrade client connection to NetSocket and implement bidirectional data flow
|
NetSocket targetSocket = connectionAttempt.result();
|
||||||
clientRequest.toNetSocket()
|
|
||||||
.onSuccess(clientSocket -> {
|
// 升级客户端连接到 NetSocket 并实现双向数据流
|
||||||
// Set up bidirectional data forwarding
|
clientRequest.toNetSocket().onComplete(clientSocketAttempt -> {
|
||||||
|
if (clientSocketAttempt.succeeded()) {
|
||||||
|
NetSocket clientSocket = clientSocketAttempt.result();
|
||||||
|
|
||||||
|
// 设置双向数据流转发
|
||||||
clientSocket.handler(targetSocket::write);
|
clientSocket.handler(targetSocket::write);
|
||||||
targetSocket.handler(clientSocket::write);
|
targetSocket.handler(clientSocket::write);
|
||||||
|
|
||||||
// Close the other socket when one side closes
|
// 关闭其中一方时关闭另一方
|
||||||
clientSocket.closeHandler(v -> targetSocket.close());
|
clientSocket.closeHandler(v -> targetSocket.close());
|
||||||
targetSocket.closeHandler(v -> clientSocket.close());
|
targetSocket.closeHandler(v -> clientSocket.close());
|
||||||
})
|
} else {
|
||||||
.onFailure(clientSocketAttempt -> {
|
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.cause().getMessage());
|
||||||
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.getMessage());
|
|
||||||
targetSocket.close();
|
targetSocket.close();
|
||||||
clientRequest.response().setStatusCode(500).end("Internal Server Error");
|
clientRequest.response().setStatusCode(500).end("Internal Server Error");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})
|
} else {
|
||||||
.onFailure(connectionAttempt -> {
|
System.err.println("Failed to connect to target: " + connectionAttempt.cause().getMessage());
|
||||||
System.err.println("Failed to connect to target: " + connectionAttempt.getMessage());
|
|
||||||
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
|
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理客户端的 HTTP 请求
|
// 处理客户端的 HTTP 请求
|
||||||
private void handleClientRequest(HttpServerRequest clientRequest) {
|
private void handleClientRequest(HttpServerRequest clientRequest) {
|
||||||
// 打印来源ip和访问目标URI
|
|
||||||
LOGGER.debug("source: {}, target: {}", clientRequest.remoteAddress().toString(), clientRequest.uri());
|
|
||||||
if (proxyServerConf.containsKey("username") &&
|
|
||||||
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
|
|
||||||
String s = clientRequest.headers().get("Proxy-Authorization");
|
String s = clientRequest.headers().get("Proxy-Authorization");
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
clientRequest.response().setStatusCode(403).end();
|
clientRequest.response().setStatusCode(403).end();
|
||||||
@@ -131,17 +104,12 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
String[] split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
|
String[] split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
|
||||||
if (split.length > 1) {
|
if (split.length > 1) {
|
||||||
|
System.out.println(split[0]);
|
||||||
|
System.out.println(split[1]);
|
||||||
// TODO
|
// TODO
|
||||||
String username = proxyServerConf.getString("username");
|
|
||||||
String password = proxyServerConf.getString("password");
|
|
||||||
if (!split[0].equals(username) || !split[1].equals(password)) {
|
|
||||||
LOGGER.info("-----auth failed------\nusername: {}\npassword: {}", username, password);
|
|
||||||
clientRequest.response().setStatusCode(403).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (clientRequest.method() == HttpMethod.CONNECT) {
|
if (clientRequest.method() == HttpMethod.CONNECT) {
|
||||||
// 处理 CONNECT 请求
|
// 处理 CONNECT 请求
|
||||||
handleConnectRequest(clientRequest);
|
handleConnectRequest(clientRequest);
|
||||||
@@ -161,7 +129,7 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String targetHost = hostHeader.split(":")[0];
|
String targetHost = hostHeader.split(":")[0];
|
||||||
int targetPort = extractPortFromUrl(clientRequest.uri()); // 默认为 HTTP 的端口
|
int targetPort = 80; // 默认为 HTTP 的端口
|
||||||
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
|
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
|
||||||
|
|
||||||
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
|
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
|
||||||
@@ -172,19 +140,16 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
|||||||
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
|
||||||
|
|
||||||
// 将客户端请求的 body 转发给目标服务器
|
// 将客户端请求的 body 转发给目标服务器
|
||||||
clientRequest.bodyHandler(body ->
|
clientRequest.bodyHandler(body -> request.send(body, ar -> {
|
||||||
request.send(body)
|
if (ar.succeeded()) {
|
||||||
.onSuccess(response -> {
|
var response = ar.result();
|
||||||
clientRequest.response().setStatusCode(response.statusCode());
|
clientRequest.response().setStatusCode(response.statusCode());
|
||||||
clientRequest.response().headers().setAll(response.headers());
|
clientRequest.response().headers().setAll(response.headers());
|
||||||
response.body()
|
response.body().onSuccess(b-> clientRequest.response().end(b));
|
||||||
.onSuccess(b -> clientRequest.response().end(b))
|
} else {
|
||||||
.onFailure(err -> clientRequest.response()
|
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to reach target");
|
||||||
.setStatusCode(502).end("Bad Gateway: Unable to reach target"));
|
}
|
||||||
})
|
}));
|
||||||
.onFailure(err -> clientRequest.response()
|
|
||||||
.setStatusCode(502).end("Bad Gateway: Unable to reach target"))
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.onFailure(err -> {
|
.onFailure(err -> {
|
||||||
err.printStackTrace();
|
err.printStackTrace();
|
||||||
@@ -192,43 +157,28 @@ public class HttpProxyVerticle extends AbstractVerticle {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 URL 中提取端口号
|
|
||||||
*
|
|
||||||
* @param urlString URL 字符串
|
|
||||||
* @return 提取的端口号,如果没有指定端口,则返回默认端口
|
|
||||||
*/
|
|
||||||
public static int extractPortFromUrl(String urlString) {
|
|
||||||
try {
|
|
||||||
URI uri = new URI(urlString);
|
|
||||||
int port = uri.getPort();
|
|
||||||
// 如果 URL 没有指定端口,使用默认端口
|
|
||||||
if (port == -1) {
|
|
||||||
if ("https".equalsIgnoreCase(uri.getScheme())) {
|
|
||||||
port = 443; // HTTPS 默认端口
|
|
||||||
} else {
|
|
||||||
port = 80; // HTTP 默认端口
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
// 出现异常时返回 -1,表示提取失败
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void stop() {
|
public void stop() {
|
||||||
// 停止 HTTP 客户端以释放资源
|
// 停止 HTTP 客户端以释放资源
|
||||||
if (httpClient != null) {
|
if (httpClient != null) {
|
||||||
httpClient.close();
|
httpClient.close();
|
||||||
}
|
}
|
||||||
if (netClient != null) {
|
|
||||||
netClient.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO add Deploy
|
||||||
|
* @param args
|
||||||
|
*/
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// 配置 DNS 解析器,使用多个 DNS 服务器来提升解析速度
|
||||||
|
Vertx vertx = Vertx.vertx(new VertxOptions()
|
||||||
|
.setAddressResolverOptions(new AddressResolverOptions()
|
||||||
|
.addServer("114.114.114.114")
|
||||||
|
.addServer("114.114.115.115")
|
||||||
|
.addServer("8.8.8.8")
|
||||||
|
.addServer("8.8.4.4")));
|
||||||
|
|
||||||
|
// 部署 Verticle 并启动动态 HTTP 代理服务器
|
||||||
|
vertx.deployVerticle(new HttpProxyVerticle());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.verticle;
|
|
||||||
|
|
||||||
import cn.qaiu.vx.core.base.AppRun;
|
|
||||||
import cn.qaiu.vx.core.base.DefaultAppRun;
|
|
||||||
import cn.qaiu.vx.core.util.CommonUtil;
|
|
||||||
import cn.qaiu.vx.core.util.ReflectionUtil;
|
|
||||||
import cn.qaiu.vx.core.util.SharedDataUtil;
|
|
||||||
import io.vertx.core.AbstractVerticle;
|
|
||||||
import io.vertx.core.Promise;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
import org.reflections.Reflections;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 后置执行Verticle - 在core启动后立即执行AppRun实现
|
|
||||||
* <br>Create date 2024-01-01 00:00:00
|
|
||||||
*
|
|
||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
|
||||||
*/
|
|
||||||
public class PostExecVerticle extends AbstractVerticle {
|
|
||||||
|
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger(PostExecVerticle.class);
|
|
||||||
private static final Set<AppRun> appRunImplementations;
|
|
||||||
private static final AtomicBoolean lock = new AtomicBoolean(false);
|
|
||||||
|
|
||||||
static {
|
|
||||||
Reflections reflections = ReflectionUtil.getReflections();
|
|
||||||
Set<Class<? extends AppRun>> subTypesOf = reflections.getSubTypesOf(AppRun.class);
|
|
||||||
subTypesOf.add(DefaultAppRun.class);
|
|
||||||
appRunImplementations = CommonUtil.sortClassSet(subTypesOf);
|
|
||||||
if (appRunImplementations.isEmpty()) {
|
|
||||||
LOGGER.warn("未找到 AppRun 接口的实现类");
|
|
||||||
} else {
|
|
||||||
LOGGER.info("找到 {} 个 AppRun 接口的实现类", appRunImplementations.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void start(Promise<Void> startPromise) {
|
|
||||||
if (!lock.compareAndSet(false, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
LOGGER.info("PostExecVerticle 开始执行...");
|
|
||||||
|
|
||||||
if (appRunImplementations != null && !appRunImplementations.isEmpty()) {
|
|
||||||
appRunImplementations.forEach(appRun -> {
|
|
||||||
try {
|
|
||||||
LOGGER.info("执行 AppRun 实现: {}", appRun.getClass().getName());
|
|
||||||
JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
|
|
||||||
appRun.execute(globalConfig);
|
|
||||||
LOGGER.info("AppRun 实现 {} 执行完成", appRun.getClass().getName());
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.error("执行 AppRun 实现 {} 时发生错误",appRun.getClass().getName(), e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
LOGGER.info("未找到 AppRun 接口的实现类");
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGER.info("PostExecVerticle 执行完成");
|
|
||||||
startPromise.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,8 @@ import io.vertx.core.AbstractVerticle;
|
|||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.http.HttpClient;
|
import io.vertx.core.http.HttpClient;
|
||||||
import io.vertx.core.http.HttpClientOptions;
|
|
||||||
import io.vertx.core.http.HttpServer;
|
import io.vertx.core.http.HttpServer;
|
||||||
import io.vertx.core.http.HttpServerOptions;
|
import io.vertx.core.http.HttpServerOptions;
|
||||||
import io.vertx.core.http.HttpServerRequest;
|
|
||||||
import io.vertx.core.json.JsonArray;
|
import io.vertx.core.json.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.core.net.PemKeyCertOptions;
|
import io.vertx.core.net.PemKeyCertOptions;
|
||||||
@@ -17,9 +15,6 @@ import io.vertx.ext.web.Router;
|
|||||||
import io.vertx.ext.web.handler.StaticHandler;
|
import io.vertx.ext.web.handler.StaticHandler;
|
||||||
import io.vertx.ext.web.proxy.handler.ProxyHandler;
|
import io.vertx.ext.web.proxy.handler.ProxyHandler;
|
||||||
import io.vertx.httpproxy.HttpProxy;
|
import io.vertx.httpproxy.HttpProxy;
|
||||||
import io.vertx.httpproxy.ProxyContext;
|
|
||||||
import io.vertx.httpproxy.ProxyInterceptor;
|
|
||||||
import io.vertx.httpproxy.ProxyResponse;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -27,16 +22,12 @@ import org.slf4j.LoggerFactory;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.net.MalformedURLException;
|
import java.net.MalformedURLException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <p>反向代理服务</p>
|
* <p>反向代理服务</p>
|
||||||
* <p>可以根据配置文件自动生成代理服务</p>
|
* <p>可以根据配置文件自动生成代理服务</p>
|
||||||
* <p>可以配置多个服务, 配置文件见示例</p>
|
* <p>可以配置多个服务, 配置文件见示例</p>
|
||||||
* <p>【优化】支持高并发场景,连接池复用,避免线程阻塞</p>
|
|
||||||
* <br>Create date 2021/9/2 0:41
|
* <br>Create date 2021/9/2 0:41
|
||||||
*
|
*
|
||||||
* @author <a href="https://qaiu.top">QAIU</a>
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
@@ -55,83 +46,14 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
public static String REROUTE_PATH_PREFIX = "/__rrvpspp"; //re_route_vert_proxy_server_path_prefix 硬编码
|
public static String REROUTE_PATH_PREFIX = "/__rrvpspp"; //re_route_vert_proxy_server_path_prefix 硬编码
|
||||||
|
|
||||||
/**
|
|
||||||
* 【优化】HttpClient连接池,按host:port缓存复用,避免每个请求都创建新连接
|
|
||||||
*/
|
|
||||||
private final Map<String, HttpClient> httpClientPool = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 【优化】高并发场景下的HttpClient配置
|
|
||||||
*/
|
|
||||||
private static final int MAX_POOL_SIZE = 100; // 最大连接池大小
|
|
||||||
private static final int MAX_WAIT_QUEUE_SIZE = 500; // 最大等待队列大小
|
|
||||||
private static final int CONNECT_TIMEOUT = 30000; // 连接超时30秒
|
|
||||||
private static final int IDLE_TIMEOUT = 60; // 空闲超时60秒
|
|
||||||
private static final boolean KEEP_ALIVE = true; // 启用Keep-Alive
|
|
||||||
private static final boolean PIPELINING = true; // 启用HTTP管线化
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Promise<Void> startPromise) {
|
public void start(Promise<Void> startPromise) {
|
||||||
CONFIG.onSuccess(this::handleProxyConfList).onFailure(e -> {
|
CONFIG.onSuccess(this::handleProxyConfList);
|
||||||
LOGGER.info("web代理配置已禁用,当前仅支持API调用");
|
|
||||||
});
|
|
||||||
// createFileListener
|
// createFileListener
|
||||||
startPromise.complete();
|
startPromise.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 【优化】Verticle停止时清理HttpClient连接池
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void stop(Promise<Void> stopPromise) {
|
|
||||||
LOGGER.info("Stopping ReverseProxyVerticle, closing {} HttpClient connections...", httpClientPool.size());
|
|
||||||
httpClientPool.values().forEach(client -> {
|
|
||||||
try {
|
|
||||||
client.close();
|
|
||||||
} catch (Exception e) {
|
|
||||||
LOGGER.warn("Error closing HttpClient: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
httpClientPool.clear();
|
|
||||||
stopPromise.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 【优化】获取或创建HttpClient,实现连接池复用
|
|
||||||
* @param host 目标主机
|
|
||||||
* @param port 目标端口
|
|
||||||
* @return HttpClient实例
|
|
||||||
*/
|
|
||||||
private HttpClient getOrCreateHttpClient(String host, int port) {
|
|
||||||
String key = host + ":" + port;
|
|
||||||
return httpClientPool.computeIfAbsent(key, k -> {
|
|
||||||
LOGGER.info("Creating new HttpClient for {}", key);
|
|
||||||
HttpClientOptions options = new HttpClientOptions()
|
|
||||||
.setMaxPoolSize(MAX_POOL_SIZE) // 连接池大小
|
|
||||||
.setMaxWaitQueueSize(MAX_WAIT_QUEUE_SIZE) // 等待队列大小
|
|
||||||
.setConnectTimeout(CONNECT_TIMEOUT) // 连接超时
|
|
||||||
.setIdleTimeout(IDLE_TIMEOUT) // 空闲超时
|
|
||||||
.setKeepAlive(KEEP_ALIVE) // Keep-Alive
|
|
||||||
.setKeepAliveTimeout(120) // Keep-Alive超时120秒
|
|
||||||
.setPipelining(PIPELINING) // HTTP管线化
|
|
||||||
.setPipeliningLimit(10) // 管线化限制
|
|
||||||
.setDecompressionSupported(true) // 支持解压响应
|
|
||||||
.setTcpKeepAlive(true) // TCP Keep-Alive
|
|
||||||
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
|
|
||||||
.setTcpFastOpen(true) // 启用TCP Fast Open
|
|
||||||
.setTcpQuickAck(true) // 启用TCP Quick ACK
|
|
||||||
.setReuseAddress(true) // 允许地址重用
|
|
||||||
.setReusePort(true); // 允许端口重用
|
|
||||||
return vertx.createHttpClient(options);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局可信上游代理 IP 集合(如 nginx),仅这些 IP 的 X-Forwarded-For 会被信任
|
|
||||||
*/
|
|
||||||
private Set<String> globalTrustedProxies = new HashSet<>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取主配置文件
|
* 获取主配置文件
|
||||||
*
|
*
|
||||||
@@ -139,15 +61,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
*/
|
*/
|
||||||
private void handleProxyConfList(JsonObject config) {
|
private void handleProxyConfList(JsonObject config) {
|
||||||
serverName = config.getString("server-name");
|
serverName = config.getString("server-name");
|
||||||
// 解析全局 trusted-proxies
|
|
||||||
JsonArray trustedArr = config.getJsonArray("trusted-proxies");
|
|
||||||
if (trustedArr != null) {
|
|
||||||
trustedArr.forEach(ip -> {
|
|
||||||
if (ip instanceof String) {
|
|
||||||
globalTrustedProxies.add(((String) ip).trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
JsonArray proxyConfList = config.getJsonArray("proxy");
|
JsonArray proxyConfList = config.getJsonArray("proxy");
|
||||||
if (proxyConfList != null) {
|
if (proxyConfList != null) {
|
||||||
proxyConfList.forEach(proxyConf -> {
|
proxyConfList.forEach(proxyConf -> {
|
||||||
@@ -158,44 +71,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析真实客户端 IP。
|
|
||||||
* 若直连来源在可信代理列表中,优先取 X-Real-IP,其次取 X-Forwarded-For 第一个值;
|
|
||||||
* 否则直接使用直连对端地址。
|
|
||||||
*/
|
|
||||||
private String resolveClientIp(HttpServerRequest request) {
|
|
||||||
String peerIp = request.remoteAddress().host();
|
|
||||||
if (globalTrustedProxies.contains(peerIp)) {
|
|
||||||
String realIp = request.getHeader("X-Real-IP");
|
|
||||||
if (StringUtils.isNotBlank(realIp)) {
|
|
||||||
return realIp.trim();
|
|
||||||
}
|
|
||||||
String xff = request.getHeader("X-Forwarded-For");
|
|
||||||
if (StringUtils.isNotBlank(xff)) {
|
|
||||||
return xff.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return peerIp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 proxy-set-headers 中的 nginx 风格变量。
|
|
||||||
* 支持:$remote_addr、$proxy_add_x_forwarded_for、$scheme、$host;
|
|
||||||
* 其他值作为字面量直接使用。
|
|
||||||
*/
|
|
||||||
private String resolveHeaderVariable(String tpl, HttpServerRequest req, String clientIp) {
|
|
||||||
return switch (tpl) {
|
|
||||||
case "$remote_addr" -> clientIp;
|
|
||||||
case "$proxy_add_x_forwarded_for" -> {
|
|
||||||
String existing = req.getHeader("X-Forwarded-For");
|
|
||||||
yield StringUtils.isNotBlank(existing) ? existing + ", " + clientIp : clientIp;
|
|
||||||
}
|
|
||||||
case "$scheme" -> req.isSSL() ? "https" : "http";
|
|
||||||
case "$host" -> req.getHeader("Host");
|
|
||||||
default -> tpl;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理单个反向代理配置
|
* 处理单个反向代理配置
|
||||||
*
|
*
|
||||||
@@ -222,25 +97,18 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
proxyConf.put("page404", DEFAULT_PATH_404);
|
proxyConf.put("page404", DEFAULT_PATH_404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
|
||||||
Router proxyRouter = Router.router(vertx);
|
Router proxyRouter = Router.router(vertx);
|
||||||
|
|
||||||
// Add Server name header
|
// Add Server name header
|
||||||
proxyRouter.route().handler(ctx -> {
|
proxyRouter.route().handler(ctx -> {
|
||||||
String realPath = ctx.request().uri();
|
|
||||||
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
|
|
||||||
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
|
|
||||||
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
|
|
||||||
ctx.reroute(rePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.response().putHeader("Server", serverName);
|
ctx.response().putHeader("Server", serverName);
|
||||||
ctx.next();
|
ctx.next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// http api proxy
|
// http api proxy
|
||||||
if (proxyConf.containsKey("location")) {
|
if (proxyConf.containsKey("location")) {
|
||||||
handleLocation(proxyConf.getJsonArray("location"), proxyRouter);
|
handleLocation(proxyConf.getJsonArray("location"), httpClient, proxyRouter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// static server
|
// static server
|
||||||
@@ -249,9 +117,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send page404 page
|
// Send page404 page
|
||||||
proxyRouter.errorHandler(404, ctx -> {
|
proxyRouter.errorHandler(404, ctx -> ctx.response().sendFile(proxyConf.getString("page404")));
|
||||||
ctx.response().sendFile(proxyConf.getString("page404"));
|
|
||||||
});
|
|
||||||
|
|
||||||
HttpServer server = getHttpsServer(proxyConf);
|
HttpServer server = getHttpsServer(proxyConf);
|
||||||
server.requestHandler(proxyRouter);
|
server.requestHandler(proxyRouter);
|
||||||
@@ -263,16 +129,8 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
private HttpServer getHttpsServer(JsonObject proxyConf) {
|
private HttpServer getHttpsServer(JsonObject proxyConf) {
|
||||||
HttpServerOptions httpServerOptions = new HttpServerOptions()
|
HttpServerOptions httpServerOptions = new HttpServerOptions()
|
||||||
// 【优化】高并发服务器配置
|
.setCompressionSupported(true);
|
||||||
.setTcpKeepAlive(true) // TCP Keep-Alive
|
|
||||||
.setTcpNoDelay(true) // 禁用Nagle算法
|
|
||||||
.setCompressionSupported(true) // 启用压缩
|
|
||||||
.setAcceptBacklog(50000) // 增加积压队列到50000
|
|
||||||
.setIdleTimeout(120) // 空闲超时120秒
|
|
||||||
.setTcpFastOpen(true) // 启用TCP Fast Open
|
|
||||||
.setTcpQuickAck(true) // 启用TCP Quick ACK
|
|
||||||
.setReuseAddress(true) // 允许地址重用
|
|
||||||
.setReusePort(true); // 允许端口重用
|
|
||||||
if (proxyConf.containsKey("ssl")) {
|
if (proxyConf.containsKey("ssl")) {
|
||||||
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
|
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
|
||||||
|
|
||||||
@@ -326,6 +184,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
} else {
|
} else {
|
||||||
staticHandler = StaticHandler.create();
|
staticHandler = StaticHandler.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (staticConf.containsKey("directory-listing")) {
|
if (staticConf.containsKey("directory-listing")) {
|
||||||
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
|
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
|
||||||
} else if (staticConf.containsKey("index")) {
|
} else if (staticConf.containsKey("index")) {
|
||||||
@@ -338,9 +197,10 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
* 处理Location配置 代理请求Location(和nginx类似?)
|
* 处理Location配置 代理请求Location(和nginx类似?)
|
||||||
*
|
*
|
||||||
* @param locationsConf location配置
|
* @param locationsConf location配置
|
||||||
|
* @param httpClient 客户端
|
||||||
* @param proxyRouter 代理路由
|
* @param proxyRouter 代理路由
|
||||||
*/
|
*/
|
||||||
private void handleLocation(JsonArray locationsConf, Router proxyRouter) {
|
private void handleLocation(JsonArray locationsConf, HttpClient httpClient, Router proxyRouter) {
|
||||||
|
|
||||||
locationsConf.stream().map(e -> (JsonObject) e).forEach(location -> {
|
locationsConf.stream().map(e -> (JsonObject) e).forEach(location -> {
|
||||||
// 代理规则
|
// 代理规则
|
||||||
@@ -356,33 +216,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
String originPath = url.getPath();
|
String originPath = url.getPath();
|
||||||
LOGGER.info("path {}, originPath {}, to {}:{}", path, originPath, host, port);
|
LOGGER.info("path {}, originPath {}, to {}:{}", path, originPath, host, port);
|
||||||
|
|
||||||
// 【优化】使用连接池获取HttpClient,避免每个location都创建新连接
|
// 注意这里不能origin多个代理地址, 一个实例只能代理一个origin
|
||||||
final HttpClient httpClient = getOrCreateHttpClient(host, port);
|
|
||||||
final HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient);
|
final HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient);
|
||||||
httpProxy.origin(port, host);
|
httpProxy.origin(port, host);
|
||||||
|
|
||||||
// proxy-set-headers 支持(nginx 风格变量替换)
|
|
||||||
if (location.containsKey("proxy-set-headers")) {
|
|
||||||
final JsonObject headerConf = location.getJsonObject("proxy-set-headers");
|
|
||||||
httpProxy.addInterceptor(new ProxyInterceptor() {
|
|
||||||
@Override
|
|
||||||
public Future<ProxyResponse> handleProxyRequest(ProxyContext ctx) {
|
|
||||||
HttpServerRequest incoming = ctx.request().proxiedRequest();
|
|
||||||
String clientIp = resolveClientIp(incoming);
|
|
||||||
headerConf.forEach(entry -> {
|
|
||||||
Object val = entry.getValue();
|
|
||||||
if (val != null) {
|
|
||||||
String resolved = resolveHeaderVariable(val.toString(), incoming, clientIp);
|
|
||||||
if (resolved != null) {
|
|
||||||
ctx.request().putHeader(entry.getKey(), resolved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return ProxyInterceptor.super.handleProxyRequest(ctx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (StringUtils.isEmpty(path)) {
|
if (StringUtils.isEmpty(path)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -391,7 +227,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
|
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
|
||||||
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
|
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
|
||||||
: proxyRouter.route(path);
|
: proxyRouter.route(path);
|
||||||
// 【优化】为代理处理器添加超时
|
|
||||||
route.handler(ProxyHandler.create(httpProxy));
|
route.handler(ProxyHandler.create(httpProxy));
|
||||||
} else {
|
} else {
|
||||||
// 配置 /api/, / => 请求 /api/test 代理后 /test
|
// 配置 /api/, / => 请求 /api/test 代理后 /test
|
||||||
@@ -410,46 +245,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
ctx.next();
|
ctx.next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 计算唯一后缀,避免多个 location 冲突
|
|
||||||
// String uniqueKey = (host + ":" + port + "|" + path).replaceAll("[^a-zA-Z0-9:_|/]", "");
|
|
||||||
// String uniqueSuffix = Integer.toHexString(uniqueKey.hashCode());
|
|
||||||
//
|
|
||||||
//// 规格化 originPath
|
|
||||||
// //String originPath = url.getPath(); // 原值
|
|
||||||
// if (StringUtils.isBlank(originPath)) originPath = "/";
|
|
||||||
//
|
|
||||||
//// 处理 index.html 的情况:用于首页兜底,其它子路径仍按目录穿透
|
|
||||||
// String indexFile;
|
|
||||||
// if (originPath.endsWith(".html")) {
|
|
||||||
// indexFile = originPath; // 例如 /index.html
|
|
||||||
// originPath = "/"; // 目录穿透基准改为根
|
|
||||||
// } else {
|
|
||||||
// indexFile = null;
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//// 唯一内部挂载前缀
|
|
||||||
// final String originMount = REROUTE_PATH_PREFIX + uniqueSuffix + originPath;
|
|
||||||
//
|
|
||||||
//// 1) 目标挂载:所有被重写的请求最终到这里走 ProxyHandler
|
|
||||||
// proxyRouter.route(originMount + "*").handler(ProxyHandler.create(httpProxy));
|
|
||||||
//
|
|
||||||
//// 2) 从外部前缀 -> 内部挂载 的重写
|
|
||||||
// final String path0 = path;
|
|
||||||
// proxyRouter.route(path0 + "*").handler(ctx -> {
|
|
||||||
// String uri = ctx.request().uri();
|
|
||||||
// if (!uri.startsWith(path0)) { ctx.next(); return; }
|
|
||||||
//
|
|
||||||
// // 首页兜底:访问 /n2 或 /n2/ 时,重写到 index.html(如果配置了)
|
|
||||||
// if (indexFile != null && (uri.equals(path0) || uri.equals(path0.substring(0, path0.length()-1)))) {
|
|
||||||
// String rePath = originMount.endsWith("/") ? (originMount + indexFile.substring(1)) : (originMount + indexFile);
|
|
||||||
// ctx.reroute(rePath);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // 一般穿透:/n2/xxx -> originMount + xxx
|
|
||||||
// String rePath = uri.replaceFirst("^" + path0, originMount);
|
|
||||||
// ctx.reroute(rePath);
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (MalformedURLException e) {
|
} catch (MalformedURLException e) {
|
||||||
|
|||||||
@@ -48,19 +48,10 @@ public class RouterVerticle extends AbstractVerticle {
|
|||||||
} else {
|
} else {
|
||||||
options = new HttpServerOptions();
|
options = new HttpServerOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定到 0.0.0.0 以允许外部访问
|
||||||
|
options.setHost("0.0.0.0");
|
||||||
options.setPort(port);
|
options.setPort(port);
|
||||||
|
|
||||||
// 【优化】高并发服务器配置
|
|
||||||
options.setTcpKeepAlive(true) // TCP Keep-Alive
|
|
||||||
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
|
|
||||||
.setCompressionSupported(true) // 启用压缩
|
|
||||||
.setAcceptBacklog(50000) // 增加积压队列到50000,防止高并发时连接被拒绝
|
|
||||||
.setIdleTimeout(120) // 空闲超时120秒
|
|
||||||
.setTcpFastOpen(true) // 启用TCP Fast Open
|
|
||||||
.setTcpQuickAck(true) // 启用TCP Quick ACK
|
|
||||||
.setReuseAddress(true) // 允许地址重用
|
|
||||||
.setReusePort(true); // 允许端口重用
|
|
||||||
|
|
||||||
server = vertx.createHttpServer(options);
|
server = vertx.createHttpServer(options);
|
||||||
|
|
||||||
server.requestHandler(router).webSocketHandler(s->{}).listen()
|
server.requestHandler(router).webSocketHandler(s->{}).listen()
|
||||||
|
|||||||
@@ -29,23 +29,20 @@ public class ServiceVerticle extends AbstractVerticle {
|
|||||||
Reflections reflections = ReflectionUtil.getReflections();
|
Reflections reflections = ReflectionUtil.getReflections();
|
||||||
handlers = reflections.getTypesAnnotatedWith(Service.class);
|
handlers = reflections.getTypesAnnotatedWith(Service.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Promise<Void> startPromise) {
|
public void start(Promise<Void> startPromise) {
|
||||||
ServiceBinder binder = new ServiceBinder(vertx);
|
ServiceBinder binder = new ServiceBinder(vertx);
|
||||||
if (null != handlers && handlers.size() > 0) {
|
if (null != handlers && handlers.size() > 0) {
|
||||||
// handlers转为拼接类列表,xxx,yyy,zzz
|
|
||||||
StringBuilder serviceNames = new StringBuilder();
|
|
||||||
handlers.forEach(asyncService -> {
|
handlers.forEach(asyncService -> {
|
||||||
try {
|
try {
|
||||||
serviceNames.append(asyncService.getName()).append("|");
|
|
||||||
BaseAsyncService asInstance = (BaseAsyncService) ReflectionUtil.newWithNoParam(asyncService);
|
BaseAsyncService asInstance = (BaseAsyncService) ReflectionUtil.newWithNoParam(asyncService);
|
||||||
binder.setAddress(asInstance.getAddress()).register(asInstance.getAsyncInterfaceClass(), asInstance);
|
binder.setAddress(asInstance.getAddress()).register(asInstance.getAsyncInterfaceClass(), asInstance);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.error("Failed to register service: {}", asyncService.getName(), e);
|
LOGGER.error(e.getMessage());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
LOGGER.info("registered async services -> id: {}", ID.getAndIncrement());
|
||||||
LOGGER.info("registered async services -> id: {}, name: {}", ID.getAndIncrement(), serviceNames.toString());
|
|
||||||
}
|
}
|
||||||
startPromise.complete();
|
startPromise.complete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.verticle.conf;
|
|
||||||
|
|
||||||
import io.vertx.codegen.annotations.DataObject;
|
|
||||||
import io.vertx.codegen.json.annotations.JsonGen;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
import io.vertx.core.net.ProxyOptions;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@DataObject
|
|
||||||
//@JsonGen(publicConverter = false)
|
|
||||||
public class HttpProxyConf {
|
|
||||||
|
|
||||||
public static final String DEFAULT_USERNAME = UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
public static final String DEFAULT_PASSWORD = UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
public static final Integer DEFAULT_PORT = 6432;
|
|
||||||
|
|
||||||
public static final Integer DEFAULT_TIMEOUT = 15000;
|
|
||||||
|
|
||||||
Integer timeout;
|
|
||||||
|
|
||||||
String username;
|
|
||||||
|
|
||||||
String password;
|
|
||||||
|
|
||||||
Integer port;
|
|
||||||
|
|
||||||
ProxyOptions preProxyOptions;
|
|
||||||
|
|
||||||
public HttpProxyConf() {
|
|
||||||
this.username = DEFAULT_USERNAME;
|
|
||||||
this.password = DEFAULT_PASSWORD;
|
|
||||||
this.timeout = DEFAULT_PORT;
|
|
||||||
this.timeout = DEFAULT_TIMEOUT;
|
|
||||||
this.preProxyOptions = new ProxyOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpProxyConf(JsonObject json) {
|
|
||||||
this();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public Integer getTimeout() {
|
|
||||||
return timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpProxyConf setTimeout(Integer timeout) {
|
|
||||||
this.timeout = timeout;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUsername() {
|
|
||||||
return username;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpProxyConf setUsername(String username) {
|
|
||||||
this.username = username;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPassword() {
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpProxyConf setPassword(String password) {
|
|
||||||
this.password = password;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getPort() {
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpProxyConf setPort(Integer port) {
|
|
||||||
this.port = port;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ProxyOptions getPreProxyOptions() {
|
|
||||||
return preProxyOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public HttpProxyConf setPreProxyOptions(ProxyOptions preProxyOptions) {
|
|
||||||
this.preProxyOptions = preProxyOptions;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
app.version=${project.version}
|
app.version=${project.version}
|
||||||
build=${build.timestamp}
|
build=${maven.build.timestamp}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.test;
|
|
||||||
|
|
||||||
import io.vertx.core.json.JsonArray;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
import org.junit.Assert;
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 单元测试:验证 RouterHandlerFactory 关于 JsonObject/JsonArray 参数绑定的核心分支逻辑是否正确
|
|
||||||
* (不启动整个 Vert.x 服务器,直接用 Vert.x JsonObject/JsonArray API 模拟验证关键逻辑)
|
|
||||||
*/
|
|
||||||
public class JsonBodyBindingLogicTest {
|
|
||||||
|
|
||||||
// === 模拟 handlerMethod 中的 JSON body 绑定逻辑 ===
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模拟:content-type = application/json,body 是 JsonObject
|
|
||||||
* 期望:JsonObject 类型参数被正确绑定
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void testJsonObjectBinding() {
|
|
||||||
String bodyStr = "{\"name\":\"test\",\"value\":123}";
|
|
||||||
|
|
||||||
// 模拟 ctx.body().asJsonObject()
|
|
||||||
JsonObject body = parseAsJsonObject(bodyStr);
|
|
||||||
Assert.assertNotNull("body 应能解析为 JsonObject", body);
|
|
||||||
|
|
||||||
// 模拟绑定逻辑中的类型判断
|
|
||||||
String targetType = JsonObject.class.getName();
|
|
||||||
boolean matched = JsonObject.class.getName().equals(targetType);
|
|
||||||
Assert.assertTrue("JsonObject 类型应命中绑定分支", matched);
|
|
||||||
|
|
||||||
// 模拟结果
|
|
||||||
Object bound = body; // parameterValueList.put(k, body)
|
|
||||||
Assert.assertNotNull("JsonObject 参数应被绑定(非null)", bound);
|
|
||||||
Assert.assertEquals("name字段应为test", "test", ((JsonObject) bound).getString("name"));
|
|
||||||
Assert.assertEquals("value字段应为123", 123, (int) ((JsonObject) bound).getInteger("value"));
|
|
||||||
|
|
||||||
System.out.println("[PASS] testJsonObjectBinding: JsonObject 绑定成功 -> " + bound);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模拟:content-type = application/json,body 是 JsonArray
|
|
||||||
* 期望:JsonArray 类型参数被正确绑定
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void testJsonArrayBinding() {
|
|
||||||
String bodyStr = "[1,2,3]";
|
|
||||||
|
|
||||||
// body 解析为 JsonObject 应返回 null
|
|
||||||
JsonObject bodyAsObj = parseAsJsonObject(bodyStr);
|
|
||||||
Assert.assertNull("JsonArray body 解析为 JsonObject 应为 null", bodyAsObj);
|
|
||||||
|
|
||||||
// 进入 else 分支,解析为 JsonArray
|
|
||||||
JsonArray bodyArr = parseAsJsonArray(bodyStr);
|
|
||||||
Assert.assertNotNull("body 应能解析为 JsonArray", bodyArr);
|
|
||||||
|
|
||||||
String targetType = JsonArray.class.getName();
|
|
||||||
boolean matched = JsonArray.class.getName().equals(targetType);
|
|
||||||
Assert.assertTrue("JsonArray 类型应命中绑定分支", matched);
|
|
||||||
|
|
||||||
Object bound = bodyArr;
|
|
||||||
Assert.assertNotNull("JsonArray 参数应被绑定(非null)", bound);
|
|
||||||
Assert.assertEquals("数组大小应为3", 3, ((JsonArray) bound).size());
|
|
||||||
|
|
||||||
System.out.println("[PASS] testJsonArrayBinding: JsonArray 绑定成功, size=" + ((JsonArray) bound).size());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证旧代码的 bug:条件 ctx.body().asJsonObject() != null 会把 JsonArray body 排除在外
|
|
||||||
* 新代码只判断 content-type,在 body==null 时才进 else 分支处理 JsonArray
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void testOldConditionBug() {
|
|
||||||
String jsonArrayBody = "[1,2,3]";
|
|
||||||
|
|
||||||
// 旧代码条件:content-type==json && asJsonObject()!=null
|
|
||||||
// 对于 JsonArray body,asJsonObject() 返回 null,整个 if 跳过
|
|
||||||
JsonObject wrongParsed = parseAsJsonObject(jsonArrayBody);
|
|
||||||
boolean oldConditionPassed = wrongParsed != null; // 旧代码的第二个条件
|
|
||||||
Assert.assertFalse("旧代码 bug: JsonArray body 会导致 asJsonObject()==null,整个分支跳过", oldConditionPassed);
|
|
||||||
|
|
||||||
// 新代码:先进 if,body==null 再走 else 解析 JsonArray
|
|
||||||
boolean newConditionFirst = true; // content-type 匹配
|
|
||||||
JsonObject newBody = parseAsJsonObject(jsonArrayBody);
|
|
||||||
boolean newBodyIsNull = newBody == null; // null -> 进 else
|
|
||||||
Assert.assertTrue("新代码: body 解析为 null 时应走 else 分支解析 JsonArray", newBodyIsNull);
|
|
||||||
|
|
||||||
JsonArray newArr = parseAsJsonArray(jsonArrayBody);
|
|
||||||
Assert.assertNotNull("新代码: else 分支正确解析出 JsonArray", newArr);
|
|
||||||
|
|
||||||
System.out.println("[PASS] testOldConditionBug: 修复验证通过,新代码正确处理 JsonArray body");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证:JsonObject 参数旧代码没有绑定分支(只处理实体类)
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
public void testOldMissingJsonObjectBranch() {
|
|
||||||
String bodyStr = "{\"key\":\"value\"}";
|
|
||||||
JsonObject body = parseAsJsonObject(bodyStr);
|
|
||||||
|
|
||||||
// 旧代码只调用 matchRegList(entityPackagesReg, typeName)
|
|
||||||
// 对于 io.vertx.core.json.JsonObject,该方法返回 false,不会被绑定
|
|
||||||
String typeName = JsonObject.class.getName(); // "io.vertx.core.json.JsonObject"
|
|
||||||
// entityPackagesReg 一般是 "cn.qaiu.*" 这类,不会匹配 io.vertx
|
|
||||||
boolean oldWouldBind = typeName.startsWith("cn.qaiu"); // 模拟旧代码逻辑
|
|
||||||
Assert.assertFalse("旧代码 bug: JsonObject 参数不会被绑定", oldWouldBind);
|
|
||||||
|
|
||||||
// 新代码:增加了 JsonObject 类型判断
|
|
||||||
boolean newWouldBind = JsonObject.class.getName().equals(typeName);
|
|
||||||
Assert.assertTrue("新代码: JsonObject 参数应能被绑定", newWouldBind);
|
|
||||||
|
|
||||||
System.out.println("[PASS] testOldMissingJsonObjectBranch: 修复验证通过");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 辅助方法:模拟 Vert.x RequestBody 的 asJsonObject/asJsonArray 行为 =====
|
|
||||||
|
|
||||||
private JsonObject parseAsJsonObject(String str) {
|
|
||||||
try {
|
|
||||||
return new JsonObject(str);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private JsonArray parseAsJsonArray(String str) {
|
|
||||||
try {
|
|
||||||
return new JsonArray(str);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.test;
|
|
||||||
|
|
||||||
import cn.qaiu.vx.core.util.VertxHolder;
|
|
||||||
import io.vertx.core.Vertx;
|
|
||||||
import io.vertx.core.json.JsonArray;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 集成测试: 验证 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定逻辑是否正确
|
|
||||||
*
|
|
||||||
* 运行方式: mvn test-compile -pl core && java -cp "core/target/test-classes:core/target/classes:..." \
|
|
||||||
* cn.qaiu.vx.core.test.RouterHandlerBindingTest
|
|
||||||
*
|
|
||||||
* 或直接在 IDE 中运行 main 方法。
|
|
||||||
*/
|
|
||||||
public class RouterHandlerBindingTest {
|
|
||||||
|
|
||||||
static final int TEST_PORT = 18989;
|
|
||||||
|
|
||||||
public static void main(String[] args) throws Exception {
|
|
||||||
System.out.println("=== RouterHandler JsonObject/JsonArray 绑定测试 ===\n");
|
|
||||||
|
|
||||||
// 1. 先初始化 Vert.x 与 VertxHolder ——必须在加载 RouterHandlerFactory 之前
|
|
||||||
Vertx vertx = Vertx.vertx();
|
|
||||||
VertxHolder.init(vertx);
|
|
||||||
|
|
||||||
// 2. 向 SharedData 注入最小化配置
|
|
||||||
// baseLocations 指向测试包,使 Reflections 只扫描 TestJsonHandler
|
|
||||||
vertx.sharedData().getLocalMap("local").put("customConfig", new JsonObject()
|
|
||||||
.put("baseLocations", "cn.qaiu.vx.core.test")
|
|
||||||
.put("routeTimeOut", 30000)
|
|
||||||
.put("entityPackagesReg", new JsonArray()));
|
|
||||||
// ReverseProxyVerticle.<clinit> 需要 globalConfig.proxyConf(非空字符串即可)
|
|
||||||
vertx.sharedData().getLocalMap("local").put("globalConfig", new JsonObject()
|
|
||||||
.put("proxyConf", "proxy.yml"));
|
|
||||||
|
|
||||||
// 3. 创建 Router(此时才触发 BaseHttpApi.reflections 静态字段初始化)
|
|
||||||
// 用反射延迟加载,确保上面的 SharedData 已就绪
|
|
||||||
cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory factory =
|
|
||||||
new cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory("api");
|
|
||||||
io.vertx.ext.web.Router router = factory.createRouter();
|
|
||||||
|
|
||||||
// 4. 启动 HTTP 服务器
|
|
||||||
CountDownLatch latch = new CountDownLatch(1);
|
|
||||||
vertx.createHttpServer()
|
|
||||||
.requestHandler(router)
|
|
||||||
.listen(TEST_PORT, res -> {
|
|
||||||
if (res.succeeded()) {
|
|
||||||
System.out.println("✔ 测试服务器启动成功 port=" + TEST_PORT);
|
|
||||||
} else {
|
|
||||||
System.err.println("✘ 服务器启动失败: " + res.cause().getMessage());
|
|
||||||
}
|
|
||||||
latch.countDown();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
|
||||||
System.err.println("服务器启动超时");
|
|
||||||
vertx.close();
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
Thread.sleep(100); // 等 Vert.x 就绪
|
|
||||||
|
|
||||||
// 5. 执行测试
|
|
||||||
boolean allPassed = true;
|
|
||||||
allPassed &= testJsonObject();
|
|
||||||
allPassed &= testJsonArray();
|
|
||||||
|
|
||||||
// 6. 关闭
|
|
||||||
CountDownLatch closeLatch = new CountDownLatch(1);
|
|
||||||
vertx.close(v -> closeLatch.countDown());
|
|
||||||
closeLatch.await(3, TimeUnit.SECONDS);
|
|
||||||
|
|
||||||
System.out.println("\n" + (allPassed ? "✅ 全部测试通过!" : "❌ 存在测试失败!"));
|
|
||||||
System.exit(allPassed ? 0 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 子测试 ----------
|
|
||||||
|
|
||||||
private static boolean testJsonObject() throws Exception {
|
|
||||||
String bodyStr = "{\"name\":\"test\",\"value\":123}";
|
|
||||||
String respBody = post("/api/test/json-object", bodyStr);
|
|
||||||
System.out.println("[JsonObject] 响应: " + respBody);
|
|
||||||
|
|
||||||
JsonObject result = new JsonObject(respBody);
|
|
||||||
JsonObject data = result.getJsonObject("data");
|
|
||||||
boolean bound = data != null && Boolean.TRUE.equals(data.getBoolean("bound"));
|
|
||||||
System.out.println("[JsonObject] " + (bound
|
|
||||||
? "PASS ✅ body 正确绑定为 JsonObject"
|
|
||||||
: "FAIL ❌ body 未绑定 (null)"));
|
|
||||||
return bound;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean testJsonArray() throws Exception {
|
|
||||||
String bodyStr = "[1,2,3]";
|
|
||||||
String respBody = post("/api/test/json-array", bodyStr);
|
|
||||||
System.out.println("[JsonArray] 响应: " + respBody);
|
|
||||||
|
|
||||||
JsonObject result = new JsonObject(respBody);
|
|
||||||
JsonObject data = result.getJsonObject("data");
|
|
||||||
boolean bound = data != null
|
|
||||||
&& Boolean.TRUE.equals(data.getBoolean("bound"))
|
|
||||||
&& Integer.valueOf(3).equals(data.getInteger("size"));
|
|
||||||
System.out.println("[JsonArray] " + (bound
|
|
||||||
? "PASS ✅ body 正确绑定为 JsonArray, size=3"
|
|
||||||
: "FAIL ❌ body 未绑定 或 size 不对"));
|
|
||||||
return bound;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String post(String path, String body) throws Exception {
|
|
||||||
HttpClient client = HttpClient.newHttpClient();
|
|
||||||
HttpRequest req = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create("http://localhost:" + TEST_PORT + path))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
|
||||||
.build();
|
|
||||||
return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package cn.qaiu.vx.core.test;
|
|
||||||
|
|
||||||
import cn.qaiu.vx.core.annotaions.RouteHandler;
|
|
||||||
import cn.qaiu.vx.core.annotaions.RouteMapping;
|
|
||||||
import cn.qaiu.vx.core.enums.MIMEType;
|
|
||||||
import cn.qaiu.vx.core.enums.RouteMethod;
|
|
||||||
import cn.qaiu.vx.core.model.JsonResult;
|
|
||||||
import io.vertx.core.Future;
|
|
||||||
import io.vertx.core.json.JsonArray;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用于测试 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定的测试 Handler
|
|
||||||
*/
|
|
||||||
@RouteHandler("test")
|
|
||||||
public class TestJsonHandler {
|
|
||||||
|
|
||||||
/** POST /api/test/json-object Body: {"name":"test","value":123} */
|
|
||||||
@RouteMapping(value = "/json-object", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
|
|
||||||
public Future<JsonResult> testJsonObject(JsonObject body) {
|
|
||||||
// 只返回是否绑定成功及已知字段值,不嵌套原始 body 避免 toJsonObject() 循环
|
|
||||||
boolean bound = body != null;
|
|
||||||
String nameVal = bound ? body.getString("name", "") : "";
|
|
||||||
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
|
|
||||||
.put("bound", bound)
|
|
||||||
.put("name", nameVal)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/test/json-array Body: [1,2,3] */
|
|
||||||
@RouteMapping(value = "/json-array", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
|
|
||||||
public Future<JsonResult> testJsonArray(JsonArray body) {
|
|
||||||
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
|
|
||||||
.put("bound", body != null)
|
|
||||||
.put("size", body != null ? body.size() : -1)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,12 +59,12 @@
|
|||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
|
||||||
<!-- Versions -->
|
<!-- Versions -->
|
||||||
<vertx.version>4.5.24</vertx.version>
|
<vertx.version>4.5.22</vertx.version>
|
||||||
<org.reflections.version>0.10.2</org.reflections.version>
|
<org.reflections.version>0.10.2</org.reflections.version>
|
||||||
<lombok.version>1.18.38</lombok.version>
|
<lombok.version>1.18.38</lombok.version>
|
||||||
<slf4j.version>2.0.16</slf4j.version>
|
<slf4j.version>2.0.5</slf4j.version>
|
||||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||||
<jackson.version>2.18.6</jackson.version>
|
<jackson.version>2.14.2</jackson.version>
|
||||||
<logback.version>1.5.19</logback.version>
|
<logback.version>1.5.19</logback.version>
|
||||||
<junit.version>4.13.2</junit.version>
|
<junit.version>4.13.2</junit.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ public enum PanDomainTemplate {
|
|||||||
"https://www.feijix.com/s/{shareKey}",
|
"https://www.feijix.com/s/{shareKey}",
|
||||||
FjTool.class),
|
FjTool.class),
|
||||||
|
|
||||||
// https://lecloud.lenovo.com/share/ https://lecloud.lenovo.com/mshare/
|
// https://lecloud.lenovo.com/share/
|
||||||
LE("联想乐云",
|
LE("联想乐云",
|
||||||
compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
|
compile("https://lecloud\\.lenovo\\.com/share/(?<KEY>.+)"),
|
||||||
"https://lecloud.lenovo.com/share/{shareKey}",
|
"https://lecloud.lenovo.com/share/{shareKey}",
|
||||||
LeTool.class),
|
LeTool.class),
|
||||||
|
|
||||||
@@ -313,14 +313,6 @@ public enum PanDomainTemplate {
|
|||||||
"https://pan.quark.cn/s/{shareKey}",
|
"https://pan.quark.cn/s/{shareKey}",
|
||||||
QkTool.class),
|
QkTool.class),
|
||||||
|
|
||||||
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
|
|
||||||
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
|
|
||||||
FS("飞书云盘",
|
|
||||||
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
|
|
||||||
"https://feishu.cn/file/{shareKey}",
|
|
||||||
"https://www.feishu.cn/",
|
|
||||||
FsTool.class),
|
|
||||||
|
|
||||||
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
||||||
// http://163cn.tv/xxx
|
// http://163cn.tv/xxx
|
||||||
MNES("网易云音乐分享",
|
MNES("网易云音乐分享",
|
||||||
|
|||||||
@@ -1,492 +0,0 @@
|
|||||||
package cn.qaiu.parser.impl;
|
|
||||||
|
|
||||||
import cn.qaiu.entity.FileInfo;
|
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
|
||||||
import cn.qaiu.parser.PanBase;
|
|
||||||
import cn.qaiu.util.CommonUtils;
|
|
||||||
import io.vertx.core.Future;
|
|
||||||
import io.vertx.core.Promise;
|
|
||||||
import io.vertx.core.json.JsonArray;
|
|
||||||
import io.vertx.core.json.JsonObject;
|
|
||||||
|
|
||||||
import java.net.URLDecoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <a href="https://www.feishu.cn/">飞书云盘</a>
|
|
||||||
* <p>
|
|
||||||
* 支持飞书公开分享文件和文件夹的解析。
|
|
||||||
* <ul>
|
|
||||||
* <li>文件链接: https://xxx.feishu.cn/file/{token}</li>
|
|
||||||
* <li>文件夹链接: https://xxx.feishu.cn/drive/folder/{token}</li>
|
|
||||||
* </ul>
|
|
||||||
* 飞书下载需要先获取匿名会话Cookie,然后使用Cookie请求下载接口。
|
|
||||||
* </p>
|
|
||||||
*/
|
|
||||||
public class FsTool extends PanBase {
|
|
||||||
|
|
||||||
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
||||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 飞书 obj_type: type=12 表示上传文件可下载
|
|
||||||
*/
|
|
||||||
private static final int OBJ_TYPE_FILE = 12;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* v3 列表 API 支持的 obj_type
|
|
||||||
*/
|
|
||||||
private static final int[] LIST_OBJ_TYPES = {
|
|
||||||
0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 每页返回条目数 */
|
|
||||||
private static final int PAGE_SIZE = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从分享链接中提取 tenant 的正则
|
|
||||||
*/
|
|
||||||
private static final Pattern TENANT_PATTERN =
|
|
||||||
Pattern.compile("https://([^.]+)\\.feishu\\.cn/");
|
|
||||||
|
|
||||||
/** 解析 Content-Disposition: filename*=UTF-8''xxx */
|
|
||||||
private static final Pattern CD_FILENAME_STAR_PATTERN =
|
|
||||||
Pattern.compile("filename\\*=UTF-8''(.+?)(?:;|$)");
|
|
||||||
|
|
||||||
/** 解析 Content-Disposition: filename="xxx" 或 filename=xxx */
|
|
||||||
private static final Pattern CD_FILENAME_PATTERN =
|
|
||||||
Pattern.compile("filename=\"?([^\";]+)\"?");
|
|
||||||
|
|
||||||
/** 解析 Content-Range 中的总大小 */
|
|
||||||
private static final Pattern CONTENT_RANGE_SIZE_PATTERN =
|
|
||||||
Pattern.compile("/(\\d+)");
|
|
||||||
|
|
||||||
public FsTool(ShareLinkInfo shareLinkInfo) {
|
|
||||||
super(shareLinkInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Future<String> parse() {
|
|
||||||
String shareUrl = shareLinkInfo.getShareUrl();
|
|
||||||
String tenant = extractTenant(shareUrl);
|
|
||||||
String token = shareLinkInfo.getShareKey();
|
|
||||||
|
|
||||||
if (tenant == null || token == null) {
|
|
||||||
fail("无法从链接中提取tenant或token: {}", shareUrl);
|
|
||||||
return promise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isFolder = shareUrl.contains("/drive/folder/");
|
|
||||||
if (isFolder) {
|
|
||||||
fetchSessionAndParseFolder(tenant, token, shareUrl);
|
|
||||||
} else {
|
|
||||||
fetchSessionAndParseFile(tenant, token, shareUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return promise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取匿名session后解析文件
|
|
||||||
*/
|
|
||||||
private void fetchSessionAndParseFile(String tenant, String token, String shareUrl) {
|
|
||||||
clientSession.getAbs(shareUrl)
|
|
||||||
.putHeader("User-Agent", UA)
|
|
||||||
.putHeader("Accept", "text/html,*/*")
|
|
||||||
.send()
|
|
||||||
.onSuccess(res -> {
|
|
||||||
String dlUrl = buildDownloadUrl(tenant, token);
|
|
||||||
|
|
||||||
// Range探测获取文件名和大小
|
|
||||||
clientSession.getAbs(dlUrl)
|
|
||||||
.putHeader("User-Agent", UA)
|
|
||||||
.putHeader("Referer", shareUrl)
|
|
||||||
.putHeader("Range", "bytes=0-0")
|
|
||||||
.send()
|
|
||||||
.onSuccess(probeRes -> {
|
|
||||||
String fileName = parseFileNameFromContentDisposition(
|
|
||||||
probeRes.getHeader("Content-Disposition"));
|
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
|
||||||
headers.put("Referer", shareUrl);
|
|
||||||
headers.put("User-Agent", UA);
|
|
||||||
|
|
||||||
String cookies = extractCookiesFromResponse(probeRes);
|
|
||||||
if (cookies != null && !cookies.isEmpty()) {
|
|
||||||
headers.put("Cookie", cookies);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileName != null) {
|
|
||||||
FileInfo fileInfo = new FileInfo();
|
|
||||||
fileInfo.setFileName(fileName);
|
|
||||||
fileInfo.setFileId(token);
|
|
||||||
fileInfo.setFileType("file");
|
|
||||||
fileInfo.setPanType(shareLinkInfo.getType());
|
|
||||||
fileInfo.setParserUrl(buildRedirectUrl(shareUrl, token));
|
|
||||||
parseSizeFromContentRange(
|
|
||||||
probeRes.getHeader("Content-Range"), fileInfo);
|
|
||||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
completeWithMeta(dlUrl, headers);
|
|
||||||
})
|
|
||||||
.onFailure(handleFail("探测文件信息失败"));
|
|
||||||
})
|
|
||||||
.onFailure(handleFail("获取匿名会话失败"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取匿名session后解析文件夹(取第一个可下载文件)
|
|
||||||
*/
|
|
||||||
private void fetchSessionAndParseFolder(String tenant, String folderToken,
|
|
||||||
String shareUrl) {
|
|
||||||
clientSession.getAbs(shareUrl)
|
|
||||||
.putHeader("User-Agent", UA)
|
|
||||||
.putHeader("Accept", "text/html,*/*")
|
|
||||||
.send()
|
|
||||||
.onSuccess(res ->
|
|
||||||
listFolderAll(tenant, folderToken, "").onSuccess(items -> {
|
|
||||||
if (items.isEmpty()) {
|
|
||||||
fail("文件夹中没有可下载的文件");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
FileInfo first = items.get(0);
|
|
||||||
String objToken = first.getFileId();
|
|
||||||
String dlUrl = buildDownloadUrl(tenant, objToken);
|
|
||||||
String referer = "https://" + tenant
|
|
||||||
+ ".feishu.cn/drive/folder/" + folderToken;
|
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
|
||||||
headers.put("Referer", referer);
|
|
||||||
headers.put("User-Agent", UA);
|
|
||||||
|
|
||||||
shareLinkInfo.getOtherParam().put("fileInfo", first);
|
|
||||||
completeWithMeta(dlUrl, headers);
|
|
||||||
}).onFailure(t -> fail("列出文件夹内容失败: {}", t.getMessage())))
|
|
||||||
.onFailure(handleFail("获取匿名会话失败"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Future<List<FileInfo>> parseFileList() {
|
|
||||||
Promise<List<FileInfo>> listPromise = Promise.promise();
|
|
||||||
String shareUrl = shareLinkInfo.getShareUrl();
|
|
||||||
String tenant = extractTenant(shareUrl);
|
|
||||||
String token = shareLinkInfo.getShareKey();
|
|
||||||
|
|
||||||
if (tenant == null || token == null) {
|
|
||||||
listPromise.fail("无法从链接中提取tenant或token: " + shareUrl);
|
|
||||||
return listPromise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isFolder = shareUrl.contains("/drive/folder/");
|
|
||||||
|
|
||||||
clientSession.getAbs(shareUrl)
|
|
||||||
.putHeader("User-Agent", UA)
|
|
||||||
.putHeader("Accept", "text/html,*/*")
|
|
||||||
.send()
|
|
||||||
.onSuccess(res -> {
|
|
||||||
if (isFolder) {
|
|
||||||
listFolderAll(tenant, token, "")
|
|
||||||
.onSuccess(listPromise::complete)
|
|
||||||
.onFailure(listPromise::fail);
|
|
||||||
} else {
|
|
||||||
probeSingleFile(tenant, token, shareUrl)
|
|
||||||
.onSuccess(fileInfo -> {
|
|
||||||
List<FileInfo> list = new ArrayList<>();
|
|
||||||
list.add(fileInfo);
|
|
||||||
listPromise.complete(list);
|
|
||||||
})
|
|
||||||
.onFailure(listPromise::fail);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFailure(t -> listPromise.fail("获取匿名会话失败: " + t.getMessage()));
|
|
||||||
|
|
||||||
return listPromise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页获取文件夹所有可下载文件
|
|
||||||
*/
|
|
||||||
private Future<List<FileInfo>> listFolderAll(String tenant, String folderToken,
|
|
||||||
String pageLabel) {
|
|
||||||
Promise<List<FileInfo>> p = Promise.promise();
|
|
||||||
|
|
||||||
listFolderPage(tenant, folderToken, pageLabel).onSuccess(pageResult -> {
|
|
||||||
List<FileInfo> items = new ArrayList<>(pageResult.items);
|
|
||||||
if (pageResult.hasMore) {
|
|
||||||
listFolderAll(tenant, folderToken, pageResult.nextLabel)
|
|
||||||
.onSuccess(moreItems -> {
|
|
||||||
items.addAll(moreItems);
|
|
||||||
p.complete(items);
|
|
||||||
})
|
|
||||||
.onFailure(p::fail);
|
|
||||||
} else {
|
|
||||||
p.complete(items);
|
|
||||||
}
|
|
||||||
}).onFailure(p::fail);
|
|
||||||
|
|
||||||
return p.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 列出文件夹内容(单页)
|
|
||||||
*/
|
|
||||||
private Future<FolderPageResult> listFolderPage(String tenant, String folderToken,
|
|
||||||
String pageLabel) {
|
|
||||||
Promise<FolderPageResult> p = Promise.promise();
|
|
||||||
String baseUrl = "https://" + tenant + ".feishu.cn";
|
|
||||||
|
|
||||||
StringBuilder urlBuilder = new StringBuilder();
|
|
||||||
urlBuilder.append(baseUrl)
|
|
||||||
.append("/space/api/explorer/v3/children/list/")
|
|
||||||
.append("?length=").append(PAGE_SIZE)
|
|
||||||
.append("&asc=1&rank=5&token=").append(folderToken);
|
|
||||||
|
|
||||||
for (int type : LIST_OBJ_TYPES) {
|
|
||||||
urlBuilder.append("&obj_type=").append(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageLabel != null && !pageLabel.isEmpty()) {
|
|
||||||
urlBuilder.append("&last_label=").append(pageLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
String url = urlBuilder.toString();
|
|
||||||
String referer = baseUrl + "/drive/folder/" + folderToken;
|
|
||||||
|
|
||||||
clientSession.getAbs(url)
|
|
||||||
.putHeader("User-Agent", UA)
|
|
||||||
.putHeader("Accept", "application/json, text/plain, */*")
|
|
||||||
.putHeader("Referer", referer)
|
|
||||||
.send()
|
|
||||||
.onSuccess(res -> {
|
|
||||||
try {
|
|
||||||
JsonObject json = asJson(res);
|
|
||||||
int code = json.getInteger("code", -1);
|
|
||||||
if (code != 0) {
|
|
||||||
p.fail("飞书API错误: " + json.getString("msg"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
JsonObject data = json.getJsonObject("data");
|
|
||||||
JsonObject entities = data.getJsonObject("entities",
|
|
||||||
new JsonObject());
|
|
||||||
JsonObject nodes = entities.getJsonObject("nodes",
|
|
||||||
new JsonObject());
|
|
||||||
JsonArray nodeList = data.getJsonArray("node_list",
|
|
||||||
new JsonArray());
|
|
||||||
|
|
||||||
List<FileInfo> items = new ArrayList<>();
|
|
||||||
for (int i = 0; i < nodeList.size(); i++) {
|
|
||||||
String nid = nodeList.getString(i);
|
|
||||||
JsonObject node = nodes.getJsonObject(nid,
|
|
||||||
new JsonObject());
|
|
||||||
int objType = node.getInteger("type", -1);
|
|
||||||
String objToken = node.getString("obj_token", "");
|
|
||||||
String name = node.getString("name", "unknown");
|
|
||||||
|
|
||||||
// 排除文件夹自身节点
|
|
||||||
if (objToken.equals(folderToken)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只返回可下载的文件(type=12)
|
|
||||||
if (objType == OBJ_TYPE_FILE) {
|
|
||||||
FileInfo fileInfo = new FileInfo();
|
|
||||||
fileInfo.setFileName(name);
|
|
||||||
fileInfo.setFileId(objToken);
|
|
||||||
fileInfo.setPanType(shareLinkInfo.getType());
|
|
||||||
fileInfo.setFileType("file");
|
|
||||||
|
|
||||||
JsonObject extra = node.getJsonObject("extra",
|
|
||||||
new JsonObject());
|
|
||||||
try {
|
|
||||||
long size = Long.parseLong(
|
|
||||||
extra.getString("size", "0"));
|
|
||||||
fileInfo.setSize(size);
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
log.warn("无法解析文件大小: {}", extra.getString("size"), e);
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInfo.setParserUrl(buildRedirectUrl(
|
|
||||||
shareLinkInfo.getShareUrl(), objToken));
|
|
||||||
|
|
||||||
// 添加下载所需的请求头到extParameters
|
|
||||||
Map<String, Object> extParams = new HashMap<>();
|
|
||||||
Map<String, String> downloadHeaders = new HashMap<>();
|
|
||||||
downloadHeaders.put("Referer", referer);
|
|
||||||
downloadHeaders.put("User-Agent", UA);
|
|
||||||
extParams.put("downloadHeaders", downloadHeaders);
|
|
||||||
fileInfo.setExtParameters(extParams);
|
|
||||||
|
|
||||||
items.add(fileInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean hasMore = data.getBoolean("has_more", false);
|
|
||||||
String nextLabel = data.getString("last_label", "");
|
|
||||||
|
|
||||||
p.complete(new FolderPageResult(items, hasMore, nextLabel));
|
|
||||||
} catch (Exception e) {
|
|
||||||
p.fail("解析文件列表响应失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.onFailure(t -> p.fail("请求文件列表失败: " + t.getMessage()));
|
|
||||||
|
|
||||||
return p.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 探测单个文件信息
|
|
||||||
*/
|
|
||||||
private Future<FileInfo> probeSingleFile(String tenant, String token,
|
|
||||||
String referer) {
|
|
||||||
Promise<FileInfo> p = Promise.promise();
|
|
||||||
String dlUrl = buildDownloadUrl(tenant, token);
|
|
||||||
|
|
||||||
clientSession.getAbs(dlUrl)
|
|
||||||
.putHeader("User-Agent", UA)
|
|
||||||
.putHeader("Referer", referer)
|
|
||||||
.putHeader("Range", "bytes=0-0")
|
|
||||||
.send()
|
|
||||||
.onSuccess(probeRes -> {
|
|
||||||
FileInfo fileInfo = new FileInfo();
|
|
||||||
String fileName = parseFileNameFromContentDisposition(
|
|
||||||
probeRes.getHeader("Content-Disposition"));
|
|
||||||
if (fileName != null) {
|
|
||||||
fileInfo.setFileName(fileName);
|
|
||||||
}
|
|
||||||
parseSizeFromContentRange(
|
|
||||||
probeRes.getHeader("Content-Range"), fileInfo);
|
|
||||||
fileInfo.setFileId(token);
|
|
||||||
fileInfo.setPanType(shareLinkInfo.getType());
|
|
||||||
fileInfo.setFileType("file");
|
|
||||||
fileInfo.setParserUrl(buildRedirectUrl(referer, token));
|
|
||||||
|
|
||||||
// 添加下载所需的请求头到extParameters
|
|
||||||
Map<String, Object> extParams = new HashMap<>();
|
|
||||||
Map<String, String> downloadHeaders = new HashMap<>();
|
|
||||||
downloadHeaders.put("Referer", referer);
|
|
||||||
downloadHeaders.put("User-Agent", UA);
|
|
||||||
extParams.put("downloadHeaders", downloadHeaders);
|
|
||||||
fileInfo.setExtParameters(extParams);
|
|
||||||
|
|
||||||
p.complete(fileInfo);
|
|
||||||
})
|
|
||||||
.onFailure(t -> p.fail("探测文件失败: " + t.getMessage()));
|
|
||||||
|
|
||||||
return p.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Future<String> parseById() {
|
|
||||||
Promise<String> parsePromise = Promise.promise();
|
|
||||||
|
|
||||||
try {
|
|
||||||
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
|
|
||||||
String shareUrl = paramJson.getString("shareUrl");
|
|
||||||
String objToken = paramJson.getString("objToken");
|
|
||||||
String tenant = extractTenant(shareUrl);
|
|
||||||
|
|
||||||
if (shareUrl == null || objToken == null || tenant == null) {
|
|
||||||
parsePromise.fail("飞书目录文件下载参数不完整");
|
|
||||||
return parsePromise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
parsePromise.complete(buildDownloadUrl(tenant, objToken));
|
|
||||||
} catch (Exception e) {
|
|
||||||
parsePromise.fail("解析飞书目录文件参数失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsePromise.future();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 工具方法 ────────────────────────────────────────
|
|
||||||
|
|
||||||
private String buildRedirectUrl(String shareUrl, String objToken) {
|
|
||||||
JsonObject paramJson = new JsonObject()
|
|
||||||
.put("shareUrl", shareUrl)
|
|
||||||
.put("objToken", objToken);
|
|
||||||
return String.format("%s/v2/redirectUrl/%s/%s",
|
|
||||||
getDomainName(),
|
|
||||||
shareLinkInfo.getType(),
|
|
||||||
CommonUtils.urlBase64Encode(paramJson.encode()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildDownloadUrl(String tenant, String objToken) {
|
|
||||||
return "https://" + tenant
|
|
||||||
+ ".feishu.cn/space/api/box/stream/download/all/" + objToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractTenant(String url) {
|
|
||||||
if (url == null) return null;
|
|
||||||
Matcher m = TENANT_PATTERN.matcher(url);
|
|
||||||
if (m.find()) {
|
|
||||||
return m.group(1);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从Content-Disposition头解析文件名。
|
|
||||||
* 支持 filename*=UTF-8''xxx 和 filename="xxx" 两种格式。
|
|
||||||
*/
|
|
||||||
private String parseFileNameFromContentDisposition(String cd) {
|
|
||||||
if (cd == null || cd.isEmpty()) return null;
|
|
||||||
|
|
||||||
// 优先解析 filename*=UTF-8''xxx
|
|
||||||
Matcher m1 = CD_FILENAME_STAR_PATTERN.matcher(cd);
|
|
||||||
if (m1.find()) {
|
|
||||||
try {
|
|
||||||
return URLDecoder.decode(m1.group(1).trim(), StandardCharsets.UTF_8);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 降级解析 filename="xxx" 或 filename=xxx
|
|
||||||
Matcher m2 = CD_FILENAME_PATTERN.matcher(cd);
|
|
||||||
if (m2.find()) {
|
|
||||||
try {
|
|
||||||
return URLDecoder.decode(m2.group(1).trim(), StandardCharsets.UTF_8);
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void parseSizeFromContentRange(String cr, FileInfo fileInfo) {
|
|
||||||
if (cr != null) {
|
|
||||||
Matcher m = CONTENT_RANGE_SIZE_PATTERN.matcher(cr);
|
|
||||||
if (m.find()) {
|
|
||||||
fileInfo.setSize(Long.parseLong(m.group(1)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractCookiesFromResponse(
|
|
||||||
io.vertx.ext.web.client.HttpResponse<?> response) {
|
|
||||||
List<String> setCookies = response.cookies();
|
|
||||||
if (setCookies == null || setCookies.isEmpty()) return null;
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (String cookie : setCookies) {
|
|
||||||
String nameValue = cookie.split(";")[0].trim();
|
|
||||||
if (!sb.isEmpty()) sb.append("; ");
|
|
||||||
sb.append(nameValue);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件夹分页结果
|
|
||||||
*/
|
|
||||||
private record FolderPageResult(List<FileInfo> items, boolean hasMore,
|
|
||||||
String nextLabel) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -99,8 +99,7 @@ public class PodTool extends PanBase {
|
|||||||
Matcher matcher1 =
|
Matcher matcher1 =
|
||||||
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\s\"]+)").matcher(body);
|
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\s\"]+)").matcher(body);
|
||||||
if (matcher1.find()) {
|
if (matcher1.find()) {
|
||||||
// 响应体是 JSON 文本,URL 中的 '&' 被转义为 \u0026,需要反转义
|
complete(matcher1.group("url"));
|
||||||
complete(unescapeJsonUnicode(matcher1.group("url")));
|
|
||||||
} else {
|
} else {
|
||||||
fail();
|
fail();
|
||||||
}
|
}
|
||||||
@@ -135,34 +134,6 @@ public class PodTool extends PanBase {
|
|||||||
throw new RuntimeException("URL匹配失败");
|
throw new RuntimeException("URL匹配失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 反转义 JSON 响应文本中残留的 Unicode 转义序列(主要是 \u0026 -> &)。
|
|
||||||
* 主分支通过正则直接从 JSON 原文抠 URL,未经过 JSON 解析器,需要手动还原。
|
|
||||||
*/
|
|
||||||
private String unescapeJsonUnicode(String s) {
|
|
||||||
if (s == null || s.indexOf("\\u") < 0) {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
StringBuilder sb = new StringBuilder(s.length());
|
|
||||||
int i = 0;
|
|
||||||
while (i < s.length()) {
|
|
||||||
char c = s.charAt(i);
|
|
||||||
if (c == '\\' && i + 5 < s.length() && s.charAt(i + 1) == 'u') {
|
|
||||||
try {
|
|
||||||
int cp = Integer.parseInt(s.substring(i + 2, i + 6), 16);
|
|
||||||
sb.append((char) cp);
|
|
||||||
i += 6;
|
|
||||||
continue;
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
// 非法转义按原样保留
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.append(c);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private String matcherToken(String html) {
|
private String matcherToken(String html) {
|
||||||
// 正则表达式来匹配 inputElem.value 中的 Token
|
// 正则表达式来匹配 inputElem.value 中的 Token
|
||||||
|
|||||||
@@ -1,105 +1,105 @@
|
|||||||
// // ==UserScript==
|
// ==UserScript==
|
||||||
// // @name Fetch API示例解析器
|
// @name Fetch API示例解析器
|
||||||
// // @type fetch_demo
|
// @type fetch_demo
|
||||||
// // @displayName Fetch演示
|
// @displayName Fetch演示
|
||||||
// // @description 演示如何在ES5环境中使用fetch API和async/await
|
// @description 演示如何在ES5环境中使用fetch API和async/await
|
||||||
// // @match https?://example\.com/s/(?<KEY>\w+)
|
// @match https?://example\.com/s/(?<KEY>\w+)
|
||||||
// // @author QAIU
|
// @author QAIU
|
||||||
// // @version 1.0.0
|
// @version 1.0.0
|
||||||
// // ==/UserScript==
|
// ==/UserScript==
|
||||||
|
|
||||||
// // 使用require导入类型定义(仅用于IDE类型提示)
|
// 使用require导入类型定义(仅用于IDE类型提示)
|
||||||
// var types = require('./types');
|
var types = require('./types');
|
||||||
// /** @typedef {types.ShareLinkInfo} ShareLinkInfo */
|
/** @typedef {types.ShareLinkInfo} ShareLinkInfo */
|
||||||
// /** @typedef {types.JsHttpClient} JsHttpClient */
|
/** @typedef {types.JsHttpClient} JsHttpClient */
|
||||||
// /** @typedef {types.JsLogger} JsLogger */
|
/** @typedef {types.JsLogger} JsLogger */
|
||||||
|
|
||||||
// /**
|
/**
|
||||||
// * 演示使用fetch API的解析器
|
* 演示使用fetch API的解析器
|
||||||
// * 注意:虽然源码中使用了ES6+语法(async/await),但在浏览器中会被编译为ES5
|
* 注意:虽然源码中使用了ES6+语法(async/await),但在浏览器中会被编译为ES5
|
||||||
// *
|
*
|
||||||
// * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
|
* @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
|
||||||
// * @param {JsHttpClient} http - HTTP客户端(传统方式)
|
* @param {JsHttpClient} http - HTTP客户端(传统方式)
|
||||||
// * @param {JsLogger} logger - 日志对象
|
* @param {JsLogger} logger - 日志对象
|
||||||
// * @returns {string} 下载链接
|
* @returns {string} 下载链接
|
||||||
// */
|
*/
|
||||||
// function parse(shareLinkInfo, http, logger) {
|
function parse(shareLinkInfo, http, logger) {
|
||||||
// logger.info("=== Fetch API Demo ===");
|
logger.info("=== Fetch API Demo ===");
|
||||||
|
|
||||||
// // 方式1:使用传统的http对象(同步)
|
// 方式1:使用传统的http对象(同步)
|
||||||
// logger.info("方式1: 使用传统http对象");
|
logger.info("方式1: 使用传统http对象");
|
||||||
// var response1 = http.get("https://httpbin.org/get");
|
var response1 = http.get("https://httpbin.org/get");
|
||||||
// logger.info("状态码: " + response1.statusCode());
|
logger.info("状态码: " + response1.statusCode());
|
||||||
|
|
||||||
// // 方式2:使用fetch API(基于Promise)
|
// 方式2:使用fetch API(基于Promise)
|
||||||
// logger.info("方式2: 使用fetch API");
|
logger.info("方式2: 使用fetch API");
|
||||||
|
|
||||||
// // 注意:在ES5环境中,我们需要手动处理Promise
|
// 注意:在ES5环境中,我们需要手动处理Promise
|
||||||
// // 这个示例展示了如何在ES5中使用fetch
|
// 这个示例展示了如何在ES5中使用fetch
|
||||||
// var fetchPromise = fetch("https://httpbin.org/get");
|
var fetchPromise = fetch("https://httpbin.org/get");
|
||||||
|
|
||||||
// // 等待Promise完成(同步等待模拟)
|
// 等待Promise完成(同步等待模拟)
|
||||||
// var result = null;
|
var result = null;
|
||||||
// var error = null;
|
var error = null;
|
||||||
|
|
||||||
// fetchPromise
|
fetchPromise
|
||||||
// .then(function(response) {
|
.then(function(response) {
|
||||||
// logger.info("Fetch响应状态: " + response.status);
|
logger.info("Fetch响应状态: " + response.status);
|
||||||
// return response.text();
|
return response.text();
|
||||||
// })
|
})
|
||||||
// .then(function(text) {
|
.then(function(text) {
|
||||||
// logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
|
logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
|
||||||
// result = "https://example.com/download/demo.file";
|
result = "https://example.com/download/demo.file";
|
||||||
// })
|
})
|
||||||
// ['catch'](function(err) {
|
['catch'](function(err) {
|
||||||
// logger.error("Fetch失败: " + err.message);
|
logger.error("Fetch失败: " + err.message);
|
||||||
// error = err;
|
error = err;
|
||||||
// });
|
});
|
||||||
|
|
||||||
// // 简单的等待循环(实际场景中不推荐,这里仅作演示)
|
// 简单的等待循环(实际场景中不推荐,这里仅作演示)
|
||||||
// var timeout = 5000; // 5秒超时
|
var timeout = 5000; // 5秒超时
|
||||||
// var start = Date.now();
|
var start = Date.now();
|
||||||
// while (result === null && error === null && (Date.now() - start) < timeout) {
|
while (result === null && error === null && (Date.now() - start) < timeout) {
|
||||||
// // 等待Promise完成
|
// 等待Promise完成
|
||||||
// java.lang.Thread.sleep(10);
|
java.lang.Thread.sleep(10);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (error !== null) {
|
if (error !== null) {
|
||||||
// throw error;
|
throw error;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (result === null) {
|
if (result === null) {
|
||||||
// throw new Error("Fetch超时");
|
throw new Error("Fetch超时");
|
||||||
// }
|
}
|
||||||
|
|
||||||
// return result;
|
return result;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// /**
|
/**
|
||||||
// * 演示POST请求
|
* 演示POST请求
|
||||||
// */
|
*/
|
||||||
// function demonstratePost(logger) {
|
function demonstratePost(logger) {
|
||||||
// logger.info("=== 演示POST请求 ===");
|
logger.info("=== 演示POST请求 ===");
|
||||||
|
|
||||||
// var postPromise = fetch("https://httpbin.org/post", {
|
var postPromise = fetch("https://httpbin.org/post", {
|
||||||
// method: "POST",
|
method: "POST",
|
||||||
// headers: {
|
headers: {
|
||||||
// "Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
// },
|
},
|
||||||
// body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
// key: "value",
|
key: "value",
|
||||||
// demo: true
|
demo: true
|
||||||
// })
|
})
|
||||||
// });
|
});
|
||||||
|
|
||||||
// postPromise
|
postPromise
|
||||||
// .then(function(response) {
|
.then(function(response) {
|
||||||
// return response.json();
|
return response.json();
|
||||||
// })
|
})
|
||||||
// .then(function(data) {
|
.then(function(data) {
|
||||||
// logger.info("POST响应: " + JSON.stringify(data));
|
logger.info("POST响应: " + JSON.stringify(data));
|
||||||
// })
|
})
|
||||||
// ['catch'](function(err) {
|
['catch'](function(err) {
|
||||||
// logger.error("POST失败: " + err.message);
|
logger.error("POST失败: " + err.message);
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
|
|||||||
355
parser/src/main/resources/py/feishu-dl.py
Normal file
355
parser/src/main/resources/py/feishu-dl.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
飞书公开分享 直链解析 + 批量下载 (aria2/Motrix)
|
||||||
|
支持: 单文件链接 / 文件夹链接(递归子目录)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python feishu_dl.py <链接> # 推送到 Motrix
|
||||||
|
python feishu_dl.py <链接> -d D:/Downloads # 指定下载目录
|
||||||
|
python feishu_dl.py <链接> --list # 仅列出文件,不下载
|
||||||
|
python feishu_dl.py <链接> --aria2c # 输出 aria2c 命令行
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys, os, re, json, uuid, ssl, gzip, argparse
|
||||||
|
import http.cookiejar
|
||||||
|
import urllib.request, urllib.error
|
||||||
|
from urllib.parse import unquote, quote
|
||||||
|
|
||||||
|
# ─── Motrix aria2 RPC 默认配置 ──────────────────────────
|
||||||
|
ARIA2_RPC_URL = "http://localhost:16800/jsonrpc"
|
||||||
|
ARIA2_SECRET = "motrix"
|
||||||
|
# ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
# 飞书 obj_type 映射 (type=12 上传文件可下载, type=0 文件夹可递归)
|
||||||
|
OBJ_TYPES = {
|
||||||
|
0: "📁 文件夹", 2: "📝 旧版文档", 3: "📊 表格", 8: "🧠 思维导图",
|
||||||
|
11: "📽 幻灯片", 12: "📄 文件", 22: "📝 新版文档", 30: "📋 画板",
|
||||||
|
44: "📊 多维表格", 84: "📑 知识库", 123: "❓ 未知", 124: "❓ 未知",
|
||||||
|
}
|
||||||
|
|
||||||
|
# v3 列表 API 支持的 obj_type
|
||||||
|
LIST_OBJ_TYPES = [0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 网络工具 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
def make_opener(jar):
|
||||||
|
return urllib.request.build_opener(
|
||||||
|
urllib.request.HTTPSHandler(context=_ctx()),
|
||||||
|
urllib.request.HTTPCookieProcessor(jar),
|
||||||
|
)
|
||||||
|
|
||||||
|
def decode_body(resp):
|
||||||
|
data = resp.read()
|
||||||
|
if resp.headers.get("Content-Encoding") == "gzip":
|
||||||
|
data = gzip.decompress(data)
|
||||||
|
return data.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
def cookie_string(jar):
|
||||||
|
return "; ".join(f"{c.name}={c.value}" for c in jar)
|
||||||
|
|
||||||
|
def human_size(n):
|
||||||
|
for u in ("B", "KB", "MB", "GB"):
|
||||||
|
if n < 1024: return f"{n:.1f} {u}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f} TB"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 飞书核心 API ────────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_share_url(url):
|
||||||
|
"""返回 (tenant, token, link_type:'file'|'folder')"""
|
||||||
|
m = re.match(r'https://([^.]+)\.feishu\.cn/file/([A-Za-z0-9_-]+)', url)
|
||||||
|
if m: return m.group(1), m.group(2), "file"
|
||||||
|
m = re.match(r'https://([^.]+)\.feishu\.cn/drive/folder/([A-Za-z0-9_-]+)', url)
|
||||||
|
if m: return m.group(1), m.group(2), "folder"
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_session(share_url):
|
||||||
|
"""访问分享页拿匿名 session cookie"""
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
opener = make_opener(jar)
|
||||||
|
req = urllib.request.Request(share_url)
|
||||||
|
req.add_header("User-Agent", UA)
|
||||||
|
req.add_header("Accept", "text/html,*/*")
|
||||||
|
opener.open(req, timeout=15).read()
|
||||||
|
return jar
|
||||||
|
|
||||||
|
|
||||||
|
def list_folder(tenant, folder_token, jar, page_label=""):
|
||||||
|
"""
|
||||||
|
v3 API 列出文件夹内容 (单页)
|
||||||
|
GET /space/api/explorer/v3/children/list/?token=xxx&length=50&...
|
||||||
|
"""
|
||||||
|
base = f"https://{tenant}.feishu.cn"
|
||||||
|
params = ["length=50", "asc=1", "rank=5", f"token={folder_token}"]
|
||||||
|
for t in LIST_OBJ_TYPES:
|
||||||
|
params.append(f"obj_type={t}")
|
||||||
|
if page_label:
|
||||||
|
params.append(f"last_label={quote(page_label, safe='')}")
|
||||||
|
|
||||||
|
url = f"{base}/space/api/explorer/v3/children/list/?{'&'.join(params)}"
|
||||||
|
opener = make_opener(jar)
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
req.add_header("User-Agent", UA)
|
||||||
|
req.add_header("Accept", "application/json, text/plain, */*")
|
||||||
|
req.add_header("Referer", f"{base}/drive/folder/{folder_token}")
|
||||||
|
|
||||||
|
resp = opener.open(req, timeout=15)
|
||||||
|
data = json.loads(decode_body(resp))
|
||||||
|
if data.get("code") != 0:
|
||||||
|
raise RuntimeError(f"API error: {data.get('msg')}")
|
||||||
|
|
||||||
|
d = data["data"]
|
||||||
|
nodes = d.get("entities", {}).get("nodes", {})
|
||||||
|
node_list = d.get("node_list", [])
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for nid in node_list:
|
||||||
|
node = nodes.get(nid, {})
|
||||||
|
obj_type = node.get("type", -1)
|
||||||
|
obj_token = node.get("obj_token", "")
|
||||||
|
name = node.get("name", "unknown")
|
||||||
|
extra = node.get("extra", {})
|
||||||
|
try: size = int(extra.get("size", "0"))
|
||||||
|
except: size = 0
|
||||||
|
items.append({
|
||||||
|
"name": name, "obj_token": obj_token, "type": obj_type,
|
||||||
|
"size": size, "url": node.get("url", ""),
|
||||||
|
"is_folder": obj_type == 0,
|
||||||
|
"type_name": OBJ_TYPES.get(obj_type, f"❓ type={obj_type}"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 排除文件夹自身节点
|
||||||
|
items = [it for it in items if it["obj_token"] != folder_token]
|
||||||
|
return items, d.get("has_more", False), d.get("last_label", "")
|
||||||
|
|
||||||
|
|
||||||
|
def list_folder_all(tenant, folder_token, jar):
|
||||||
|
"""分页获取文件夹全部内容"""
|
||||||
|
all_items, label = [], ""
|
||||||
|
while True:
|
||||||
|
items, has_more, label = list_folder(tenant, folder_token, jar, label)
|
||||||
|
all_items.extend(items)
|
||||||
|
if not has_more: break
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
|
||||||
|
def walk_folder(tenant, folder_token, jar, prefix="", depth=0):
|
||||||
|
"""递归遍历, 返回扁平列表 [{..., path:"a/b/file.txt"}]"""
|
||||||
|
if depth > 10: # 防止无限递归
|
||||||
|
return []
|
||||||
|
items = list_folder_all(tenant, folder_token, jar)
|
||||||
|
result = []
|
||||||
|
for it in items:
|
||||||
|
if it["is_folder"]:
|
||||||
|
sub = walk_folder(tenant, it["obj_token"], jar,
|
||||||
|
prefix=f"{prefix}{it['name']}/", depth=depth+1)
|
||||||
|
result.extend(sub)
|
||||||
|
else:
|
||||||
|
it["path"] = f"{prefix}{it['name']}"
|
||||||
|
result.append(it)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def probe_file(tenant, obj_token, jar, referer):
|
||||||
|
"""Range 探测文件名 + 大小 (只取1字节)"""
|
||||||
|
dl_url = f"https://{tenant}.feishu.cn/space/api/box/stream/download/all/{obj_token}"
|
||||||
|
opener = make_opener(jar)
|
||||||
|
req = urllib.request.Request(dl_url)
|
||||||
|
req.add_header("User-Agent", UA)
|
||||||
|
req.add_header("Referer", referer)
|
||||||
|
req.add_header("Range", "bytes=0-0")
|
||||||
|
|
||||||
|
resp = opener.open(req, timeout=15)
|
||||||
|
cd = resp.headers.get("Content-Disposition", "")
|
||||||
|
cr = resp.headers.get("Content-Range", "")
|
||||||
|
resp.read()
|
||||||
|
|
||||||
|
filename = ""
|
||||||
|
m = re.search(r"filename\*=UTF-8''(.+?)(?:;|$)", cd)
|
||||||
|
if m: filename = unquote(m.group(1).strip())
|
||||||
|
if not filename:
|
||||||
|
m = re.search(r'filename="?([^";]+)"?', cd)
|
||||||
|
if m: filename = unquote(m.group(1).strip())
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
m = re.search(r'/(\d+)', cr)
|
||||||
|
if m: total = int(m.group(1))
|
||||||
|
return filename, total
|
||||||
|
|
||||||
|
|
||||||
|
# ─── aria2 RPC ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def aria2_add(dl_url, cs, referer, filename, out_dir=None):
|
||||||
|
opts = {"header": [f"Cookie: {cs}", f"Referer: {referer}", f"User-Agent: {UA}"]}
|
||||||
|
if filename: opts["out"] = filename
|
||||||
|
if out_dir: opts["dir"] = out_dir
|
||||||
|
payload = json.dumps({
|
||||||
|
"jsonrpc": "2.0", "id": str(uuid.uuid4()),
|
||||||
|
"method": "aria2.addUri",
|
||||||
|
"params": [f"token:{ARIA2_SECRET}", [dl_url], opts],
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(ARIA2_RPC_URL, data=payload,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(resp.read().decode()).get("result", "")
|
||||||
|
|
||||||
|
|
||||||
|
def push_one(dl_url, cs, referer, filename, out_dir, quiet=False):
|
||||||
|
try:
|
||||||
|
gid = aria2_add(dl_url, cs, referer, filename, out_dir)
|
||||||
|
if gid:
|
||||||
|
if not quiet: print(f" [✓] GID={gid} {filename}")
|
||||||
|
return True
|
||||||
|
except urllib.error.URLError:
|
||||||
|
if not quiet:
|
||||||
|
print(f" [✗] Motrix 未启动, RPC: {ARIA2_RPC_URL}")
|
||||||
|
except Exception as e:
|
||||||
|
if not quiet: print(f" [✗] {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def print_aria2c(dl_url, cs, referer, filename, out_dir):
|
||||||
|
print(f'aria2c --header="Cookie: {cs}" \\')
|
||||||
|
print(f' --header="Referer: {referer}" \\')
|
||||||
|
print(f' --header="User-Agent: {UA}" \\')
|
||||||
|
if filename: print(f' -o "{filename}" \\')
|
||||||
|
if out_dir: print(f' -d "{out_dir}" \\')
|
||||||
|
print(f' "{dl_url}"')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 主流程 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def handle_file(tenant, token, jar, args):
|
||||||
|
share_url = f"https://{tenant}.feishu.cn/file/{token}"
|
||||||
|
dl_url = f"https://{tenant}.feishu.cn/space/api/box/stream/download/all/{token}"
|
||||||
|
cs = cookie_string(jar)
|
||||||
|
|
||||||
|
print(f"[2/3] 探测文件 ...")
|
||||||
|
filename, size = probe_file(tenant, token, jar, share_url)
|
||||||
|
print(f" {filename} ({human_size(size)})")
|
||||||
|
|
||||||
|
if args.list: return
|
||||||
|
if args.aria2c:
|
||||||
|
print_aria2c(dl_url, cs, share_url, filename, args.dir); return
|
||||||
|
|
||||||
|
print(f"[3/3] 推送到 Motrix ...")
|
||||||
|
if not push_one(dl_url, cs, share_url, filename, args.dir):
|
||||||
|
print(f"\n 降级输出 aria2c 命令:\n")
|
||||||
|
print_aria2c(dl_url, cs, share_url, filename, args.dir)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_folder(tenant, token, jar, args):
|
||||||
|
base = f"https://{tenant}.feishu.cn"
|
||||||
|
cs = cookie_string(jar)
|
||||||
|
|
||||||
|
print(f"[2/4] 递归扫描文件夹 ...")
|
||||||
|
all_files = walk_folder(tenant, token, jar)
|
||||||
|
|
||||||
|
downloadable = [f for f in all_files if f["type"] == 12]
|
||||||
|
skipped = [f for f in all_files if f["type"] != 12]
|
||||||
|
|
||||||
|
print(f" 共 {len(all_files)} 项: "
|
||||||
|
f"{len(downloadable)} 可下载, {len(skipped)} 在线文档(跳过)")
|
||||||
|
|
||||||
|
if skipped:
|
||||||
|
print(f"\n ⏭ 跳过的在线文档:")
|
||||||
|
for f in skipped:
|
||||||
|
print(f" {f['type_name']} {f['path']}")
|
||||||
|
|
||||||
|
if not downloadable:
|
||||||
|
print("\n 没有可下载的文件"); return
|
||||||
|
|
||||||
|
# 探测真实文件名和大小
|
||||||
|
print(f"\n[3/4] 探测文件信息 ...")
|
||||||
|
total_size = 0
|
||||||
|
for f in downloadable:
|
||||||
|
referer = f.get("url", f"{base}/drive/folder/{token}")
|
||||||
|
try:
|
||||||
|
real_name, size = probe_file(tenant, f["obj_token"], jar, referer)
|
||||||
|
f["real_name"] = real_name or f["name"]
|
||||||
|
f["size"] = size or f["size"]
|
||||||
|
except:
|
||||||
|
f["real_name"] = f["name"]
|
||||||
|
total_size += f["size"]
|
||||||
|
|
||||||
|
# 打印文件列表
|
||||||
|
print(f"\n {'─'*60}")
|
||||||
|
print(f" {'#':>3} {'文件名':<35} {'大小':>10} 路径")
|
||||||
|
print(f" {'─'*60}")
|
||||||
|
for i, f in enumerate(downloadable):
|
||||||
|
sz = human_size(f["size"]) if f["size"] else " ?"
|
||||||
|
print(f" {i+1:>3} {f['real_name']:<35} {sz:>10} {f['path']}")
|
||||||
|
print(f" {'─'*60}")
|
||||||
|
print(f" 合计: {len(downloadable)} 文件, {human_size(total_size)}")
|
||||||
|
|
||||||
|
if args.list: return
|
||||||
|
|
||||||
|
# 下载
|
||||||
|
print(f"\n[4/4] {'aria2c 命令' if args.aria2c else '推送 Motrix'} ...")
|
||||||
|
ok = 0
|
||||||
|
for f in downloadable:
|
||||||
|
dl_url = f"{base}/space/api/box/stream/download/all/{f['obj_token']}"
|
||||||
|
sub = os.path.dirname(f["path"])
|
||||||
|
out_dir = os.path.join(args.dir, sub) if args.dir and sub else (args.dir or None)
|
||||||
|
|
||||||
|
if args.aria2c:
|
||||||
|
print_aria2c(dl_url, cs, base, f["real_name"], out_dir)
|
||||||
|
print()
|
||||||
|
ok += 1
|
||||||
|
else:
|
||||||
|
if push_one(dl_url, cs, base, f["real_name"], out_dir, quiet=True):
|
||||||
|
ok += 1
|
||||||
|
print(f" [✓] {f['real_name']}")
|
||||||
|
else:
|
||||||
|
print(f" [✗] {f['real_name']} — Motrix 未响应")
|
||||||
|
print(f" 降级 aria2c:\n")
|
||||||
|
print_aria2c(dl_url, cs, base, f["real_name"], out_dir)
|
||||||
|
return # Motrix 挂了就不继续了
|
||||||
|
|
||||||
|
print(f"\n 完成! {ok}/{len(downloadable)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 入口 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="飞书分享解析下载器 v2")
|
||||||
|
ap.add_argument("url", help="飞书分享链接 (文件/文件夹)")
|
||||||
|
ap.add_argument("-d", "--dir", default=None, help="下载目录")
|
||||||
|
ap.add_argument("--list", action="store_true", help="仅列出,不下载")
|
||||||
|
ap.add_argument("--aria2c", action="store_true", help="输出 aria2c 命令")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
tenant, token, lt = parse_share_url(args.url)
|
||||||
|
if not token:
|
||||||
|
print(f"[✗] 不支持的链接: {args.url}")
|
||||||
|
print(f" 格式: https://xxx.feishu.cn/file/xxxToken")
|
||||||
|
print(f" https://xxx.feishu.cn/drive/folder/xxxToken")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\n{'📄' if lt=='file' else '📁'} 飞书分享解析 [{lt}] {tenant}/{token}")
|
||||||
|
|
||||||
|
print(f"[1/{3 if lt=='file' else 4}] 获取匿名会话 ...")
|
||||||
|
jar = fetch_session(args.url)
|
||||||
|
print(f" {sum(1 for _ in jar)} cookies")
|
||||||
|
|
||||||
|
if lt == "file":
|
||||||
|
handle_file(tenant, token, jar, args)
|
||||||
|
else:
|
||||||
|
handle_folder(tenant, token, jar, args)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -161,23 +161,14 @@ public class PanDomainTemplateTest {
|
|||||||
public void testLePatternFix() {
|
public void testLePatternFix() {
|
||||||
Pattern lePattern = PanDomainTemplate.LE.getPattern();
|
Pattern lePattern = PanDomainTemplate.LE.getPattern();
|
||||||
|
|
||||||
// /share/ 格式应匹配
|
// lecloud.lenovo.com 应匹配
|
||||||
Matcher m1 = lePattern.matcher("https://lecloud.lenovo.com/share/abc123");
|
Matcher m1 = lePattern.matcher("https://lecloud.lenovo.com/share/abc123");
|
||||||
assertTrue("LE should match /share/ format", m1.find());
|
assertTrue("LE should match lecloud.lenovo.com", m1.find());
|
||||||
assertEquals("abc123", m1.group("KEY"));
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
// /mshare/ 格式应匹配
|
// leclou.lenovo.com (去掉'd') 不应匹配(原 lecloud? 的 bug)
|
||||||
Matcher m2 = lePattern.matcher("https://lecloud.lenovo.com/mshare/xyz789");
|
|
||||||
assertTrue("LE should match /mshare/ format", m2.find());
|
|
||||||
assertEquals("xyz789", m2.group("KEY"));
|
|
||||||
|
|
||||||
// leclou.lenovo.com (去掉'd') 不应匹配
|
|
||||||
assertFalse("LE should NOT match leclou.lenovo.com",
|
assertFalse("LE should NOT match leclou.lenovo.com",
|
||||||
lePattern.matcher("https://leclou.lenovo.com/share/abc123").find());
|
lePattern.matcher("https://leclou.lenovo.com/share/abc123").find());
|
||||||
|
|
||||||
// 错误路径不应匹配
|
|
||||||
assertFalse("LE should NOT match wrong path",
|
|
||||||
lePattern.matcher("https://lecloud.lenovo.com/s/abc123").find());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -259,72 +250,6 @@ public class PanDomainTemplateTest {
|
|||||||
assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY"));
|
assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFsPatternMatching() {
|
|
||||||
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
|
|
||||||
|
|
||||||
// 文件链接
|
|
||||||
Matcher m1 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc");
|
|
||||||
assertTrue("FS should match file link", m1.matches());
|
|
||||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m1.group("KEY"));
|
|
||||||
|
|
||||||
// 文件链接带 ?from=from_copylink
|
|
||||||
Matcher m2 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink");
|
|
||||||
assertTrue("FS should match file link with query param", m2.matches());
|
|
||||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m2.group("KEY"));
|
|
||||||
|
|
||||||
// 文件夹链接
|
|
||||||
Matcher m3 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg");
|
|
||||||
assertTrue("FS should match folder link", m3.matches());
|
|
||||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m3.group("KEY"));
|
|
||||||
|
|
||||||
// 文件夹链接带 ?from=from_copylink
|
|
||||||
Matcher m4 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink");
|
|
||||||
assertTrue("FS should match folder link with query param", m4.matches());
|
|
||||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m4.group("KEY"));
|
|
||||||
|
|
||||||
// 不同的 tenant 子域名
|
|
||||||
Matcher m5 = fsPattern.matcher(
|
|
||||||
"https://pokepangle.feishu.cn/file/VW30bpK74ontiTxvRg1cZcgvnGg");
|
|
||||||
assertTrue("FS should match different tenant", m5.matches());
|
|
||||||
assertEquals("VW30bpK74ontiTxvRg1cZcgvnGg", m5.group("KEY"));
|
|
||||||
|
|
||||||
// 负例: 非feishu域名不匹配
|
|
||||||
assertFalse("FS should NOT match non-feishu domain",
|
|
||||||
fsPattern.matcher("https://evil.com/file/abc123").matches());
|
|
||||||
|
|
||||||
// 负例: feishu.cn上的其他路径不匹配
|
|
||||||
assertFalse("FS should NOT match other feishu paths",
|
|
||||||
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFsFromShareUrl() {
|
|
||||||
// 测试文件链接解析
|
|
||||||
String fileUrl = "https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink";
|
|
||||||
ParserCreate parserCreate = ParserCreate.fromShareUrl(fileUrl);
|
|
||||||
ShareLinkInfo info = parserCreate.getShareLinkInfo();
|
|
||||||
|
|
||||||
assertNotNull("ShareLinkInfo should not be null", info);
|
|
||||||
assertEquals("fs", info.getType());
|
|
||||||
assertEquals("飞书云盘", info.getPanName());
|
|
||||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", info.getShareKey());
|
|
||||||
|
|
||||||
// 测试文件夹链接解析
|
|
||||||
String folderUrl = "https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg";
|
|
||||||
ParserCreate parserCreate2 = ParserCreate.fromShareUrl(folderUrl);
|
|
||||||
ShareLinkInfo info2 = parserCreate2.getShareLinkInfo();
|
|
||||||
|
|
||||||
assertNotNull("ShareLinkInfo should not be null", info2);
|
|
||||||
assertEquals("fs", info2.getType());
|
|
||||||
assertEquals("飞书云盘", info2.getPanName());
|
|
||||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", info2.getShareKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void verifyDuplicates() {
|
public void verifyDuplicates() {
|
||||||
|
|
||||||
|
|||||||
4
pom.xml
4
pom.xml
@@ -26,13 +26,13 @@
|
|||||||
<packageDirectory>${project.basedir}/web-service/target/package</packageDirectory>
|
<packageDirectory>${project.basedir}/web-service/target/package</packageDirectory>
|
||||||
|
|
||||||
<!-- Vert.x 4.5.24 已包含安全修复,无需单独指定 Netty 版本 -->
|
<!-- Vert.x 4.5.24 已包含安全修复,无需单独指定 Netty 版本 -->
|
||||||
<vertx.version>4.5.24</vertx.version>
|
<vertx.version>4.5.14</vertx.version>
|
||||||
<org.reflections.version>0.10.2</org.reflections.version>
|
<org.reflections.version>0.10.2</org.reflections.version>
|
||||||
<lombok.version>1.18.38</lombok.version>
|
<lombok.version>1.18.38</lombok.version>
|
||||||
<slf4j.version>2.0.16</slf4j.version>
|
<slf4j.version>2.0.16</slf4j.version>
|
||||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||||
<commons-beanutils2.version>2.0.0</commons-beanutils2.version>
|
<commons-beanutils2.version>2.0.0</commons-beanutils2.version>
|
||||||
<jackson.version>2.18.6</jackson.version>
|
<jackson.version>2.18.2</jackson.version>
|
||||||
<!-- Logback 最新稳定版 -->
|
<!-- Logback 最新稳定版 -->
|
||||||
<logback.version>1.5.18</logback.version>
|
<logback.version>1.5.18</logback.version>
|
||||||
<junit.version>4.13.2</junit.version>
|
<junit.version>4.13.2</junit.version>
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
content="Netdisk fast download 网盘直链解析工具">
|
content="Netdisk fast download 网盘直链解析工具">
|
||||||
<!-- Font Awesome 图标库 - 使用国内CDN -->
|
<!-- Font Awesome 图标库 - 使用国内CDN -->
|
||||||
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<!-- 迅雷 JS-SDK -->
|
|
||||||
<script src="//open.thunderurl.com/thunder-link.js"></script>
|
|
||||||
<style>
|
<style>
|
||||||
.page-loading-wrap {
|
.page-loading-wrap {
|
||||||
padding: 120px;
|
padding: 120px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,266 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
title="文件下载"
|
|
||||||
v-model="dialogVisible"
|
|
||||||
width="600px"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
@close="$emit('update:visible', false)"
|
|
||||||
>
|
|
||||||
<div v-if="info" class="download-info-content">
|
|
||||||
<div class="download-file-header">
|
|
||||||
<i class="fas fa-file" style="margin-right: 8px; color: #409eff;"></i>
|
|
||||||
<strong>{{ info.fileName || '未命名文件' }}</strong>
|
|
||||||
</div>
|
|
||||||
<el-alert
|
|
||||||
title="该文件需要特殊请求头才能下载,无法直接通过浏览器下载。请使用以下方式之一:"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
style="margin-bottom: 16px;"
|
|
||||||
/>
|
|
||||||
<el-tabs v-model="activeTab">
|
|
||||||
<el-tab-pane label="发送到下载器" name="downloader">
|
|
||||||
<div class="downloader-section">
|
|
||||||
<template v-if="isThunder">
|
|
||||||
<p v-if="thunderNeedsCookie" style="color: #f56c6c; margin-bottom: 12px;">
|
|
||||||
<i class="fas fa-exclamation-circle"></i>
|
|
||||||
该文件需要 Cookie 认证,迅雷不支持自定义 Cookie,请切换到 Aria2/Motrix/Gopeed
|
|
||||||
</p>
|
|
||||||
<p v-else-if="thunderNeedsUa" style="color: #e6a23c; margin-bottom: 12px;">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
该文件需要特殊 User-Agent 才能下载,迅雷客户端可能不支持自定义 UA,下载可能失败。建议切换到 Aria2/Motrix/Gopeed
|
|
||||||
</p>
|
|
||||||
<p v-else style="color: #909399; margin-bottom: 12px;">
|
|
||||||
<i class="fas fa-bolt"></i>
|
|
||||||
迅雷将通过浏览器唤起本地客户端下载
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p v-if="!connected" style="color: #e6a23c; margin-bottom: 12px;">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
未检测到下载器连接,请先在首页配置下载器(Aria2/Motrix/Gopeed/迅雷)
|
|
||||||
</p>
|
|
||||||
<p v-else style="color: #67c23a; margin-bottom: 12px;">
|
|
||||||
<i class="fas fa-check-circle"></i>
|
|
||||||
下载器已连接 ({{ downloaderVersion }})
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<el-button
|
|
||||||
type="success"
|
|
||||||
@click="sendToDownloader"
|
|
||||||
:disabled="(isThunder && thunderNeedsCookie) || (!isThunder && !connected)"
|
|
||||||
:loading="sending"
|
|
||||||
>
|
|
||||||
<i class="fas fa-paper-plane"></i> 发送到下载器
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="!isThunder"
|
|
||||||
size="small"
|
|
||||||
@click="doTestConnection"
|
|
||||||
style="margin-left: 8px;"
|
|
||||||
>
|
|
||||||
测试连接
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
<el-tab-pane label="Aria2 命令" name="aria2">
|
|
||||||
<el-input
|
|
||||||
type="textarea"
|
|
||||||
:model-value="info.aria2Command"
|
|
||||||
:rows="4"
|
|
||||||
readonly
|
|
||||||
resize="none"
|
|
||||||
class="download-command-textarea"
|
|
||||||
/>
|
|
||||||
<div class="download-actions">
|
|
||||||
<el-button type="primary" size="small" @click="copyText(info.aria2Command)">
|
|
||||||
<i class="fas fa-copy"></i> 复制 Aria2 命令
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
<el-tab-pane label="Curl 命令" name="curl">
|
|
||||||
<el-input
|
|
||||||
type="textarea"
|
|
||||||
:model-value="info.curlCommand"
|
|
||||||
:rows="4"
|
|
||||||
readonly
|
|
||||||
resize="none"
|
|
||||||
class="download-command-textarea"
|
|
||||||
/>
|
|
||||||
<div class="download-actions">
|
|
||||||
<el-button type="primary" size="small" @click="copyText(info.curlCommand)">
|
|
||||||
<i class="fas fa-copy"></i> 复制 Curl 命令
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
<div style="margin-top: 16px; text-align: right;">
|
|
||||||
<el-button size="small" type="warning" @click="doDirectDownload">
|
|
||||||
直接打开链接(可能失败)
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { testConnection, addDownload, getConfig, hasCookieHeader, hasCustomUaHeader } from '@/utils/downloaderService'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DownloadDialog',
|
|
||||||
props: {
|
|
||||||
/** v-model:visible 控制弹窗显示 */
|
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 下载信息对象
|
|
||||||
* { downloadUrl, fileName, downloadHeaders, aria2Command, curlCommand, aria2JsonRpc, needDownloader }
|
|
||||||
*/
|
|
||||||
downloadInfo: {
|
|
||||||
type: Object,
|
|
||||||
default: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
emits: ['update:visible', 'close'],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
activeTab: 'downloader',
|
|
||||||
connected: false,
|
|
||||||
downloaderVersion: '',
|
|
||||||
sending: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
dialogVisible: {
|
|
||||||
get() { return this.visible },
|
|
||||||
set(val) { this.$emit('update:visible', val) }
|
|
||||||
},
|
|
||||||
info() { return this.downloadInfo },
|
|
||||||
isThunder() { return getConfig().downloaderType === 'thunder' },
|
|
||||||
thunderNeedsCookie() { return this.isThunder && this.info && hasCookieHeader(this.info.downloadHeaders) },
|
|
||||||
thunderNeedsUa() { return this.isThunder && this.info && hasCustomUaHeader(this.info.downloadHeaders) }
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
visible(val) {
|
|
||||||
if (val) {
|
|
||||||
this.activeTab = 'downloader'
|
|
||||||
this.checkConnection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
/** 检测下载器连接状态 */
|
|
||||||
async checkConnection() {
|
|
||||||
const result = await testConnection()
|
|
||||||
this.connected = result.connected
|
|
||||||
this.downloaderVersion = result.version
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 手动测试连接 */
|
|
||||||
async doTestConnection() {
|
|
||||||
const result = await testConnection()
|
|
||||||
this.connected = result.connected
|
|
||||||
this.downloaderVersion = result.version
|
|
||||||
if (result.connected) {
|
|
||||||
this.$message.success(`下载器连接正常 (${result.version})`)
|
|
||||||
} else {
|
|
||||||
this.$message.error('无法连接到下载器,请检查配置')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 发送到 Aria2/Motrix/Gopeed */
|
|
||||||
async sendToDownloader() {
|
|
||||||
if (!this.info) return
|
|
||||||
this.sending = true
|
|
||||||
try {
|
|
||||||
const gid = await addDownload(
|
|
||||||
this.info.downloadUrl,
|
|
||||||
this.info.downloadHeaders,
|
|
||||||
this.info.fileName
|
|
||||||
)
|
|
||||||
this.$message.success('已发送到下载器,任务ID: ' + gid)
|
|
||||||
this.dialogVisible = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('发送到下载器失败:', error)
|
|
||||||
this.$message.error('发送到下载器失败: ' + (error.message || '未知错误'))
|
|
||||||
} finally {
|
|
||||||
this.sending = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 直接打开下载链接(可能因缺请求头而失败) */
|
|
||||||
doDirectDownload() {
|
|
||||||
if (this.info && this.info.downloadUrl) {
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = this.info.downloadUrl
|
|
||||||
a.target = '_blank'
|
|
||||||
a.rel = 'noopener noreferrer'
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
this.dialogVisible = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 复制文本到剪贴板 */
|
|
||||||
async copyText(text) {
|
|
||||||
if (!text) return
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
this.$message.success('已复制到剪贴板')
|
|
||||||
} catch {
|
|
||||||
const textarea = document.createElement('textarea')
|
|
||||||
textarea.value = text
|
|
||||||
textarea.style.position = 'fixed'
|
|
||||||
textarea.style.opacity = '0'
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
document.execCommand('copy')
|
|
||||||
document.body.removeChild(textarea)
|
|
||||||
this.$message.success('已复制到剪贴板')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.download-info-content {
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
.download-file-header {
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: #f0f7ff;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
:deep(.dark) .download-file-header,
|
|
||||||
.dark-theme .download-file-header {
|
|
||||||
background: #1a3350;
|
|
||||||
}
|
|
||||||
.download-command-textarea :deep(.el-textarea__inner) {
|
|
||||||
font-family: 'Courier New', Courier, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
:deep(.dark) .download-command-textarea :deep(.el-textarea__inner),
|
|
||||||
.dark-theme .download-command-textarea :deep(.el-textarea__inner) {
|
|
||||||
background: #1e1e1e;
|
|
||||||
color: #d4d4d4;
|
|
||||||
}
|
|
||||||
.download-actions {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.downloader-section {
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
/**
|
|
||||||
* 下载器服务 - 统一管理 Aria2/Motrix/Gopeed/迅雷 的配置读取、连接检测、RPC 调用
|
|
||||||
* 供 Home.vue、DirectoryTree.vue、DownloadDialog.vue 等共用
|
|
||||||
*/
|
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'nfd-aria2-local-config'
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
downloaderType: 'aria2',
|
|
||||||
rpcUrl: 'http://localhost:6800/jsonrpc',
|
|
||||||
rpcSecret: '',
|
|
||||||
downloadDir: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 localStorage 读取下载器配置
|
|
||||||
* @returns {{ downloaderType: string, rpcUrl: string, rpcSecret: string, downloadDir: string }}
|
|
||||||
*/
|
|
||||||
export function getConfig() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
return {
|
|
||||||
downloaderType: parsed.downloaderType || DEFAULT_CONFIG.downloaderType,
|
|
||||||
rpcUrl: parsed.rpcUrl || DEFAULT_CONFIG.rpcUrl,
|
|
||||||
rpcSecret: parsed.rpcSecret || '',
|
|
||||||
downloadDir: parsed.downloadDir || ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('读取下载器配置失败', e)
|
|
||||||
}
|
|
||||||
return { ...DEFAULT_CONFIG }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存下载器配置到 localStorage
|
|
||||||
* @param {{ downloaderType?: string, rpcUrl?: string, rpcSecret?: string, downloadDir?: string }} config
|
|
||||||
*/
|
|
||||||
export function saveConfig(config) {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建 RPC 参数数组(自动添加 token)
|
|
||||||
* @param {string} rpcSecret
|
|
||||||
* @param {Array} extraParams
|
|
||||||
* @returns {Array}
|
|
||||||
*/
|
|
||||||
function buildRpcParams(rpcSecret, extraParams = []) {
|
|
||||||
const params = []
|
|
||||||
if (rpcSecret && rpcSecret.trim()) {
|
|
||||||
params.push(`token:${rpcSecret}`)
|
|
||||||
}
|
|
||||||
if (extraParams && extraParams.length > 0) {
|
|
||||||
params.push(...extraParams)
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用 Aria2 JSON-RPC 接口
|
|
||||||
* @param {string} rpcUrl
|
|
||||||
* @param {string} rpcSecret
|
|
||||||
* @param {string} method - 例如 'aria2.getVersion', 'aria2.addUri'
|
|
||||||
* @param {Array} [extraParams] - 除 token 外的参数
|
|
||||||
* @param {number} [timeout=5000]
|
|
||||||
* @returns {Promise<Object>} RPC 响应的 data
|
|
||||||
*/
|
|
||||||
export async function callRpc(rpcUrl, rpcSecret, method, extraParams = [], timeout = 5000) {
|
|
||||||
const requestBody = {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: Date.now().toString(),
|
|
||||||
method,
|
|
||||||
params: buildRpcParams(rpcSecret, extraParams)
|
|
||||||
}
|
|
||||||
const response = await axios.post(rpcUrl, requestBody, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
timeout
|
|
||||||
})
|
|
||||||
if (response.data && response.data.error) {
|
|
||||||
throw new Error(response.data.error.message || 'Aria2 RPC 错误')
|
|
||||||
}
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断 rpcUrl 是否指向 Gopeed(端口 9999 或 URL 含 /api/v1)
|
|
||||||
* @param {string} url
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function isGopeedUrl(url) {
|
|
||||||
if (!url) return false
|
|
||||||
return url.includes(':9999') || url.includes('/api/v1')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 Gopeed rpcUrl 中提取 baseUrl(去掉 /jsonrpc 或 /api/v1 后缀)
|
|
||||||
* 例如 "http://localhost:9999/jsonrpc" → "http://localhost:9999"
|
|
||||||
* @param {string} rpcUrl
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function gopeedBaseUrl(rpcUrl) {
|
|
||||||
return rpcUrl.replace(/\/jsonrpc$/, '').replace(/\/api\/v1.*$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 调用 Gopeed REST API
|
|
||||||
* @param {string} baseUrl - 例如 "http://localhost:9999"
|
|
||||||
* @param {string} rpcSecret - Bearer token
|
|
||||||
* @param {string} method - 'GET' | 'POST'
|
|
||||||
* @param {string} path - 例如 '/api/v1/version'
|
|
||||||
* @param {Object} [body] - POST body
|
|
||||||
* @param {number} [timeout=5000]
|
|
||||||
* @returns {Promise<Object>} 响应 data
|
|
||||||
*/
|
|
||||||
async function callGopeedApi(baseUrl, rpcSecret, method, path, body, timeout = 5000) {
|
|
||||||
const headers = { 'Content-Type': 'application/json' }
|
|
||||||
if (rpcSecret && rpcSecret.trim()) {
|
|
||||||
headers['X-Api-Token'] = rpcSecret
|
|
||||||
}
|
|
||||||
const url = baseUrl.replace(/\/$/, '') + path
|
|
||||||
const response = await axios({ method, url, headers, data: body, timeout })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 测试下载器连接(自动识别 迅雷 / Gopeed / Aria2 / Motrix)
|
|
||||||
* @param {string} [rpcUrl] - 不传则自动读取配置
|
|
||||||
* @param {string} [rpcSecret] - 不传则自动读取配置
|
|
||||||
* @returns {Promise<{ connected: boolean, version: string }>}
|
|
||||||
*/
|
|
||||||
export async function testConnection(rpcUrl, rpcSecret) {
|
|
||||||
if (!rpcUrl) {
|
|
||||||
const config = getConfig()
|
|
||||||
// 迅雷不需要 RPC,直接检测 JS SDK
|
|
||||||
if (config.downloaderType === 'thunder') {
|
|
||||||
const available = typeof window !== 'undefined' && window.thunderLink && typeof window.thunderLink.newTask === 'function'
|
|
||||||
return { connected: available, version: available ? 'JS-SDK' : '' }
|
|
||||||
}
|
|
||||||
rpcUrl = config.rpcUrl
|
|
||||||
rpcSecret = rpcSecret ?? config.rpcSecret
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (isGopeedUrl(rpcUrl)) {
|
|
||||||
// Gopeed 使用 REST API:GET /api/v1/info
|
|
||||||
const base = gopeedBaseUrl(rpcUrl)
|
|
||||||
const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000)
|
|
||||||
const d = (res && res.code === 0 && res.data) ? res.data : {}
|
|
||||||
const version = [d.version, d.runtime].filter(Boolean).join(' + ') || ''
|
|
||||||
return { connected: true, version }
|
|
||||||
} else {
|
|
||||||
// Aria2 / Motrix 使用 JSON-RPC
|
|
||||||
const res = await callRpc(rpcUrl, rpcSecret || '', 'aria2.getVersion', [], 3000)
|
|
||||||
if (res && res.result && res.result.version) {
|
|
||||||
return { connected: true, version: res.result.version }
|
|
||||||
}
|
|
||||||
return { connected: false, version: '' }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { connected: false, version: '' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动检测本地下载器(依次尝试 Motrix/Gopeed/Aria2)
|
|
||||||
* @param {string} [rpcSecret] - 可选密钥
|
|
||||||
* @returns {Promise<{ found: boolean, type: string, rpcUrl: string, version: string }>}
|
|
||||||
*/
|
|
||||||
export async function autoDetect(rpcSecret = '') {
|
|
||||||
const candidates = [
|
|
||||||
{ type: 'motrix', port: 16800, path: '/jsonrpc' },
|
|
||||||
{ type: 'gopeed', port: 9999, path: '/api/v1/info', gopeed: true },
|
|
||||||
{ type: 'aria2', port: 6800, path: '/jsonrpc' }
|
|
||||||
]
|
|
||||||
for (const c of candidates) {
|
|
||||||
try {
|
|
||||||
if (c.gopeed) {
|
|
||||||
// Gopeed:直接调 REST GET /api/v1/info
|
|
||||||
const base = `http://localhost:${c.port}`
|
|
||||||
const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000)
|
|
||||||
const d = (res && res.code === 0 && res.data) ? res.data : {}
|
|
||||||
const version = [d.version, d.runtime].filter(Boolean).join(' + ') || 'unknown'
|
|
||||||
return { found: true, type: c.type, rpcUrl: `${base}/api/v1`, version }
|
|
||||||
} else {
|
|
||||||
const url = `http://localhost:${c.port}${c.path}`
|
|
||||||
const result = await testConnection(url, rpcSecret)
|
|
||||||
if (result.connected) {
|
|
||||||
return { found: true, type: c.type, rpcUrl: url, version: result.version }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 该端口未响应,继续下一个
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { found: false, type: '', rpcUrl: '', version: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送下载任务到下载器(自动识别 迅雷 / Gopeed / Aria2 / Motrix)
|
|
||||||
* @param {string} downloadUrl - 文件下载地址
|
|
||||||
* @param {Object} [headers] - 请求头 {cookie, referer, user-agent, ...}
|
|
||||||
* @param {string} [fileName] - 输出文件名
|
|
||||||
* @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride] - 覆盖配置
|
|
||||||
* @returns {Promise<string>} 任务 ID / GID
|
|
||||||
*/
|
|
||||||
export async function addDownload(downloadUrl, headers, fileName, configOverride) {
|
|
||||||
const config = { ...getConfig(), ...configOverride }
|
|
||||||
|
|
||||||
if (config.downloaderType === 'thunder') {
|
|
||||||
return addThunderDownload([{ url: downloadUrl, headers, fileName }], config)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGopeedUrl(config.rpcUrl)) {
|
|
||||||
// Gopeed REST API:POST /api/v1/tasks
|
|
||||||
const base = gopeedBaseUrl(config.rpcUrl)
|
|
||||||
const extraHeader = {}
|
|
||||||
if (headers && typeof headers === 'object') {
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
if (key && value) extraHeader[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const body = {
|
|
||||||
req: { url: downloadUrl, extra: { header: extraHeader } },
|
|
||||||
opt: {}
|
|
||||||
}
|
|
||||||
if (config.downloadDir) body.opt.path = config.downloadDir
|
|
||||||
const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks', body, 10000)
|
|
||||||
// Gopeed 返回 { code: 0, data: "task-id" }
|
|
||||||
if (res && res.code !== undefined && res.code !== 0) throw new Error(res.message || 'Gopeed 发送失败')
|
|
||||||
if (res && res.data) return typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
|
|
||||||
return 'ok'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aria2 / Motrix JSON-RPC
|
|
||||||
const options = {}
|
|
||||||
if (headers && typeof headers === 'object') {
|
|
||||||
const headerArray = []
|
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
|
||||||
if (key && value) headerArray.push(`${key}: ${value}`)
|
|
||||||
}
|
|
||||||
if (headerArray.length > 0) options.header = headerArray
|
|
||||||
}
|
|
||||||
if (fileName) options.out = fileName
|
|
||||||
if (config.downloadDir) options.dir = config.downloadDir
|
|
||||||
|
|
||||||
const res = await callRpc(config.rpcUrl, config.rpcSecret, 'aria2.addUri', [[downloadUrl], options], 10000)
|
|
||||||
if (res && res.result) return res.result // GID
|
|
||||||
throw new Error('未知错误')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量发送下载任务到下载器(aria2 用 system.multicall,gopeed 用 batch API,迅雷用 JS-SDK newTask)
|
|
||||||
* @param {{ url: string, headers?: Object, fileName?: string }[]} tasks - 下载任务列表
|
|
||||||
* @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride]
|
|
||||||
* @returns {Promise<{ succeeded: number, failed: number, errors: string[] }>}
|
|
||||||
*/
|
|
||||||
export async function batchAddDownload(tasks, configOverride) {
|
|
||||||
if (!tasks || tasks.length === 0) return { succeeded: 0, failed: 0, errors: [] }
|
|
||||||
if (tasks.length === 1) {
|
|
||||||
try {
|
|
||||||
await addDownload(tasks[0].url, tasks[0].headers, tasks[0].fileName, configOverride)
|
|
||||||
return { succeeded: 1, failed: 0, errors: [] }
|
|
||||||
} catch (e) {
|
|
||||||
return { succeeded: 0, failed: 1, errors: [e.message || '未知错误'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = { ...getConfig(), ...configOverride }
|
|
||||||
|
|
||||||
if (config.downloaderType === 'thunder') {
|
|
||||||
try {
|
|
||||||
await addThunderDownload(tasks, config)
|
|
||||||
return { succeeded: tasks.length, failed: 0, errors: [] }
|
|
||||||
} catch (e) {
|
|
||||||
return { succeeded: 0, failed: tasks.length, errors: [e.message || '迅雷下载失败'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGopeedUrl(config.rpcUrl)) {
|
|
||||||
return batchAddGopeed(tasks, config)
|
|
||||||
} else {
|
|
||||||
return batchAddAria2(tasks, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function batchAddAria2(tasks, config) {
|
|
||||||
const calls = tasks.map(task => {
|
|
||||||
const options = {}
|
|
||||||
if (task.headers && typeof task.headers === 'object') {
|
|
||||||
const headerArray = []
|
|
||||||
for (const [key, value] of Object.entries(task.headers)) {
|
|
||||||
if (key && value) headerArray.push(`${key}: ${value}`)
|
|
||||||
}
|
|
||||||
if (headerArray.length > 0) options.header = headerArray
|
|
||||||
}
|
|
||||||
if (task.fileName) options.out = task.fileName
|
|
||||||
if (config.downloadDir) options.dir = config.downloadDir
|
|
||||||
|
|
||||||
const params = []
|
|
||||||
if (config.rpcSecret && config.rpcSecret.trim()) {
|
|
||||||
params.push(`token:${config.rpcSecret}`)
|
|
||||||
}
|
|
||||||
params.push([task.url], options)
|
|
||||||
return { methodName: 'aria2.addUri', params }
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const requestBody = {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: Date.now().toString(),
|
|
||||||
method: 'system.multicall',
|
|
||||||
params: [calls]
|
|
||||||
}
|
|
||||||
const response = await axios.post(config.rpcUrl, requestBody, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
timeout: Math.max(10000, tasks.length * 500)
|
|
||||||
})
|
|
||||||
const results = response.data && response.data.result
|
|
||||||
if (!Array.isArray(results)) {
|
|
||||||
throw new Error(response.data?.error?.message || 'system.multicall 返回异常')
|
|
||||||
}
|
|
||||||
let succeeded = 0, failed = 0
|
|
||||||
const errors = []
|
|
||||||
for (let i = 0; i < results.length; i++) {
|
|
||||||
const r = results[i]
|
|
||||||
if (Array.isArray(r) && r.length > 0 && typeof r[0] === 'string') {
|
|
||||||
succeeded++
|
|
||||||
} else if (r && r.faultCode) {
|
|
||||||
failed++
|
|
||||||
errors.push(`${tasks[i].fileName || tasks[i].url}: ${r.faultString || '未知错误'}`)
|
|
||||||
} else {
|
|
||||||
succeeded++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { succeeded, failed, errors }
|
|
||||||
} catch (e) {
|
|
||||||
return { succeeded: 0, failed: tasks.length, errors: [e.message || 'multicall 请求失败'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function batchAddGopeed(tasks, config) {
|
|
||||||
const base = gopeedBaseUrl(config.rpcUrl)
|
|
||||||
const reqs = tasks.map(task => {
|
|
||||||
const extraHeader = {}
|
|
||||||
if (task.headers && typeof task.headers === 'object') {
|
|
||||||
for (const [key, value] of Object.entries(task.headers)) {
|
|
||||||
if (key && value) extraHeader[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const item = { req: { url: task.url, extra: { header: extraHeader } } }
|
|
||||||
if (task.fileName) {
|
|
||||||
item.opts = { name: task.fileName }
|
|
||||||
}
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
|
|
||||||
const body = { reqs }
|
|
||||||
if (config.downloadDir) body.opts = { path: config.downloadDir }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks/batch', body,
|
|
||||||
Math.max(10000, tasks.length * 500))
|
|
||||||
if (res && res.code !== undefined && res.code !== 0) {
|
|
||||||
return { succeeded: 0, failed: tasks.length, errors: [res.message || 'Gopeed batch 失败'] }
|
|
||||||
}
|
|
||||||
const ids = Array.isArray(res?.data) ? res.data : []
|
|
||||||
return { succeeded: ids.length || tasks.length, failed: 0, errors: [] }
|
|
||||||
} catch (e) {
|
|
||||||
return { succeeded: 0, failed: tasks.length, errors: [e.message || 'Gopeed batch 请求失败'] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过迅雷 JS-SDK 发送下载任务
|
|
||||||
* @param {{ url: string, headers?: Object, fileName?: string }[]} tasks
|
|
||||||
* @param {{ downloadDir?: string }} config
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
function addThunderDownload(tasks, config) {
|
|
||||||
if (typeof window === 'undefined' || !window.thunderLink || typeof window.thunderLink.newTask !== 'function') {
|
|
||||||
return Promise.reject(new Error('迅雷客户端未检测到,请确认已安装并启动迅雷'))
|
|
||||||
}
|
|
||||||
// 迅雷 JS-SDK 不支持自定义 Cookie,含 Cookie 的下载链接无法通过迅雷下载
|
|
||||||
const firstHeaders = (tasks[0] && tasks[0].headers) || {}
|
|
||||||
if (firstHeaders.cookie || firstHeaders.Cookie) {
|
|
||||||
return Promise.reject(new Error('该文件需要 Cookie 认证,迅雷不支持自定义 Cookie,请使用 Aria2/Motrix/Gopeed'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历所有 header key 大小写不敏感地提取 referer / user-agent
|
|
||||||
let referer = ''
|
|
||||||
let userAgent = ''
|
|
||||||
for (const [key, value] of Object.entries(firstHeaders)) {
|
|
||||||
const lk = key.toLowerCase()
|
|
||||||
if (lk === 'referer' && value) referer = value
|
|
||||||
if (lk === 'user-agent' && value) userAgent = value
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskParam = {
|
|
||||||
tasks: tasks.map(t => {
|
|
||||||
const item = { url: t.url }
|
|
||||||
if (t.fileName) item.name = t.fileName
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (config.downloadDir) taskParam.downloadDir = config.downloadDir
|
|
||||||
if (referer) taskParam.referer = referer
|
|
||||||
if (userAgent) taskParam.userAgent = userAgent
|
|
||||||
taskParam.threadCount = '1'
|
|
||||||
|
|
||||||
console.log('[Thunder SDK] newTask params:', JSON.stringify(taskParam))
|
|
||||||
window.thunderLink.newTask(taskParam)
|
|
||||||
return Promise.resolve('thunder-ok')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据 RPC URL 猜测下载器类型
|
|
||||||
* @param {string} url
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function guessDownloaderType(url) {
|
|
||||||
if (!url) return 'aria2'
|
|
||||||
if (url.includes(':16800')) return 'motrix'
|
|
||||||
if (url.includes(':9999')) return 'gopeed'
|
|
||||||
return 'aria2'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查下载头中是否含有 Cookie(迅雷不支持)
|
|
||||||
* @param {Object} [headers]
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function hasCookieHeader(headers) {
|
|
||||||
if (!headers || typeof headers !== 'object') return false
|
|
||||||
return !!(headers.cookie || headers.Cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查下载头中是否含有自定义 User-Agent(迅雷客户端可能不支持)
|
|
||||||
* @param {Object} [headers]
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
export function hasCustomUaHeader(headers) {
|
|
||||||
if (!headers || typeof headers !== 'object') return false
|
|
||||||
for (const key of Object.keys(headers)) {
|
|
||||||
if (key.toLowerCase() === 'user-agent' && headers[key]) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
getConfig,
|
|
||||||
saveConfig,
|
|
||||||
callRpc,
|
|
||||||
testConnection,
|
|
||||||
autoDetect,
|
|
||||||
addDownload,
|
|
||||||
batchAddDownload,
|
|
||||||
guessDownloaderType,
|
|
||||||
hasCookieHeader
|
|
||||||
}
|
|
||||||
@@ -35,12 +35,15 @@
|
|||||||
<i class="fas fa-server feedback-icon"></i>
|
<i class="fas fa-server feedback-icon"></i>
|
||||||
部署
|
部署
|
||||||
</a>
|
</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>
|
</div>
|
||||||
<el-row :gutter="20" style="margin-left: 0; margin-right: 0;">
|
<el-row :gutter="20" style="margin-left: 0; margin-right: 0;">
|
||||||
<el-card class="box-card">
|
<el-card class="box-card">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
||||||
<!-- 左侧:认证配置 + 捐赠账号 按钮组 -->
|
<!-- 左侧认证配置按钮 -->
|
||||||
<div style="display: flex; gap: 6px; align-items: center;">
|
|
||||||
<el-tooltip content="配置临时认证信息" placement="bottom">
|
<el-tooltip content="配置临时认证信息" placement="bottom">
|
||||||
<el-button
|
<el-button
|
||||||
:type="hasAuthConfig ? 'primary' : 'default'"
|
:type="hasAuthConfig ? 'primary' : 'default'"
|
||||||
@@ -51,21 +54,9 @@
|
|||||||
<el-icon><Key /></el-icon>
|
<el-icon><Key /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-tooltip content="捐赠网盘账号" placement="bottom">
|
<!-- 右侧暗色模式切换 -->
|
||||||
<el-button circle size="small" type="warning" @click="showDonateDialog = true">
|
|
||||||
<el-icon><Present /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
<!-- 右侧:下载器 + 暗色模式 -->
|
|
||||||
<div style="display: flex; gap: 8px; align-items: center;">
|
|
||||||
<el-button link type="primary" @click="openAria2Dialog" style="position: relative;">
|
|
||||||
<span :class="['aria2-status-dot', aria2Connected ? 'connected' : 'disconnected']"></span>
|
|
||||||
{{ aria2Connected ? ('已连接 - ' + downloaderTypeName) : '下载器' }}
|
|
||||||
</el-button>
|
|
||||||
<DarkMode @theme-change="handleThemeChange" />
|
<DarkMode @theme-change="handleThemeChange" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="demo-basic--circle">
|
<div class="demo-basic--circle">
|
||||||
<div class="block" style="text-align: center;">
|
<div class="block" style="text-align: center;">
|
||||||
<img :height="150" src="../../public/images/lanzou111.png" alt="lz">
|
<img :height="150" src="../../public/images/lanzou111.png" alt="lz">
|
||||||
@@ -73,9 +64,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 项目简介移到卡片内 -->
|
<!-- 项目简介移到卡片内 -->
|
||||||
<div class="project-intro">
|
<div class="project-intro">
|
||||||
<div class="intro-title">NFD网盘直链解析0.3.0</div>
|
<div class="intro-title">NFD网盘直链解析0.2.1b3</div>
|
||||||
<div class="intro-desc">
|
<div class="intro-desc">
|
||||||
<div>支持网盘:蓝奏云、蓝奏云优享、小飞机盘、123云盘、iCloud、移动云空间、联想乐云、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云盘、奶牛快传、移动云空间、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>
|
<div>文件夹解析支持:蓝奏云、蓝奏云优享、小飞机盘、123云盘</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,12 +102,12 @@
|
|||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<p style="text-align: center">
|
<p style="text-align: center">
|
||||||
<el-button class="parse-action-btn" type="success" style="margin-left: 40px" @click="parseFile">解析文件</el-button>
|
<el-button style="margin-left: 40px" @click="parseFile">解析文件</el-button>
|
||||||
<el-button class="parse-action-btn" type="success" style="margin-left: 20px" @click="parseDirectory">解析目录</el-button>
|
<el-button style="margin-left: 20px" @click="parseDirectory">解析目录</el-button>
|
||||||
<el-button style="margin-left: 20px" @click="generateMarkdown">生成Markdown</el-button>
|
<el-button style="margin-left: 20px" @click="generateMarkdown">生成Markdown</el-button>
|
||||||
<el-button style="margin-left: 20px" @click="generateQRCode">扫码下载</el-button>
|
<el-button style="margin-left: 20px" @click="generateQRCode">扫码下载</el-button>
|
||||||
<el-button style="margin-left: 20px" @click="getStatistics">分享统计</el-button>
|
<el-button style="margin-left: 20px" @click="getStatistics">分享统计</el-button>
|
||||||
|
<el-button style="margin-left: 20px" @click="goToClientLinks" type="primary">生成命令行链接</el-button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,92 +115,31 @@
|
|||||||
<div v-if="parseResult.code" style="margin-top: 10px">
|
<div v-if="parseResult.code" style="margin-top: 10px">
|
||||||
<strong>解析结果: </strong>
|
<strong>解析结果: </strong>
|
||||||
<json-viewer :value="parseResult" :expand-depth="5" copyable boxed sort />
|
<json-viewer :value="parseResult" :expand-depth="5" copyable boxed sort />
|
||||||
<!-- 下载链接卡片 -->
|
<!-- 文件信息美化展示区 -->
|
||||||
<div v-if="downloadUrl" style="margin-top: 15px;">
|
<div v-if="downloadUrl" class="file-meta-info-card">
|
||||||
<el-card shadow="hover" class="download-result-card">
|
<div class="file-meta-row">
|
||||||
<template #header>
|
<span class="file-meta-label">下载链接:</span>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between;">
|
<a :href="downloadUrl" target="_blank" class="file-meta-link" rel="noreferrer noopener">点击下载</a>
|
||||||
<span>下载链接</span>
|
</div>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div class="file-meta-row" v-if="parseResult.data?.downloadShortUrl">
|
||||||
<el-button @click="openUrl(downloadUrl)" type="primary" size="small">
|
<span class="file-meta-label">下载短链:</span>
|
||||||
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
|
<a :href="parseResult.data.downloadShortUrl" target="_blank" class="file-meta-link">{{ parseResult.data.downloadShortUrl }}</a>
|
||||||
</el-button>
|
</div>
|
||||||
<el-button @click="openUrl(getPreviewLink())" type="default" size="small">
|
<div class="file-meta-row">
|
||||||
<el-icon style="margin-right: 4px;"><View /></el-icon> 预览
|
<span class="file-meta-label">文件预览:</span>
|
||||||
</el-button>
|
<a :href="getPreviewLink()" target="_blank" class="file-meta-link">点击预览</a>
|
||||||
<el-tooltip :disabled="aria2Connected"
|
</div>
|
||||||
content="下载器未连接,请点击右上角「下载器」配置" placement="top">
|
<div class="file-meta-row">
|
||||||
<el-button
|
<span class="file-meta-label">文件名:</span>{{ extractFileNameAndExt(downloadUrl).name }}
|
||||||
@click="handleAria2Download" :loading="aria2Downloading"
|
</div>
|
||||||
type="success" size="small" :disabled="!aria2Connected">
|
<div class="file-meta-row">
|
||||||
<el-icon style="margin-right: 4px;"><Download /></el-icon> 发送到下载器
|
<span class="file-meta-label">文件类型:</span>{{ getFileTypeClass({ fileName: extractFileNameAndExt(downloadUrl).name }) }}
|
||||||
</el-button>
|
</div>
|
||||||
</el-tooltip>
|
<div class="file-meta-row" v-if="parseResult.data?.sizeStr">
|
||||||
|
<span class="file-meta-label">文件大小:</span>{{ parseResult.data.sizeStr }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<el-input :value="downloadUrl" readonly>
|
|
||||||
<template #append>
|
|
||||||
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
|
|
||||||
v-clipboard:error="onError" style="padding: 0 14px;">
|
|
||||||
<el-icon><CopyDocument/></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
<!-- 文件元信息 -->
|
|
||||||
<div style="margin-top: 10px; font-size: 13px; color: var(--el-text-color-secondary);">
|
|
||||||
<span v-if="parseResult.data?.sizeStr" style="margin-right: 16px;">
|
|
||||||
大小: <strong>{{ parseResult.data.sizeStr }}</strong>
|
|
||||||
</span>
|
|
||||||
<span v-if="parseResult.data?.downloadShortUrl" style="margin-right: 16px;">
|
|
||||||
短链: <a :href="parseResult.data.downloadShortUrl" target="_blank" class="file-meta-link">{{ parseResult.data.downloadShortUrl }}</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 调试命令区(默认折叠) -->
|
|
||||||
<div v-if="aria2Command || aria2JsonRpc || curlCommand" style="margin-top: 12px;">
|
|
||||||
<el-collapse v-model="activeDebugCommands">
|
|
||||||
<el-collapse-item name="debug">
|
|
||||||
<template #title>
|
|
||||||
<span style="font-size: 13px; color: var(--el-text-color-secondary);">命令行 / 调试参数</span>
|
|
||||||
</template>
|
|
||||||
<div v-if="aria2Command" class="debug-cmd-section">
|
|
||||||
<div class="debug-cmd-label">Aria2 下载命令</div>
|
|
||||||
<el-input :value="aria2Command" type="textarea" :rows="2" readonly />
|
|
||||||
<div style="text-align: right; margin-top: 6px;">
|
|
||||||
<el-button v-clipboard:copy="aria2Command" v-clipboard:success="onCopy"
|
|
||||||
v-clipboard:error="onError" size="small">
|
|
||||||
<el-icon><CopyDocument/></el-icon> 复制
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="aria2JsonRpc" class="debug-cmd-section">
|
|
||||||
<div class="debug-cmd-label">Aria2 JSON-RPC</div>
|
|
||||||
<el-input :value="aria2JsonRpc" type="textarea" :rows="2" readonly />
|
|
||||||
<div style="text-align: right; margin-top: 6px;">
|
|
||||||
<el-button v-clipboard:copy="aria2JsonRpc" v-clipboard:success="onCopy"
|
|
||||||
v-clipboard:error="onError" size="small">
|
|
||||||
<el-icon><CopyDocument/></el-icon> 复制
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="curlCommand" class="debug-cmd-section">
|
|
||||||
<div class="debug-cmd-label">curl 下载命令</div>
|
|
||||||
<el-input :value="curlCommand" type="textarea" :rows="2" readonly />
|
|
||||||
<div style="text-align: right; margin-top: 6px;">
|
|
||||||
<el-button v-clipboard:copy="curlCommand" v-clipboard:success="onCopy"
|
|
||||||
v-clipboard:error="onError" size="small">
|
|
||||||
<el-icon><CopyDocument/></el-icon> 复制
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-collapse-item>
|
|
||||||
</el-collapse>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 文件需要下载器弹窗 -->
|
|
||||||
<DownloadDialog v-model:visible="downloadDialogVisible" :download-info="downloadDialogInfo" />
|
|
||||||
|
|
||||||
<!-- Markdown链接 -->
|
<!-- Markdown链接 -->
|
||||||
<div v-if="markdownText" style="text-align: center">
|
<div v-if="markdownText" style="text-align: center">
|
||||||
@@ -405,85 +335,27 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<!-- 下载器设置 Dialog -->
|
|
||||||
<el-dialog v-model="aria2DialogVisible" title="下载器设置" width="min(500px, 92vw)" :close-on-click-modal="false">
|
|
||||||
<div class="aria2-config-section">
|
|
||||||
<div class="aria2-config-title">
|
|
||||||
<el-icon><Setting /></el-icon>
|
|
||||||
<span>下载器类型</span>
|
|
||||||
</div>
|
|
||||||
<el-select v-model="aria2ConfigForm.downloaderType" style="width: 100%;" @change="onDownloaderTypeChange">
|
|
||||||
<el-option label="Motrix (推荐)" value="motrix" />
|
|
||||||
<el-option label="Gopeed" value="gopeed" />
|
|
||||||
<el-option label="Aria2" value="aria2" />
|
|
||||||
<el-option label="迅雷" value="thunder" />
|
|
||||||
</el-select>
|
|
||||||
<el-alert
|
|
||||||
v-if="aria2ConfigForm.downloaderType !== 'thunder'"
|
|
||||||
title="Motrix: 端口 16800 | Gopeed: 端口 9999 | Aria2: 端口 6800"
|
|
||||||
type="info" :closable="false" show-icon style="margin-top: 8px;"
|
|
||||||
/>
|
|
||||||
<el-alert
|
|
||||||
v-else
|
|
||||||
title="迅雷通过 JS-SDK 调用本地客户端,无需配置 RPC"
|
|
||||||
type="info" :closable="false" show-icon style="margin-top: 8px;"
|
|
||||||
/>
|
|
||||||
<div style="margin-top: 10px; font-size: 13px; color: var(--el-text-color-secondary);">
|
|
||||||
没有下载器?
|
|
||||||
<el-link type="primary" href="https://motrix.app" target="_blank" rel="noopener noreferrer">Motrix</el-link> /
|
|
||||||
<el-link type="primary" href="https://github.com/GopeedLab/gopeed/releases" target="_blank" rel="noopener noreferrer">Gopeed</el-link> /
|
|
||||||
<el-link type="primary" href="https://www.xunlei.com" target="_blank" rel="noopener noreferrer">迅雷</el-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-show="aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section">
|
|
||||||
<div class="aria2-config-title"><el-icon><Monitor /></el-icon><span>RPC 地址</span></div>
|
|
||||||
<el-input v-model="aria2ConfigForm.rpcUrl" placeholder="http://localhost:6800/jsonrpc" clearable />
|
|
||||||
</div>
|
|
||||||
<div v-show="aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section">
|
|
||||||
<div class="aria2-config-title"><el-icon><Key /></el-icon><span>RPC 密钥 (可选)</span></div>
|
|
||||||
<el-input v-model="aria2ConfigForm.rpcSecret" placeholder="如果设置了密钥请输入" show-password clearable autocomplete="new-password" />
|
|
||||||
</div>
|
|
||||||
<div class="aria2-config-section">
|
|
||||||
<el-button link type="primary" @click="aria2ShowAdvanced = !aria2ShowAdvanced">
|
|
||||||
{{ aria2ShowAdvanced ? '收起选项 ▲' : '更多选项 ▼' }}
|
|
||||||
</el-button>
|
|
||||||
<el-collapse-transition>
|
|
||||||
<div v-show="aria2ShowAdvanced" style="margin-top: 10px;">
|
|
||||||
<div class="aria2-config-title"><el-icon><Folder /></el-icon><span>下载目录</span></div>
|
|
||||||
<el-input v-model="aria2ConfigForm.downloadDir" placeholder="留空使用默认下载目录" clearable />
|
|
||||||
</div>
|
|
||||||
</el-collapse-transition>
|
|
||||||
</div>
|
|
||||||
<div v-if="aria2Version && aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section" style="text-align: center;">
|
|
||||||
<el-tag type="success" size="small">
|
|
||||||
<el-icon style="vertical-align: middle;"><SuccessFilled /></el-icon>
|
|
||||||
已连接 - {{ downloaderTypeName }} {{ aria2Version }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
<div v-if="aria2ConfigForm.downloaderType === 'thunder'" class="aria2-config-section" style="text-align: center;">
|
|
||||||
<el-tag type="info" size="small">迅雷通过浏览器唤起本地客户端,无需测试连接</el-tag>
|
|
||||||
</div>
|
|
||||||
<div v-show="aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section" style="display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
|
|
||||||
<el-button :loading="aria2Testing" @click="testAria2Connection(false)" type="primary" plain>
|
|
||||||
<el-icon><Download /></el-icon> 测试连接
|
|
||||||
</el-button>
|
|
||||||
<el-button :loading="aria2AutoDetecting" @click="autoDetectDownloader" type="success" plain>
|
|
||||||
<el-icon><Search /></el-icon> 自动检测
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<div style="text-align: center; margin-top: 12px;">
|
|
||||||
<el-button type="primary" @click="saveAria2Config" style="min-width: 180px;">
|
|
||||||
<el-icon><Select /></el-icon> 保存设置
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<!-- 版本号显示 -->
|
<!-- 版本号显示 -->
|
||||||
<div class="version-info">
|
<div class="version-info">
|
||||||
<span class="version-text">内部版本: {{ buildVersion }}</span>
|
<span class="version-text">内部版本: {{ buildVersion }}</span>
|
||||||
<el-link v-if="playgroundEnabled" :href="'/playground'" class="playground-link">脚本演练场</el-link>
|
<el-link v-if="playgroundEnabled" :href="'/playground'" class="playground-link">脚本演练场</el-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件解析结果区下方加分享按钮 -->
|
||||||
|
<!-- <div v-if="parseResult.code && downloadUrl" style="margin-top: 10px; text-align: right;">-->
|
||||||
|
<!-- <el-button type="primary" @click="copyShowFileLink">分享文件直链</el-button>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
<!-- 目录解析结果区下方加分享按钮 -->
|
||||||
|
<!-- <div v-if="showDirectoryTree && directoryData.length" style="margin-top: 10px; text-align: right;">-->
|
||||||
|
<!-- <el-input :value="showListLink" readonly style="width: 350px; margin-right: 10px;">-->
|
||||||
|
<!-- <template #append>-->
|
||||||
|
<!-- <el-button v-clipboard:copy="showListLink" v-clipboard:success="onCopy" v-clipboard:error="onError">-->
|
||||||
|
<!-- <el-icon><CopyDocument /></el-icon>复制分享链接-->
|
||||||
|
<!-- </el-button>-->
|
||||||
|
<!-- </template>-->
|
||||||
|
<!-- </el-input>-->
|
||||||
|
<!-- </div>-->
|
||||||
|
|
||||||
<!-- 捐赠账号弹窗 -->
|
<!-- 捐赠账号弹窗 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showDonateDialog"
|
v-model="showDonateDialog"
|
||||||
@@ -589,18 +461,16 @@ import axios from 'axios'
|
|||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import DarkMode from '@/components/DarkMode'
|
import DarkMode from '@/components/DarkMode'
|
||||||
import DirectoryTree from '@/components/DirectoryTree'
|
import DirectoryTree from '@/components/DirectoryTree'
|
||||||
import DownloadDialog from '@/components/DownloadDialog'
|
|
||||||
import parserUrl from '../parserUrl1'
|
import parserUrl from '../parserUrl1'
|
||||||
import fileTypeUtils from '@/utils/fileTypeUtils'
|
import fileTypeUtils from '@/utils/fileTypeUtils'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { playgroundApi } from '@/utils/playgroundApi'
|
import { playgroundApi } from '@/utils/playgroundApi'
|
||||||
import { testConnection, autoDetect, addDownload, getConfig, saveConfig } from '@/utils/downloaderService'
|
|
||||||
|
|
||||||
export const previewBaseUrl = 'https://nfd-parser.github.io/nfd-preview/preview.html?src=';
|
export const previewBaseUrl = 'https://nfd-parser.github.io/nfd-preview/preview.html?src=';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
components: { DarkMode, DirectoryTree, DownloadDialog },
|
components: { DarkMode, DirectoryTree },
|
||||||
mixins: [fileTypeUtils],
|
mixins: [fileTypeUtils],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -683,34 +553,7 @@ export default {
|
|||||||
donateAccountCounts: {
|
donateAccountCounts: {
|
||||||
active: { total: 0 },
|
active: { total: 0 },
|
||||||
inactive: { total: 0 }
|
inactive: { total: 0 }
|
||||||
},
|
}
|
||||||
|
|
||||||
// 下载器相关
|
|
||||||
aria2Connected: false,
|
|
||||||
aria2Version: '',
|
|
||||||
aria2DialogVisible: false,
|
|
||||||
aria2ShowAdvanced: false,
|
|
||||||
aria2Testing: false,
|
|
||||||
aria2AutoDetecting: false,
|
|
||||||
aria2Downloading: false,
|
|
||||||
aria2ConfigForm: {
|
|
||||||
downloaderType: 'aria2',
|
|
||||||
rpcUrl: 'http://localhost:6800/jsonrpc',
|
|
||||||
rpcSecret: '',
|
|
||||||
downloadDir: ''
|
|
||||||
},
|
|
||||||
// 下载命令
|
|
||||||
aria2Command: '',
|
|
||||||
aria2JsonRpc: '',
|
|
||||||
curlCommand: '',
|
|
||||||
activeDebugCommands: [],
|
|
||||||
// 下载器特殊头对话框
|
|
||||||
downloadDialogVisible: false,
|
|
||||||
downloadDialogInfo: null,
|
|
||||||
// 目录解析支持的网盘列表
|
|
||||||
directoryParseSupportedPans: [],
|
|
||||||
// 后端支持网盘列表(用于短格式 type:key@pwd 展开)
|
|
||||||
panList: []
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -723,16 +566,6 @@ export default {
|
|||||||
// 获取已配置认证的网盘数量
|
// 获取已配置认证的网盘数量
|
||||||
authConfigCount() {
|
authConfigCount() {
|
||||||
return Object.keys(this.allAuthConfigs).length
|
return Object.keys(this.allAuthConfigs).length
|
||||||
},
|
|
||||||
// 下载器类型名称
|
|
||||||
downloaderTypeName() {
|
|
||||||
const map = {
|
|
||||||
motrix: 'Motrix',
|
|
||||||
gopeed: 'Gopeed',
|
|
||||||
aria2: 'Aria2',
|
|
||||||
thunder: '迅雷'
|
|
||||||
}
|
|
||||||
return map[this.aria2ConfigForm.downloaderType] || 'Aria2'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -1043,7 +876,6 @@ export default {
|
|||||||
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
validateInput() {
|
validateInput() {
|
||||||
this.normalizeShortcutInput()
|
|
||||||
this.clearResults()
|
this.clearResults()
|
||||||
|
|
||||||
if (!this.link.startsWith("https://") && !this.link.startsWith("http://")) {
|
if (!this.link.startsWith("https://") && !this.link.startsWith("http://")) {
|
||||||
@@ -1052,58 +884,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取后端支持网盘列表
|
|
||||||
async getPanList() {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`${this.baseAPI}/v2/getPanList`)
|
|
||||||
const payload = response?.data
|
|
||||||
const list = Array.isArray(payload)
|
|
||||||
? payload
|
|
||||||
: (Array.isArray(payload?.data) ? payload.data : [])
|
|
||||||
if (list.length > 0) {
|
|
||||||
this.panList = list
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 静默失败:短格式解析会自动回退
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 按后端网盘列表展开短格式(type:key@pwd)
|
|
||||||
expandShortFormat(text) {
|
|
||||||
const raw = (text || '').trim()
|
|
||||||
if (!raw) return null
|
|
||||||
|
|
||||||
const shortMatch = raw.match(/^([a-zA-Z][a-zA-Z0-9]{1,10}):([^@]+?)(?:@(.+))?$/)
|
|
||||||
if (!shortMatch) return null
|
|
||||||
|
|
||||||
const [, shortType, shortKey, shortPwd] = shortMatch
|
|
||||||
const pan = this.panList.find(p => (p.type || '').toLowerCase() === shortType.toLowerCase())
|
|
||||||
if (!pan || !pan.shareUrlFormat) return null
|
|
||||||
|
|
||||||
const link = pan.shareUrlFormat
|
|
||||||
.replace('{shareKey}', shortKey)
|
|
||||||
.replace(/\{pwd}/g, shortPwd || '')
|
|
||||||
|
|
||||||
return {
|
|
||||||
link,
|
|
||||||
pwd: shortPwd || '',
|
|
||||||
name: pan.name || pan.type || shortType
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 识别并转换短链输入(如 lz:shareKey@pwd)
|
|
||||||
normalizeShortcutInput() {
|
|
||||||
const shortInfo = this.expandShortFormat(this.link)
|
|
||||||
if (!shortInfo) return
|
|
||||||
|
|
||||||
this.link = shortInfo.link
|
|
||||||
if (!this.password && shortInfo.pwd) {
|
|
||||||
this.password = shortInfo.pwd
|
|
||||||
}
|
|
||||||
this.$message.success(`已识别短格式并自动转换,网盘类型: ${shortInfo.name}`)
|
|
||||||
this.updateDirectLink()
|
|
||||||
},
|
|
||||||
|
|
||||||
// 清除结果
|
// 清除结果
|
||||||
clearResults() {
|
clearResults() {
|
||||||
this.parseResult = {}
|
this.parseResult = {}
|
||||||
@@ -1187,27 +967,8 @@ export default {
|
|||||||
const result = await this.callAPI('/json/parser', params)
|
const result = await this.callAPI('/json/parser', params)
|
||||||
this.parseResult = result
|
this.parseResult = result
|
||||||
this.downloadUrl = result.data?.directLink
|
this.downloadUrl = result.data?.directLink
|
||||||
// 提取命令行参数
|
|
||||||
const otherParam = result.data?.otherParam || {}
|
|
||||||
this.aria2Command = otherParam.aria2Command || ''
|
|
||||||
this.aria2JsonRpc = otherParam.aria2JsonRpc || ''
|
|
||||||
this.curlCommand = otherParam.curlCommand || ''
|
|
||||||
this.activeDebugCommands = []
|
|
||||||
// 更新智能直链(包含认证参数)
|
// 更新智能直链(包含认证参数)
|
||||||
this.updateDirectLink()
|
this.updateDirectLink()
|
||||||
// 如果需要下载器(含特殊头),弹出下载器对话框
|
|
||||||
if (result.data?.needDownloader) {
|
|
||||||
this.downloadDialogInfo = {
|
|
||||||
downloadUrl: result.data.directLink,
|
|
||||||
fileName: result.data.fileName || '',
|
|
||||||
downloadHeaders: result.data.downloadHeaders || {},
|
|
||||||
aria2Command: this.aria2Command,
|
|
||||||
curlCommand: this.curlCommand,
|
|
||||||
aria2JsonRpc: this.aria2JsonRpc,
|
|
||||||
needDownloader: true
|
|
||||||
}
|
|
||||||
this.downloadDialogVisible = true
|
|
||||||
}
|
|
||||||
this.$message.success('文件解析成功!')
|
this.$message.success('文件解析成功!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('文件解析失败:', error)
|
console.error('文件解析失败:', error)
|
||||||
@@ -1221,7 +982,17 @@ export default {
|
|||||||
const params = { url: this.link }
|
const params = { url: this.link }
|
||||||
if (this.password) params.pwd = this.password
|
if (this.password) params.pwd = this.password
|
||||||
|
|
||||||
// 直接调用 getFileList,让后端返回错误(不做客户端类型检查)
|
const result = await this.callAPI('/v2/linkInfo', params)
|
||||||
|
const data = result.data
|
||||||
|
|
||||||
|
// 检查是否支持目录解析
|
||||||
|
const supportedPans = ["iz", "lz", "fj", "ye", "le"]
|
||||||
|
if (!supportedPans.includes(data.shareLinkInfo.type)) {
|
||||||
|
this.$message.error("当前网盘不支持目录解析")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取目录数据
|
||||||
const directoryResult = await this.callAPI('/v2/getFileList', params)
|
const directoryResult = await this.callAPI('/v2/getFileList', params)
|
||||||
this.directoryData = directoryResult.data || []
|
this.directoryData = directoryResult.data || []
|
||||||
this.showDirectoryTree = true
|
this.showDirectoryTree = true
|
||||||
@@ -1321,23 +1092,6 @@ export default {
|
|||||||
const text = await navigator.clipboard.readText()
|
const text = await navigator.clipboard.readText()
|
||||||
console.log('获取到的文本内容是:', text)
|
console.log('获取到的文本内容是:', text)
|
||||||
|
|
||||||
const shortInfo = this.expandShortFormat(text)
|
|
||||||
if (shortInfo) {
|
|
||||||
if (shortInfo.link !== this.link || shortInfo.pwd !== this.password) {
|
|
||||||
this.password = shortInfo.pwd
|
|
||||||
this.link = shortInfo.link
|
|
||||||
this.updateDirectLink()
|
|
||||||
if (!this.hasClipboardSuccessTip) {
|
|
||||||
this.$message.success(`自动识别分享成功, 网盘类型: ${shortInfo.name}; 分享URL ${this.link}; 分享密码: ${this.password || '空'}`)
|
|
||||||
this.hasClipboardSuccessTip = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.$message.warning(`[${shortInfo.name}]分享信息无变化`)
|
|
||||||
}
|
|
||||||
this.hasWarnedNoLink = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkInfo = parserUrl.parseLink(text)
|
const linkInfo = parserUrl.parseLink(text)
|
||||||
const pwd = parserUrl.parsePwd(text) || ''
|
const pwd = parserUrl.parsePwd(text) || ''
|
||||||
|
|
||||||
@@ -1447,7 +1201,6 @@ export default {
|
|||||||
|
|
||||||
// 跳转到客户端链接页面
|
// 跳转到客户端链接页面
|
||||||
async goToClientLinks() {
|
async goToClientLinks() {
|
||||||
this.normalizeShortcutInput()
|
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!this.link.trim()) {
|
if (!this.link.trim()) {
|
||||||
this.$message.warning('请先输入分享链接')
|
this.$message.warning('请先输入分享链接')
|
||||||
@@ -1610,145 +1363,6 @@ export default {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载捐赠账号统计失败:', e)
|
console.error('加载捐赠账号统计失败:', e)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 下载器相关方法 =====
|
|
||||||
openAria2Dialog() {
|
|
||||||
this.aria2DialogVisible = true
|
|
||||||
},
|
|
||||||
onDownloaderTypeChange() {
|
|
||||||
const defaults = {
|
|
||||||
motrix: 'http://localhost:16800/jsonrpc',
|
|
||||||
gopeed: 'http://localhost:9999/api/v1',
|
|
||||||
aria2: 'http://localhost:6800/jsonrpc',
|
|
||||||
thunder: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换类型时先清空旧连接状态,避免显示残留版本信息
|
|
||||||
this.aria2Connected = false
|
|
||||||
this.aria2Version = ''
|
|
||||||
|
|
||||||
if (defaults[this.aria2ConfigForm.downloaderType] !== undefined) {
|
|
||||||
this.aria2ConfigForm.rpcUrl = defaults[this.aria2ConfigForm.downloaderType]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非迅雷类型在切换后自动静默重测,刷新连接状态
|
|
||||||
if (this.aria2ConfigForm.downloaderType !== 'thunder') {
|
|
||||||
this.$nextTick(() => this.testAria2Connection(true))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async testAria2Connection(silent = false) {
|
|
||||||
this.aria2Testing = true
|
|
||||||
try {
|
|
||||||
if (this.aria2ConfigForm.downloaderType === 'thunder') {
|
|
||||||
const result = await testConnection()
|
|
||||||
this.aria2Connected = result.connected
|
|
||||||
this.aria2Version = result.version || 'JS-SDK'
|
|
||||||
if (!silent) {
|
|
||||||
if (result.connected) this.$message.success('迅雷 JS-SDK 已就绪')
|
|
||||||
else this.$message.error('迅雷客户端未检测到,请确认已安装并启动迅雷')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await testConnection(
|
|
||||||
this.aria2ConfigForm.rpcUrl,
|
|
||||||
this.aria2ConfigForm.rpcSecret
|
|
||||||
)
|
|
||||||
if (result.connected) {
|
|
||||||
this.aria2Connected = true
|
|
||||||
this.aria2Version = result.version || ''
|
|
||||||
if (!silent) this.$message.success(`连接成功:${this.downloaderTypeName} ${this.aria2Version}`)
|
|
||||||
} else {
|
|
||||||
this.aria2Connected = false
|
|
||||||
this.aria2Version = ''
|
|
||||||
if (!silent) this.$message.error('连接失败:请检查下载器是否启动')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.aria2Connected = false
|
|
||||||
this.aria2Version = ''
|
|
||||||
if (!silent) this.$message.error('连接失败:' + e.message)
|
|
||||||
} finally {
|
|
||||||
this.aria2Testing = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async autoDetectDownloader() {
|
|
||||||
this.aria2AutoDetecting = true
|
|
||||||
try {
|
|
||||||
const result = await autoDetect(this.aria2ConfigForm.rpcSecret)
|
|
||||||
if (result.found) {
|
|
||||||
this.aria2ConfigForm.rpcUrl = result.rpcUrl
|
|
||||||
this.aria2ConfigForm.downloaderType = result.type || 'aria2'
|
|
||||||
this.aria2Connected = true
|
|
||||||
this.aria2Version = result.version || ''
|
|
||||||
this.$message.success(`检测到 ${this.downloaderTypeName} ${this.aria2Version}`)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
'未检测到本地下载器,是否切换为迅雷下载?',
|
|
||||||
'下载器未检测到',
|
|
||||||
{
|
|
||||||
confirmButtonText: '使用迅雷',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.aria2ConfigForm.downloaderType = 'thunder'
|
|
||||||
this.aria2ConfigForm.rpcUrl = ''
|
|
||||||
saveConfig(this.aria2ConfigForm)
|
|
||||||
this.$message.success('已切换并保存为迅雷下载器配置')
|
|
||||||
this.aria2DialogVisible = true
|
|
||||||
await this.testAria2Connection(true)
|
|
||||||
} catch {
|
|
||||||
this.$message.warning('未检测到本地下载器,请确认 Motrix/Gopeed/Aria2 正在运行')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
this.$message.error('自动检测失败:' + e.message)
|
|
||||||
} finally {
|
|
||||||
this.aria2AutoDetecting = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
saveAria2Config() {
|
|
||||||
saveConfig(this.aria2ConfigForm)
|
|
||||||
this.$message.success('下载器配置已保存')
|
|
||||||
this.aria2DialogVisible = false
|
|
||||||
// 保存后自动测试连接
|
|
||||||
this.testAria2Connection(true)
|
|
||||||
},
|
|
||||||
getAria2Config() {
|
|
||||||
const cfg = getConfig()
|
|
||||||
if (cfg) {
|
|
||||||
this.aria2ConfigForm = { ...this.aria2ConfigForm, ...cfg }
|
|
||||||
// 启动后静默测试连接
|
|
||||||
this.testAria2Connection(true)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async handleAria2Download() {
|
|
||||||
if (!this.downloadUrl) return
|
|
||||||
this.aria2Downloading = true
|
|
||||||
try {
|
|
||||||
const headers = this.parseResult.data?.otherParam?.downloadHeaders || {}
|
|
||||||
const fileName = this.parseResult.data?.fileInfo?.fileName || ''
|
|
||||||
await addDownload(this.downloadUrl, headers, fileName, this.aria2ConfigForm)
|
|
||||||
this.$message.success('已发送到下载器')
|
|
||||||
} catch (e) {
|
|
||||||
this.$message.error('发送失败:' + e.message)
|
|
||||||
} finally {
|
|
||||||
this.aria2Downloading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
openUrl(url) {
|
|
||||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
||||||
},
|
|
||||||
async loadDirectoryParseSupportedPans() {
|
|
||||||
try {
|
|
||||||
const result = await this.callAPI('/v2/supportedParsePans', {})
|
|
||||||
if (result.data && Array.isArray(result.data)) {
|
|
||||||
this.directoryParseSupportedPans = result.data.map(p => (typeof p === 'string' ? p.toLowerCase() : p))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 静默失败,使用默认列表
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1774,12 +1388,6 @@ export default {
|
|||||||
// 检查演练场是否启用
|
// 检查演练场是否启用
|
||||||
this.checkPlaygroundEnabled()
|
this.checkPlaygroundEnabled()
|
||||||
|
|
||||||
// 初始化下载器配置
|
|
||||||
this.getAria2Config()
|
|
||||||
|
|
||||||
// 拉取后端网盘支持列表(用于 type:key@pwd 短格式)
|
|
||||||
this.getPanList()
|
|
||||||
|
|
||||||
// 自动读取剪切板
|
// 自动读取剪切板
|
||||||
if (this.autoReadClipboard) {
|
if (this.autoReadClipboard) {
|
||||||
this.getPaste()
|
this.getPaste()
|
||||||
@@ -2054,45 +1662,6 @@ hr {
|
|||||||
color: #eee !important;
|
color: #eee !important;
|
||||||
border-color: #444 !important;
|
border-color: #444 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 下载器状态指示点 */
|
|
||||||
.aria2-status-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
.aria2-status-dot.connected { background: #67c23a; }
|
|
||||||
.aria2-status-dot.disconnected { background: #909399; }
|
|
||||||
|
|
||||||
/* 下载器配置区块 */
|
|
||||||
.aria2-config-section {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.aria2-config-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 下载结果卡片 */
|
|
||||||
.download-result-card {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 调试命令区 */
|
|
||||||
.debug-cmd-section {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.debug-cmd-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
#app.dark-theme .jv-key {
|
#app.dark-theme .jv-key {
|
||||||
color: #4a9eff !important;
|
color: #4a9eff !important;
|
||||||
}
|
}
|
||||||
@@ -2198,26 +1767,4 @@ hr {
|
|||||||
#app.dark-theme .el-form-item__label {
|
#app.dark-theme .el-form-item__label {
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 解析按钮专用配色:亮色浅绿,暗色深绿 */
|
|
||||||
.parse-action-btn.el-button--success {
|
|
||||||
--el-button-bg-color: #7fcb96;
|
|
||||||
--el-button-border-color: #7fcb96;
|
|
||||||
--el-button-text-color: #f7fff9;
|
|
||||||
--el-button-hover-bg-color: #93d8a8;
|
|
||||||
--el-button-hover-border-color: #93d8a8;
|
|
||||||
--el-button-active-bg-color: #69b884;
|
|
||||||
--el-button-active-border-color: #69b884;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app.dark-theme .parse-action-btn.el-button--success,
|
|
||||||
body.dark-theme .parse-action-btn.el-button--success {
|
|
||||||
--el-button-bg-color: #1f6b3a;
|
|
||||||
--el-button-border-color: #1f6b3a;
|
|
||||||
--el-button-text-color: #ecf9f0;
|
|
||||||
--el-button-hover-bg-color: #2b7d49;
|
|
||||||
--el-button-hover-border-color: #2b7d49;
|
|
||||||
--el-button-active-bg-color: #185731;
|
|
||||||
--el-button-active-border-color: #185731;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,19 +19,19 @@ module.exports = {
|
|||||||
port: 6444,
|
port: 6444,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/parser': {
|
'/parser': {
|
||||||
target: 'http://127.0.0.1:6401/', // 请求本地
|
target: 'http://127.0.0.1:6400/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
'/v2': {
|
'/v2': {
|
||||||
target: 'http://127.0.0.1:6401/', // 请求本地
|
target: 'http://127.0.0.1:6400/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
'/json': {
|
'/json': {
|
||||||
target: 'http://127.0.0.1:6401/', // 请求本地
|
target: 'http://127.0.0.1:6400/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
'/d': {
|
'/d': {
|
||||||
target: 'http://127.0.0.1:6401/', // 请求本地
|
target: 'http://127.0.0.1:6400/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -85,8 +85,7 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
source: './node_modules/monaco-editor/min/vs',
|
source: './node_modules/monaco-editor/min/vs',
|
||||||
destination: './nfd-front/js/vs'
|
destination: './nfd-front/js/vs'
|
||||||
},
|
}
|
||||||
{ source: './nfd-front', destination: '../webroot/nfd-front' }
|
|
||||||
],
|
],
|
||||||
archive: [ //然后我们选择dist文件夹将之打包成dist.zip并放在根目录
|
archive: [ //然后我们选择dist文件夹将之打包成dist.zip并放在根目录
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# 反向代理
|
|
||||||
server-name: Vert.x-proxy-server(v4.1.2)
|
|
||||||
|
|
||||||
proxy:
|
|
||||||
- listen: 16401
|
|
||||||
# 404的路径
|
|
||||||
page404: webroot/nfd-front/index.html
|
|
||||||
static:
|
|
||||||
path: /
|
|
||||||
add-headers:
|
|
||||||
x-token: ABC
|
|
||||||
root: webroot/nfd-front/
|
|
||||||
# index: index.html
|
|
||||||
# ~开头(没有空格)表示正则匹配否则为前缀匹配, 当origin带子路径时进行路由重写,
|
|
||||||
# 1.origin代理地址端口后有目录(包括 / ),转发后地址:代理地址+访问URL目录部分去除location匹配目录
|
|
||||||
# 2.origin代理地址端口后无任何,转发后地址:代理地址+访问URL目录部
|
|
||||||
location:
|
|
||||||
- path: ~^/(json/|v2/|d/|parser|ye/|lz/|cow/|ec/|fj/|fc/|le/|qq/|ws/|iz/|ce/).*
|
|
||||||
origin: 127.0.0.1:16400
|
|
||||||
|
|
||||||
# json/parser -> xxx/parser
|
|
||||||
# - path: /json/
|
|
||||||
# origin: 127.0.0.1:16400/
|
|
||||||
- path: /n1/
|
|
||||||
origin: 127.0.0.1:16400/v2/
|
|
||||||
|
|
||||||
# # SSL HTTPS配置
|
|
||||||
ssl:
|
|
||||||
enable: false
|
|
||||||
# 强制https 暂不支持
|
|
||||||
#ssl_force: true
|
|
||||||
# SSL 协议版本
|
|
||||||
ssl_protocols: TLSv1.2
|
|
||||||
# 证书
|
|
||||||
ssl_certificate: ssl/server.pem
|
|
||||||
# 私钥
|
|
||||||
ssl_certificate_key: ssl/privkey.key
|
|
||||||
# 加密套件 ssl_ciphers 暂不支持
|
|
||||||
# ssl_ciphers: AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
|
|
||||||
|
|
||||||
# - listen: 8086
|
|
||||||
# static:
|
|
||||||
# path: /t2/
|
|
||||||
# root: webroot/test/
|
|
||||||
# index: sockTest.html
|
|
||||||
# location:
|
|
||||||
# - path: /real/
|
|
||||||
# origin: 127.0.0.1:8088
|
|
||||||
# sock:
|
|
||||||
# - path: /real/
|
|
||||||
# origin: 127.0.0.1:8088
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user