mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-22 16:46:56 +00:00
Compare commits
31 Commits
copilot/cl
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8582290db3 | ||
|
|
5ff33d7c58 | ||
|
|
0cfb69a240 | ||
|
|
110a9beda4 | ||
|
|
fd6a3f5929 | ||
|
|
82ad6ec427 | ||
|
|
1bfc7c960d | ||
|
|
332f49f483 | ||
|
|
b967c7a1bb | ||
|
|
519dbe1f77 | ||
|
|
c64855d4ad | ||
|
|
d50d10ba89 | ||
|
|
e79478c421 | ||
|
|
c401a84eb8 | ||
|
|
a9978a6202 | ||
|
|
cc9d0a4b30 | ||
|
|
696ef832f8 | ||
|
|
442f9d1d2e | ||
|
|
a45a64380c | ||
|
|
df7442c3dd | ||
|
|
1a949725f3 | ||
|
|
7c14f3437b | ||
|
|
bc402da365 | ||
|
|
b95b474660 | ||
|
|
691a3770d9 | ||
|
|
49ec54a3b5 | ||
|
|
2fc15f437e | ||
|
|
190f6ca7ab | ||
|
|
c683fd27d4 | ||
|
|
d815cc1010 | ||
|
|
fd84ff1200 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -41,7 +41,9 @@ 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
|
||||||
|
|||||||
86
README.md
86
README.md
@@ -1,3 +1,15 @@
|
|||||||
|
# 一款网盘分享链接云解析快速下载服务
|
||||||
|
QQ交流群:1017480890
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
|
||||||
|
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
|
||||||
|
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.22-blue?style=flat"></a>
|
||||||
|
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
||||||
|
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div align="center" style="display:flex; justify-content:center; gap:10px; align-items:flex-start;">
|
<div align="center" style="display:flex; justify-content:center; gap:10px; align-items:flex-start;">
|
||||||
<img
|
<img
|
||||||
src="https://github.com/user-attachments/assets/bf266d0a-aaf8-4772-9231-e38a4b7bb6cb"
|
src="https://github.com/user-attachments/assets/bf266d0a-aaf8-4772-9231-e38a4b7bb6cb"
|
||||||
@@ -10,21 +22,15 @@
|
|||||||
style="width:300px; max-width:300px; flex:none;"
|
style="width:300px; max-width:300px; flex:none;"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p align="center">
|
|
||||||
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||||
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
|
|
||||||
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
|
|
||||||
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.22-blue?style=flat"></a>
|
|
||||||
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
|
||||||
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
|
||||||
|
|
||||||
# netdisk-fast-download 网盘分享链接云解析服务
|
[官方文档](https://nfd-parser.github.io/)
|
||||||
QQ交流群:1017480890
|
[API接入](https://nfdparser.apifox.cn/)
|
||||||
|
[公益解析,lz站](https://lz.qaiu.top)
|
||||||
|
[公益解析,lz0站](https://lz0.qaiu.top)
|
||||||
|
[专业版](https://189.qaiu.top)
|
||||||
|
|
||||||
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
命令行下载分享文件:
|
命令行下载分享文件:
|
||||||
@@ -50,15 +56,10 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
|
|
||||||
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
|
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
|
||||||
|
|
||||||
## 体验地址
|
|
||||||
[公益解析1](https://lz.qaiu.top)
|
|
||||||
[公益解析2](https://lz0.qaiu.top)
|
|
||||||
[大文件解析专属版,限时开放,注册体验](https://189.qaiu.top)
|
|
||||||
|
|
||||||
main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/netdisk-fast-download/tree/main-jdk11)
|
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
||||||
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
|
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
|
||||||
**小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效。**
|
||||||
**注意: 请不要过度依赖lz.qaiu.top预览地址服务,建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制,不推荐做公共解析。**
|
|
||||||
|
|
||||||
## 网盘支持情况:
|
## 网盘支持情况:
|
||||||
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
||||||
@@ -86,20 +87,14 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/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接口
|
||||||
|
|
||||||
@@ -331,15 +326,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 | 不限大小 |
|
||||||
|
|
||||||
# 打包部署
|
# 打包部署
|
||||||
|
|
||||||
@@ -492,23 +487,6 @@ 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
|
||||||
@@ -533,20 +511,6 @@ Core模块集成Vert.x实现类似spring的注解式路由API
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
### 关于赞助定制专属版
|
|
||||||
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
|
|
||||||
2. 可提供托管服务:包含部署服务和云服务器环境。
|
|
||||||
3. 可提供功能定制开发。
|
|
||||||
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
|
|
||||||
<p>qq: 197575894</p>
|
|
||||||
<p>wechat: imcoding_</p>
|
|
||||||
|
|
||||||
<!--
|
|
||||||

|
|
||||||
|
|
||||||
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
|
|
||||||
-->
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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.nio.file.Path;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,25 +78,13 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
* @param proxyConf 代理配置
|
* @param proxyConf 代理配置
|
||||||
*/
|
*/
|
||||||
private void handleProxyConf(JsonObject proxyConf) {
|
private void handleProxyConf(JsonObject proxyConf) {
|
||||||
// page404 path
|
// page404 path: 兼容不同启动目录(根目录或子模块目录)
|
||||||
if (proxyConf.containsKey(
|
String configured404 = proxyConf.getString("page404");
|
||||||
|
String resolved404 = resolveExistingPath(configured404, false);
|
||||||
"page404")) {
|
if (resolved404 == null) {
|
||||||
System.getProperty("user.dir");
|
resolved404 = resolveExistingPath(DEFAULT_PATH_404, false);
|
||||||
String path = proxyConf.getString("page404");
|
|
||||||
if (StringUtils.isEmpty(path)) {
|
|
||||||
proxyConf.put("page404", DEFAULT_PATH_404);
|
|
||||||
} else {
|
|
||||||
if (!path.startsWith("/")) {
|
|
||||||
path = "/" + path;
|
|
||||||
}
|
|
||||||
if (!new File(System.getProperty("user.dir") + path).exists()) {
|
|
||||||
proxyConf.put("page404", DEFAULT_PATH_404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
proxyConf.put("page404", DEFAULT_PATH_404);
|
|
||||||
}
|
}
|
||||||
|
proxyConf.put("page404", resolved404 == null ? DEFAULT_PATH_404 : resolved404);
|
||||||
|
|
||||||
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
|
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
|
||||||
Router proxyRouter = Router.router(vertx);
|
Router proxyRouter = Router.router(vertx);
|
||||||
@@ -180,7 +169,14 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
StaticHandler staticHandler;
|
StaticHandler staticHandler;
|
||||||
if (staticConf.containsKey("root")) {
|
if (staticConf.containsKey("root")) {
|
||||||
staticHandler = StaticHandler.create(staticConf.getString("root"));
|
String configuredRoot = staticConf.getString("root");
|
||||||
|
String resolvedRoot = resolveStaticRoot(configuredRoot);
|
||||||
|
if (resolvedRoot != null) {
|
||||||
|
staticHandler = StaticHandler.create(resolvedRoot);
|
||||||
|
} else {
|
||||||
|
LOGGER.warn("static root not found, fallback to configured path: {}", configuredRoot);
|
||||||
|
staticHandler = StaticHandler.create(configuredRoot);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
staticHandler = StaticHandler.create();
|
staticHandler = StaticHandler.create();
|
||||||
}
|
}
|
||||||
@@ -253,4 +249,77 @@ public class ReverseProxyVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析配置路径: 优先绝对路径, 否则尝试 user.dir 和 user.dir/..。
|
||||||
|
*/
|
||||||
|
private String resolveExistingPath(String path, boolean directory) {
|
||||||
|
if (StringUtils.isBlank(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
File directFile = new File(path);
|
||||||
|
if (existsByType(directFile, directory)) {
|
||||||
|
return directFile.getAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
String userDir = System.getProperty("user.dir");
|
||||||
|
File inUserDir = new File(userDir, path);
|
||||||
|
if (existsByType(inUserDir, directory)) {
|
||||||
|
return inUserDir.getAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
File inParentDir = new File(new File(userDir).getParentFile(), path);
|
||||||
|
if (existsByType(inParentDir, directory)) {
|
||||||
|
return inParentDir.getAbsolutePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StaticHandler 只接受相对 web root,不接受以 / 开头的绝对路径。
|
||||||
|
*/
|
||||||
|
private String resolveStaticRoot(String path) {
|
||||||
|
if (StringUtils.isBlank(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
File directFile = new File(path);
|
||||||
|
if (existsByType(directFile, true)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
String userDir = System.getProperty("user.dir");
|
||||||
|
File inUserDir = new File(userDir, path);
|
||||||
|
if (existsByType(inUserDir, true)) {
|
||||||
|
return relativizePath(new File(userDir), inUserDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
File userDirFile = new File(userDir);
|
||||||
|
File parentDir = userDirFile.getParentFile();
|
||||||
|
File inParentDir = parentDir == null ? null : new File(parentDir, path);
|
||||||
|
if (existsByType(inParentDir, true)) {
|
||||||
|
return relativizePath(userDirFile, inParentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String relativizePath(File baseDir, File target) {
|
||||||
|
try {
|
||||||
|
Path basePath = baseDir.toPath().toAbsolutePath().normalize();
|
||||||
|
Path targetPath = target.toPath().toAbsolutePath().normalize();
|
||||||
|
return basePath.relativize(targetPath).toString().replace(File.separatorChar, '/');
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return target.getPath().replace(File.separatorChar, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean existsByType(File file, boolean directory) {
|
||||||
|
if (file == null || !file.exists()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return directory ? file.isDirectory() : file.isFile();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.22</vertx.version>
|
<vertx.version>4.5.24</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.5</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>
|
||||||
<jackson.version>2.14.2</jackson.version>
|
<jackson.version>2.18.6</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>
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ public enum PanDomainTemplate {
|
|||||||
t-is.cn
|
t-is.cn
|
||||||
*/
|
*/
|
||||||
LZ("蓝奏云",
|
LZ("蓝奏云",
|
||||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(" +
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?(?:" +
|
||||||
"lanzoul|" +
|
"(?:lanzoul|" +
|
||||||
"lanzouh|" +
|
"lanzouh|" +
|
||||||
"lanosso|" +
|
"lanosso|" +
|
||||||
"lanpv|" +
|
"lanpv|" +
|
||||||
@@ -95,14 +95,16 @@ public enum PanDomainTemplate {
|
|||||||
"lanzv|" +
|
"lanzv|" +
|
||||||
"dmpdmp|" +
|
"dmpdmp|" +
|
||||||
"lanrar|" +
|
"lanrar|" +
|
||||||
|
"webgetstore|" +
|
||||||
"lanzb|" +
|
"lanzb|" +
|
||||||
"lanzoux|" +
|
"lanzoux|" +
|
||||||
"lanzout|" +
|
"lanzout|" +
|
||||||
"lanzouc|" +
|
"lanzouc|" +
|
||||||
"lanzoui|" +
|
"lanzoui|" +
|
||||||
"lanzoug|" +
|
"lanzoug|" +
|
||||||
"lanzoum" +
|
"lanzoum)\\.com" +
|
||||||
")\\.com/(?<KEY>.+)"),
|
"|t-is\\.cn" +
|
||||||
|
")/(?<KEY>.+)"),
|
||||||
"https://w1.lanzn.com/{shareKey}",
|
"https://w1.lanzn.com/{shareKey}",
|
||||||
LzTool.class),
|
LzTool.class),
|
||||||
|
|
||||||
@@ -113,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/share/ https://lecloud.lenovo.com/mshare/
|
||||||
LE("联想乐云",
|
LE("联想乐云",
|
||||||
compile("https://lecloud?\\.lenovo\\.com/share/(?<KEY>.+)"),
|
compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
|
||||||
"https://lecloud.lenovo.com/share/{shareKey}",
|
"https://lecloud.lenovo.com/share/{shareKey}",
|
||||||
LeTool.class),
|
LeTool.class),
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@ public enum PanDomainTemplate {
|
|||||||
EcTool.class),
|
EcTool.class),
|
||||||
// https://cowtransfer.com/s/
|
// https://cowtransfer.com/s/
|
||||||
COW("奶牛快传",
|
COW("奶牛快传",
|
||||||
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
|
||||||
"https://cowtransfer.com/s/{shareKey}",
|
"https://cowtransfer.com/s/{shareKey}",
|
||||||
CowTool.class),
|
CowTool.class),
|
||||||
CT("城通网盘",
|
CT("城通网盘",
|
||||||
@@ -264,7 +266,7 @@ public enum PanDomainTemplate {
|
|||||||
PodTool.class),
|
PodTool.class),
|
||||||
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
||||||
PGD("GoogleDrive",
|
PGD("GoogleDrive",
|
||||||
compile("https://drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
||||||
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
|
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
|
||||||
PgdTool.class),
|
PgdTool.class),
|
||||||
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
||||||
@@ -274,11 +276,11 @@ public enum PanDomainTemplate {
|
|||||||
PicTool.class),
|
PicTool.class),
|
||||||
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
|
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
|
||||||
PDB("dropbox",
|
PDB("dropbox",
|
||||||
compile("https://www.dropbox.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
compile("https://www\\.dropbox\\.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
||||||
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
|
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
|
||||||
PdbTool.class),
|
PdbTool.class),
|
||||||
P115("115网盘",
|
P115("115网盘",
|
||||||
compile("https://(115|anxia).com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
compile("https://(115|anxia)\\.com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
||||||
"https://115.com/s/{shareKey}?password={pwd}",
|
"https://115.com/s/{shareKey}?password={pwd}",
|
||||||
P115Tool.class),
|
P115Tool.class),
|
||||||
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
||||||
@@ -311,6 +313,14 @@ 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("网易云音乐分享",
|
||||||
@@ -319,7 +329,7 @@ public enum PanDomainTemplate {
|
|||||||
MnesTool.class),
|
MnesTool.class),
|
||||||
// https://music.163.com/#/song?id=xxx
|
// https://music.163.com/#/song?id=xxx
|
||||||
MNE("网易云音乐歌曲详情",
|
MNE("网易云音乐歌曲详情",
|
||||||
compile("https://(y.)?music\\.163\\.com/(#|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
compile("https://(y\\.)?music\\.163\\.com/(?:#/|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
||||||
"https://music.163.com/#/song?id={shareKey}",
|
"https://music.163.com/#/song?id={shareKey}",
|
||||||
MnesTool.MneTool.class),
|
MnesTool.MneTool.class),
|
||||||
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
||||||
@@ -340,7 +350,7 @@ public enum PanDomainTemplate {
|
|||||||
MkgsTool.class),
|
MkgsTool.class),
|
||||||
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
||||||
MKGS2("酷狗音乐分享2",
|
MKGS2("酷狗音乐分享2",
|
||||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+).html.*"),
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+)\\.html.*"),
|
||||||
"https://www.kugou.com/share/{shareKey}.html",
|
"https://www.kugou.com/share/{shareKey}.html",
|
||||||
MkgsTool.Mkgs2Tool.class),
|
MkgsTool.Mkgs2Tool.class),
|
||||||
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
||||||
|
|||||||
492
parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
Normal file
492
parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
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,7 +99,8 @@ 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()) {
|
||||||
complete(matcher1.group("url"));
|
// 响应体是 JSON 文本,URL 中的 '&' 被转义为 \u0026,需要反转义
|
||||||
|
complete(unescapeJsonUnicode(matcher1.group("url")));
|
||||||
} else {
|
} else {
|
||||||
fail();
|
fail();
|
||||||
}
|
}
|
||||||
@@ -134,6 +135,34 @@ 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
|
||||||
|
|||||||
@@ -129,15 +129,205 @@ public class PanDomainTemplateTest {
|
|||||||
wsPattern.matcher("https://www.evil.com/f/abc123").matches());
|
wsPattern.matcher("https://www.evil.com/f/abc123").matches());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLzPatternWebgetstore() {
|
||||||
|
Pattern lzPattern = PanDomainTemplate.LZ.getPattern();
|
||||||
|
|
||||||
|
// webgetstore.com 以前遗漏,现已补入
|
||||||
|
Matcher m1 = lzPattern.matcher("https://webgetstore.com/somekey");
|
||||||
|
assertTrue("LZ should match webgetstore.com", m1.find());
|
||||||
|
assertEquals("somekey", m1.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m2 = lzPattern.matcher("https://www.webgetstore.com/somekey");
|
||||||
|
assertTrue("LZ should match www.webgetstore.com", m2.find());
|
||||||
|
assertEquals("somekey", m2.group("KEY"));
|
||||||
|
|
||||||
|
// t-is.cn 以前遗漏,现已补入
|
||||||
|
Matcher m3 = lzPattern.matcher("https://t-is.cn/somekey");
|
||||||
|
assertTrue("LZ should match t-is.cn", m3.find());
|
||||||
|
assertEquals("somekey", m3.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m4 = lzPattern.matcher("https://www.t-is.cn/somekey");
|
||||||
|
assertTrue("LZ should match www.t-is.cn", m4.find());
|
||||||
|
assertEquals("somekey", m4.group("KEY"));
|
||||||
|
|
||||||
|
// 已有域名仍然正常匹配
|
||||||
|
Matcher m5 = lzPattern.matcher("https://www.lanzoul.com/somekey");
|
||||||
|
assertTrue("LZ should match existing domain lanzoul.com", m5.find());
|
||||||
|
assertEquals("somekey", m5.group("KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLePatternFix() {
|
||||||
|
Pattern lePattern = PanDomainTemplate.LE.getPattern();
|
||||||
|
|
||||||
|
// /share/ 格式应匹配
|
||||||
|
Matcher m1 = lePattern.matcher("https://lecloud.lenovo.com/share/abc123");
|
||||||
|
assertTrue("LE should match /share/ format", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
// /mshare/ 格式应匹配
|
||||||
|
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",
|
||||||
|
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
|
||||||
|
public void testCowPatternFix() {
|
||||||
|
Pattern cowPattern = PanDomainTemplate.COW.getPattern();
|
||||||
|
|
||||||
|
// 正常域名
|
||||||
|
Matcher m1 = cowPattern.matcher("https://cowtransfer.com/s/abc123");
|
||||||
|
assertTrue("COW should match cowtransfer.com", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m2 = cowPattern.matcher("https://share.cowtransfer.com/s/abc123");
|
||||||
|
assertTrue("COW should match share.cowtransfer.com", m2.find());
|
||||||
|
assertEquals("abc123", m2.group("KEY"));
|
||||||
|
|
||||||
|
// 潜在的URL注入:`(.*)` 是贪婪捕获组,可匹配 `evil.com/redirect/` 等前缀,
|
||||||
|
// 使形如 `https://evil.com/redirect/cowtransfer.com/s/key` 的 URL 被误识别。
|
||||||
|
// 修复后改为 `(?:[a-zA-Z\d-]+\.)?` 仅匹配一级合法子域名(可选),消除误匹配。
|
||||||
|
assertFalse("COW should NOT match redirect URLs containing cowtransfer.com in path",
|
||||||
|
cowPattern.matcher("https://evil.com/redirect/cowtransfer.com/s/abc").find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testMnePatternFix() {
|
||||||
|
Pattern mnePattern = PanDomainTemplate.MNE.getPattern();
|
||||||
|
|
||||||
|
// 带 #/ 前缀的完整网页链接(修复前因 (y.) 未转义而存在 bug)
|
||||||
|
Matcher m1 = mnePattern.matcher("https://music.163.com/#/song?id=12345");
|
||||||
|
assertTrue("MNE should match #/song format", m1.find());
|
||||||
|
assertEquals("12345", m1.group("KEY"));
|
||||||
|
|
||||||
|
// 带 m/ 前缀的移动端链接
|
||||||
|
Matcher m2 = mnePattern.matcher("https://music.163.com/m/song?id=12345");
|
||||||
|
assertTrue("MNE should match m/song format", m2.find());
|
||||||
|
assertEquals("12345", m2.group("KEY"));
|
||||||
|
|
||||||
|
// y.music.163.com 子域名
|
||||||
|
Matcher m3 = mnePattern.matcher("https://y.music.163.com/song?id=12345");
|
||||||
|
assertTrue("MNE should match y.music.163.com", m3.find());
|
||||||
|
assertEquals("12345", m3.group("KEY"));
|
||||||
|
|
||||||
|
// 原 (y.) 中 `.` 未转义(`.` 匹配任意字符):对于 `yXmusic.163.com`,
|
||||||
|
// `(y.)` 会消费 `yX`(y + 任意字符),剩余 `music.163.com` 再被 `music\.163\.com` 匹配,导致误匹配。
|
||||||
|
// 修复后 `(y\.)` 要求字面 `.`,`yX` 中 X ≠ `.` 无法匹配,不再误匹配。
|
||||||
|
assertFalse("MNE should NOT match yXmusic.163.com (old (y.) could erroneously match via backtracking)",
|
||||||
|
mnePattern.matcher("https://yXmusic.163.com/song?id=12345").find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testP115PatternFix() {
|
||||||
|
Pattern p115Pattern = PanDomainTemplate.P115.getPattern();
|
||||||
|
|
||||||
|
// 正常匹配
|
||||||
|
Matcher m1 = p115Pattern.matcher("https://115.com/s/abc123");
|
||||||
|
assertTrue("P115 should match 115.com", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m2 = p115Pattern.matcher("https://anxia.com/s/abc123");
|
||||||
|
assertTrue("P115 should match anxia.com", m2.find());
|
||||||
|
assertEquals("abc123", m2.group("KEY"));
|
||||||
|
|
||||||
|
// 原 .com 未转义时 115Xcom 会被误匹配(现已修复)
|
||||||
|
assertFalse("P115 should NOT match 115Xcom",
|
||||||
|
p115Pattern.matcher("https://115Xcom/s/abc123").find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPgdSubdomain() {
|
||||||
|
Pattern pgdPattern = PanDomainTemplate.PGD.getPattern();
|
||||||
|
|
||||||
|
// 标准链接
|
||||||
|
Matcher m1 = pgdPattern.matcher("https://drive.google.com/file/d/abc123/view?usp=sharing");
|
||||||
|
assertTrue("PGD should match standard drive.google.com", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
// 带子域名的链接(修复后支持)
|
||||||
|
Matcher m2 = pgdPattern.matcher("https://adsd.drive.google.com/file/d/151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz/view");
|
||||||
|
assertTrue("PGD should match subdomain.drive.google.com", m2.find());
|
||||||
|
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() {
|
||||||
|
|
||||||
Matcher matcher = compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?")
|
|
||||||
.matcher("https://adsd.drive.google.com/file/d/151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz/view");
|
|
||||||
if (matcher.find()) {
|
|
||||||
System.out.println(matcher.group());
|
|
||||||
System.out.println(matcher.group("KEY"));
|
|
||||||
}
|
|
||||||
// 校验重复
|
// 校验重复
|
||||||
Set<String> collect =
|
Set<String> collect =
|
||||||
Arrays.stream(PanDomainTemplate.values()).map(PanDomainTemplate::getRegex).collect(Collectors.toSet());
|
Arrays.stream(PanDomainTemplate.values()).map(PanDomainTemplate::getRegex).collect(Collectors.toSet());
|
||||||
|
|||||||
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.14</vertx.version>
|
<vertx.version>4.5.24</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.2</jackson.version>
|
<jackson.version>2.18.6</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,6 +11,8 @@
|
|||||||
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
266
web-front/src/components/DownloadDialog.vue
Normal file
266
web-front/src/components/DownloadDialog.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<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>
|
||||||
463
web-front/src/utils/downloaderService.js
Normal file
463
web-front/src/utils/downloaderService.js
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
/**
|
||||||
|
* 下载器服务 - 统一管理 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,15 +35,12 @@
|
|||||||
<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="text-align: right; display: flex; justify-content: space-between; align-items: center;">
|
<div style="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'"
|
||||||
@@ -54,9 +51,21 @@
|
|||||||
<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">
|
||||||
@@ -64,9 +73,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 项目简介移到卡片内 -->
|
<!-- 项目简介移到卡片内 -->
|
||||||
<div class="project-intro">
|
<div class="project-intro">
|
||||||
<div class="intro-title">NFD网盘直链解析0.2.1b3</div>
|
<div class="intro-title">NFD网盘直链解析0.3.0</div>
|
||||||
<div class="intro-desc">
|
<div class="intro-desc">
|
||||||
<div>支持网盘:蓝奏云、蓝奏云优享、小飞机盘、123云盘、奶牛快传、移动云空间、QQ邮箱云盘、QQ闪传等 <el-link style="color:#606cf5" href="https://github.com/qaiu/netdisk-fast-download?tab=readme-ov-file#%E7%BD%91%E7%9B%98%E6%94%AF%E6%8C%81%E6%83%85%E5%86%B5" target="_blank"> >> </el-link></div>
|
<div>支持网盘:蓝奏云、蓝奏云优享、小飞机盘、123云盘、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云盘</div>
|
<div>文件夹解析支持:蓝奏云、蓝奏云优享、小飞机盘、123云盘</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,12 +111,12 @@
|
|||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<p style="text-align: center">
|
<p style="text-align: center">
|
||||||
<el-button style="margin-left: 40px" @click="parseFile">解析文件</el-button>
|
<el-button class="parse-action-btn" type="success" style="margin-left: 40px" @click="parseFile">解析文件</el-button>
|
||||||
<el-button style="margin-left: 20px" @click="parseDirectory">解析目录</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="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>
|
||||||
|
|
||||||
@@ -115,31 +124,92 @@
|
|||||||
<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" class="file-meta-info-card">
|
<div v-if="downloadUrl" style="margin-top: 15px;">
|
||||||
<div class="file-meta-row">
|
<el-card shadow="hover" class="download-result-card">
|
||||||
<span class="file-meta-label">下载链接:</span>
|
<template #header>
|
||||||
<a :href="downloadUrl" target="_blank" class="file-meta-link" rel="noreferrer noopener">点击下载</a>
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
</div>
|
<span>下载链接</span>
|
||||||
<div class="file-meta-row" v-if="parseResult.data?.downloadShortUrl">
|
<div style="display: flex; gap: 8px;">
|
||||||
<span class="file-meta-label">下载短链:</span>
|
<el-button @click="openUrl(downloadUrl)" type="primary" size="small">
|
||||||
<a :href="parseResult.data.downloadShortUrl" target="_blank" class="file-meta-link">{{ parseResult.data.downloadShortUrl }}</a>
|
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
|
||||||
</div>
|
</el-button>
|
||||||
<div class="file-meta-row">
|
<el-button @click="openUrl(getPreviewLink())" type="default" size="small">
|
||||||
<span class="file-meta-label">文件预览:</span>
|
<el-icon style="margin-right: 4px;"><View /></el-icon> 预览
|
||||||
<a :href="getPreviewLink()" target="_blank" class="file-meta-link">点击预览</a>
|
</el-button>
|
||||||
</div>
|
<el-tooltip :disabled="aria2Connected"
|
||||||
<div class="file-meta-row">
|
content="下载器未连接,请点击右上角「下载器」配置" placement="top">
|
||||||
<span class="file-meta-label">文件名:</span>{{ extractFileNameAndExt(downloadUrl).name }}
|
<el-button
|
||||||
</div>
|
@click="handleAria2Download" :loading="aria2Downloading"
|
||||||
<div class="file-meta-row">
|
type="success" size="small" :disabled="!aria2Connected">
|
||||||
<span class="file-meta-label">文件类型:</span>{{ getFileTypeClass({ fileName: extractFileNameAndExt(downloadUrl).name }) }}
|
<el-icon style="margin-right: 4px;"><Download /></el-icon> 发送到下载器
|
||||||
</div>
|
</el-button>
|
||||||
<div class="file-meta-row" v-if="parseResult.data?.sizeStr">
|
</el-tooltip>
|
||||||
<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">
|
||||||
@@ -335,27 +405,85 @@
|
|||||||
</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"
|
||||||
@@ -461,16 +589,18 @@ 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 } from 'element-plus'
|
import { ElMessage, ElMessageBox } 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 },
|
components: { DarkMode, DirectoryTree, DownloadDialog },
|
||||||
mixins: [fileTypeUtils],
|
mixins: [fileTypeUtils],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -553,7 +683,34 @@ 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: {
|
||||||
@@ -566,6 +723,16 @@ 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: {
|
||||||
@@ -876,6 +1043,7 @@ 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://")) {
|
||||||
@@ -884,6 +1052,58 @@ 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 = {}
|
||||||
@@ -967,8 +1187,27 @@ 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)
|
||||||
@@ -982,17 +1221,7 @@ 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
|
||||||
|
|
||||||
const result = await this.callAPI('/v2/linkInfo', params)
|
// 直接调用 getFileList,让后端返回错误(不做客户端类型检查)
|
||||||
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
|
||||||
@@ -1092,6 +1321,23 @@ 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) || ''
|
||||||
|
|
||||||
@@ -1201,6 +1447,7 @@ export default {
|
|||||||
|
|
||||||
// 跳转到客户端链接页面
|
// 跳转到客户端链接页面
|
||||||
async goToClientLinks() {
|
async goToClientLinks() {
|
||||||
|
this.normalizeShortcutInput()
|
||||||
// 验证输入
|
// 验证输入
|
||||||
if (!this.link.trim()) {
|
if (!this.link.trim()) {
|
||||||
this.$message.warning('请先输入分享链接')
|
this.$message.warning('请先输入分享链接')
|
||||||
@@ -1363,6 +1610,145 @@ 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) {
|
||||||
|
// 静默失败,使用默认列表
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1388,6 +1774,12 @@ export default {
|
|||||||
// 检查演练场是否启用
|
// 检查演练场是否启用
|
||||||
this.checkPlaygroundEnabled()
|
this.checkPlaygroundEnabled()
|
||||||
|
|
||||||
|
// 初始化下载器配置
|
||||||
|
this.getAria2Config()
|
||||||
|
|
||||||
|
// 拉取后端网盘支持列表(用于 type:key@pwd 短格式)
|
||||||
|
this.getPanList()
|
||||||
|
|
||||||
// 自动读取剪切板
|
// 自动读取剪切板
|
||||||
if (this.autoReadClipboard) {
|
if (this.autoReadClipboard) {
|
||||||
this.getPaste()
|
this.getPaste()
|
||||||
@@ -1662,6 +2054,45 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1767,4 +2198,26 @@ 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:6400/', // 请求本地
|
target: 'http://127.0.0.1:6401/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
'/v2': {
|
'/v2': {
|
||||||
target: 'http://127.0.0.1:6400/', // 请求本地
|
target: 'http://127.0.0.1:6401/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
'/json': {
|
'/json': {
|
||||||
target: 'http://127.0.0.1:6400/', // 请求本地
|
target: 'http://127.0.0.1:6401/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
'/d': {
|
'/d': {
|
||||||
target: 'http://127.0.0.1:6400/', // 请求本地
|
target: 'http://127.0.0.1:6401/', // 请求本地
|
||||||
ws: false
|
ws: false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,8 @@ 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并放在根目录
|
||||||
{
|
{
|
||||||
|
|||||||
185
web-service/src/main/java/cn/qaiu/lz/common/util/JwtUtil.java
Normal file
185
web-service/src/main/java/cn/qaiu/lz/common/util/JwtUtil.java
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package cn.qaiu.lz.common.util;
|
||||||
|
|
||||||
|
import cn.qaiu.lz.web.model.SysUser;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT工具类,用于生成和验证JWT token
|
||||||
|
*/
|
||||||
|
public class JwtUtil {
|
||||||
|
|
||||||
|
private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000; // token过期时间,24小时
|
||||||
|
private static final String SECRET_KEY = "netdisk-fast-download-jwt-secret-key"; // 密钥
|
||||||
|
private static final String ALGORITHM = "HmacSHA256";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成JWT token
|
||||||
|
*
|
||||||
|
* @param user 用户信息
|
||||||
|
* @return JWT token
|
||||||
|
*/
|
||||||
|
public static String generateToken(SysUser user) {
|
||||||
|
long expireTime = getExpireTime();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
JsonObject header = new JsonObject()
|
||||||
|
.put("alg", "HS256")
|
||||||
|
.put("typ", "JWT");
|
||||||
|
|
||||||
|
// Payload
|
||||||
|
JsonObject payload = new JsonObject()
|
||||||
|
.put("id", user.getId())
|
||||||
|
.put("username", user.getUsername())
|
||||||
|
.put("role", user.getRole())
|
||||||
|
.put("exp", expireTime)
|
||||||
|
.put("iat", System.currentTimeMillis())
|
||||||
|
.put("iss", "netdisk-fast-download");
|
||||||
|
|
||||||
|
// Base64 encode header and payload
|
||||||
|
String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.encode().getBytes(StandardCharsets.UTF_8));
|
||||||
|
String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.encode().getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
// Create signature
|
||||||
|
String signature = hmacSha256(encodedHeader + "." + encodedPayload, SECRET_KEY);
|
||||||
|
|
||||||
|
// Combine to form JWT
|
||||||
|
return encodedHeader + "." + encodedPayload + "." + signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用HMAC-SHA256算法生成签名
|
||||||
|
*
|
||||||
|
* @param data 要签名的数据
|
||||||
|
* @param key 密钥
|
||||||
|
* @return 签名
|
||||||
|
*/
|
||||||
|
private static String hmacSha256(String data, String key) {
|
||||||
|
try {
|
||||||
|
Mac sha256Hmac = Mac.getInstance(ALGORITHM);
|
||||||
|
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), ALGORITHM);
|
||||||
|
sha256Hmac.init(secretKey);
|
||||||
|
byte[] signedBytes = sha256Hmac.doFinal(data.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(signedBytes);
|
||||||
|
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||||
|
throw new RuntimeException("Error creating HMAC SHA256 signature", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证JWT token
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return 如果token有效返回true,否则返回false
|
||||||
|
*/
|
||||||
|
public static boolean validateToken(String token) {
|
||||||
|
try {
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String encodedHeader = parts[0];
|
||||||
|
String encodedPayload = parts[1];
|
||||||
|
String signature = parts[2];
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
String expectedSignature = hmacSha256(encodedHeader + "." + encodedPayload, SECRET_KEY);
|
||||||
|
if (!expectedSignature.equals(signature)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证过期时间
|
||||||
|
String payload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8);
|
||||||
|
JsonObject payloadJson = new JsonObject(payload);
|
||||||
|
long expTime = payloadJson.getLong("exp", 0L);
|
||||||
|
|
||||||
|
return System.currentTimeMillis() < expTime;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从token中获取用户ID
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return 用户ID
|
||||||
|
*/
|
||||||
|
public static String getUserIdFromToken(String token) {
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64解码
|
||||||
|
String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
|
||||||
|
JsonObject jsonObject = new JsonObject(payload);
|
||||||
|
return jsonObject.getString("id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从token中获取用户名
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return 用户名
|
||||||
|
*/
|
||||||
|
public static String getUsernameFromToken(String token) {
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64解码
|
||||||
|
String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
|
||||||
|
JsonObject jsonObject = new JsonObject(payload);
|
||||||
|
return jsonObject.getString("username");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从token中获取用户角色
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return 用户角色
|
||||||
|
*/
|
||||||
|
public static String getRoleFromToken(String token) {
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64解码
|
||||||
|
String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
|
||||||
|
JsonObject jsonObject = new JsonObject(payload);
|
||||||
|
return jsonObject.getString("role");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取过期时间
|
||||||
|
*
|
||||||
|
* @return 过期时间戳
|
||||||
|
*/
|
||||||
|
private static long getExpireTime() {
|
||||||
|
return System.currentTimeMillis() + EXPIRE_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将过期时间戳转换为LocalDateTime
|
||||||
|
*
|
||||||
|
* @param expireTime 过期时间戳
|
||||||
|
* @return LocalDateTime
|
||||||
|
*/
|
||||||
|
public static LocalDateTime getExpireTimeAsLocalDateTime(long expireTime) {
|
||||||
|
return LocalDateTime.ofInstant(Instant.ofEpochMilli(expireTime), ZoneId.systemDefault());
|
||||||
|
}
|
||||||
|
}
|
||||||
60
web-service/src/main/java/cn/qaiu/lz/web/model/SysUser.java
Normal file
60
web-service/src/main/java/cn/qaiu/lz/web/model/SysUser.java
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package cn.qaiu.lz.web.model;
|
||||||
|
|
||||||
|
import cn.qaiu.db.ddl.Table;
|
||||||
|
import cn.qaiu.lz.common.ToJson;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import io.vertx.codegen.annotations.DataObject;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@DataObject
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Table("sys_user")
|
||||||
|
public class SysUser implements ToJson {
|
||||||
|
private String id;
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
private String email;
|
||||||
|
private String phone;
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
// 用户状态:0-禁用,1-正常
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
// 用户角色:admin-管理员,user-普通用户
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
// 最后登录时间
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
|
private LocalDateTime lastLoginTime;
|
||||||
|
|
||||||
|
private Integer age;
|
||||||
|
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
public SysUser(JsonObject json) {
|
||||||
|
this.id = json.getString("id");
|
||||||
|
this.username = json.getString("username");
|
||||||
|
this.password = json.getString("password");
|
||||||
|
this.email = json.getString("email");
|
||||||
|
this.phone = json.getString("phone");
|
||||||
|
this.avatar = json.getString("avatar");
|
||||||
|
this.status = json.getInteger("status");
|
||||||
|
this.role = json.getString("role");
|
||||||
|
this.age = json.getInteger("age");
|
||||||
|
if (json.getString("createTime") != null) {
|
||||||
|
this.createTime = LocalDateTime.parse(json.getString("createTime"));
|
||||||
|
}
|
||||||
|
if (json.getString("lastLoginTime") != null) {
|
||||||
|
this.lastLoginTime = LocalDateTime.parse(json.getString("lastLoginTime"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package cn.qaiu.lz.web.service;
|
||||||
|
|
||||||
|
import cn.qaiu.lz.web.model.SysUser;
|
||||||
|
import cn.qaiu.vx.core.base.BaseAsyncService;
|
||||||
|
import io.vertx.codegen.annotations.ProxyGen;
|
||||||
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户服务接口
|
||||||
|
* <br>Create date 2021/8/27 14:06
|
||||||
|
*
|
||||||
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
|
*/
|
||||||
|
@ProxyGen
|
||||||
|
public interface UserService extends BaseAsyncService {
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
* @param user 包含用户名和密码的用户对象
|
||||||
|
* @return 登录成功返回用户信息和token,失败返回错误信息
|
||||||
|
*/
|
||||||
|
Future<JsonObject> login(SysUser user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户名获取用户信息
|
||||||
|
* @param username 用户名
|
||||||
|
* @return 用户信息
|
||||||
|
*/
|
||||||
|
Future<SysUser> getUserByUsername(String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新用户
|
||||||
|
* @param user 用户信息
|
||||||
|
* @return 创建成功返回用户信息,失败返回错误信息
|
||||||
|
*/
|
||||||
|
Future<SysUser> createUser(SysUser user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
* @param user 用户信息
|
||||||
|
* @return 更新成功返回用户信息,失败返回错误信息
|
||||||
|
*/
|
||||||
|
Future<SysUser> updateUser(SysUser user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证token
|
||||||
|
* @param token JWT token
|
||||||
|
* @return 验证成功返回用户信息,失败返回错误信息
|
||||||
|
*/
|
||||||
|
Future<JsonObject> validateToken(String token);
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package cn.qaiu.lz.web.service.impl;
|
||||||
|
|
||||||
|
import cn.qaiu.db.pool.JDBCPoolInit;
|
||||||
|
import cn.qaiu.lz.common.util.JwtUtil;
|
||||||
|
import cn.qaiu.lz.common.util.PasswordUtil;
|
||||||
|
import cn.qaiu.lz.web.model.SysUser;
|
||||||
|
import cn.qaiu.lz.web.service.UserService;
|
||||||
|
import cn.qaiu.vx.core.annotaions.Service;
|
||||||
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Promise;
|
||||||
|
import io.vertx.core.json.JsonObject;
|
||||||
|
import io.vertx.jdbcclient.JDBCPool;
|
||||||
|
import io.vertx.sqlclient.Row;
|
||||||
|
import io.vertx.sqlclient.RowSet;
|
||||||
|
import io.vertx.sqlclient.Tuple;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户服务实现类
|
||||||
|
* <br>Create date 2021/8/27 14:09
|
||||||
|
*
|
||||||
|
* @author <a href="https://qaiu.top">QAIU</a>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class UserServiceImpl implements UserService {
|
||||||
|
|
||||||
|
private final JDBCPool jdbcPool = JDBCPoolInit.instance().getPool();
|
||||||
|
|
||||||
|
// 初始化方法,确保管理员用户存在
|
||||||
|
public void init() {
|
||||||
|
// 检查管理员用户是否存在
|
||||||
|
getUserByUsername("admin")
|
||||||
|
.onSuccess(user -> {
|
||||||
|
log.info("管理员用户已存在");
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
// 创建管理员用户
|
||||||
|
SysUser admin = new SysUser();
|
||||||
|
admin.setId(UUID.randomUUID().toString());
|
||||||
|
admin.setUsername("admin");
|
||||||
|
admin.setPassword(PasswordUtil.hashPassword("admin123"));
|
||||||
|
admin.setEmail("admin@example.com");
|
||||||
|
admin.setRole("admin");
|
||||||
|
admin.setStatus(1);
|
||||||
|
admin.setCreateTime(LocalDateTime.now());
|
||||||
|
|
||||||
|
createUser(admin)
|
||||||
|
.onSuccess(result -> log.info("管理员用户创建成功"))
|
||||||
|
.onFailure(error -> log.error("管理员用户创建失败", error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增一个工具方法来过滤敏感信息
|
||||||
|
private SysUser filterSensitiveInfo(SysUser user) {
|
||||||
|
if (user != null) {
|
||||||
|
SysUser filtered = new SysUser();
|
||||||
|
// 复制除密码外的所有字段
|
||||||
|
filtered.setId(user.getId());
|
||||||
|
filtered.setUsername(user.getUsername());
|
||||||
|
filtered.setEmail(user.getEmail());
|
||||||
|
filtered.setPhone(user.getPhone());
|
||||||
|
filtered.setAvatar(user.getAvatar());
|
||||||
|
filtered.setRole(user.getRole());
|
||||||
|
filtered.setStatus(user.getStatus());
|
||||||
|
filtered.setCreateTime(user.getCreateTime());
|
||||||
|
filtered.setLastLoginTime(user.getLastLoginTime());
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将Row转换为SysUser对象
|
||||||
|
private SysUser rowToUser(Row row) {
|
||||||
|
if (row == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
SysUser user = new SysUser();
|
||||||
|
user.setId(row.getString("id"));
|
||||||
|
user.setUsername(row.getString("username"));
|
||||||
|
user.setPassword(row.getString("password"));
|
||||||
|
user.setEmail(row.getString("email"));
|
||||||
|
user.setPhone(row.getString("phone"));
|
||||||
|
user.setAvatar(row.getString("avatar"));
|
||||||
|
user.setRole(row.getString("role"));
|
||||||
|
user.setStatus(row.getInteger("status"));
|
||||||
|
|
||||||
|
// 处理日期时间字段
|
||||||
|
LocalDateTime createTime = row.getLocalDateTime("create_time");
|
||||||
|
if (createTime != null) {
|
||||||
|
user.setCreateTime(createTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime lastLoginTime = row.getLocalDateTime("last_login_time");
|
||||||
|
if (lastLoginTime != null) {
|
||||||
|
user.setLastLoginTime(lastLoginTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Future<JsonObject> login(SysUser user) {
|
||||||
|
// 参数校验
|
||||||
|
if (user == null || user.getUsername() == null || user.getPassword() == null) {
|
||||||
|
return Future.succeededFuture(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("message", "用户名和密码不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise<JsonObject> promise = Promise.promise();
|
||||||
|
|
||||||
|
// 查询用户
|
||||||
|
String sql = "SELECT * FROM sys_user WHERE username = ?";
|
||||||
|
|
||||||
|
jdbcPool.preparedQuery(sql)
|
||||||
|
.execute(Tuple.of(user.getUsername()))
|
||||||
|
.onSuccess(rows -> {
|
||||||
|
if (rows.size() == 0) {
|
||||||
|
promise.complete(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("message", "用户不存在"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Row row = rows.iterator().next();
|
||||||
|
SysUser existUser = rowToUser(row);
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (!PasswordUtil.checkPassword(user.getPassword(), existUser.getPassword())) {
|
||||||
|
promise.complete(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("message", "密码错误"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后登录时间
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
existUser.setLastLoginTime(now);
|
||||||
|
|
||||||
|
// 更新数据库中的最后登录时间
|
||||||
|
String updateSql = "UPDATE sys_user SET last_login_time = ? WHERE username = ?";
|
||||||
|
jdbcPool.preparedQuery(updateSql)
|
||||||
|
.execute(Tuple.of(
|
||||||
|
Timestamp.from(now.atZone(ZoneId.systemDefault()).toInstant()),
|
||||||
|
existUser.getUsername()
|
||||||
|
))
|
||||||
|
.onFailure(err -> log.error("更新最后登录时间失败", err));
|
||||||
|
|
||||||
|
// 生成token
|
||||||
|
String token = JwtUtil.generateToken(existUser);
|
||||||
|
|
||||||
|
// 返回用户信息和token
|
||||||
|
JsonObject value = JsonObject.mapFrom(existUser);
|
||||||
|
value.remove("password");
|
||||||
|
promise.complete(new JsonObject()
|
||||||
|
.put("success", true)
|
||||||
|
.put("message", "登录成功")
|
||||||
|
.put("token", token)
|
||||||
|
.put("user", value));
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error("登录查询失败", err);
|
||||||
|
promise.complete(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("message", "登录失败: " + err.getMessage()));
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Future<SysUser> getUserByUsername(String username) {
|
||||||
|
if (username == null || username.isEmpty()) {
|
||||||
|
return Future.failedFuture("用户名不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise<SysUser> promise = Promise.promise();
|
||||||
|
|
||||||
|
String sql = "SELECT * FROM sys_user WHERE username = ?";
|
||||||
|
|
||||||
|
jdbcPool.preparedQuery(sql)
|
||||||
|
.execute(Tuple.of(username))
|
||||||
|
.onSuccess(rows -> {
|
||||||
|
if (rows.size() == 0) {
|
||||||
|
promise.fail("用户不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Row row = rows.iterator().next();
|
||||||
|
SysUser user = rowToUser(row);
|
||||||
|
promise.complete(filterSensitiveInfo(user));
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error("查询用户失败", err);
|
||||||
|
promise.fail("查询用户失败: " + err.getMessage());
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Future<SysUser> createUser(SysUser user) {
|
||||||
|
// 参数校验
|
||||||
|
if (user == null || user.getUsername() == null || user.getPassword() == null) {
|
||||||
|
return Future.failedFuture("用户名和密码不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise<SysUser> promise = Promise.promise();
|
||||||
|
|
||||||
|
// 先检查用户是否已存在
|
||||||
|
String checkSql = "SELECT COUNT(*) as count FROM sys_user WHERE username = ?";
|
||||||
|
|
||||||
|
jdbcPool.preparedQuery(checkSql)
|
||||||
|
.execute(Tuple.of(user.getUsername()))
|
||||||
|
.onSuccess(rows -> {
|
||||||
|
Row row = rows.iterator().next();
|
||||||
|
long count = row.getLong("count");
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
promise.fail("用户名已存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置用户ID和创建时间
|
||||||
|
if (user.getId() == null) {
|
||||||
|
user.setId(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
if (user.getCreateTime() == null) {
|
||||||
|
user.setCreateTime(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认角色和状态
|
||||||
|
if (user.getRole() == null) {
|
||||||
|
user.setRole("user");
|
||||||
|
}
|
||||||
|
if (user.getStatus() == null) {
|
||||||
|
user.setStatus(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对密码进行加密
|
||||||
|
String plainPassword = user.getPassword();
|
||||||
|
user.setPassword(PasswordUtil.hashPassword(plainPassword));
|
||||||
|
|
||||||
|
// 插入用户
|
||||||
|
String insertSql = "INSERT INTO sys_user (id, username, password, email, phone, avatar, role, status, create_time) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
|
jdbcPool.preparedQuery(insertSql)
|
||||||
|
.execute(Tuple.of(
|
||||||
|
user.getId(),
|
||||||
|
user.getUsername(),
|
||||||
|
user.getPassword(),
|
||||||
|
user.getEmail(),
|
||||||
|
user.getPhone(),
|
||||||
|
user.getAvatar(),
|
||||||
|
user.getRole(),
|
||||||
|
user.getStatus(),
|
||||||
|
Timestamp.from(user.getCreateTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
|
))
|
||||||
|
.onSuccess(result -> {
|
||||||
|
promise.complete(filterSensitiveInfo(user));
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error("创建用户失败", err);
|
||||||
|
promise.fail("创建用户失败: " + err.getMessage());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error("检查用户是否存在失败", err);
|
||||||
|
promise.fail("创建用户失败: " + err.getMessage());
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Future<SysUser> updateUser(SysUser user) {
|
||||||
|
// 参数校验
|
||||||
|
if (user == null || user.getUsername() == null) {
|
||||||
|
return Future.failedFuture("用户名不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise<SysUser> promise = Promise.promise();
|
||||||
|
|
||||||
|
// 先检查用户是否存在
|
||||||
|
String checkSql = "SELECT * FROM sys_user WHERE username = ?";
|
||||||
|
|
||||||
|
jdbcPool.preparedQuery(checkSql)
|
||||||
|
.execute(Tuple.of(user.getUsername()))
|
||||||
|
.onSuccess(rows -> {
|
||||||
|
if (rows.size() == 0) {
|
||||||
|
promise.fail("用户不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Row row = rows.iterator().next();
|
||||||
|
SysUser existUser = rowToUser(row);
|
||||||
|
|
||||||
|
// 构建更新SQL
|
||||||
|
StringBuilder updateSql = new StringBuilder("UPDATE sys_user SET ");
|
||||||
|
Tuple params = Tuple.tuple();
|
||||||
|
|
||||||
|
if (user.getEmail() != null) {
|
||||||
|
updateSql.append("email = ?, ");
|
||||||
|
params.addValue(user.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getPhone() != null) {
|
||||||
|
updateSql.append("phone = ?, ");
|
||||||
|
params.addValue(user.getPhone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getAvatar() != null) {
|
||||||
|
updateSql.append("avatar = ?, ");
|
||||||
|
params.addValue(user.getAvatar());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getStatus() != null) {
|
||||||
|
updateSql.append("status = ?, ");
|
||||||
|
params.addValue(user.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getRole() != null) {
|
||||||
|
updateSql.append("role = ?, ");
|
||||||
|
params.addValue(user.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getPassword() != null) {
|
||||||
|
updateSql.append("password = ?, ");
|
||||||
|
params.addValue(PasswordUtil.hashPassword(user.getPassword()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除最后的逗号和空格
|
||||||
|
String sql = updateSql.toString();
|
||||||
|
if (sql.endsWith(", ")) {
|
||||||
|
sql = sql.substring(0, sql.length() - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有要更新的字段,直接返回
|
||||||
|
if (params.size() == 0) {
|
||||||
|
promise.complete(filterSensitiveInfo(existUser));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加WHERE条件
|
||||||
|
sql += " WHERE username = ?";
|
||||||
|
params.addValue(user.getUsername());
|
||||||
|
|
||||||
|
// 执行更新
|
||||||
|
jdbcPool.preparedQuery(sql)
|
||||||
|
.execute(params)
|
||||||
|
.onSuccess(result -> {
|
||||||
|
// 重新查询用户信息
|
||||||
|
getUserByUsername(user.getUsername())
|
||||||
|
.onSuccess(promise::complete)
|
||||||
|
.onFailure(promise::fail);
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error("更新用户失败", err);
|
||||||
|
promise.fail("更新用户失败: " + err.getMessage());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error("查询用户失败", err);
|
||||||
|
promise.fail("更新用户失败: " + err.getMessage());
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Future<JsonObject> validateToken(String token) {
|
||||||
|
if (token == null || token.isEmpty()) {
|
||||||
|
return Future.succeededFuture(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("message", "Token不能为空"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证token
|
||||||
|
boolean isValid = JwtUtil.validateToken(token);
|
||||||
|
if (!isValid) {
|
||||||
|
return Future.succeededFuture(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("message", "Token无效或已过期"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
String username = JwtUtil.getUsernameFromToken(token);
|
||||||
|
|
||||||
|
Promise<JsonObject> promise = Promise.promise();
|
||||||
|
|
||||||
|
getUserByUsername(username)
|
||||||
|
.onSuccess(user -> {
|
||||||
|
promise.complete(new JsonObject()
|
||||||
|
.put("success", true)
|
||||||
|
.put("message", "Token有效")
|
||||||
|
.put("user", JsonObject.mapFrom(user)));
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
promise.complete(new JsonObject()
|
||||||
|
.put("success", false)
|
||||||
|
.put("message", "用户不存在"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return promise.future();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user