Compare commits

...

200 Commits

Author SHA1 Message Date
qaiu
452fd0ea2c Merge pull request #190 from yukaidi1220/pr/native-package
CI: 新增原生环境打包 & 启动日志改进
2026-05-31 17:02:50 +08:00
yukaidi
dd8f2efb37 ci: Release 自动生成更新说明(generate_release_notes) 2026-05-31 16:39:23 +08:00
yukaidi
0feb8e798a fix: PR#190 review 修复 — 配置查找顺序/页面日志/ZIP结构/注释
- 配置文件查找顺序与 Deploy 保持一致(先当前目录,再 resources/)
- 页面地址日志改用 onComplete,无论演练场加载成功失败均输出
- Windows ZIP 移除 /* 通配符,与 Linux 保持一致的顶层目录结构
- 修正注释:同步读文件会阻塞 event loop,不再声称'避免阻塞'
- YAML 正则和 jdeps 回退列表补充适用范围说明
2026-05-31 16:34:19 +08:00
yukaidi
d55d8edd2f AppMain: 启动日志增加前端页面访问地址提示 2026-05-31 14:00:45 +08:00
yukaidi
451496f102 CI: 新增 Linux/Windows 原生环境打包 (jlink + 精简 JRE) 及 Docker 多平台构建 2026-05-31 14:00:45 +08:00
qaiu
6d6351bd58 更新 README.md 2026-05-31 07:02:09 +08:00
q
1a5fc8d1ef feat: truncate long error msg, fix lanzou folder regex, bump version to 0.3.4 2026-05-30 01:16:17 +08:00
qaiu
a44f30f7e5 Merge pull request #189 from qaiu/copilot/update-readme-badge
Update README maven badge to track tag-triggered workflow status
2026-05-30 00:17:51 +08:00
copilot-swe-agent[bot]
f9ebd34de3 docs: remove branch filter from maven workflow badge 2026-05-29 16:16:43 +00:00
copilot-swe-agent[bot]
b47db300a6 Initial plan 2026-05-29 16:10:44 +00:00
qaiu
d19d8573f9 Merge pull request #188 from yukaidi1220/fix/qqwtool-json-api
fix(QQwTool): 改用 POST JSON API 解析 QQ 邮箱云盘链接
2026-05-29 23:54:20 +08:00
qaiu
799e120069 Merge pull request #187 from yukaidi1220/feat/contributions
安全加固、新功能、Bug 修复与代码质量改进
2026-05-29 23:53:09 +08:00
yukaidi
13f83e8795 fix(QQwTool): 改用 POST JSON API 解析 QQ 邮箱云盘链接
旧实现通过 GET 请求获取 HTML 并正则提取 JS 变量,但接口已改为返回 JSON,
导致 NumberFormatException。改为 POST 请求 `https://wx.mail.qq.com/s`,
body 为 `f=json&k={shareKey}`,解析 JSON 响应中的 body.url/name/size。

- 使用 postAbs() 替代 request() 以正确处理 HTTPS
- 使用 asJson() 兼容 gzip 响应
- 使用 complete() 正确存储 downloadUrl
- 添加 User-Agent、URL 编码 shareKey
2026-05-29 22:26:44 +08:00
yukaidi
7b5900aae4 refactor: 代码质量清理与日志规范化
- 替换 System.out.println/printStackTrace 为 Logger: MkgsTool, PodTool, WsTool, IpExtractor, ReqIpUtil, LogStatistics
- JsPlaygroundLogger 日志列表限制最大 1000 条防止内存泄漏
- JsScriptLoader JarFile 改用 try-with-resources 防止文件句柄泄漏
- DbServiceImpl Thread.sleep 改为 vertx.setTimer 避免阻塞 event loop
- 删除未使用的 api.js,删除空的 ParserApiClientLinkTest
- 移除前端未使用的导入和死代码 (downloaderService, monacoTypes)
- 提取 previewBaseUrl 到 constants.js 常量文件
2026-05-29 14:23:26 +08:00
yukaidi
e36c0bbe45 fix: Docker 部署优化
- run.sh 改用 exec 直接运行 Java,修复 Docker 中 ShutdownHook 失效
- Dockerfile 预创建 db 和 logs 目录,添加非 root 用户运行
- Docker entrypoint 以 root 运行再降权,解决 volume 权限问题
- EXPOSE 改为仅 6401,entrypoint 添加 -Duser.timezone
2026-05-29 14:23:01 +08:00
yukaidi
af723aed3a fix: NPE 修复、资源泄漏修复及其他 Bug 修复
- 修复 12 处 NPE 风险: FjTool/FsTool/IzTool/LzTool/MkwTool/P115Tool/PdbTool/QQTool/ParserCreate/CommonUtils/ShareLinkInfo/URLParamUtil
- 修复 4 处 Vert.x 资源泄漏: 测试类中 Vertx 实例未关闭
- 修复 CacheManager 防重入和 registerPeriodicCleanup 就绪检查
- 修复 ParserApi 中 redirectUrl()/viewUrl() Promise 未 complete
- 修复 CacheManager.updateTotalByField Promise 永不完成
- 修复 AppMain ShutdownHook 注册,确保 Vert.x 先于 JDBCPoolInit 关闭
- 修复 RouterHandlerFactory failureHandler 恢复返回 failure message
- 修复 ParserCreate/LzTool 收窄 catch 异常类型
- 修复 IzTool/FjTool/IzToolWithAuth 并发安全 (volatile + header 副本)
- 修复 P115Tool UA 为 null 时的 NPE,添加默认 User-Agent
- Font Awesome CDN 换源为 s4.zstatic.net,避免 bootcdn 投毒风险
- DirectoryTree selectAll 补 parserUrl 检查,Home 组件名 App→Home
2026-05-29 14:22:40 +08:00
yukaidi
0978186679 feat: 新功能与配置优化
- QQscTool: 支持多文件和目录解析,通过 GetFileList API 实现递归目录导航
- Home: 从粘贴文本中自动提取分享链接
- DirectoryTree: 目录浏览添加复制直链按钮
- domainName 改为可选,未配置时自动从请求地址推断
- 统一版本号管理,GitHub URL 构建时自动从 git remote origin 识别
- vue.config.js 添加前端构建配置,sync-version.js 构建时同步版本号
2026-05-29 14:21:32 +08:00
yukaidi
17460ff271 fix(security): 安全漏洞修复与依赖升级
- 升级 Vert.x 4.5.24 → 4.5.27, postgresql 42.7.3 → 42.7.11, logback 1.5.18 → 1.5.32, axios 1.13.5 → 1.16.1
- 修复 JWT 签名验证和密码比较的时序攻击漏洞 (MessageDigest.isEqual)
- 修复 AESUtils 使用不安全 Random 改为 SecureRandom
- 修复登录用户枚举和异常信息泄露,统一错误提示
- 修复 RateLimiter count++ 非原子操作 (AtomicInteger)
- 修复 JsParserExecutor DCL 模式缺少 volatile
- 修复 Token 日志泄露,仅打印前8字符
- 修复 Playground 密码时序攻击和堆栈泄露
- 所有 window.open 添加 noopener,noreferrer
- LocalConstant 改用 ConcurrentHashMap 保证线程安全
- Dockerfile 添加非 root 用户运行,secret.yml 加入 .gitignore
2026-05-29 14:20:54 +08:00
yukaidi
f81b3852ee fix: Font Awesome CDN 换源为 s4.zstatic.net,避免 bootcdn 投毒风险 2026-05-29 14:10:06 +08:00
yukaidi
37abebf8f8 fix(QQscTool): 简化 filesetId 正则,避免反斜杠转义问题 2026-05-29 13:58:13 +08:00
yukaidi
79fab8c0d6 fix: 修复前端错误信息丢失 & QQscTool filesetId 提取失败
- 前端 axios 对 HTTP 非2xx直接reject,catch块丢失后端错误信息,从 error.response.data.msg 提取实际错误展示给用户

- QQscTool extractFilesetId 正则未适配 Nuxt 转义JSON格式
2026-05-29 13:46:25 +08:00
yukaidi
9b70fb2778 feat(QQscTool): 支持多文件和目录解析,通过 GetFileList API 实现递归目录导航 2026-05-29 13:22:12 +08:00
yukaidi
bd2868748f feat(Home): 从粘贴文本中自动提取分享链接 2026-05-29 13:08:22 +08:00
yukaidi
1f47bf13b5 fix(QQscTool): 检测被和谐文件,避免返回无效直链 2026-05-29 12:56:28 +08:00
yukaidi
7d8b33afe0 refactor: 前端代码质量清理
- fix: Home.vue 组件名 'App' → 'Home'
- fix: DirectoryTree selectAll 补 parserUrl 空值检查
- fix: 提取 previewBaseUrl 到 utils/constants.js,解除 ShowFile 对 Home 的耦合
- fix: Home.vue focus 事件监听器改为命名函数,加 beforeUnmount 移除
- fix: Playground.vue MutationObserver 保存引用,onUnmounted 中 disconnect
- chore: 删除未使用的 api.js
- chore: 删除 ClientLinks.vue 死代码 downloadClient/shouldShowDownloadButton
- chore: 删除 DirectoryTree.vue 死代码 buildTree
2026-05-29 12:38:34 +08:00
yukaidi
367f7c78a4 Merge remote-tracking branch 'upstream/main' 2026-05-29 12:17:51 +08:00
yukaidi
1bd23ec4ae feat: 目录浏览添加复制直链按钮 2026-05-29 12:13:00 +08:00
yukaidi
3461532679 fix: ConfigUtil 读取配置失败时自动尝试 resources/ 目录
当文件系统直接读取 app.yml 失败时(如 Docker 卷挂载场景),
ConfigUtil.readConfig 现在会自动尝试 resources/ 子目录作为
fallback,确保配置文件在各种部署方式下都能被正确加载。
2026-05-29 11:54:13 +08:00
yukaidi
4cfcdfa1f8 fix: Deploy 启动时自动从 resources/ 子目录查找配置文件
当 app.yml 在当前目录不存在时,自动回退到 resources/app.yml,
解决 Docker 部署时配置文件在 resources/ 子目录导致启动失败的问题。
2026-05-29 11:39:21 +08:00
yukaidi
ac2f526a1c fix: clientLinks/clientLink 补全参数注入,统一 _requestOrigin 设置
- clientLinks 无 auth 时调用 URLParamUtil.addParam() 注入代理/认证配置
- clientLink 补设 _requestOrigin 并调用 addParam()
- getFileList/getFileDownUrl 补设 _requestOrigin 保持一致性
- getDownLink 回退地址补上端口号
2026-05-29 11:36:20 +08:00
yukaidi
cfb624e9e0 fix: 统一 origin 解析逻辑,修复反向代理下 domainName 不一致
- ServerApi 添加 resolveOrigin() 统一处理 X-Forwarded-Host 头
- ParserApi.parse() 补设 _requestOrigin,修复 /v2/linkInfo 路径遗漏
- 清理 app-dev.yml 残留注释
2026-05-29 11:31:08 +08:00
yukaidi
e1bf45b5c8 feat: domainName 改为可选,未配置时自动从请求地址推断
- app-dev.yml 注释掉默认 domainName
- ParserApi 添加 getLinkPrefix() 支持 X-Forwarded-Host 反向代理
- ServerApi 传递 _requestOrigin 到 otherParam 供 parser 层使用
- URLParamUtil.addParam() 读不到配置时用 _requestOrigin 兜底
- AppMain 启动日志 domainName 为空时显示本地端口地址
2026-05-29 11:27:32 +08:00
yukaidi
e2dc611aa4 fix: 剪切板自动读取失败时静默,仅手动读取时提示 2026-05-29 11:02:00 +08:00
yukaidi
77c953626f fix: api.js baseURL 改为相对路径,修复反向代理下请求绕过代理的问题 2026-05-29 10:59:12 +08:00
yukaidi
0e14c9a925 refactor: 统一版本号管理,消除硬编码
项目版本(pom.xml revision)和parser版本(parserVersion)统一为单一来源,
前端构建时自动同步,发版只需改根pom.xml的两个属性。
2026-05-29 10:57:49 +08:00
yukaidi
a4e8585e2c fix: 错误处理返回具体异常信息而非通用"服务器内部错误"
RouterHandlerFactory 的 onFailure 和 catch 两处均丢弃了
e.getMessage(),导致前端无法获知具体报错原因。
2026-05-29 10:49:54 +08:00
yukaidi
cf7d64916e fix: Groovy 正则改为字符串形式避免斜杠解析问题 2026-05-29 10:37:31 +08:00
yukaidi
4ac80bbfe8 fix: gmavenplus-plugin 添加 Groovy 依赖 2026-05-29 10:34:24 +08:00
yukaidi
cfe8352d45 refactor: GitHub URL 改为构建时自动从 git remote origin 识别
前端:vue.config.js 通过 DefinePlugin 注入 VUE_APP_GITHUB_REPO_URL,
Home.vue/Playground.vue 中硬编码的 GitHub URL 全部改为动态变量。
后端:parser/pom.xml 添加 gmavenplus-plugin 在 initialize 阶段从
git remote origin 解析 github.owner/github.repo,SCM 字段引用 property。
2026-05-29 10:29:44 +08:00
yukaidi
4a0fe61d30 fix: ParserCreate 正则匹配 PWD 组时捕获 IllegalArgumentException
matcher.group(PWD) 在正则未定义 PWD 命名组时抛出
IllegalArgumentException 而非 IllegalStateException,原 catch 未覆盖
导致无提取码的链接(如QQ文件分享)返回 500。
2026-05-29 10:13:22 +08:00
yukaidi
c62e109aff fix: 将硬编码的上游 GitHub URL 改为 fork 地址
Home.vue、Playground.vue、parser/pom.xml 中的 qaiu/netdisk-fast-download
URL 全部替换为 yukaidi1220/netdisk-fast-download。
2026-05-29 10:09:49 +08:00
qaiu
ff400d3be3 Merge pull request #186 from yukaidi1220/260529
fix: 修复多个内存泄漏问题、资源管理缺陷及安全漏洞
感谢 @yukaidi1220 的贡献! 🎉

本次 PR 对项目做了一次全面深入的审查,涵盖了内存泄漏、资源管理、安全漏洞和代码缺陷,工作量很大,质量也很高,辛苦了👍

几个小建议供参考:

registerPeriodicCleanup 注册时机:目前在 的 块中调用,类加载时机不能保证 Vertx 已完全就绪。建议后续改为实现 接口,在 启动后执行,时机更可控。CacheServiceImplstaticAppRunPostExecVerticle

RateLimiter 并发安全:去掉 方向正确,但需确认 底层是 ,否则 与 并发执行存在竞态风险。另外 不能保证 的原子性,可考虑改用 。synchronizedipRequestMapConcurrentHashMapremoveIfcomputevolatile int countcount++AtomicInteger

PanBase 子类覆写 client:static 共享 WebClient 本身没问题,但如果有子类在构造中重新赋值 用于特殊场景(如代理),需确保这些子类有对应的逻辑,否则泄漏依然存在。this.client = WebClient.create(...)close()
2026-05-29 09:59:56 +08:00
yukaidi
1d243b8f1b fix: Docker EXPOSE 改为仅 6401,entrypoint 添加 -Duser.timezone
审查发现:
- EXPOSE 6400 误导用户映射后端端口,实际只需 6401(反向代理)
- TZ 环境变量不如 JVM -Duser.timezone 可靠
2026-05-29 09:57:05 +08:00
yukaidi
732a7f86fe fix: Docker entrypoint 以 root 运行再降权,彻底解决 volume 权限问题
去掉 USER appuser,entrypoint 以 root 身份运行,先 chown 修复
volume 挂载目录的权限,再通过 su 降权到 appuser 执行应用。
2026-05-29 09:49:22 +08:00
yukaidi
3c428f6a6d fix: ShutdownHook 注册顺序调整,确保 Vert.x 先于 JDBCPoolInit 关闭
JVM ShutdownHook 按注册逆序执行。将 AppMain 的 hook 移到 Deploy.start() 之前注册,
使执行顺序变为:Deploy hook(关闭 Vert.x)-> AppMain hook(关闭 JDBCPoolInit/JsParserExecutor)。
2026-05-29 09:01:08 +08:00
yukaidi
bff17f2d4e fix: run.sh 改用 exec 直接运行 Java,修复 Docker 中 ShutdownHook 失效
原 nohup+tail-f 模式下,Docker SIGTERM 发给 tail 而非 Java 进程,
导致 ShutdownHook 永远不会触发,资源无法优雅关闭。
改为 exec 让 Java 成为 PID 1,正确接收信号。
同时支持通过 JVM_XMX/JVM_OPTS 环境变量自定义 JVM 参数。
2026-05-29 08:46:57 +08:00
yukaidi
df600eaada fix: Dockerfile 预创建 db 和 logs 目录,修复非 root 用户无法写入 H2 数据库
appuser 运行时无法在 /app 下创建新子目录,H2 尝试创建 /app/db/nfdData.mv.db
时因目录不存在导致 AccessDeniedException。在 chown 之前预创建目录。
2026-05-29 08:34:59 +08:00
yukaidi
dd4027c931 fix: LzTool/IzToolWithAuth 最后两处 .get().toString() NPE 风险
- LzTool:221 — obj.get("url").toString() 改为 String.valueOf()
- IzToolWithAuth:434 — get("uuid").toString() 添加 null 检查
2026-05-29 07:54:33 +08:00
yukaidi
d1569195e4 fix: AppMain 注册 ShutdownHook 关闭 JDBCPoolInit 和 JsParserExecutor
审查发现 9c3945f 因模块依赖问题回退了 ShutdownHook 中的清理逻辑,
导致 JDBCPoolInit 连接池和 JsParserExecutor WorkerExecutor 在进程退出时
无法被显式关闭。将清理逻辑移到 web-service 模块的 AppMain(可依赖所有模块)。
2026-05-29 07:22:57 +08:00
yukaidi
d6e88f0c53 fix: PlaygroundApi 移除重复 import 和未使用的 getStackTrace 死代码
审查发现 42925c8 引入了重复的 StandardCharsets import,且 getStackTrace
方法在移除堆栈泄露后无任何调用方,属于死代码。
2026-05-29 07:06:53 +08:00
yukaidi
2a9fa81e56 fix: CacheManager registerPeriodicCleanup 添加防重入和 Vertx 就绪检查
审查发现 static 块中注册定时任务存在时序风险:如果 CacheServiceImpl 在 Vertx
初始化前被加载,定时任务将注册失败且无法恢复。添加 cleanupRegistered 标志防止
重复注册,Vertx 未就绪时跳过并等待下次调用。
2026-05-29 07:02:23 +08:00
yukaidi
5eed1fdfa0 revert: 回退 MyData.java 的构造函数修改,恢复为空 TODO
用户明确表示不需要改动 MyData 的 TODO,回退 741d7aa 对该文件的修改。
2026-05-29 06:58:41 +08:00
yukaidi
cf7fc4f502 fix: FjTool login() 中 token.substring 添加 null 保护
token 脱敏日志在 token 可能为 null 时会抛 NPE,添加 null 检查。
2026-05-29 06:58:09 +08:00
yukaidi
0ea31d631a fix: 移除 IzTool/IzToolWithAuth login() 中未使用的 h 变量(死代码)
并发安全修复引入的 h 变量创建后从未使用,后续请求仍通过
setTemplateParam("appToken", token) 传递 token。删除死代码并为
同一行的 token.substring 添加 null 保护。
2026-05-29 06:57:44 +08:00
yukaidi
74840ab63f fix(FsTool): 收窄 parseFileNameFromContentDisposition 中的 catch 异常类型
将 catch(Exception) 改为 catch(IllegalArgumentException),
只捕获 URLDecoder.decode 在遇到非法百分比编码时抛出的具体异常。
2026-05-29 06:38:35 +08:00
yukaidi
31f33339f1 fix(FsTool): 修复 parseById 中 get("paramJson") 可能导致的 NPE
当 otherParam 中缺少 "paramJson" 键时,后续 getString 调用会抛出 NPE。
添加 null 检查并提前返回失败。
2026-05-29 06:37:53 +08:00
yukaidi
c0a0d0dc47 fix(FjTool): 修复 parseById 中 get("paramJson") 可能导致的 NPE
当 otherParam 中缺少 "paramJson" 键时,后续 getString 调用会抛出 NPE。
添加 null 检查并提前返回失败。
2026-05-29 06:37:12 +08:00
yukaidi
0cd77ee9b9 fix(IzTool): 修复 parseFileList 中 get("uuid") 可能导致的 NPE
当 otherParam 中缺少 "uuid" 键时,原代码直接调用 .toString() 会抛出
NullPointerException。改为先取出 Object 再做 null 检查。
2026-05-29 06:36:43 +08:00
yukaidi
741d7aa8ca fix: implement MyData @DataObject constructor deserialization
Implement the MyData(JsonObject) constructor to deserialize `id` and
`maxSize` fields from the provided JsonObject, replacing the empty TODO.
2026-05-29 06:28:39 +08:00
yukaidi
d06a80dc73 fix: narrow catch exception type in LzTool.java
Replace 2 instances of `catch (Exception ignored)` with
`catch (MalformedURLException ignored)` around `new java.net.URL(url)`
calls, since that constructor only throws MalformedURLException.
2026-05-29 06:28:20 +08:00
yukaidi
86b9c43b8b fix: narrow catch exception type in ParserCreate.java
Replace 5 instances of `catch (Exception ignored)` with
`catch (IllegalStateException ignored)` around matcher.group() calls,
since that method only throws IllegalStateException when a named group
does not exist.
2026-05-29 06:27:51 +08:00
yukaidi
206981d4b4 revert: RouterHandlerFactory failureHandler 恢复返回 failure message
原 ctx.failure().getMessage() 是故意设计——RateLimiter 等组件通过 promise.fail()
传递用户友好的错误消息(如"请求次数太多了"),这些消息需要通过 failureHandler
返回给客户端。改为固定"服务器内部错误"会导致这些消息丢失。
仅添加 null 检查防止 NPE。
2026-05-29 06:15:56 +08:00
yukaidi
7d5831b5f4 fix: 彻底消除用户枚举和异常信息泄露的遗留问题
- UserServiceImpl: 3处"用户不存在"统一改为"用户名或密码错误"/"认证失败"
- RouterHandlerFactory: failureHandler 中 ctx.failure().getMessage() 改为"服务器内部错误"
2026-05-29 05:53:40 +08:00
yukaidi
7ca63985bd fix(test): 删除空的 ParserApiClientLinkTest 文件
该文件仅包含空行,无实际测试代码,直接删除。
2026-05-29 04:06:21 +08:00
yukaidi
06416a4e5f fix(test): JsFetchBridgeTest Vertx 资源泄漏修复
将各 @Test 方法中局部创建的 Vertx.vertx() 统一为成员变量,
通过 @Before 创建并初始化,@After 关闭,避免资源泄漏。
2026-05-29 04:06:13 +08:00
yukaidi
88739e8d1a fix(test): JsParserTest Vertx 资源泄漏修复
将各 @Test 方法中局部创建的 Vertx.vertx() 统一为成员变量,
通过 @Before 创建并初始化,@After 关闭,避免资源泄漏。
2026-05-29 04:04:52 +08:00
yukaidi
ffaba4f496 fix(test): BaiduPhotoParserTest Vertx 资源泄漏修复
将各 @Test 方法中局部创建的 Vertx.vertx() 统一为成员变量,
通过 @Before 创建并初始化,@After 关闭,避免资源泄漏。
2026-05-29 04:03:37 +08:00
yukaidi
c4f94a2bc7 fix: replace printStackTrace/System.out.println with logger in JsHttpClient, PlaygroundApi, LogStatistics, CacheServiceImpl, JsPlaygroundExecutor, JsPlaygroundLogger 2026-05-29 03:48:11 +08:00
yukaidi
49a3918244 fix: replace double-brace init with static block + Collections.unmodifiableMap; add final to UNIQUE_PREFIX 2026-05-29 03:40:06 +08:00
yukaidi
caddff567f fix: replace System.out.println with logger in IpExtractor 2026-05-29 03:39:36 +08:00
yukaidi
aed9e9f10d fix: replace System.out.println/printStackTrace with logger in ReqIpUtil; add final to static fields 2026-05-29 03:39:11 +08:00
yukaidi
6557b49383 fix: replace System.out.println with log.debug in MkgsTool 2026-05-29 03:38:36 +08:00
yukaidi
082cc4c743 fix: replace System.out.println with log in PodTool; mask token in log output 2026-05-29 03:38:07 +08:00
yukaidi
e261ebe698 fix: replace System.out.println with log.debug in WsTool 2026-05-29 03:37:32 +08:00
yukaidi
5a0dc69186 chore: remove debug console.log statements
Remove debug logging from production code while preserving
console.error and console.warn for actual error/warning cases.
2026-05-29 03:28:22 +08:00
yukaidi
29d8bf3ea4 fix(security): add noopener,noreferrer to all window.open calls
Prevent reverse tabnapping by adding security attributes to all
window.open calls that open links in new tabs.
2026-05-29 03:24:53 +08:00
yukaidi
07a330cfd4 fix: LocalConstant 改用 ConcurrentHashMap 保证线程安全,LzTool 方法名拼写修正
- LocalConstant: HashMap → ConcurrentHashMap,put() 改用 putIfAbsent 消除 check-then-act 竞态
- LzTool: 私有方法 setDateAndComplate → setDateAndComplete(拼写修正,仅内部调用)
2026-05-29 03:14:10 +08:00
yukaidi
aa30571709 fix(security): 升级 axios 版本 1.13.5 -> 1.16.1
修复 14 个安全漏洞
2026-05-29 03:08:59 +08:00
yukaidi
2bb9912cf5 fix(security): 升级 Vert.x 版本 4.5.24 -> 4.5.27 (parser 模块)
修复 SslContext 缓存 DoS 漏洞
2026-05-29 03:08:43 +08:00
yukaidi
8f77d9fe98 fix(security): 升级 Vert.x 版本 4.5.24 -> 4.5.27 (根 pom.xml)
修复 SslContext 缓存 DoS 漏洞
2026-05-29 03:08:27 +08:00
yukaidi
9cb32b3e8f fix(security): 升级 postgresql 驱动版本 42.7.3 -> 42.7.11
修复 SCRAM 认证 DoS 漏洞
2026-05-29 03:08:00 +08:00
yukaidi
080206925f fix(security): 升级 logback 版本 1.5.19 -> 1.5.32 (parser 模块)
修复 CVE-2024-12798 等 3 个安全漏洞
2026-05-29 03:07:39 +08:00
yukaidi
54d2a8189c fix(security): 升级 logback 版本 1.5.18 -> 1.5.32 (根 pom.xml)
修复 CVE-2024-12798 等 3 个安全漏洞
2026-05-29 03:07:22 +08:00
yukaidi
377bc12cf9 fix: Dockerfile 添加非 root 用户运行应用,提升容器安全性 2026-05-29 02:55:57 +08:00
yukaidi
da715c8a8f fix: CacheManager 消除双括号初始化,改用标准 HashMap 写法 2026-05-29 02:55:32 +08:00
yukaidi
fa4028296f fix: FjTool parseFileList 中 uuid 参数为 null 时的 NPE 2026-05-29 02:54:00 +08:00
yukaidi
dc629a3126 fix: P115Tool 中 UA 参数为 null 时的 NPE,添加默认 User-Agent 2026-05-29 02:53:32 +08:00
yukaidi
746c7ad5b3 fix: 替换 e.printStackTrace() 和 System.out.println 为 logger 调用
- HttpProxyVerticle: err.printStackTrace() / e.printStackTrace() -> LOGGER.error()
- RouterHandlerFactory: 5处 printStackTrace() -> LOGGER.error()
- CommonUtil: e.printStackTrace() -> LOGGER.error()
- ReflectionUtil: 新增 LOGGER,3处 printStackTrace() -> LOGGER.error()
- CreateDatabase: e.printStackTrace() -> LOGGER.error()
- URLUtil: 新增 LOGGER,e.printStackTrace() -> LOGGER.error()
- LzTool: e.printStackTrace() -> log.error()
- MkwTool: 3处 System.out.println + 1处 printStackTrace -> log.debug()/log.error()
- PdbTool: e.printStackTrace() -> log.error()
- ParserApi: t.printStackTrace() -> log.error()
- CacheManager: 2处 Throwable::printStackTrace -> LOGGER.error()
- QQTool: 3处 System.out.println -> log.debug()
- FjTool: System.out.println -> log.debug()
2026-05-29 02:50:06 +08:00
yukaidi
aef1b9ab11 fix: IzToolWithAuth 并发安全 - token/authFlag 改为 volatile,header 副本替代共享修改 2026-05-29 02:49:01 +08:00
yukaidi
79c9eb3dda fix: FjTool 并发安全 - token/userId/authFlag 改为 volatile,header0 副本替代共享修改 2026-05-29 02:48:13 +08:00
yukaidi
5a08ed68c2 fix: IzTool 并发安全 - token/authFlag 改为 volatile,header 副本替代共享修改 2026-05-29 02:47:27 +08:00
yukaidi
e5a623c5a8 fix: 将 secret.yml 加入 .gitignore 防止敏感配置泄露 2026-05-29 02:41:00 +08:00
yukaidi
4586138bf1 fix: 修复 AESUtils.getRandomString 使用不安全的 Random,改为 SecureRandom 2026-05-29 02:40:32 +08:00
yukaidi
1dddec110e fix: 修复 PasswordUtil.checkPassword 中的时序攻击漏洞,使用 MessageDigest.isEqual() 2026-05-29 02:39:31 +08:00
yukaidi
46e9999e4c fix: 修复 CacheManager.updateTotalByField 中 getShareKeyTotal 缺少 onFailure 导致 Promise 永不完成 2026-05-29 02:39:05 +08:00
yukaidi
a664ae3a56 fix: 修复 ParserApi 中 redirectUrl() 和 viewUrl() 的 Promise 未 complete 问题 2026-05-29 02:38:27 +08:00
yukaidi
9bcdcb2cb7 fix: 修复 MkwTool 中 set-cookie 为 null 时的 NPE 2026-05-29 02:37:22 +08:00
yukaidi
0b8592559a fix: 修复 CommonUtils.getURLParams() 中 fullUrl.getQuery() 返回 null 时的 NPE 2026-05-29 02:36:47 +08:00
yukaidi
c0b18be5ab fix: 修复 ShareLinkInfo.getCacheKey() 中 otherParam.get("UA") 可能导致的 NPE 2026-05-29 02:36:20 +08:00
yukaidi
46b2eb1ccd 修复RouterHandlerFactory异常信息泄露:Future失败和异常捕获时返回通用错误消息,详细异常仅记日志 2026-05-29 02:26:12 +08:00
yukaidi
d323376bed 修复RateLimiter count++非原子操作:将volatile int改为AtomicInteger,使用incrementAndGet() 2026-05-29 02:25:07 +08:00
yukaidi
838c86ae15 修复JsParserExecutor DCL模式缺少volatile:EXECUTOR字段添加volatile保证多线程可见性 2026-05-29 02:23:17 +08:00
yukaidi
42925c857c 修复Playground密码时序攻击和堆栈泄露:使用MessageDigest.isEqual()比较密码,移除返回给客户端的完整堆栈信息 2026-05-29 02:22:52 +08:00
yukaidi
ba981d281f 修复Token日志泄露:日志中token仅打印前8个字符,其余用...替代 2026-05-29 02:21:12 +08:00
yukaidi
4159b884de 修复登录用户枚举和异常信息泄露:统一登录失败提示为'用户名或密码错误',隐藏数据库异常详情 2026-05-29 02:19:22 +08:00
yukaidi
36b38421e5 修复JWT签名验证时序攻击:使用MessageDigest.isEqual()替代String.equals()进行签名比较 2026-05-29 02:18:43 +08:00
yukaidi
b77c8a80e9 fix(web-service): ParserApi 中 CacheManager/ServerApi 改为 static 避免每次请求重复创建
CacheManager 和 ServerApi 无请求级状态,每次 new 会造成不必要的对象分配,
改为 static final 字段复用;同时修复 viewURL 中内联 new ServerApi()。
2026-05-29 02:14:32 +08:00
yukaidi
886dcd039f fix(web-service): DbServiceImpl Thread.sleep 改为 vertx.setTimer 避免阻塞 event loop
sayOk() 中使用 Thread.sleep(4000) 会阻塞 Vert.x event loop 线程,
改为 vertx.setTimer 异步延迟完成 promise。
2026-05-29 02:13:42 +08:00
yukaidi
d99885d396 fix(core): RouterVerticle Router 从 static final 改为实例字段
Router 声明为 static final 会在类加载时提前创建,
与 Vert.x 实例生命周期不匹配,改为在 start() 中初始化为实例字段。
2026-05-29 02:12:37 +08:00
yukaidi
942de9c430 fix(core): ConfigRetriever 成功路径也调用 close() 防止资源泄漏
readConfig() 中 onSuccess 回调未关闭 ConfigRetriever,
文件监听器和底层资源无法释放,现在成功和失败路径均调用 close()。
2026-05-29 02:11:35 +08:00
yukaidi
ae3ff9ecbb fix(parser): JsPlaygroundLogger 日志列表限制最大 1000 条防止内存泄漏
日志列表 Collections.synchronizedList 无容量限制,长时间运行会无界增长。
新增 addLog() 方法,在添加前检查容量,超过 1000 条时移除最早的条目。
2026-05-29 02:09:59 +08:00
yukaidi
bcc4315ea9 fix(parser): JsScriptLoader JarFile 改用 try-with-resources 防止文件句柄泄漏
JarFile 在手动 close() 时若中间抛异常会导致文件句柄未关闭,
改为 try-with-resources 确保无论正常或异常都能释放资源。
2026-05-29 02:08:50 +08:00
yukaidi
2f7304ab2d fix: Docker 镜像地址改为动态获取仓库名,修复 fork 仓库推送被拒绝 2026-05-29 01:47:16 +08:00
yukaidi
0df01ba3d5 fix: Deploy配置读取失败时主线程永久阻塞
BUG-05: 配置读取失败时仅调用printStackTrace,未调用LockSupport.unpark()
导致主线程永远阻塞在LockSupport.park()
现在失败时记录错误日志、unpark主线程并退出进程
2026-05-29 01:40:38 +08:00
yukaidi
710e454fd0 fix: dependency graph 步骤添加 continue-on-error,fork 仓库未启用时不影响 CI 2026-05-29 01:40:30 +08:00
yukaidi
c46dfa00a0 fix: ReverseProxyVerticle HTTPS默认端口应为443而非80
BUG-03: URL使用https://前缀构造,但默认端口设为80(HTTP)
导致所有未指定端口的HTTPS代理目标连接失败
2026-05-29 01:40:05 +08:00
yukaidi
9a3ea05023 fix: HttpProxyVerticle代理认证绕过漏洞
SEC-01: 修复三个安全问题:
1. split.length<=1时直接放行请求,现在返回403
2. Base64解码无异常处理,现在捕获IllegalArgumentException返回403
3. 日志中明文记录密码,现在只记录用户名
2026-05-29 01:39:31 +08:00
yukaidi
66d7a62d3a fix: ReflectionUtil正则拼写错误boolen应为boolean
BUG-02: boolen拼写错误导致boolean[]类型参数永远不会被识别为基本类型数组
参数绑定失败并抛出RuntimeException
2026-05-29 01:38:53 +08:00
yukaidi
f1b6cd3e18 fix: HttpProxyConf构造器port字段从未赋值,timeout被重复赋值
BUG-01: this.timeout = DEFAULT_PORT 应为 this.port = DEFAULT_PORT
导致port字段始终为null,代理服务器无法获取正确端口
2026-05-29 01:38:24 +08:00
yukaidi
189d1477a8 fix: 将 fetch-runtime.js 复制到 test resources,修复测试类加载不到资源文件
CI 运行测试时 JsParserExecutor.loadFetchRuntime() 通过 ClassLoader.getResourceAsStream
找不到 fetch-runtime.js。将文件复制到 parser/src/test/resources/ 确保测试类路径可用。
2026-05-29 01:25:32 +08:00
yukaidi
9c3945f45a fix: 修复编译错误,core 模块不能依赖 web-service/parser/core-database
core 模块的 Deploy.java 和 PostExecVerticle.java 直接引用了上层模块的类,
导致编译失败(package does not exist)。

- Deploy.java: 移除对 JDBCPoolInit 和 JsParserExecutor 的显式调用,
  vertx.close() 会级联关闭 Vert.x 创建的资源
- PostExecVerticle.java: 移除缓存定时清理逻辑(不能引用 web-service 的 CacheManager)
- CacheManager: 添加 registerPeriodicCleanup() 静态方法,通过 VertxHolder 注册定时任务
- CacheServiceImpl: static 块中调用 CacheManager.registerPeriodicCleanup(),服务加载时自动注册
2026-05-29 01:08:15 +08:00
yukaidi
77c7d6c5d6 fix: ShutdownHook 中 JDBCPoolInit.instance() 添加 null 检查,防止未初始化时 NPE
安装引导模式下数据库可能未配置,JDBCPoolInit.instance() 为 null,直接调用 close() 会 NPE。
2026-05-29 00:53:31 +08:00
yukaidi
ab3009e9cc fix: ShutdownHook 接入 JDBCPoolInit.close() 和 JsParserExecutor.shutdownExecutor()
将已实现但未调用的 close()/shutdownExecutor() 接入 JVM ShutdownHook,显式释放资源。
关闭顺序:vertx.close() → JDBC 连接池 → WorkerExecutor 线程池,确保依赖关系正确。
2026-05-29 00:50:45 +08:00
yukaidi
1c2291f9cf Revert "fix(performance): CommonUtil initConfig 改为异步非阻塞读取"
This reverts commit 6dfa770137.
2026-05-29 00:37:27 +08:00
yukaidi
efb135ee48 Revert "fix(error): URLUtil 异常不再吞没,改为抛出 IllegalArgumentException"
This reverts commit 0dfee8ab22.
2026-05-29 00:37:18 +08:00
yukaidi
0699c4a127 Revert "fix(memory): ReflectionUtil 添加 SoftReference + TTL 缓存清理"
This reverts commit be1ed3d46d.
2026-05-29 00:37:09 +08:00
yukaidi
33cef5f8e1 Revert "fix(resource): ReqIpUtil 使用统一 Vertx 单例"
This reverts commit 1fca578c07.
2026-05-29 00:36:55 +08:00
yukaidi
32d467b6d9 Revert "fix(security): SecurityClassFilter 改为白名单策略"
This reverts commit a83665ac44.
2026-05-29 00:35:10 +08:00
yukaidi
6dfa770137 fix(performance): CommonUtil initConfig 改为异步非阻塞读取 2026-05-29 00:33:27 +08:00
yukaidi
0dfee8ab22 fix(error): URLUtil 异常不再吞没,改为抛出 IllegalArgumentException
原代码 catch Exception 后仅打印堆栈,调用方无法感知解析失败。
改为抛出 IllegalArgumentException,让调用方明确知道 URL 解析失败。
2026-05-29 00:33:10 +08:00
yukaidi
85fe910f25 fix(bug): ParamUtil 修复数组越界问题
原代码当 kv.length == 0 时访问 kv[0] 会抛出异常。
改为跳过空参数,使用 split(=, 2) 限制分割次数。
2026-05-29 00:32:56 +08:00
yukaidi
6c60b0116f fix(resource): JDBCPoolInit 实现 AutoCloseable 添加 close() 方法
原代码单例模式无关闭方法,应用退出时数据库连接池无法释放。

改为:
- 实现 AutoCloseable 接口
- 添加 close() 方法关闭连接池
- 关闭后将 pool 置 null 防止重复关闭
2026-05-29 00:32:26 +08:00
yukaidi
8dfcf510f6 fix(resource): JsParserExecutor WorkerExecutor 懒加载 + 关闭支持
原代码静态初始化 WorkerExecutor,应用关闭时无法释放线程资源。

改为:
- 懒加载创建 WorkerExecutor
- 实现 AutoCloseable 接口
- 添加 shutdownExecutor() 静态方法供应用关闭时调用
2026-05-29 00:32:13 +08:00
yukaidi
be1ed3d46d fix(memory): ReflectionUtil 添加 SoftReference + TTL 缓存清理
原代码使用永久缓存 Reflections 实例,占用大量内存且不释放。

改为:
- 使用 SoftReference 允许 GC 在内存不足时回收
- 添加 1 小时 TTL 防止长期占用
- 每次获取时自动清理过期条目
2026-05-29 00:32:02 +08:00
yukaidi
1fca578c07 fix(resource): ReqIpUtil 使用统一 Vertx 单例
原代码在字段级别直接创建 Vertx.vertx() 实例,
可能导致多个 Vertx 实例重复创建,浪费系统资源。

改为使用 WebClientVertxInit.get() 获取统一单例。
2026-05-29 00:31:49 +08:00
yukaidi
a83665ac44 fix(security): SecurityClassFilter 改为白名单策略
原黑名单策略默认放行所有类,存在安全风险。
改为白名单策略,仅允许明确安全的 Java 类被 JS 访问。

允许: java.util.*, java.time.*, java.lang 基础类型, Nashorn API
拒绝: 默认拒绝所有未在白名单中的类
2026-05-29 00:31:38 +08:00
yukaidi
21e8a370c3 fix: ShutdownHook 改为同步等待 vertx.close(),修复 JVM 提前退出导致资源未释放
审查发现 vertx.close() 是异步操作,ShutdownHook 线程提交关闭任务后立即退出,
JVM 在资源实际释放前就终止了,与未修复时行为等价。
改为 CompletableFuture.get(10s) 阻塞等待,超时有 warn 日志。
同时移除无用的 mainVertx 字段,修正 JsExecUtils 误导性注释。
2026-05-28 23:58:52 +08:00
yukaidi
3dd4dd139b fix: 缓存清理异常日志级别从 debug 改为 warn,确保生产环境可见
审查发现数据库异常时 debug 级别会被静默吞掉,运维无法感知。
2026-05-28 23:43:07 +08:00
yukaidi
afe2046bc8 fix: RateLimiter 移除 synchronized 并添加 volatile,修复事件循环阻塞
审查发现 synchronized 在 Vert.x 事件循环中会严重阻塞并发。
ConcurrentHashMap 本身已线程安全,移除 synchronized 锁。
RequestInfo 字段添加 volatile 保证多线程内存可见性。
2026-05-28 23:42:40 +08:00
yukaidi
6d24388690 fix: ServiceVerticle 保存 MessageConsumer 引用,修复 unregister 参数类型错误
审查发现 unregister(address) 参数类型不匹配,ServiceBinder.unregister() 需要
MessageConsumer 而非 String。改为保存 register() 返回的 MessageConsumer,
stop() 中直接调用 consumer.unregister()。同时修复日志在 clear() 后读 size 始终为 0 的 bug。
2026-05-28 23:42:24 +08:00
yukaidi
0b024a849a fix: 添加缓存表定时清理任务,修复 cache_link_info 无限增长
- CacheManager 添加 cleanupExpiredCache() 方法删除过期缓存记录
- PostExecVerticle 注册每小时执行一次的定时清理任务
- 原实现只有读时惰性检查过期,过期记录永远不会被删除,长期运行后数据库持续膨胀
2026-05-28 23:20:17 +08:00
yukaidi
8745dc3567 fix: RateLimiter 添加过期条目清理,修复 ipRequestMap 无限增长
当 Map 超过 1000 条目时触发惰性清理,移除所有已过期的 IP 条目。
原实现中过期条目只重置计数不删除 key,长期运行后 Map 持续膨胀。
同时消除多余的 ipRequestMap.get(ip) 调用,直接使用 compute() 返回值。
2026-05-28 23:16:53 +08:00
yukaidi
1f4c7019d4 fix: ServiceVerticle 添加 stop() 方法注销 EventBus 消费者,修复重部署时消费者累积泄漏
保存已注册的 EventBus 地址列表,在 stop() 中通过 ServiceBinder 逐一注销。
原实现有 start() 无 stop(),Verticle 重部署时旧消费者不会被注销,导致重复注册。
2026-05-28 23:15:12 +08:00
yukaidi
255e7b2fb5 fix: JsParserExecutor 和 JsHttpClient 添加资源清理,修复解析完成后资源泄漏
- JsHttpClient 添加 close() 方法释放 WebClient 连接池
- JsParserExecutor 添加 close() 方法,清除 ScriptEngine 中注入的 Java 对象引用
- parse()/parseFileList()/parseById() 均在 onComplete 回调中调用 close() 释放资源
2026-05-28 23:13:09 +08:00
yukaidi
7419e536cf fix: JsExecUtils 缓存 ScriptEngineManager,避免每次调用都创建新实例
ScriptEngineManager 是重量级对象(含类加载器扫描等),将其缓存为 static 字段,
executeDynamicJs/executeOtherJs 每次调用只创建轻量的 ScriptEngine 实例。
2026-05-28 23:08:50 +08:00
yukaidi
74df000287 fix: PanBase WebClient 改为静态共享单例,修复每请求创建4个实例的资源泄漏
WebClient 是线程安全的,将 client/clientNoRedirects/clientDisableUA 改为 static 共享实例,
避免每次解析请求创建4个独立 WebClient(各含连接池)。
clientSession 仍保持实例级(管理 cookie,非线程安全)。
代理模式下仍创建独立 WebClient 实例。
2026-05-28 23:06:45 +08:00
yukaidi
2e0127d609 fix: 注册 JVM ShutdownHook,修复 Vert.x 实例进程退出时不关闭的资源泄漏
Deploy.deployVerticle() 中创建的 Vert.x 实例是局部变量,进程退出时无法优雅关闭,
导致 Netty EventLoopGroup、JDBC 连接池、内部定时器等资源泄漏。
添加 ShutdownHook 在 JVM 关闭时调用 vertx.close() 级联释放所有资源。
2026-05-28 23:04:54 +08:00
qaiu
2b9168e8df 更新 LzTool.java
fix:蓝奏目录识别问题
2026-05-28 12:33:16 +08:00
qaiu
7bb3cf7c51 Update README.md 2026-05-17 21:59:19 +08:00
qaiu
2ce5058be0 Add professional version cloud storage options 2026-05-17 21:57:39 +08:00
q
a5fc41f152 1 2026-04-29 22:40:57 +08:00
q
3245a27156 release v3.0.2 2026-04-29 22:37:07 +08:00
q
ce0fbf65aa fixed test docx 2026-04-28 15:57:50 +08:00
qaiu
eb87c3d865 Update app.properties 2026-04-27 20:06:19 +08:00
qaiu
990a90e461 更新 README.md 2026-04-27 12:04:29 +08:00
qaiu
45cb255a4d 更新 README.md 2026-04-27 12:01:11 +08:00
qaiu
5e8b8a3575 更新 README.md 2026-04-24 03:05:29 +08:00
qaiu
c317e53c24 Merge pull request #182 from qaiu/copilot/update-readme-netdisk-comparison
README: Remove 奶牛快传(cowtransfer) references
2026-04-23 19:41:33 +08:00
copilot-swe-agent[bot]
29b59d8450 Remove 奶牛快传(cowtransfer) from README.md
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/63030ac7-158c-43ca-9dcd-254e03c6f8f4

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-23 11:27:04 +00:00
qaiu
97b7e2f86e Merge pull request #181 from qaiu/copilot/check-authentication-mechanism
fix: 演练场密码登录后所有API返回"未授权访问"
2026-04-23 09:34:18 +08:00
copilot-swe-agent[bot]
2f55294b58 fix: 修复演练场输入密码后提示未授权访问的问题
根本原因:框架 RouterHandlerFactory 未注册 SessionHandler,
导致 ctx.session() 始终返回 null。登录时密码校验通过但认证
状态被静默丢弃,后续所有请求均返回"未授权访问"。

修复方案:将 Session 鉴权改为 Token(Bearer)鉴权:
- PlaygroundConfig: 新增 generateToken()/validateToken(),
  使用 SecureRandom 生成密码学安全 Token,并在生成时
  清理过期 Token 防止内存泄漏
- PlaygroundApi: login() 返回 Token;checkAuth() 从
  Authorization 请求头中读取并校验 Token
- playgroundApi.js: 添加请求拦截器自动携带 Token;
  login() 从响应中提取并保存 Token 到 localStorage
- Playground.vue: 后端报告未认证时同步清除 playground_token

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/52144d13-cd49-4a3d-b279-9b8d6cbad757

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-23 01:22:09 +00:00
q
2161190d9a config: switch active profile to dev 2026-04-22 16:10:22 +08:00
q
aaae301cbc release v3.0.0: core refactoring, new AppRun/PostExecVerticle, proxy and router improvements 2026-04-22 15:57:35 +08:00
qaiu
9ca6511235 Merge pull request #180 from qaiu/copilot/fix-filemanagerplugin-copy-back
fix: restore missing copy of build output to webroot/nfd-front in vue.config.js
2026-04-22 12:57:06 +08:00
copilot-swe-agent[bot]
8582290db3 fix: restore copy of nfd-front to webroot/nfd-front in vue.config.js
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/e4e9323a-d8f5-48b1-9476-7efab611f978

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-22 04:40:03 +00:00
copilot-swe-agent[bot]
5ff33d7c58 Initial plan 2026-04-22 04:39:22 +00:00
q
0cfb69a240 fix frontend shortcut parsing and proxy static serving 2026-04-22 04:24:22 +08:00
q
110a9beda4 fix parser onedrive url decoding and bump vulnerable deps 2026-04-22 02:03:04 +08:00
qaiu
fd6a3f5929 更新 README.md 2026-04-21 23:22:52 +08:00
qaiu
82ad6ec427 更新 README.md 2026-04-20 07:51:57 +08:00
qaiu
1bfc7c960d 更新 README.md 2026-04-19 19:34:57 +08:00
qaiu
332f49f483 更新 README.md 2026-04-19 19:32:13 +08:00
q
b967c7a1bb Merge pull request #177 from qaiu/pr-177
# Conflicts:
#	parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java
#	parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java
2026-04-19 08:51:44 +08:00
q
519dbe1f77 docs: update fs support and dev proxy port 2026-04-19 08:47:01 +08:00
q
c64855d4ad feat: improve downloader integration for parsed files 2026-04-19 08:43:27 +08:00
qaiu
d50d10ba89 Merge pull request #178 from qaiu/copilot/implement-feishu-share-parser-java
feat: 支持飞书云盘分享解析
2026-04-18 16:47:33 +08:00
copilot-swe-agent[bot]
e79478c421 refactor: address code review - extract constants, improve logging
- Extract Pattern constants as static final fields
- Extract PAGE_SIZE constant for API pagination
- Add logging for NumberFormatException in file size parsing

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/56418d09-a396-40cf-a080-c71e4a69c323

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-18 08:46:06 +00:00
copilot-swe-agent[bot]
c401a84eb8 feat: add Feishu cloud disk share parser (file + folder support)
Add FsTool parser for Feishu (飞书) cloud disk share links.
Supports both file and folder share URL formats:
- File: https://xxx.feishu.cn/file/{token}
- Folder: https://xxx.feishu.cn/drive/folder/{token}

The parser:
- Fetches anonymous session cookies from share page
- Uses Range probe to detect filename and size
- Returns download URL with required headers (Cookie, Referer)
- Supports folder listing via v3 API with pagination
- Updates README with Feishu in supported cloud disk list

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/56418d09-a396-40cf-a080-c71e4a69c323

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-18 08:43:26 +00:00
copilot-swe-agent[bot]
a9978a6202 Initial plan 2026-04-18 08:36:17 +00:00
qaiu
cc9d0a4b30 更新 README.md 2026-04-15 05:16:47 +08:00
qaiu
696ef832f8 更新 README.md 2026-04-13 08:03:04 +08:00
qaiu
442f9d1d2e Merge pull request #176 from qaiu/copilot/optimize-pandomain-template
fix(PanDomainTemplate): 修复现有网盘域名模板正则表达式中的多处缺陷
2026-04-12 19:45:06 +08:00
copilot-swe-agent[bot]
a45a64380c 优化乐云(LE)正则以支持 /mshare/ 格式,补充测试用例
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/7341ab49-5648-498c-b153-0fcd3b3f8aad

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-12 11:40:55 +00:00
copilot-swe-agent[bot]
df7442c3dd Initial plan 2026-04-12 11:39:26 +00:00
qaiu
1a949725f3 更新 README.md 2026-04-12 19:33:44 +08:00
qaiu
7c14f3437b 更新 README.md 2026-04-12 19:32:20 +08:00
qaiu
bc402da365 更新 README.md 2026-04-12 19:30:47 +08:00
qaiu
b95b474660 更新 README.md 2026-04-12 19:29:10 +08:00
qaiu
691a3770d9 更新 README.md 2026-04-12 19:28:06 +08:00
copilot-swe-agent[bot]
49ec54a3b5 refactor(tests): 改善测试注释说明,增强可读性
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/5523822b-ffe2-4e95-ac13-fd3f0dc41970

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-12 11:19:51 +00:00
qaiu
2fc15f437e 更新 README.md 2026-04-12 19:18:15 +08:00
qaiu
190f6ca7ab 更新 README.md 2026-04-12 19:17:44 +08:00
qaiu
c683fd27d4 更新 README.md 2026-04-12 19:17:18 +08:00
copilot-swe-agent[bot]
d815cc1010 fix(PanDomainTemplate): 优化现有网盘域名模板正则表达式
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/5523822b-ffe2-4e95-ac13-fd3f0dc41970

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-12 11:17:06 +00:00
copilot-swe-agent[bot]
fd84ff1200 Initial plan 2026-04-12 11:05:17 +00:00
qaiu
a420bad305 Update README.md 2026-04-09 16:46:29 +08:00
qaiu
6ef6e47580 Update README.md 2026-04-07 08:39:41 +08:00
qaiu
94f83ec296 Fix duplicate Trendshift badge in README
Removed duplicate Trendshift badge from README.
2026-04-07 08:23:44 +08:00
qaiu
702569c701 Add Trendshift badge to README
Added Trendshift badge to README for repository tracking.
2026-04-07 08:22:40 +08:00
qaiu
d4940ca9ee fixed: 123-YePan: Fix regex pattern for share key extraction 2026-04-07 08:20:06 +08:00
118 changed files with 6129 additions and 1374 deletions

View File

@@ -1,14 +1,5 @@
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven
name: Java CIMaven 构建 + Docker 镜像 + 原生环境打包)
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Java CI with Maven
# The API requires write permission on the repository to submit dependencies
permissions:
contents: write
packages: write
@@ -16,9 +7,9 @@ permissions:
on:
push:
tags:
- '*' # 只有推送tag时才会触发构建
- '*'
branches-ignore:
- '*' # 排除所有分支的提交
- '*'
paths-ignore:
- 'bin/**'
- '.github/**'
@@ -32,70 +23,250 @@ on:
- "main"
jobs:
# ================================================================
# 阶段一:构建前端 + Maven 打包(只执行一次,产物共享)
# ================================================================
build:
name: 编译构建
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: 检出代码
uses: actions/checkout@v3
- uses: actions/setup-node@v4
- name: 设置 Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Set up JDK 17
- name: 设置 JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: Build Frontend
- name: 构建前端
run: cd web-front && yarn install && yarn run build
- name: Build with Maven
- name: Maven 编译打包
run: mvn -B package -DskipTests --file pom.xml
# Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive
- name: Update dependency graph
- name: 更新依赖图谱
uses: advanced-security/maven-dependency-submission-action@v3
if: github.event_name != 'pull_request'
continue-on-error: true
with:
ignore-maven-wrapper: true
# - uses: release-drafter/release-drafter@v5
# env:
# GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Upload Artifact
- name: 分享应用打包目录(供原生包和 Docker 复用)
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: app-package
path: web-service/target/package/
- name: 分享 bin-zip供 Docker 复用)
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: app-bin-zip
path: web-service/target/netdisk-fast-download-bin.zip
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
# ================================================================
# 阶段二-ADocker 镜像构建(并行)
# ================================================================
docker:
name: Docker 镜像
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v3
- name: 下载 bin-zip 产物
uses: actions/download-artifact@v4
with:
name: app-bin-zip
path: web-service/target/
- name: 登录 GitHub 容器仓库
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
- name: 设置 QEMU多平台构建支持
uses: docker/setup-qemu-action@v3
- name: 设置 Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract git tag
id: tag
run: |
GIT_TAG=$(git tag --points-at HEAD | head -n 1)
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
- name: Build and push Docker image
if: github.event_name != 'pull_request'
- name: 构建并推送 Docker 镜像
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/qaiu/netdisk-fast-download:${{ steps.tag.outputs.tag }}
ghcr.io/qaiu/netdisk-fast-download:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
# ================================================================
# 阶段二-B原生环境打包 Linux + Windows并行
# ================================================================
native-package:
name: 原生环境打包 → ${{ matrix.artifact-name }}
needs: build
if: github.event_name != 'pull_request'
strategy:
matrix:
include:
- os: ubuntu-latest
artifact-name: netdisk-fast-download-linux-amd64
- os: windows-latest
artifact-name: netdisk-fast-download-windows-amd64
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: 设置 JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: 下载 Maven 构建产物
uses: actions/download-artifact@v4
with:
name: app-package
path: web-service/target/package
# ============================================================
# jdeps 分析 → 确定所需 JDK 模块
# ============================================================
- name: 分析所需 JDK 模块jdeps
run: |
MAIN_JAR="web-service/target/package/netdisk-fast-download.jar"
LIB_DIR="web-service/target/package/lib"
CP=""
for jar in "$LIB_DIR"/*.jar; do
CP="$CP${CP:+:}$jar"
done
RAW_MODULES=$(jdeps --print-module-deps --ignore-missing-deps --multi-release 17 \
--class-path "$CP" "$MAIN_JAR" 2>/dev/null | head -n 1 | tr -d '\r\n' || true)
if [ -z "$RAW_MODULES" ] || [[ "$RAW_MODULES" == *"Missing"* ]] || [[ "$RAW_MODULES" == *"Error"* ]]; then
# ⚠️ 回退列表:若项目新增了需要 java.* / jdk.* 模块的依赖,需同步更新此处
RAW_MODULES="java.base,java.logging,java.sql,java.naming,java.management,java.xml,jdk.unsupported,java.net.http,java.instrument,java.security.jgss,java.security.sasl,java.desktop,jdk.crypto.ec"
echo "jdeps 分析失败,使用回退模块列表"
else
# 补上 jdeps 无法检测的反射/SPI依赖
RAW_MODULES="$RAW_MODULES,java.desktop,jdk.crypto.ec"
fi
echo "detected modules: $RAW_MODULES"
printf 'JDK_MODULES=%s\n' "$RAW_MODULES" >> $GITHUB_ENV
# ============================================================
# jlink 生成精简 JRE
# ============================================================
- name: 生成精简 JREjlink
run: |
jlink \
--module-path "$JAVA_HOME/jmods" \
--add-modules "$JDK_MODULES" \
--output "native-package/netdisk-fast-download/jre" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages
echo "JRE size:"
du -sh native-package/netdisk-fast-download/jre || true
# Windows: 确保 MSVC 运行时 DLL 到位
if [[ "$RUNNER_OS" == "Windows" ]]; then
JRE_BIN="native-package/netdisk-fast-download/jre/bin"
for dll in vcruntime140.dll msvcp140.dll vcruntime140_1.dll; do
if [ ! -f "$JRE_BIN/$dll" ] && [ -f "$JAVA_HOME/bin/$dll" ]; then
echo "jlink 未包含 $dll从 JDK 补拷"
cp "$JAVA_HOME/bin/$dll" "$JRE_BIN/"
fi
done
echo "=== JRE bin 目录 DLL 清单 ==="
ls -la "$JRE_BIN"/*.dll 2>/dev/null || echo "(无 .dll 文件)"
fi
# ============================================================
# 组装包目录
# ============================================================
- name: 组装包目录
run: |
PKG="native-package/netdisk-fast-download"
SRC="web-service/target/package"
cp "$SRC/netdisk-fast-download.jar" "$PKG/"
cp -r "$SRC/lib" "$PKG/"
cp -r "$SRC/resources" "$PKG/"
cp -r "$SRC/webroot" "$PKG/"
mkdir -p "$PKG/db"
mkdir -p "$PKG/logs"
# ============================================================
# 生成启动脚本
# ============================================================
- name: 生成启动脚本Linux
run: |
PKG="native-package/netdisk-fast-download"
echo '#!/bin/bash' > "$PKG/run.sh"
echo 'DIR="$(cd "$(dirname "$0")" && pwd)"' >> "$PKG/run.sh"
echo 'cd "$DIR" || exit 1' >> "$PKG/run.sh"
echo 'exec "$DIR/jre/bin/java" -Xmx512M -Dfile.encoding=utf-8 -jar "$DIR/netdisk-fast-download.jar" "$@"' >> "$PKG/run.sh"
chmod +x "$PKG/run.sh"
- name: 生成启动脚本Windows
run: |
PKG="native-package/netdisk-fast-download"
echo '@echo off' > "$PKG/run.bat"
echo 'chcp 65001 > nul' >> "$PKG/run.bat"
echo 'pushd %~dp0' >> "$PKG/run.bat"
echo '"%~dp0jre\bin\java.exe" -Xmx512M -Dfile.encoding=utf-8 -jar "%~dp0netdisk-fast-download.jar" %*' >> "$PKG/run.bat"
# ============================================================
# 打包为 zip
# ============================================================
- name: 打包 ZIPLinux
if: runner.os == 'Linux'
run: |
cd native-package
zip -r "../${{ matrix.artifact-name }}.zip" netdisk-fast-download/
- name: 打包 ZIPWindows
if: runner.os == 'Windows'
shell: pwsh
run: |
Compress-Archive -Path native-package/netdisk-fast-download -DestinationPath "${{ matrix.artifact-name }}.zip"
# ============================================================
# 上传产物
# ============================================================
- name: 上传原生安装包
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-name }}.zip
- name: 上传到 Release
uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.artifact-name }}.zip
tag_name: ${{ github.ref_name }}
generate_release_notes: true

4
.gitignore vendored
View File

@@ -31,6 +31,7 @@ target/
sdkTest.log
app.yml
app-local.yml
secret.yml
#some local files
@@ -41,7 +42,9 @@ gradlew.bat
unused.txt
/web-service/src/main/generated/
/db
/netdisk-fast-download/
/webroot/nfd-front/
/netdisk-fast-download/webroot/nfd-front/
package-lock.json
# Maven generated files
@@ -89,3 +92,4 @@ yarn-error.log*
**/${project.build.directory}/
**/${project.basedir}/target/
**/${basedir}/target/
.spec-workflow/

View File

@@ -1,4 +1,5 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive"
"java.configuration.updateBuildConfiguration": "interactive",
"java.debug.settings.onBuildFailureProceed": true
}

View File

@@ -10,8 +10,13 @@ COPY ./web-service/target/netdisk-fast-download-bin.zip .
RUN unzip netdisk-fast-download-bin.zip && \
mv netdisk-fast-download/* ./ && \
rm netdisk-fast-download-bin.zip && \
chmod +x run.sh
chmod +x run.sh && \
mkdir -p db logs
EXPOSE 6400 6401
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["sh", "run.sh"]
EXPOSE 6401
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
ENTRYPOINT ["/docker-entrypoint.sh"]

View File

@@ -1,23 +1,35 @@
# 一款网盘分享链接云解析快速下载服务
QQ交流群1017480890
<p align="center">
<img src="https://github.com/user-attachments/assets/87401aae-b0b6-4ffb-bbeb-44756404d26f" alt="项目预览图" />
</p>
<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://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/build.yml?branch=main&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://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.27-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>
<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>
<a href="https://atomgit.com/QAIU/netdisk-fast-download"><img src="https://atomgit.com/QAIU/netdisk-fast-download/star/badge.svg" alt="AtomGit"></a>
<a href="https://oosmetrics.com/repo/qaiu/netdisk-fast-download"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/826aa27a-6e59-4de5-b7fa-cd189f484035.svg"></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>
![alt text](web-front/img/image.png)
# netdisk-fast-download 网盘分享链接云解析服务
QQ交流群1017480890
## 国内镜像
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等支持加密分享以及部分网盘文件夹分享。
本项目同步托管于 **AtomGit**,国内访问更流畅:👉 [https://atomgit.com/QAIU/netdisk-fast-download](https://atomgit.com/QAIU/netdisk-fast-download)
## 介绍
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链可广泛应用于各类下载站资源站个人博客图床APP下载更新视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等支持加密分享以及部分网盘文件夹分享。
[官方文档](https://nfd-parser.github.io/)
[API接入](https://nfdparser.apifox.cn/)
[公益解析lz站](https://lz.qaiu.top)
[公益解析lz0站](https://lz0.qaiu.top)
[专业版](https://189.qaiu.top)
## 快速开始
命令行下载分享文件:
@@ -43,18 +55,12 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
**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)
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
**小飞机解析有IP限制多数云服务商的大陆IP会被拦截可以自行配置代理和本程序无关**
**注意: 请不要过度依赖lz.qaiu.top预览地址服务建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制不推荐做公共解析。**
**注意⚠小飞机解析有IP限制多数云服务商的大陆IP会被拦截可以自行配置代理和本程序无关**
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
**注意⚠️请不要过度依赖 lz.qaiu.top建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制遇到解析失败的分享链接不要着急提issues请先检查分享是否有效。**
## 网盘支持情况:
> 20230905 奶牛云直链做了防盗链需加入请求头Referer: https://cowtransfer.com/
> 20230824 123云盘解析大文件(>100MB)失效,需要登录
> 20230722 UC网盘解析失效需要登录
@@ -62,7 +68,6 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
- [蓝奏云-lz](https://pc.woozooo.com/)
- [蓝奏云优享-iz](https://www.ilanzou.com/)
- [奶牛快传-cow](https://cowtransfer.com/)
- [移动云云空间-ec](https://www.ecpan.cn/web)
- [小飞机网盘-fj](https://www.feijipan.com/)
- [亿方云-fc](https://www.fangcloud.com/)
@@ -80,15 +85,16 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
- [飞书云盘-fs](https://www.feishu.cn/)
- [WPS云文档-pwps](https://www.kdocs.cn/)
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
- [咪咕音乐-migu](https://music.migu.cn/)
- [一刻相册-baidu_photo](https://photo.baidu.com/)
- Google云盘-pgd
- Onedrive-pod
- Dropbox-pdp
- iCloud-pic
### 专版提供
### 专版提供
- 迅雷网盘-xl
- [夸克云盘-qk](https://pan.quark.cn/)
- [UC云盘-uc](https://fast.uc.cn/)
- [移动云盘-p139](https://yun.139.com/)
@@ -325,15 +331,15 @@ json返回数据格式示例:
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|-------------|---------|----------|-----------|-----------------|
| 蓝奏云 | √ | √ | 不限空间 | 100M |
| 奶牛快传 | √ | X | 10G | 不限大小 |
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
| 小飞机网盘 | √ | √(密码可忽略) | 10G | 不限大小 |
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G>100M需要登录 |
| 文叔叔 | √ | √ | 10G | 5GB |
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 |
| 飞书云盘 | √ | X | 15G | 不限大小 |
# 打包部署
@@ -413,7 +419,7 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
```shell
cd ~
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.1.9b7/netdisk-fast-download-bin.zip
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip
unzip netdisk-fast-download-bin.zip
cd netdisk-fast-download
bash service-install.sh
@@ -486,23 +492,6 @@ auths:
**注意:** 目前仅支持 123ye的认证配置。
## 开发计划
### 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
@@ -527,20 +516,5 @@ Core模块集成Vert.x实现类似spring的注解式路由API
</p>
### 关于赞助定制专属版
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
2. 可提供托管服务:包含部署服务和云服务器环境。
3. 可提供功能定制开发。
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
<p>qq: 197575894</p>
<p>wechat: imcoding_</p>
<!--
![image](https://github.com/qaiu/netdisk-fast-download/assets/29825328/54276aee-cc3f-4ebd-8973-2e15f6295819)
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
-->

View File

@@ -1,6 +1,5 @@
#!/bin/bash
# set -x
LAUNCH_JAR="netdisk-fast-download.jar"
nohup java -Xmx512M -jar "$LAUNCH_JAR" "$@" >startup.log 2>&1 &
tail -f startup.log
exec java -Xmx${JVM_XMX:-512M} ${JVM_OPTS} -jar "$LAUNCH_JAR" "$@"

View File

@@ -65,7 +65,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.3</version>
<version>42.7.11</version>
</dependency>
</dependencies>

View File

@@ -53,7 +53,7 @@ public class CreateDatabase {
stmt.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
LOGGER.info(">>>>>>>>>>> 数据库'{}'创建成功 <<<<<<<<<<<<", dbName);
} catch (SQLException e) {
e.printStackTrace();
LOGGER.error("创建数据库失败", e);
}
}

View File

@@ -24,35 +24,39 @@ import java.util.*;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class CreateTable {
public static Map<Class<?>, String> javaProperty2SqlColumnMap = new HashMap<>() {{
public static final Map<Class<?>, String> javaProperty2SqlColumnMap;
static {
Map<Class<?>, String> map = new HashMap<>();
// Java类型到SQL类型的映射
put(Integer.class, "INT");
put(Short.class, "SMALLINT");
put(Byte.class, "TINYINT");
put(Long.class, "BIGINT");
put(java.math.BigDecimal.class, "DECIMAL");
put(Double.class, "DOUBLE");
put(Float.class, "REAL");
put(Boolean.class, "BOOLEAN");
put(String.class, "VARCHAR");
put(Date.class, "TIMESTAMP");
put(java.time.LocalDateTime.class, "TIMESTAMP");
put(java.sql.Timestamp.class, "TIMESTAMP");
put(java.sql.Date.class, "DATE");
put(java.sql.Time.class, "TIME");
map.put(Integer.class, "INT");
map.put(Short.class, "SMALLINT");
map.put(Byte.class, "TINYINT");
map.put(Long.class, "BIGINT");
map.put(java.math.BigDecimal.class, "DECIMAL");
map.put(Double.class, "DOUBLE");
map.put(Float.class, "REAL");
map.put(Boolean.class, "BOOLEAN");
map.put(String.class, "VARCHAR");
map.put(Date.class, "TIMESTAMP");
map.put(java.time.LocalDateTime.class, "TIMESTAMP");
map.put(java.sql.Timestamp.class, "TIMESTAMP");
map.put(java.sql.Date.class, "DATE");
map.put(java.sql.Time.class, "TIME");
// 基本数据类型
put(int.class, "INT");
put(short.class, "SMALLINT");
put(byte.class, "TINYINT");
put(long.class, "BIGINT");
put(double.class, "DOUBLE");
put(float.class, "REAL");
put(boolean.class, "BOOLEAN");
}};
map.put(int.class, "INT");
map.put(short.class, "SMALLINT");
map.put(byte.class, "TINYINT");
map.put(long.class, "BIGINT");
map.put(double.class, "DOUBLE");
map.put(float.class, "REAL");
map.put(boolean.class, "BOOLEAN");
javaProperty2SqlColumnMap = Collections.unmodifiableMap(map);
}
private static final Logger LOGGER = LoggerFactory.getLogger(CreateTable.class);
public static String UNIQUE_PREFIX = "idx_";
public static final String UNIQUE_PREFIX = "idx_";
private static Case getCase(Class<?> clz) {
return switch (clz.getName()) {

View File

@@ -17,7 +17,7 @@ import org.slf4j.LoggerFactory;
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JDBCPoolInit {
public class JDBCPoolInit implements AutoCloseable {
private static final Logger LOGGER = LoggerFactory.getLogger(JDBCPoolInit.class);
@@ -101,4 +101,16 @@ public class JDBCPoolInit {
synchronized public JDBCPool getPool() {
return pool;
}
/**
* 关闭连接池,释放数据库资源
*/
@Override
public synchronized void close() {
if (pool != null) {
pool.close();
LOGGER.info("数据库连接池已关闭: URL={}", url);
pool = null;
}
}
}

View File

@@ -73,6 +73,12 @@
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

View File

@@ -3,20 +3,24 @@ package cn.qaiu.vx.core;
import cn.qaiu.vx.core.util.CommonUtil;
import cn.qaiu.vx.core.util.ConfigUtil;
import cn.qaiu.vx.core.util.VertxHolder;
import cn.qaiu.vx.core.verticle.HttpProxyVerticle;
import cn.qaiu.vx.core.verticle.PostExecVerticle;
import cn.qaiu.vx.core.verticle.ReverseProxyVerticle;
import cn.qaiu.vx.core.verticle.RouterVerticle;
import cn.qaiu.vx.core.verticle.ServiceVerticle;
import io.vertx.core.*;
import io.vertx.core.dns.AddressResolverOptions;
import io.vertx.core.impl.launcher.commands.VersionCommand;
import io.vertx.core.json.JsonObject;
import io.vertx.core.shareddata.LocalMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
import java.util.concurrent.locks.LockSupport;
import static cn.qaiu.vx.core.util.ConfigConstant.*;
@@ -54,15 +58,25 @@ public final class Deploy {
public void start(String[] args, Handler<JsonObject> handle) {
this.mainThread = Thread.currentThread();
this.handle = handle;
if (args.length > 0 && args[0].startsWith("app-")) {
// 启动参数dev或者prod
path.append("-").append(args[0].replace("app-",""));
}
// 读取yml配置
// 读取yml配置,优先当前目录,其次 resources/ 子目录
String configFile = path + ".yml";
if (!Files.exists(Path.of(configFile)) && Files.exists(Path.of("resources", configFile))) {
path.insert(0, "resources/");
LOGGER.info("从 resources/ 目录加载配置: {}", path + ".yml");
}
ConfigUtil.readYamlConfig(path.toString(), tempVertx)
.onSuccess(this::readConf)
.onFailure(Throwable::printStackTrace);
.onFailure(err -> {
LOGGER.error("读取配置文件失败: {}", err.getMessage(), err);
LockSupport.unpark(mainThread);
System.exit(-1);
});
LockSupport.park();
deployVerticle();
}
@@ -104,7 +118,7 @@ public final class Deploy {
System.out.printf(logoTemplate,
CommonUtil.getAppVersion(),
VersionCommand.getVersion(),
"4x",
conf.getString("copyright"),
year
);
@@ -123,17 +137,28 @@ public final class Deploy {
var vertxOptions = vertxConfigELPS == 0 ?
new VertxOptions() : new VertxOptions(vertxConfig);
vertxOptions.setAddressResolverOptions(
new AddressResolverOptions().
addServer("114.114.114.114").
addServer("114.114.115.115").
addServer("8.8.8.8").
addServer("8.8.4.4"));
// vertxOptions.setAddressResolverOptions(
// new AddressResolverOptions().
// addServer("114.114.114.114").
// addServer("114.114.115.115").
// addServer("8.8.8.8").
// addServer("8.8.4.4"));
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
vertxOptions.getEventLoopPoolSize(),
vertxOptions.getWorkerPoolSize());
var vertx = Vertx.vertx(vertxOptions);
VertxHolder.init(vertx);
// 注册 ShutdownHook确保进程退出时优雅关闭资源
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOGGER.info("JVM shutting down, closing Vert.x...");
try {
vertx.close().toCompletionStage().toCompletableFuture().get(10, java.util.concurrent.TimeUnit.SECONDS);
LOGGER.info("Vert.x closed successfully");
} catch (Exception e) {
LOGGER.warn("Vert.x close error or timeout", e);
}
}));
//配置保存在共享数据中
var sharedData = vertx.sharedData();
LocalMap<String, Object> localMap = sharedData.getLocalMap(LOCAL);
@@ -153,12 +178,39 @@ public final class Deploy {
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
Future.all(future1, future2, future3)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
if (jsonObject != null) {
genPwd(jsonObject);
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy"));
future4.onSuccess(LOGGER::info);
future4.onFailure(e -> LOGGER.error("Other handle error", e));
Future.all(future1, future2, future3, future4)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
} else {
Future.all(future1, future2, future3)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
}
}).onFailure(e -> LOGGER.error("Other handle error", e));
}
private static void genPwd(JsonObject jsonObject) {
if (jsonObject.getBoolean("randUserPwd")) {
var username = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
var password = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
jsonObject.put("username", username);
jsonObject.put("password", password);
}
LOGGER.info("=============server info=================");
LOGGER.info("\nport: {}\nusername: {}\npassword: {}",
jsonObject.getString("port"),
jsonObject.getString("username"),
jsonObject.getString("password"));
LOGGER.info("==============server info================");
}
/**
* 部署失败
*
@@ -178,6 +230,42 @@ public final class Deploy {
var t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
// 检查是否处于安装引导模式(数据库未配置)
Object installMode = VertxHolder.getVertxInstance().sharedData()
.getLocalMap(LOCAL).get("installMode");
if (Boolean.TRUE.equals(installMode)) {
LOGGER.info("系统处于安装引导模式,等待用户完成数据库配置后再启动后置初始化...");
return;
}
// 正常模式:部署 PostExecVerticle 执行 AppRun 实现
deployPostExec();
}
/**
* 部署 PostExecVerticle执行所有 AppRun 实现)
* 安装引导完成后也可手动调用此方法触发后置初始化
*/
public void deployPostExec() {
var vertx = VertxHolder.getVertxInstance();
var postExecFuture = vertx.deployVerticle(PostExecVerticle.class, getWorkDeploymentOptions("postExec", 2));
postExecFuture.onSuccess(id -> {
LOGGER.info("PostExecVerticle 部署成功AppRun 实现执行完成");
}).onFailure(e -> {
LOGGER.error("PostExecVerticle 部署失败", e);
});
}
/**
* 重新部署 ServiceVerticle重新注册因 DB 未就绪而失败的服务到 EventBus
* 安装引导完成、DB 初始化后调用
*/
public void redeployServices() {
var vertx = VertxHolder.getVertxInstance();
vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"))
.onSuccess(id -> LOGGER.info("ServiceVerticle 重新部署成功DB 相关服务已注册"))
.onFailure(e -> LOGGER.error("ServiceVerticle 重新部署失败", e));
}
/**

View File

@@ -9,6 +9,7 @@ import java.lang.annotation.*;
public @interface HandleSortFilter {
/**
* 注册顺序,数字越大越先注册<br>
* 前置拦截器会先执行后注册即数字小的, 后置拦截器会先执行先注册的即数字大的<br>
* 值<0时会过滤掉该处理器
*/
int value() default 0;

View File

@@ -0,0 +1,12 @@
package cn.qaiu.vx.core.base;
import io.vertx.core.json.JsonObject;
public interface AppRun {
/**
* 执行方法
* @param config 启动配置文件
*/
void execute(JsonObject config);
}

View File

@@ -38,6 +38,20 @@ public interface BaseHttpApi {
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
if (!ctx.response().ended()) {
fireJsonObjectResponse(ctx, jsonObject, statusCode);
}
handleAfterInterceptor(ctx, jsonObject);
}
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
if (!ctx.response().ended()) {
fireJsonResultResponse(ctx, jsonResult, statusCode);
}
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default Set<AfterInterceptor> getAfterInterceptor() {

View File

@@ -0,0 +1,23 @@
package cn.qaiu.vx.core.base;
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
import io.vertx.core.json.JsonObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 默认的AppRun实现示例
* <br>Create date 2024-01-01 00:00:00
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@HandleSortFilter
public class DefaultAppRun implements AppRun {
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAppRun.class);
@Override
public void execute(JsonObject config) {
LOGGER.info("======> AppRun实现类开始执行配置数: {}", config.size());
}
}

View File

@@ -23,8 +23,6 @@ import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.*;
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
import io.vertx.ext.web.handler.sockjs.SockJSHandlerOptions;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore;
import javassist.CtClass;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
@@ -76,15 +74,15 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 主路由
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
mainRouter.route().handler(ctx -> {
String realPath = ctx.request().uri();;
String realPath = ctx.request().uri();
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.substring(REROUTE_PATH_PREFIX.length());
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
ctx.reroute(rePath);
return;
}
LOGGER.debug("The HTTP service request address information ===>path:{}, uri:{}, method:{}",
LOGGER.debug("New request:{}, {}, {}",
ctx.request().path(), ctx.request().absoluteURI(), ctx.request().method());
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.response().headers().add(DATE, LocalDateTime.now().format(ISO_LOCAL_DATE_TIME));
@@ -100,16 +98,6 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 配置文件上传路径
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
// 配置Session管理 - 用于演练场登录状态持久化
// 30天过期时间毫秒
SessionStore sessionStore = LocalSessionStore.create(VertxHolder.getVertxInstance());
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionTimeout(30L * 24 * 60 * 60 * 1000) // 30天
.setSessionCookieName("SESSIONID") // Cookie名称
.setCookieHttpOnlyFlag(true) // 防止XSS攻击
.setCookieSecureFlag(false); // 非HTTPS环境设置为false
mainRouter.route().handler(sessionHandler);
// 拦截器
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
Route route0 = mainRouter.route("/*");
@@ -139,8 +127,9 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 错误请求处理
mainRouter.errorHandler(405, ctx -> doFireJsonResultResponse(ctx, JsonResult
.error("Method Not Allowed", 405)));
mainRouter.errorHandler(404, ctx -> ctx.response().setStatusCode(404).setChunked(true)
.end("Internal server error: 404 not found"));
mainRouter.errorHandler(404, ctx -> {
ctx.response().setStatusCode(404).end("404 not found");
});
return mainRouter;
}
@@ -189,10 +178,11 @@ public class RouterHandlerFactory implements BaseHttpApi {
if (ctx.response().ended()) return;
// 超时处理器状态码503
if (ctx.statusCode() == 503 || ctx.failure() == null) {
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员", 500));
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员"), 503);
} else {
ctx.failure().printStackTrace();
doFireJsonResultResponse(ctx, JsonResult.error(ctx.failure().getMessage(), 500));
LOGGER.error("路由处理失败", ctx.failure());
String msg = ctx.failure() != null ? ctx.failure().getMessage() : "未知异常";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
}
});
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
@@ -210,7 +200,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
try {
ReflectionUtil.invokeWithArguments(method, instance, sock);
} catch (Throwable e) {
e.printStackTrace();
LOGGER.error("WebSocket处理异常", e);
}
});
if (url.endsWith("*")) {
@@ -246,7 +236,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
*/
private Set<Handler<RoutingContext>> getInterceptorSet() {
// 配置拦截
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toSet());
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
@@ -315,38 +305,42 @@ public class RouterHandlerFactory implements BaseHttpApi {
final MultiMap queryParams = ctx.queryParams();
// 解析body-json参数
// 只处理POST/PUT/PATCH等有body的请求方法避免GET请求读取body导致"Request has already been read"错误
String httpMethod = ctx.request().method().name();
if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& ctx.parsedHeaders() != null && ctx.parsedHeaders().contentType() != null
&& HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())
&& ctx.body() != null && ctx.body().asJsonObject() != null) {
if (HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())) {
JsonObject body = ctx.body().asJsonObject();
if (body != null) {
methodParametersTemp.forEach((k, v) -> {
String typeName = v.getRight().getName();
// 直接绑定 JsonObject 类型参数
if (JsonObject.class.getName().equals(typeName)) {
parameterValueList.put(k, body);
}
// 只解析已配置包名前缀的实体类
if (CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
else if (CommonUtil.matchRegList(entityPackagesReg.getList(), typeName)) {
try {
Class<?> aClass = Class.forName(v.getRight().getName());
Class<?> aClass = Class.forName(typeName);
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
if (!data.isEmpty()) {
Object entity = data.mapTo(aClass);
parameterValueList.put(k, entity);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
LOGGER.error("实体类绑定异常: {}", typeName, e);
}
}
});
} else {
// body 可能是 JsonArray
JsonArray bodyArray = ctx.body().asJsonArray();
if (bodyArray != null) {
methodParametersTemp.forEach((k, v) -> {
if (JsonArray.class.getName().equals(v.getRight().getName())) {
parameterValueList.put(k, bodyArray);
}
});
}
}
} else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& ctx.body() != null && ctx.body().length() > 0) {
try {
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
} catch (Exception e) {
LOGGER.debug("Failed to parse body as params: {}", e.getMessage());
}
} else if (ctx.body() != null) {
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
}
// 解析其他参数
@@ -365,12 +359,6 @@ public class RouterHandlerFactory implements BaseHttpApi {
parameterValueList.put(k, ctx.request());
} else if (HttpServerResponse.class.getName().equals(v.getRight().getName())) {
parameterValueList.put(k, ctx.response());
} else if (JsonObject.class.getName().equals(v.getRight().getName())) {
if (ctx.body() != null && ctx.body().asJsonObject() != null) {
parameterValueList.put(k, ctx.body().asJsonObject());
} else {
parameterValueList.put(k, new JsonObject());
}
} else if (parameterValueList.get(k) == null
&& CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
// 绑定实体类
@@ -379,62 +367,62 @@ public class RouterHandlerFactory implements BaseHttpApi {
Object entity = ParamUtil.multiMapToEntity(queryParams, aClass);
parameterValueList.put(k, entity);
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("参数绑定异常: {}", v.getRight().getName(), e);
}
} else if (parameterValueList.get(k) == null
&& JsonObject.class.getName().equals(v.getRight().getName())) {
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonObject
if (ctx.body() != null) {
JsonObject jo = ctx.body().asJsonObject();
if (jo != null) parameterValueList.put(k, jo);
}
} else if (parameterValueList.get(k) == null
&& JsonArray.class.getName().equals(v.getRight().getName())) {
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonArray
if (ctx.body() != null) {
JsonArray ja = ctx.body().asJsonArray();
if (ja != null) parameterValueList.put(k, ja);
}
}
});
// 调用handle 获取响应对象
Object[] parameterValueArray = parameterValueList.values().toArray(new Object[0]);
// 打印调试信息,确认参数注入的情况
if (LOGGER.isDebugEnabled() && method.getName().equals("donateAccount")) {
LOGGER.debug("donateAccount parameter list:");
int i = 0;
for (Map.Entry<String, Object> entry : parameterValueList.entrySet()) {
LOGGER.debug("Param [{}]: {} = {}", i++, entry.getKey(),
entry.getValue() != null ? entry.getValue().toString() : "null");
}
}
try {
// 反射调用
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
if (data != null) {
if (data instanceof JsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) data);
if (data instanceof JsonResult jsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
}
if (data instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) data));
} else if (data instanceof Future) { // 处理异步响应
((Future<?>) data).onSuccess(res -> {
if (res instanceof JsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) res);
if (res instanceof JsonResult jsonResult) {
doFireJsonResultResponse(ctx, jsonResult, jsonResult.getCode());
}
if (res instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) res));
} else if (res != null) {
doFireJsonResultResponse(ctx, JsonResult.data(res));
} else {
handleAfterInterceptor(ctx, null);
doFireJsonResultResponse(ctx, JsonResult.data(null));
}
}).onFailure(e -> doFireJsonResultResponse(ctx, JsonResult.error(e.getMessage())));
}).onFailure(e -> {
LOGGER.error("请求处理失败", e);
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
});
} else {
doFireJsonResultResponse(ctx, JsonResult.data(data));
}
}
} catch (Throwable e) {
e.printStackTrace();
String err = e.getMessage();
if (e.getCause() != null) {
if (e.getCause() instanceof InvocationTargetException) {
err = ((InvocationTargetException) e.getCause()).getTargetException().getMessage();
} else {
err = e.getCause().getMessage();
}
}
doFireJsonResultResponse(ctx, JsonResult.error(err));
LOGGER.error("请求处理异常", e);
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
}
}

View File

@@ -3,10 +3,12 @@ package cn.qaiu.vx.core.interceptor;
import io.vertx.core.Handler;
import io.vertx.ext.web.RoutingContext;
import static cn.qaiu.vx.core.util.ResponseUtil.sendError;
/**
* 前置拦截器接口
* <p>
* 注意Vert.x是异步非阻塞框架不能在Event Loop中使用synchronized等阻塞操作
* 所有操作都应该是非阻塞的使用Vert.x的上下文数据存储机制保证线程安全。
* </p>
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@@ -14,28 +16,25 @@ public interface BeforeInterceptor extends Handler<RoutingContext> {
String IS_NEXT = "RoutingContextIsNext";
default Handler<RoutingContext> doHandle() {
return ctx -> {
// 加同步锁
synchronized (BeforeInterceptor.class) {
ctx.put(IS_NEXT, false);
BeforeInterceptor.this.handle(ctx);
if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
sendError(ctx, 403);
}
}
// 【优化】移除synchronized锁Vert.x的RoutingContext本身就是线程安全的
// 每个请求都有独立的RoutingContext不需要额外加锁
ctx.put(IS_NEXT, false);
handle(ctx); // 调用具体的处理逻辑
// 确保如果没有调用doNext()并且响应未结束,则返回错误
// if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
// sendError(ctx, 403);
// }
};
}
default void doNext(RoutingContext context) {
// 设置上下文状态为可以继续执行
// 添加同步锁保障多线程下执行时序
synchronized (BeforeInterceptor.class) {
context.put(IS_NEXT, true);
context.next();
}
// 【优化】移除synchronized锁
// RoutingContext的put和next操作是线程安全的不需要额外同步
context.put(IS_NEXT, true);
context.next(); // 继续执行下一个处理器
}
void handle(RoutingContext context);
void handle(RoutingContext context); // 实现具体的拦截处理逻辑
}

View File

@@ -30,7 +30,7 @@ public class JsonResult<T> implements Serializable {
private int code = SUCCESS_CODE;//状态码
private String msg = SUCCESS_MESSAGE; //消息
private String msg = SUCCESS_MESSAGE;//消息
private boolean success = true; //是否成功

View File

@@ -1,7 +1,7 @@
/**
* ModuleGen cn.qaiu.vx.core
*/
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core", useFutures = true)
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core")
package cn.qaiu.vx.core;
import io.vertx.codegen.annotations.ModuleGen;

View File

@@ -5,7 +5,7 @@ import io.vertx.serviceproxy.ServiceProxyBuilder;
/**
* @author Xu Haidong
* Create at 2018/8/15
* @date 2018/8/15
*/
public final class AsyncServiceUtil {

View File

@@ -13,6 +13,7 @@ import java.net.Socket;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
@@ -117,7 +118,7 @@ public class CommonUtil {
return set.stream().filter(c1 -> {
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
if (s1 != null) {
return s1.value() > 0;
return s1.value() >= 0;
} else {
return true;
}
@@ -138,7 +139,7 @@ public class CommonUtil {
} catch (Exception e) {
throw new RuntimeException(e);
}
}).collect(Collectors.toSet());
}).collect(Collectors.toCollection(LinkedHashSet::new));
}
private static String appVersion;
@@ -152,7 +153,7 @@ public class CommonUtil {
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
}
} catch (IOException e) {
e.printStackTrace();
LOGGER.error("读取app.properties失败", e);
}
}
return appVersion;

View File

@@ -4,9 +4,15 @@ import io.vertx.config.ConfigRetriever;
import io.vertx.config.ConfigRetrieverOptions;
import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 异步读取配置工具类
* <br>Create date 2021/9/2 1:23
@@ -24,7 +30,29 @@ public class ConfigUtil {
* @return JsonObject的Future
*/
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
// 读取yml配置
// 支持 classpath: 前缀从类路径读取,否则从文件系统读取
if (path != null && path.startsWith("classpath:")) {
String resource = path.substring("classpath:".length());
// 使用 executeBlocking(Callable) 直接返回 Future<JsonObject>
return vertx.executeBlocking(() -> {
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
if (is == null) {
throw new RuntimeException("classpath resource not found: " + resource);
}
try (InputStream in = is) {
byte[] bytes = in.readAllBytes();
String content = new String(bytes, StandardCharsets.UTF_8);
if ("json".equalsIgnoreCase(format)) {
return new JsonObject(content);
} else {
throw new RuntimeException("unsupported classpath format: " + format);
}
}
});
}
Promise<JsonObject> promise = Promise.promise();
ConfigStoreOptions store = new ConfigStoreOptions()
.setType("file")
.setFormat(format)
@@ -33,10 +61,45 @@ public class ConfigUtil {
ConfigRetriever retriever = ConfigRetriever
.create(vertx, new ConfigRetrieverOptions().addStore(store));
return retriever.getConfig();
// 异步获取配置
// 成功直接完成 promise
retriever.getConfig()
.onSuccess(config -> {
promise.complete(config);
retriever.close();
})
.onFailure(err -> {
retriever.close();
// 读取失败时,尝试从 resources/ 子目录读取(兼容 Docker 卷挂载场景)
String resourcesPath = "resources/" + path;
if (!path.startsWith("resources/") && Files.exists(Path.of(resourcesPath))) {
ConfigStoreOptions fallbackStore = new ConfigStoreOptions()
.setType("file")
.setFormat(format)
.setConfig(new JsonObject().put("path", resourcesPath));
ConfigRetriever fallbackRetriever = ConfigRetriever
.create(vertx, new ConfigRetrieverOptions().addStore(fallbackStore));
fallbackRetriever.getConfig()
.onSuccess(config -> {
promise.complete(config);
fallbackRetriever.close();
})
.onFailure(e2 -> {
promise.fail(new RuntimeException(
"读取配置文件失败: " + path + " (也尝试了 " + resourcesPath + ")", e2));
fallbackRetriever.close();
});
} else {
promise.fail(new RuntimeException(
"读取配置文件失败: " + path, err));
}
});
return promise.future();
}
/**
* 异步读取Yaml配置文件
*

View File

@@ -0,0 +1,20 @@
package cn.qaiu.vx.core.util;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import java.util.concurrent.ExecutionException;
public class FutureUtils {
public static <T> T getResult(Future<T> future) {
try {
return future.toCompletionStage().toCompletableFuture().get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
public static <T> T getResult(Promise<T> promise) {
return promise.future().toCompletionStage().toCompletableFuture().join();
}
}

View File

@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter;
/**
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2023/10/14 9:07
* @date 2023/10/14 9:07
*/
public class JacksonConfig {

View File

@@ -1,7 +1,7 @@
package cn.qaiu.vx.core.util;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* vertx 上下文外的本地容器 为不在vertx线程的方法传递数据
@@ -10,11 +10,10 @@ import java.util.Map;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class LocalConstant {
private static final Map<String, Object> LOCAL_CONST = new HashMap<>();
private static final Map<String, Object> LOCAL_CONST = new ConcurrentHashMap<>();
public static Map<String, Object> put(String k, Object v) {
if (LOCAL_CONST.containsKey(k)) return LOCAL_CONST;
LOCAL_CONST.put(k, v);
LOCAL_CONST.putIfAbsent(k, v);
return LOCAL_CONST;
}

View File

@@ -36,16 +36,20 @@ public final class ParamUtil {
public static MultiMap paramsToMap(String paramString) {
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
if (paramString == null) return entries;
if (paramString == null || paramString.isEmpty()) return entries;
String[] params = paramString.split("&");
if (params.length == 0) return entries;
for (String param : params) {
String[] kv = param.split("=");
if (param == null || param.isEmpty()) {
continue;
}
String[] kv = param.split("=", 2);
if (kv.length == 2) {
entries.set(kv[0], kv[1]);
} else {
} else if (kv.length == 1) {
entries.set(kv[0], "");
}
// kv.length == 0 时(空字符串),跳过
}
return entries;
}

View File

@@ -25,6 +25,9 @@ import java.net.URL;
import java.text.ParseException;
import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
/**
@@ -36,6 +39,10 @@ import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
*/
public final class ReflectionUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(ReflectionUtil.class);
// 缓存Reflections实例避免重复扫描每次扫描约35K+值耗时1-3秒占用大量内存
private static final Map<String, Reflections> REFLECTIONS_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
/**
* 以默认配置的基础包路径获取反射器
@@ -47,52 +54,48 @@ public final class ReflectionUtil {
}
/**
* 获取反射器
* 获取反射器(带缓存)
*
* @param packageAddress Package address String
* @return Reflections object
*/
public static Reflections getReflections(String packageAddress) {
List<String> packageAddressList;
if (packageAddress.contains(",")) {
packageAddressList = Arrays.asList(packageAddress.split(","));
} else if (packageAddress.contains(";")) {
packageAddressList = Arrays.asList(packageAddress.split(";"));
} else {
packageAddressList = Collections.singletonList(packageAddress);
}
return getReflections(packageAddressList);
return REFLECTIONS_CACHE.computeIfAbsent(packageAddress, key -> {
List<String> packageAddressList;
if (key.contains(",")) {
packageAddressList = Arrays.asList(key.split(","));
} else if (key.contains(";")) {
packageAddressList = Arrays.asList(key.split(";"));
} else {
packageAddressList = Collections.singletonList(key);
}
return createReflections(packageAddressList);
});
}
/**
* 获取反射器
* 获取反射器(带缓存)
*
* @param packageAddresses Package address List
* @return Reflections object
*/
public static Reflections getReflections(List<String> packageAddresses) {
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
FilterBuilder filterBuilder = new FilterBuilder();
packageAddresses.forEach(str -> {
Collection<URL> urls = ClasspathHelper.forPackage(str.trim());
configurationBuilder.addUrls(urls);
filterBuilder.includePackage(str.trim());
});
String cacheKey = String.join(",", packageAddresses);
return REFLECTIONS_CACHE.computeIfAbsent(cacheKey, key -> createReflections(packageAddresses));
}
// 采坑记录 2021-05-08
// 发现注解api层 没有继承父类时 这里反射一直有问题(Scanner SubTypesScanner was not configured)
// 因此这里需要手动配置各种Scanner扫描器 -- https://blog.csdn.net/qq_29499107/article/details/106889781
configurationBuilder.setScanners(
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
// 会报错.默认为true.
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
);
configurationBuilder.filterInputsBy(filterBuilder);
private static Reflections createReflections(List<String> packageAddresses) {
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
.addClassLoaders(Thread.currentThread().getContextClassLoader())
.forPackages(packageAddresses.toArray(new String[0]))
.setScanners(
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
// 会报错.默认为true.
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
);
return new Reflections(configurationBuilder);
}
@@ -130,7 +133,7 @@ public final class ReflectionUtil {
parameterTypes[j - k]));
}
} catch (NotFoundException e) {
e.printStackTrace();
LOGGER.error("获取方法参数失败", e);
}
return paramMap;
}
@@ -185,7 +188,7 @@ public final class ReflectionUtil {
try {
return DateUtils.parseDate(value, fmt);
} catch (ParseException e) {
e.printStackTrace();
LOGGER.error("日期解析失败: {}", value, e);
throw new RuntimeException("无法将格式化日期");
}
default:
@@ -217,7 +220,7 @@ public final class ReflectionUtil {
}
return arr;
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("数组类型转换失败: {}", value, e);
}
return null;
}
@@ -243,7 +246,7 @@ public final class ReflectionUtil {
public static boolean isBasicTypeArray(CtClass ctClass) {
if (!ctClass.isArray()) {
return false;
} else return (ctClass.getName().matches("^(boolen|char|byte|short|int|long|float|double|String)\\[]$"));
} else return (ctClass.getName().matches("^(boolean|char|byte|short|int|long|float|double|String)\\[]$"));
}
/**

View File

@@ -13,6 +13,7 @@ public class ResponseUtil {
public static void redirect(HttpServerResponse response, String url) {
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
.putHeader("Referrer-Policy", "no-referrer")
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
}
@@ -22,14 +23,22 @@ public class ResponseUtil {
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(200)
.end(jsonObject.encode());
fireJsonObjectResponse(ctx, jsonObject, 200);
}
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
fireJsonObjectResponse(ctx, jsonObject, 200);
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(statusCode)
.end(jsonObject.encode());
}
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject, int statusCode) {
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(200)
.setStatusCode(statusCode)
.end(jsonObject.encode());
}
@@ -37,6 +46,10 @@ public class ResponseUtil {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
}
public static <T> void fireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject(), statusCode);
}
public static <T> void fireJsonResultResponse(HttpServerResponse ctx, JsonResult<T> jsonResult) {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
}

View File

@@ -1,50 +1,77 @@
package cn.qaiu.vx.core.verticle;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Vertx;
import io.vertx.core.VertxOptions;
import io.vertx.core.dns.AddressResolverOptions;
import io.vertx.core.http.*;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.NetClient;
import io.vertx.core.net.NetClientOptions;
import io.vertx.core.net.NetSocket;
import io.vertx.core.net.ProxyOptions;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.util.Base64;
import static cn.qaiu.vx.core.util.ConfigConstant.GLOBAL_CONFIG;
import static cn.qaiu.vx.core.util.ConfigConstant.LOCAL;
/**
*
*/
public class HttpProxyVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpProxyVerticle.class);
private HttpClient httpClient;
private NetClient netClient;
private JsonObject proxyPreConf;
private JsonObject proxyServerConf;
@Override
public void start() {
ProxyOptions proxyOptions = new ProxyOptions().setHost("127.0.0.1").setPort(7890);
proxyServerConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
proxyPreConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-pre");
Integer serverPort = proxyServerConf.getInteger("port");
ProxyOptions proxyOptions = null;
if (proxyPreConf != null && StringUtils.isNotBlank(proxyPreConf.getString("ip"))) {
proxyOptions = new ProxyOptions(proxyPreConf);
}
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
HttpClientOptions httpClientOptions = new HttpClientOptions();
httpClient = vertx.createHttpClient(httpClientOptions.setProxyOptions(proxyOptions));
if (proxyOptions != null) {
httpClientOptions.setProxyOptions(proxyOptions);
}
httpClient = vertx.createHttpClient(httpClientOptions);
// 创建并启动 HTTP 代理服务器,监听指定端口
HttpServer server = vertx.createHttpServer(new HttpServerOptions().setClientAuth(ClientAuth.REQUIRED));
HttpServerOptions httpServerOptions = new HttpServerOptions();
if (proxyServerConf.containsKey("username") &&
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
httpServerOptions.setClientAuth(ClientAuth.REQUIRED);
}
HttpServer server = vertx.createHttpServer();
server.requestHandler(this::handleClientRequest);
// 初始化 NetClient用于在 CONNECT 请求中建立 TCP 连接隧道
netClient = vertx.createNetClient(new NetClientOptions()
.setProxyOptions(proxyOptions)
NetClientOptions netClientOptions = new NetClientOptions();
if (proxyOptions != null) {
httpClientOptions.setProxyOptions(proxyOptions);
}
netClient = vertx.createNetClient(netClientOptions
.setConnectTimeout(15000)
.setTrustAll(true));
// 启动 HTTP 代理服务器
server.listen(7891, ar -> {
if (ar.succeeded()) {
System.out.println("HTTP Proxy server started on port 7891");
} else {
System.err.println("Failed to start HTTP Proxy server: " + ar.cause());
}
});
server.listen(serverPort)
.onSuccess(res-> LOGGER.info("HTTP Proxy server started on port {}", serverPort))
.onFailure(err-> LOGGER.error("Failed to start HTTP Proxy server: " + err.getMessage()));
}
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
@@ -66,49 +93,63 @@ public class HttpProxyVerticle extends AbstractVerticle {
}
clientRequest.pause();
// 通过 NetClient 连接目标服务器并创建隧道
netClient.connect(targetPort, targetHost, connectionAttempt -> {
if (connectionAttempt.succeeded()) {
NetSocket targetSocket = connectionAttempt.result();
netClient.connect(targetPort, targetHost)
.onSuccess(targetSocket -> {
// Upgrade client connection to NetSocket and implement bidirectional data flow
clientRequest.toNetSocket()
.onSuccess(clientSocket -> {
// Set up bidirectional data forwarding
clientSocket.handler(targetSocket::write);
targetSocket.handler(clientSocket::write);
// 升级客户端连接到 NetSocket 并实现双向数据流
clientRequest.toNetSocket().onComplete(clientSocketAttempt -> {
if (clientSocketAttempt.succeeded()) {
NetSocket clientSocket = clientSocketAttempt.result();
// 设置双向数据流转发
clientSocket.handler(targetSocket::write);
targetSocket.handler(clientSocket::write);
// 关闭其中一方时关闭另一方
clientSocket.closeHandler(v -> targetSocket.close());
targetSocket.closeHandler(v -> clientSocket.close());
} else {
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.cause().getMessage());
targetSocket.close();
clientRequest.response().setStatusCode(500).end("Internal Server Error");
}
// Close the other socket when one side closes
clientSocket.closeHandler(v -> targetSocket.close());
targetSocket.closeHandler(v -> clientSocket.close());
})
.onFailure(clientSocketAttempt -> {
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.getMessage());
targetSocket.close();
clientRequest.response().setStatusCode(500).end("Internal Server Error");
});
})
.onFailure(connectionAttempt -> {
System.err.println("Failed to connect to target: " + connectionAttempt.getMessage());
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
});
} else {
System.err.println("Failed to connect to target: " + connectionAttempt.cause().getMessage());
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
}
});
}
// 处理客户端的 HTTP 请求
private void handleClientRequest(HttpServerRequest clientRequest) {
String s = clientRequest.headers().get("Proxy-Authorization");
if (s == null) {
clientRequest.response().setStatusCode(403).end();
return;
// 打印来源ip和访问目标URI
LOGGER.debug("source: {}, target: {}", clientRequest.remoteAddress().toString(), clientRequest.uri());
if (proxyServerConf.containsKey("username") &&
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
String s = clientRequest.headers().get("Proxy-Authorization");
if (s == null) {
clientRequest.response().setStatusCode(403).end();
return;
}
String[] split;
try {
split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
} catch (IllegalArgumentException e) {
LOGGER.warn("Proxy-Authorization header is not valid Base64");
clientRequest.response().setStatusCode(403).end();
return;
}
if (split.length <= 1) {
LOGGER.warn("Proxy-Authorization header format invalid: missing username:password separator");
clientRequest.response().setStatusCode(403).end();
return;
}
String username = proxyServerConf.getString("username");
String password = proxyServerConf.getString("password");
if (!split[0].equals(username) || !split[1].equals(password)) {
LOGGER.info("-----auth failed------\nusername: {}", split[0]);
clientRequest.response().setStatusCode(403).end();
return;
}
}
String[] split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
if (split.length > 1) {
System.out.println(split[0]);
System.out.println(split[1]);
// TODO
}
if (clientRequest.method() == HttpMethod.CONNECT) {
// 处理 CONNECT 请求
@@ -129,7 +170,7 @@ public class HttpProxyVerticle extends AbstractVerticle {
}
String targetHost = hostHeader.split(":")[0];
int targetPort = 80; // 默认为 HTTP 的端口
int targetPort = extractPortFromUrl(clientRequest.uri()); // 默认为 HTTP 的端口
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
@@ -140,45 +181,63 @@ public class HttpProxyVerticle extends AbstractVerticle {
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
// 将客户端请求的 body 转发给目标服务器
clientRequest.bodyHandler(body -> request.send(body, ar -> {
if (ar.succeeded()) {
var response = ar.result();
clientRequest.response().setStatusCode(response.statusCode());
clientRequest.response().headers().setAll(response.headers());
response.body().onSuccess(b-> clientRequest.response().end(b));
} else {
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to reach target");
}
}));
clientRequest.bodyHandler(body ->
request.send(body)
.onSuccess(response -> {
clientRequest.response().setStatusCode(response.statusCode());
clientRequest.response().headers().setAll(response.headers());
response.body()
.onSuccess(b -> clientRequest.response().end(b))
.onFailure(err -> clientRequest.response()
.setStatusCode(502).end("Bad Gateway: Unable to reach target"));
})
.onFailure(err -> clientRequest.response()
.setStatusCode(502).end("Bad Gateway: Unable to reach target"))
);
})
.onFailure(err -> {
err.printStackTrace();
LOGGER.error("HTTP请求失败", err);
clientRequest.response().setStatusCode(502).end("Bad Gateway: Request failed");
});
}
/**
* 从 URL 中提取端口号
*
* @param urlString URL 字符串
* @return 提取的端口号,如果没有指定端口,则返回默认端口
*/
public static int extractPortFromUrl(String urlString) {
try {
URI uri = new URI(urlString);
int port = uri.getPort();
// 如果 URL 没有指定端口,使用默认端口
if (port == -1) {
if ("https".equalsIgnoreCase(uri.getScheme())) {
port = 443; // HTTPS 默认端口
} else {
port = 80; // HTTP 默认端口
}
}
return port;
} catch (Exception e) {
LOGGER.error("提取端口失败: {}", urlString, e);
// 出现异常时返回 -1表示提取失败
return -1;
}
}
@Override
public void stop() {
// 停止 HTTP 客户端以释放资源
if (httpClient != null) {
httpClient.close();
}
if (netClient != null) {
netClient.close();
}
}
/**
* TODO add Deploy
* @param args
*/
public static void main(String[] args) {
// 配置 DNS 解析器,使用多个 DNS 服务器来提升解析速度
Vertx vertx = Vertx.vertx(new VertxOptions()
.setAddressResolverOptions(new AddressResolverOptions()
.addServer("114.114.114.114")
.addServer("114.114.115.115")
.addServer("8.8.8.8")
.addServer("8.8.4.4")));
// 部署 Verticle 并启动动态 HTTP 代理服务器
vertx.deployVerticle(new HttpProxyVerticle());
}
}

View File

@@ -0,0 +1,68 @@
package cn.qaiu.vx.core.verticle;
import cn.qaiu.vx.core.base.AppRun;
import cn.qaiu.vx.core.base.DefaultAppRun;
import cn.qaiu.vx.core.util.CommonUtil;
import cn.qaiu.vx.core.util.ReflectionUtil;
import cn.qaiu.vx.core.util.SharedDataUtil;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 后置执行Verticle - 在core启动后立即执行AppRun实现
* <br>Create date 2024-01-01 00:00:00
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class PostExecVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(PostExecVerticle.class);
private static final Set<AppRun> appRunImplementations;
private static final AtomicBoolean lock = new AtomicBoolean(false);
static {
Reflections reflections = ReflectionUtil.getReflections();
Set<Class<? extends AppRun>> subTypesOf = reflections.getSubTypesOf(AppRun.class);
subTypesOf.add(DefaultAppRun.class);
appRunImplementations = CommonUtil.sortClassSet(subTypesOf);
if (appRunImplementations.isEmpty()) {
LOGGER.warn("未找到 AppRun 接口的实现类");
} else {
LOGGER.info("找到 {} 个 AppRun 接口的实现类", appRunImplementations.size());
}
}
@Override
public void start(Promise<Void> startPromise) {
if (!lock.compareAndSet(false, true)) {
return;
}
LOGGER.info("PostExecVerticle 开始执行...");
if (appRunImplementations != null && !appRunImplementations.isEmpty()) {
appRunImplementations.forEach(appRun -> {
try {
LOGGER.info("执行 AppRun 实现: {}", appRun.getClass().getName());
JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
appRun.execute(globalConfig);
LOGGER.info("AppRun 实现 {} 执行完成", appRun.getClass().getName());
} catch (Exception e) {
LOGGER.error("执行 AppRun 实现 {} 时发生错误",appRun.getClass().getName(), e);
}
});
} else {
LOGGER.info("未找到 AppRun 接口的实现类");
}
LOGGER.info("PostExecVerticle 执行完成");
startPromise.complete();
}
}

View File

@@ -5,8 +5,10 @@ import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpServer;
import io.vertx.core.http.HttpServerOptions;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.PemKeyCertOptions;
@@ -15,6 +17,9 @@ import io.vertx.ext.web.Router;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.proxy.handler.ProxyHandler;
import io.vertx.httpproxy.HttpProxy;
import io.vertx.httpproxy.ProxyContext;
import io.vertx.httpproxy.ProxyInterceptor;
import io.vertx.httpproxy.ProxyResponse;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,12 +27,16 @@ import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* <p>反向代理服务</p>
* <p>可以根据配置文件自动生成代理服务</p>
* <p>可以配置多个服务, 配置文件见示例</p>
* <p>【优化】支持高并发场景,连接池复用,避免线程阻塞</p>
* <br>Create date 2021/9/2 0:41
*
* @author <a href="https://qaiu.top">QAIU</a>
@@ -46,14 +55,83 @@ public class ReverseProxyVerticle extends AbstractVerticle {
public static String REROUTE_PATH_PREFIX = "/__rrvpspp"; //re_route_vert_proxy_server_path_prefix 硬编码
/**
* 【优化】HttpClient连接池按host:port缓存复用避免每个请求都创建新连接
*/
private final Map<String, HttpClient> httpClientPool = new ConcurrentHashMap<>();
/**
* 【优化】高并发场景下的HttpClient配置
*/
private static final int MAX_POOL_SIZE = 100; // 最大连接池大小
private static final int MAX_WAIT_QUEUE_SIZE = 500; // 最大等待队列大小
private static final int CONNECT_TIMEOUT = 30000; // 连接超时30秒
private static final int IDLE_TIMEOUT = 60; // 空闲超时60秒
private static final boolean KEEP_ALIVE = true; // 启用Keep-Alive
private static final boolean PIPELINING = true; // 启用HTTP管线化
@Override
public void start(Promise<Void> startPromise) {
CONFIG.onSuccess(this::handleProxyConfList);
CONFIG.onSuccess(this::handleProxyConfList).onFailure(e -> {
LOGGER.info("web代理配置已禁用当前仅支持API调用");
});
// createFileListener
startPromise.complete();
}
/**
* 【优化】Verticle停止时清理HttpClient连接池
*/
@Override
public void stop(Promise<Void> stopPromise) {
LOGGER.info("Stopping ReverseProxyVerticle, closing {} HttpClient connections...", httpClientPool.size());
httpClientPool.values().forEach(client -> {
try {
client.close();
} catch (Exception e) {
LOGGER.warn("Error closing HttpClient: {}", e.getMessage());
}
});
httpClientPool.clear();
stopPromise.complete();
}
/**
* 【优化】获取或创建HttpClient实现连接池复用
* @param host 目标主机
* @param port 目标端口
* @return HttpClient实例
*/
private HttpClient getOrCreateHttpClient(String host, int port) {
String key = host + ":" + port;
return httpClientPool.computeIfAbsent(key, k -> {
LOGGER.info("Creating new HttpClient for {}", key);
HttpClientOptions options = new HttpClientOptions()
.setMaxPoolSize(MAX_POOL_SIZE) // 连接池大小
.setMaxWaitQueueSize(MAX_WAIT_QUEUE_SIZE) // 等待队列大小
.setConnectTimeout(CONNECT_TIMEOUT) // 连接超时
.setIdleTimeout(IDLE_TIMEOUT) // 空闲超时
.setKeepAlive(KEEP_ALIVE) // Keep-Alive
.setKeepAliveTimeout(120) // Keep-Alive超时120秒
.setPipelining(PIPELINING) // HTTP管线化
.setPipeliningLimit(10) // 管线化限制
.setDecompressionSupported(true) // 支持解压响应
.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法降低延迟
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
return vertx.createHttpClient(options);
});
}
/**
* 全局可信上游代理 IP 集合(如 nginx仅这些 IP 的 X-Forwarded-For 会被信任
*/
private Set<String> globalTrustedProxies = new HashSet<>();
/**
* 获取主配置文件
*
@@ -61,6 +139,15 @@ public class ReverseProxyVerticle extends AbstractVerticle {
*/
private void handleProxyConfList(JsonObject config) {
serverName = config.getString("server-name");
// 解析全局 trusted-proxies
JsonArray trustedArr = config.getJsonArray("trusted-proxies");
if (trustedArr != null) {
trustedArr.forEach(ip -> {
if (ip instanceof String) {
globalTrustedProxies.add(((String) ip).trim());
}
});
}
JsonArray proxyConfList = config.getJsonArray("proxy");
if (proxyConfList != null) {
proxyConfList.forEach(proxyConf -> {
@@ -71,6 +158,44 @@ public class ReverseProxyVerticle extends AbstractVerticle {
}
}
/**
* 解析真实客户端 IP。
* 若直连来源在可信代理列表中,优先取 X-Real-IP其次取 X-Forwarded-For 第一个值;
* 否则直接使用直连对端地址。
*/
private String resolveClientIp(HttpServerRequest request) {
String peerIp = request.remoteAddress().host();
if (globalTrustedProxies.contains(peerIp)) {
String realIp = request.getHeader("X-Real-IP");
if (StringUtils.isNotBlank(realIp)) {
return realIp.trim();
}
String xff = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(xff)) {
return xff.split(",")[0].trim();
}
}
return peerIp;
}
/**
* 解析 proxy-set-headers 中的 nginx 风格变量。
* 支持:$remote_addr、$proxy_add_x_forwarded_for、$scheme、$host
* 其他值作为字面量直接使用。
*/
private String resolveHeaderVariable(String tpl, HttpServerRequest req, String clientIp) {
return switch (tpl) {
case "$remote_addr" -> clientIp;
case "$proxy_add_x_forwarded_for" -> {
String existing = req.getHeader("X-Forwarded-For");
yield StringUtils.isNotBlank(existing) ? existing + ", " + clientIp : clientIp;
}
case "$scheme" -> req.isSSL() ? "https" : "http";
case "$host" -> req.getHeader("Host");
default -> tpl;
};
}
/**
* 处理单个反向代理配置
*
@@ -97,18 +222,25 @@ public class ReverseProxyVerticle extends AbstractVerticle {
proxyConf.put("page404", DEFAULT_PATH_404);
}
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
Router proxyRouter = Router.router(vertx);
// Add Server name header
proxyRouter.route().handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
ctx.reroute(rePath);
return;
}
ctx.response().putHeader("Server", serverName);
ctx.next();
});
// http api proxy
if (proxyConf.containsKey("location")) {
handleLocation(proxyConf.getJsonArray("location"), httpClient, proxyRouter);
handleLocation(proxyConf.getJsonArray("location"), proxyRouter);
}
// static server
@@ -117,7 +249,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
}
// Send page404 page
proxyRouter.errorHandler(404, ctx -> ctx.response().sendFile(proxyConf.getString("page404")));
proxyRouter.errorHandler(404, ctx -> {
ctx.response().sendFile(proxyConf.getString("page404"));
});
HttpServer server = getHttpsServer(proxyConf);
server.requestHandler(proxyRouter);
@@ -129,8 +263,16 @@ public class ReverseProxyVerticle extends AbstractVerticle {
private HttpServer getHttpsServer(JsonObject proxyConf) {
HttpServerOptions httpServerOptions = new HttpServerOptions()
.setCompressionSupported(true);
// 【优化】高并发服务器配置
.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法
.setCompressionSupported(true) // 启用压缩
.setAcceptBacklog(50000) // 增加积压队列到50000
.setIdleTimeout(120) // 空闲超时120秒
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
if (proxyConf.containsKey("ssl")) {
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
@@ -184,7 +326,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
} else {
staticHandler = StaticHandler.create();
}
if (staticConf.containsKey("directory-listing")) {
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
} else if (staticConf.containsKey("index")) {
@@ -197,10 +338,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
* 处理Location配置 代理请求Location(和nginx类似?)
*
* @param locationsConf location配置
* @param httpClient 客户端
* @param proxyRouter 代理路由
*/
private void handleLocation(JsonArray locationsConf, HttpClient httpClient, Router proxyRouter) {
private void handleLocation(JsonArray locationsConf, Router proxyRouter) {
locationsConf.stream().map(e -> (JsonObject) e).forEach(location -> {
// 代理规则
@@ -211,14 +351,38 @@ public class ReverseProxyVerticle extends AbstractVerticle {
String host = url.getHost();
int port = url.getPort();
if (port == -1) {
port = 80;
port = 443;
}
String originPath = url.getPath();
LOGGER.info("path {}, originPath {}, to {}:{}", path, originPath, host, port);
// 注意这里不能origin多个代理地址, 一个实例只能代理一个origin
// 【优化】使用连接池获取HttpClient避免每个location都创建新连接
final HttpClient httpClient = getOrCreateHttpClient(host, port);
final HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient);
httpProxy.origin(port, host);
// proxy-set-headers 支持nginx 风格变量替换)
if (location.containsKey("proxy-set-headers")) {
final JsonObject headerConf = location.getJsonObject("proxy-set-headers");
httpProxy.addInterceptor(new ProxyInterceptor() {
@Override
public Future<ProxyResponse> handleProxyRequest(ProxyContext ctx) {
HttpServerRequest incoming = ctx.request().proxiedRequest();
String clientIp = resolveClientIp(incoming);
headerConf.forEach(entry -> {
Object val = entry.getValue();
if (val != null) {
String resolved = resolveHeaderVariable(val.toString(), incoming, clientIp);
if (resolved != null) {
ctx.request().putHeader(entry.getKey(), resolved);
}
}
});
return ProxyInterceptor.super.handleProxyRequest(ctx);
}
});
}
if (StringUtils.isEmpty(path)) {
return;
}
@@ -227,24 +391,65 @@ public class ReverseProxyVerticle extends AbstractVerticle {
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
: proxyRouter.route(path);
// 【优化】为代理处理器添加超时
route.handler(ProxyHandler.create(httpProxy));
} else {
// 配置 /api/, / => 请求 /api/test 代理后 /test
// 配置 /api/, /xxx => 请求 /api/test 代理后 /xxx/test
final String path0 = path;
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
final String path0 = path;
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
proxyRouter.route(path0 + "*").handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(path0)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replaceAll("^" + path0, originPath0);
ctx.reroute(rePath);
} else {
ctx.next();
}
});
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
proxyRouter.route(path0 + "*").handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(path0)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replaceAll("^" + path0, originPath0);
ctx.reroute(rePath);
} else {
ctx.next();
}
});
// 计算唯一后缀,避免多个 location 冲突
// String uniqueKey = (host + ":" + port + "|" + path).replaceAll("[^a-zA-Z0-9:_|/]", "");
// String uniqueSuffix = Integer.toHexString(uniqueKey.hashCode());
//
//// 规格化 originPath
// //String originPath = url.getPath(); // 原值
// if (StringUtils.isBlank(originPath)) originPath = "/";
//
//// 处理 index.html 的情况:用于首页兜底,其它子路径仍按目录穿透
// String indexFile;
// if (originPath.endsWith(".html")) {
// indexFile = originPath; // 例如 /index.html
// originPath = "/"; // 目录穿透基准改为根
// } else {
// indexFile = null;
// }
//
//// 唯一内部挂载前缀
// final String originMount = REROUTE_PATH_PREFIX + uniqueSuffix + originPath;
//
//// 1) 目标挂载:所有被重写的请求最终到这里走 ProxyHandler
// proxyRouter.route(originMount + "*").handler(ProxyHandler.create(httpProxy));
//
//// 2) 从外部前缀 -> 内部挂载 的重写
// final String path0 = path;
// proxyRouter.route(path0 + "*").handler(ctx -> {
// String uri = ctx.request().uri();
// if (!uri.startsWith(path0)) { ctx.next(); return; }
//
// // 首页兜底:访问 /n2 或 /n2/ 时,重写到 index.html如果配置了
// if (indexFile != null && (uri.equals(path0) || uri.equals(path0.substring(0, path0.length()-1)))) {
// String rePath = originMount.endsWith("/") ? (originMount + indexFile.substring(1)) : (originMount + indexFile);
// ctx.reroute(rePath);
// return;
// }
//
// // 一般穿透:/n2/xxx -> originMount + xxx
// String rePath = uri.replaceFirst("^" + path0, originMount);
// ctx.reroute(rePath);
// });
}
} catch (MalformedURLException e) {

View File

@@ -23,12 +23,11 @@ public class RouterVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(RouterVerticle.class);
private static final int port = SharedDataUtil.getValueForServerConfig("port");
private static final Router router = new RouterHandlerFactory(
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
private static final JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
private HttpServer server;
private Router router;
static {
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
@@ -48,10 +47,21 @@ public class RouterVerticle extends AbstractVerticle {
} else {
options = new HttpServerOptions();
}
// 绑定到 0.0.0.0 以允许外部访问
options.setHost("0.0.0.0");
options.setPort(port);
// 【优化】高并发服务器配置
options.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法降低延迟
.setCompressionSupported(true) // 启用压缩
.setAcceptBacklog(50000) // 增加积压队列到50000防止高并发时连接被拒绝
.setIdleTimeout(120) // 空闲超时120秒
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
router = new RouterHandlerFactory(
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
server = vertx.createHttpServer(options);
server.requestHandler(router).webSocketHandler(s->{}).listen()

View File

@@ -5,11 +5,15 @@ import cn.qaiu.vx.core.base.BaseAsyncService;
import cn.qaiu.vx.core.util.ReflectionUtil;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.eventbus.MessageConsumer;
import io.vertx.core.json.JsonObject;
import io.vertx.serviceproxy.ServiceBinder;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
@@ -24,26 +28,48 @@ public class ServiceVerticle extends AbstractVerticle {
Logger LOGGER = LoggerFactory.getLogger(ServiceVerticle.class);
private static final AtomicInteger ID = new AtomicInteger(1);
private static final Set<Class<?>> handlers;
private final List<MessageConsumer<JsonObject>> consumers = new ArrayList<>();
static {
Reflections reflections = ReflectionUtil.getReflections();
handlers = reflections.getTypesAnnotatedWith(Service.class);
}
@Override
public void start(Promise<Void> startPromise) {
ServiceBinder binder = new ServiceBinder(vertx);
if (null != handlers && handlers.size() > 0) {
// handlers转为拼接类列表xxx,yyy,zzz
StringBuilder serviceNames = new StringBuilder();
handlers.forEach(asyncService -> {
try {
serviceNames.append(asyncService.getName()).append("|");
BaseAsyncService asInstance = (BaseAsyncService) ReflectionUtil.newWithNoParam(asyncService);
binder.setAddress(asInstance.getAddress()).register(asInstance.getAsyncInterfaceClass(), asInstance);
String address = asInstance.getAddress();
MessageConsumer<JsonObject> consumer = binder.setAddress(address)
.register(asInstance.getAsyncInterfaceClass(), asInstance);
consumers.add(consumer);
} catch (Exception e) {
LOGGER.error(e.getMessage());
LOGGER.error("Failed to register service: {}", asyncService.getName(), e);
}
});
LOGGER.info("registered async services -> id: {}", ID.getAndIncrement());
LOGGER.info("registered async services -> id: {}, name: {}", ID.getAndIncrement(), serviceNames.toString());
}
startPromise.complete();
}
@Override
public void stop(Promise<Void> stopPromise) {
int count = consumers.size();
consumers.forEach(consumer -> {
try {
consumer.unregister();
} catch (Exception e) {
LOGGER.warn("Failed to unregister service consumer at address: {}", consumer.address(), e);
}
});
consumers.clear();
LOGGER.info("ServiceVerticle stopped, unregistered {} services", count);
stopPromise.complete();
}
}

View File

@@ -0,0 +1,89 @@
package cn.qaiu.vx.core.verticle.conf;
import io.vertx.codegen.annotations.DataObject;
import io.vertx.codegen.json.annotations.JsonGen;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.ProxyOptions;
import java.util.UUID;
@DataObject
//@JsonGen(publicConverter = false)
public class HttpProxyConf {
public static final String DEFAULT_USERNAME = UUID.randomUUID().toString();
public static final String DEFAULT_PASSWORD = UUID.randomUUID().toString();
public static final Integer DEFAULT_PORT = 6432;
public static final Integer DEFAULT_TIMEOUT = 15000;
Integer timeout;
String username;
String password;
Integer port;
ProxyOptions preProxyOptions;
public HttpProxyConf() {
this.username = DEFAULT_USERNAME;
this.password = DEFAULT_PASSWORD;
this.port = DEFAULT_PORT;
this.timeout = DEFAULT_TIMEOUT;
this.preProxyOptions = new ProxyOptions();
}
public HttpProxyConf(JsonObject json) {
this();
}
public Integer getTimeout() {
return timeout;
}
public HttpProxyConf setTimeout(Integer timeout) {
this.timeout = timeout;
return this;
}
public String getUsername() {
return username;
}
public HttpProxyConf setUsername(String username) {
this.username = username;
return this;
}
public String getPassword() {
return password;
}
public HttpProxyConf setPassword(String password) {
this.password = password;
return this;
}
public Integer getPort() {
return port;
}
public HttpProxyConf setPort(Integer port) {
this.port = port;
return this;
}
public ProxyOptions getPreProxyOptions() {
return preProxyOptions;
}
public HttpProxyConf setPreProxyOptions(ProxyOptions preProxyOptions) {
this.preProxyOptions = preProxyOptions;
return this;
}
}

View File

@@ -0,0 +1,134 @@
package cn.qaiu.vx.core.test;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.junit.Assert;
import org.junit.Test;
/**
* 单元测试:验证 RouterHandlerFactory 关于 JsonObject/JsonArray 参数绑定的核心分支逻辑是否正确
* (不启动整个 Vert.x 服务器,直接用 Vert.x JsonObject/JsonArray API 模拟验证关键逻辑)
*/
public class JsonBodyBindingLogicTest {
// === 模拟 handlerMethod 中的 JSON body 绑定逻辑 ===
/**
* 模拟content-type = application/jsonbody 是 JsonObject
* 期望JsonObject 类型参数被正确绑定
*/
@Test
public void testJsonObjectBinding() {
String bodyStr = "{\"name\":\"test\",\"value\":123}";
// 模拟 ctx.body().asJsonObject()
JsonObject body = parseAsJsonObject(bodyStr);
Assert.assertNotNull("body 应能解析为 JsonObject", body);
// 模拟绑定逻辑中的类型判断
String targetType = JsonObject.class.getName();
boolean matched = JsonObject.class.getName().equals(targetType);
Assert.assertTrue("JsonObject 类型应命中绑定分支", matched);
// 模拟结果
Object bound = body; // parameterValueList.put(k, body)
Assert.assertNotNull("JsonObject 参数应被绑定非null", bound);
Assert.assertEquals("name字段应为test", "test", ((JsonObject) bound).getString("name"));
Assert.assertEquals("value字段应为123", 123, (int) ((JsonObject) bound).getInteger("value"));
System.out.println("[PASS] testJsonObjectBinding: JsonObject 绑定成功 -> " + bound);
}
/**
* 模拟content-type = application/jsonbody 是 JsonArray
* 期望JsonArray 类型参数被正确绑定
*/
@Test
public void testJsonArrayBinding() {
String bodyStr = "[1,2,3]";
// body 解析为 JsonObject 应返回 null
JsonObject bodyAsObj = parseAsJsonObject(bodyStr);
Assert.assertNull("JsonArray body 解析为 JsonObject 应为 null", bodyAsObj);
// 进入 else 分支,解析为 JsonArray
JsonArray bodyArr = parseAsJsonArray(bodyStr);
Assert.assertNotNull("body 应能解析为 JsonArray", bodyArr);
String targetType = JsonArray.class.getName();
boolean matched = JsonArray.class.getName().equals(targetType);
Assert.assertTrue("JsonArray 类型应命中绑定分支", matched);
Object bound = bodyArr;
Assert.assertNotNull("JsonArray 参数应被绑定非null", bound);
Assert.assertEquals("数组大小应为3", 3, ((JsonArray) bound).size());
System.out.println("[PASS] testJsonArrayBinding: JsonArray 绑定成功, size=" + ((JsonArray) bound).size());
}
/**
* 验证旧代码的 bug条件 ctx.body().asJsonObject() != null 会把 JsonArray body 排除在外
* 新代码只判断 content-type在 body==null 时才进 else 分支处理 JsonArray
*/
@Test
public void testOldConditionBug() {
String jsonArrayBody = "[1,2,3]";
// 旧代码条件content-type==json && asJsonObject()!=null
// 对于 JsonArray bodyasJsonObject() 返回 null整个 if 跳过
JsonObject wrongParsed = parseAsJsonObject(jsonArrayBody);
boolean oldConditionPassed = wrongParsed != null; // 旧代码的第二个条件
Assert.assertFalse("旧代码 bug: JsonArray body 会导致 asJsonObject()==null整个分支跳过", oldConditionPassed);
// 新代码:先进 ifbody==null 再走 else 解析 JsonArray
boolean newConditionFirst = true; // content-type 匹配
JsonObject newBody = parseAsJsonObject(jsonArrayBody);
boolean newBodyIsNull = newBody == null; // null -> 进 else
Assert.assertTrue("新代码: body 解析为 null 时应走 else 分支解析 JsonArray", newBodyIsNull);
JsonArray newArr = parseAsJsonArray(jsonArrayBody);
Assert.assertNotNull("新代码: else 分支正确解析出 JsonArray", newArr);
System.out.println("[PASS] testOldConditionBug: 修复验证通过,新代码正确处理 JsonArray body");
}
/**
* 验证JsonObject 参数旧代码没有绑定分支(只处理实体类)
*/
@Test
public void testOldMissingJsonObjectBranch() {
String bodyStr = "{\"key\":\"value\"}";
JsonObject body = parseAsJsonObject(bodyStr);
// 旧代码只调用 matchRegList(entityPackagesReg, typeName)
// 对于 io.vertx.core.json.JsonObject该方法返回 false不会被绑定
String typeName = JsonObject.class.getName(); // "io.vertx.core.json.JsonObject"
// entityPackagesReg 一般是 "cn.qaiu.*" 这类,不会匹配 io.vertx
boolean oldWouldBind = typeName.startsWith("cn.qaiu"); // 模拟旧代码逻辑
Assert.assertFalse("旧代码 bug: JsonObject 参数不会被绑定", oldWouldBind);
// 新代码:增加了 JsonObject 类型判断
boolean newWouldBind = JsonObject.class.getName().equals(typeName);
Assert.assertTrue("新代码: JsonObject 参数应能被绑定", newWouldBind);
System.out.println("[PASS] testOldMissingJsonObjectBranch: 修复验证通过");
}
// ===== 辅助方法:模拟 Vert.x RequestBody 的 asJsonObject/asJsonArray 行为 =====
private JsonObject parseAsJsonObject(String str) {
try {
return new JsonObject(str);
} catch (Exception e) {
return null;
}
}
private JsonArray parseAsJsonArray(String str) {
try {
return new JsonArray(str);
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,125 @@
package cn.qaiu.vx.core.test;
import cn.qaiu.vx.core.util.VertxHolder;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* 集成测试: 验证 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定逻辑是否正确
*
* 运行方式: mvn test-compile -pl core && java -cp "core/target/test-classes:core/target/classes:..." \
* cn.qaiu.vx.core.test.RouterHandlerBindingTest
*
* 或直接在 IDE 中运行 main 方法。
*/
public class RouterHandlerBindingTest {
static final int TEST_PORT = 18989;
public static void main(String[] args) throws Exception {
System.out.println("=== RouterHandler JsonObject/JsonArray 绑定测试 ===\n");
// 1. 先初始化 Vert.x 与 VertxHolder ——必须在加载 RouterHandlerFactory 之前
Vertx vertx = Vertx.vertx();
VertxHolder.init(vertx);
// 2. 向 SharedData 注入最小化配置
// baseLocations 指向测试包,使 Reflections 只扫描 TestJsonHandler
vertx.sharedData().getLocalMap("local").put("customConfig", new JsonObject()
.put("baseLocations", "cn.qaiu.vx.core.test")
.put("routeTimeOut", 30000)
.put("entityPackagesReg", new JsonArray()));
// ReverseProxyVerticle.<clinit> 需要 globalConfig.proxyConf非空字符串即可
vertx.sharedData().getLocalMap("local").put("globalConfig", new JsonObject()
.put("proxyConf", "proxy.yml"));
// 3. 创建 Router此时才触发 BaseHttpApi.reflections 静态字段初始化)
// 用反射延迟加载,确保上面的 SharedData 已就绪
cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory factory =
new cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory("api");
io.vertx.ext.web.Router router = factory.createRouter();
// 4. 启动 HTTP 服务器
CountDownLatch latch = new CountDownLatch(1);
vertx.createHttpServer()
.requestHandler(router)
.listen(TEST_PORT, res -> {
if (res.succeeded()) {
System.out.println("✔ 测试服务器启动成功 port=" + TEST_PORT);
} else {
System.err.println("✘ 服务器启动失败: " + res.cause().getMessage());
}
latch.countDown();
});
if (!latch.await(5, TimeUnit.SECONDS)) {
System.err.println("服务器启动超时");
vertx.close();
System.exit(1);
}
Thread.sleep(100); // 等 Vert.x 就绪
// 5. 执行测试
boolean allPassed = true;
allPassed &= testJsonObject();
allPassed &= testJsonArray();
// 6. 关闭
CountDownLatch closeLatch = new CountDownLatch(1);
vertx.close(v -> closeLatch.countDown());
closeLatch.await(3, TimeUnit.SECONDS);
System.out.println("\n" + (allPassed ? "✅ 全部测试通过!" : "❌ 存在测试失败!"));
System.exit(allPassed ? 0 : 1);
}
// ---------- 子测试 ----------
private static boolean testJsonObject() throws Exception {
String bodyStr = "{\"name\":\"test\",\"value\":123}";
String respBody = post("/api/test/json-object", bodyStr);
System.out.println("[JsonObject] 响应: " + respBody);
JsonObject result = new JsonObject(respBody);
JsonObject data = result.getJsonObject("data");
boolean bound = data != null && Boolean.TRUE.equals(data.getBoolean("bound"));
System.out.println("[JsonObject] " + (bound
? "PASS ✅ body 正确绑定为 JsonObject"
: "FAIL ❌ body 未绑定 (null)"));
return bound;
}
private static boolean testJsonArray() throws Exception {
String bodyStr = "[1,2,3]";
String respBody = post("/api/test/json-array", bodyStr);
System.out.println("[JsonArray] 响应: " + respBody);
JsonObject result = new JsonObject(respBody);
JsonObject data = result.getJsonObject("data");
boolean bound = data != null
&& Boolean.TRUE.equals(data.getBoolean("bound"))
&& Integer.valueOf(3).equals(data.getInteger("size"));
System.out.println("[JsonArray] " + (bound
? "PASS ✅ body 正确绑定为 JsonArray, size=3"
: "FAIL ❌ body 未绑定 或 size 不对"));
return bound;
}
private static String post(String path, String body) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + TEST_PORT + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
}

View File

@@ -0,0 +1,36 @@
package cn.qaiu.vx.core.test;
import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.MIMEType;
import cn.qaiu.vx.core.enums.RouteMethod;
import cn.qaiu.vx.core.model.JsonResult;
import io.vertx.core.Future;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
/**
* 用于测试 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定的测试 Handler
*/
@RouteHandler("test")
public class TestJsonHandler {
/** POST /api/test/json-object Body: {"name":"test","value":123} */
@RouteMapping(value = "/json-object", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
public Future<JsonResult> testJsonObject(JsonObject body) {
// 只返回是否绑定成功及已知字段值,不嵌套原始 body 避免 toJsonObject() 循环
boolean bound = body != null;
String nameVal = bound ? body.getString("name", "") : "";
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
.put("bound", bound)
.put("name", nameVal)));
}
/** POST /api/test/json-array Body: [1,2,3] */
@RouteMapping(value = "/json-array", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
public Future<JsonResult> testJsonArray(JsonArray body) {
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
.put("bound", body != null)
.put("size", body != null ? body.size() : -1)));
}
}

9
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Fix permissions on volume-mounted directories (runs as root)
chown -R appuser:appgroup /app/db /app/logs /app/resources 2>/dev/null || true
# Run Java directly - entrypoint is PID 1, exec makes Java PID 1
# Docker SIGTERM goes directly to Java, triggering ShutdownHook
exec java -Xmx${JVM_XMX:-512M} ${JVM_OPTS} -Duser.timezone=${TZ:-Asia/Shanghai} -jar /app/netdisk-fast-download.jar

View File

@@ -4,26 +4,26 @@ NFD 解析器模块:聚合各类网盘/分享页解析,统一输出文件列
- 语言Java 17
- 构建Maven
- 模块版本10.1.17
- 模块版本10.2.5
## 依赖Maven Central
```xml
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.1.17</version>
<version>10.2.5</version>
</dependency>
```
- Gradle Groovy DSL
```groovy
dependencies {
implementation 'cn.qaiu:parser:10.1.17'
implementation 'cn.qaiu:parser:10.2.5'
}
```
- Gradle Kotlin DSL
```kotlin
dependencies {
implementation("cn.qaiu:parser:10.1.17")
implementation("cn.qaiu:parser:10.2.5")
}
```

View File

@@ -28,7 +28,7 @@
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.1.17</version>
<version>10.2.5</version>
</dependency>
```

View File

@@ -11,7 +11,7 @@
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.1.17</version>
<version>10.2.5</version>
</dependency>
```

View File

@@ -12,7 +12,7 @@
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.2.5</version>
<version>${parserVersion}</version>
<packaging>jar</packaging>
<name>cn.qaiu:parser</name>
@@ -35,9 +35,9 @@
</developers>
<scm>
<connection>scm:git:https://github.com/qaiu/netdisk-fast-download.git</connection>
<developerConnection>scm:git:ssh://git@github.com:qaiu/netdisk-fast-download.git</developerConnection>
<url>https://github.com/qaiu/netdisk-fast-download</url>
<connection>scm:git:https://github.com/${github.owner}/${github.repo}.git</connection>
<developerConnection>scm:git:ssh://git@github.com:${github.owner}/${github.repo}.git</developerConnection>
<url>https://github.com/${github.owner}/${github.repo}</url>
</scm>
<distributionManagement>
@@ -52,20 +52,19 @@
</distributionManagement>
<properties>
<revision>0.2.1</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Versions -->
<vertx.version>4.5.22</vertx.version>
<vertx.version>4.5.27</vertx.version>
<org.reflections.version>0.10.2</org.reflections.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>
<jackson.version>2.14.2</jackson.version>
<logback.version>1.5.19</logback.version>
<jackson.version>2.18.6</jackson.version>
<logback.version>1.5.32</logback.version>
<junit.version>4.13.2</junit.version>
</properties>
@@ -124,6 +123,41 @@
<build>
<plugins>
<!-- 从 git remote origin 自动识别 GitHub 仓库地址 -->
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>4.1.1</version>
<dependencies>
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy</artifactId>
<version>4.0.24</version>
</dependency>
</dependencies>
<executions>
<execution>
<phase>initialize</phase>
<goals><goal>execute</goal></goals>
<configuration>
<scripts>
<script>
def url = 'git remote get-url origin'.execute().text.trim()
def m = (url =~ 'github\\.com[:/]([^/]+)/([^/.]+?)(?:\\.git)?$')
if (m.find()) {
project.properties.setProperty('github.owner', m.group(1))
project.properties.setProperty('github.repo', m.group(2))
} else {
project.properties.setProperty('github.owner', 'qaiu')
project.properties.setProperty('github.repo', 'netdisk-fast-download')
}
</script>
</scripts>
</configuration>
</execution>
</executions>
</plugin>
<!-- 编译 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>

View File

@@ -86,7 +86,10 @@ public class ShareLinkInfo {
// 将type和shareKey组合成一个字符串作为缓存key
String key = type + ":" + shareKey;
if (type.equals("p115")) {
key += ("_" + otherParam.get("UA").toString().hashCode());
Object ua = otherParam != null ? otherParam.get("UA") : null;
if (ua != null) {
key += ("_" + ua.toString().hashCode());
}
}
return key;
}

View File

@@ -40,28 +40,34 @@ public abstract class PanBase implements IPanTool {
protected Promise<String> promise = Promise.promise();
/**
* Http client
* 共享的 WebClient 实例(线程安全,避免每请求创建导致资源泄漏)
*/
protected WebClient client = WebClient.create(WebClientVertxInit.get(),
private static final WebClient SHARED_CLIENT = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions());
private static final WebClient SHARED_CLIENT_NO_REDIRECTS = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setFollowRedirects(false));
private static final WebClient SHARED_CLIENT_DISABLE_UA = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setUserAgentEnabled(false));
/**
* Http client session (会话管理, 带cookie请求)
* Http client (默认使用共享实例,代理模式下使用独立实例)
*/
protected WebClient client = SHARED_CLIENT;
/**
* Http client session (会话管理, 带cookie请求, 每实例独立)
*/
protected WebClientSession clientSession = WebClientSession.create(client);
/**
* Http client 不自动跳转
*/
protected WebClient clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setFollowRedirects(false));
protected WebClient clientNoRedirects = SHARED_CLIENT_NO_REDIRECTS;
/**
* Http client disable UserAgent
*/
protected WebClient clientDisableUA = WebClient.create(WebClientVertxInit.get()
, new WebClientOptions().setUserAgentEnabled(false)
);
protected WebClient clientDisableUA = SHARED_CLIENT_DISABLE_UA;
protected ShareLinkInfo shareLinkInfo;

View File

@@ -68,8 +68,8 @@ public enum PanDomainTemplate {
t-is.cn
*/
LZ("蓝奏云",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(" +
"lanzoul|" +
compile("https://(?:[a-zA-Z\\d-]+\\.)?(?:" +
"(?:lanzoul|" +
"lanzouh|" +
"lanosso|" +
"lanpv|" +
@@ -95,14 +95,16 @@ public enum PanDomainTemplate {
"lanzv|" +
"dmpdmp|" +
"lanrar|" +
"webgetstore|" +
"lanzb|" +
"lanzoux|" +
"lanzout|" +
"lanzouc|" +
"lanzoui|" +
"lanzoug|" +
"lanzoum" +
")\\.com/(?<KEY>.+)"),
"lanzoum)\\.com" +
"|t-is\\.cn" +
")/(?<KEY>.+)"),
"https://w1.lanzn.com/{shareKey}",
LzTool.class),
@@ -113,9 +115,9 @@ public enum PanDomainTemplate {
"https://www.feijix.com/s/{shareKey}",
FjTool.class),
// https://lecloud.lenovo.com/share/
LE("联想乐云",
compile("https://lecloud?\\.lenovo\\.com/share/(?<KEY>.+)"),
// https://lecloud.lenovo.com/share/ https://lecloud.lenovo.com/mshare/
LE("联想乐云",
compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
"https://lecloud.lenovo.com/share/{shareKey}",
LeTool.class),
@@ -230,7 +232,7 @@ public enum PanDomainTemplate {
"123635\\.com|" +
"123242\\.com|" +
"123795\\.com" +
")/s/(?<KEY>.+)(.html)?"),
")/s/(?<KEY>[a-zA-Z0-9_-]+)(?:\\.html)?"),
"https://www.123pan.com/s/{shareKey}",
Ye2Tool.class),
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
@@ -241,7 +243,7 @@ public enum PanDomainTemplate {
EcTool.class),
// https://cowtransfer.com/s/
COW("奶牛快传",
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
"https://cowtransfer.com/s/{shareKey}",
CowTool.class),
CT("城通网盘",
@@ -264,7 +266,7 @@ public enum PanDomainTemplate {
PodTool.class),
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
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",
PgdTool.class),
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
@@ -274,11 +276,11 @@ public enum PanDomainTemplate {
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
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",
PdbTool.class),
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}",
P115Tool.class),
// 链接https://www.yunpan.com/surl_yD7wz4VgU9v提取码fc70
@@ -311,6 +313,14 @@ public enum PanDomainTemplate {
"https://pan.quark.cn/s/{shareKey}",
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 (单歌曲/普通音质)==========================
// http://163cn.tv/xxx
MNES("网易云音乐分享",
@@ -319,7 +329,7 @@ public enum PanDomainTemplate {
MnesTool.class),
// https://music.163.com/#/song?id=xxx
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}",
MnesTool.MneTool.class),
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
@@ -340,7 +350,7 @@ public enum PanDomainTemplate {
MkgsTool.class),
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
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",
MkgsTool.Mkgs2Tool.class),
// https://www.kugou.com/mixsong/2bi8Fe9CSV3

View File

@@ -81,16 +81,16 @@ public class ParserCreate {
if (shareKey != null) {
shareLinkInfo.setShareKey(shareKey);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
// 提取密码
try {
String pwd = matcher.group("PWD");
if (StringUtils.isNotEmpty(pwd)) {
shareLinkInfo.setSharePassword(pwd);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
// 设置标准URL
if (customParserConfig.getStandardUrlTemplate() != null) {
String standardUrl = customParserConfig.getStandardUrlTemplate()
@@ -133,7 +133,7 @@ public class ParserCreate {
shareLinkInfo.setSharePassword(pwd);
}
standardUrl = standardUrl.replace("{pwd}", pwd);
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
shareLinkInfo.setShareUrl(shareUrl);
shareLinkInfo.setShareKey(shareKey);
@@ -266,15 +266,15 @@ public class ParserCreate {
if (shareKey != null) {
shareLinkInfo.setShareKey(shareKey);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
try {
String password = matcher.group("PWD");
if (password != null) {
shareLinkInfo.setSharePassword(password);
}
} catch (Exception ignored) {}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
// 设置标准URL如果有模板
if (customConfig.getStandardUrlTemplate() != null) {
String standardUrl = customConfig.getStandardUrlTemplate()

View File

@@ -61,7 +61,7 @@ public class JsHttpClient {
};
public JsHttpClient() {
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());;
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());
this.clientSession = WebClientSession.create(client);
this.headers = MultiMap.caseInsensitiveMultiMap();
// 设置默认的Accept-Encoding头以支持压缩响应
@@ -534,8 +534,8 @@ public class JsHttpClient {
} else {
promise.fail(result.cause());
}
}).onFailure(Throwable::printStackTrace);
}).onFailure(e -> log.error("HTTP请求失败", e));
// 等待响应完成(使用配置的超时时间)
HttpResponse<Buffer> response = promise.future().toCompletionStage()
.toCompletableFuture()
@@ -677,4 +677,13 @@ public class JsHttpClient {
return buffer.length();
}
}
/**
* 关闭 WebClient 释放连接池资源
*/
public void close() {
if (client != null) {
client.close();
}
}
}

View File

@@ -29,12 +29,13 @@ import java.util.stream.Collectors;
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/10/17
*/
public class JsParserExecutor implements IPanTool {
public class JsParserExecutor implements IPanTool, AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(JsParserExecutor.class);
private static final WorkerExecutor EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
private static volatile WorkerExecutor EXECUTOR;
private static final Object EXECUTOR_LOCK = new Object();
private static String FETCH_RUNTIME_JS = null;
private final CustomParserConfig config;
@@ -146,12 +147,57 @@ public class JsParserExecutor implements IPanTool {
}
}
/**
* 释放资源ScriptEngine 和 HttpClient避免内存泄漏
*/
@Override
public void close() {
if (httpClient != null) {
httpClient.close();
}
// 清除 ScriptEngine 持有的 Java 对象引用,帮助 GC 回收
if (engine != null) {
engine.put("http", null);
engine.put("logger", null);
engine.put("shareLinkInfo", null);
engine.put("JavaFetch", null);
}
}
/**
* 关闭全局 WorkerExecutor应在应用关闭时调用
*/
public static void shutdownExecutor() {
synchronized (EXECUTOR_LOCK) {
if (EXECUTOR != null) {
EXECUTOR.close();
EXECUTOR = null;
log.info("JsParserExecutor WorkerExecutor 已关闭");
}
}
}
/**
* 获取或创建 WorkerExecutor懒加载
*/
private static WorkerExecutor getExecutor() {
if (EXECUTOR != null) {
return EXECUTOR;
}
synchronized (EXECUTOR_LOCK) {
if (EXECUTOR == null) {
EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
}
return EXECUTOR;
}
}
@Override
public Future<String> parse() {
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
// 使用executeBlocking在工作线程上执行避免阻塞EventLoop线程
return EXECUTOR.executeBlocking(() -> {
return getExecutor().executeBlocking(() -> {
// 直接调用全局parse函数
Object parseFunction = engine.get("parse");
if (parseFunction == null) {
@@ -173,7 +219,7 @@ public class JsParserExecutor implements IPanTool {
} else {
throw new RuntimeException("parse函数类型错误");
}
});
}).onComplete(ar -> close());
}
@Override
@@ -181,7 +227,7 @@ public class JsParserExecutor implements IPanTool {
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行避免阻塞EventLoop线程
return EXECUTOR.executeBlocking(() -> {
return getExecutor().executeBlocking(() -> {
// 直接调用全局parseFileList函数
Object parseFileListFunction = engine.get("parseFileList");
if (parseFileListFunction == null) {
@@ -206,7 +252,7 @@ public class JsParserExecutor implements IPanTool {
} else {
throw new RuntimeException("parseFileList函数类型错误");
}
});
}).onComplete(ar -> close());
}
@Override
@@ -214,7 +260,7 @@ public class JsParserExecutor implements IPanTool {
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行避免阻塞EventLoop线程
return EXECUTOR.executeBlocking(() -> {
return getExecutor().executeBlocking(() -> {
// 直接调用全局parseById函数
Object parseByIdFunction = engine.get("parseById");
if (parseByIdFunction == null) {
@@ -237,7 +283,7 @@ public class JsParserExecutor implements IPanTool {
} else {
throw new RuntimeException("parseById函数类型错误");
}
});
}).onComplete(ar -> close());
}
/**

View File

@@ -355,7 +355,7 @@ public class JsPlaygroundExecutor {
*/
public List<JsPlaygroundLogger.LogEntry> getLogs() {
List<JsPlaygroundLogger.LogEntry> logs = playgroundLogger.getLogs();
System.out.println("[JsPlaygroundExecutor] 获取日志,数量: " + logs.size());
log.debug("获取日志,数量: {}", logs.size());
return logs;
}

View File

@@ -4,6 +4,9 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 演练场日志收集器
* 收集JavaScript执行过程中的日志信息
@@ -12,8 +15,11 @@ import java.util.List;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JsPlaygroundLogger {
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundLogger.class);
// 使用线程安全的列表
private static final int MAX_LOG_SIZE = 1000;
private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
/**
@@ -59,6 +65,18 @@ public class JsPlaygroundLogger {
return obj.toString();
}
/**
* 添加日志条目,超过最大容量时移除最早的条目
*/
private void addLog(LogEntry entry) {
synchronized (logs) {
if (logs.size() >= MAX_LOG_SIZE) {
logs.remove(0);
}
logs.add(entry);
}
}
/**
* 记录日志(内部方法)
* @param level 日志级别
@@ -67,8 +85,8 @@ public class JsPlaygroundLogger {
*/
private void log(String level, Object message, String source) {
String msg = toString(message);
logs.add(new LogEntry(level, msg, source));
System.out.println("[" + source + "PlaygroundLogger] " + level + ": " + msg);
addLog(new LogEntry(level, msg, source));
log.debug("[{}PlaygroundLogger] {}: {}", source, level, msg);
}
/**
@@ -111,8 +129,8 @@ public class JsPlaygroundLogger {
if (throwable != null) {
msg = msg + ": " + throwable.getMessage();
}
logs.add(new LogEntry("ERROR", msg, "JS"));
System.out.println("[JSPlaygroundLogger] ERROR: " + msg);
addLog(new LogEntry("ERROR", msg, "JS"));
log.debug("[JSPlaygroundLogger] ERROR: {}", msg);
}
// ===== 以下是供Java层调用的内部方法 =====
@@ -153,8 +171,8 @@ public class JsPlaygroundLogger {
if (throwable != null) {
msg = msg + ": " + throwable.getMessage();
}
logs.add(new LogEntry("ERROR", msg, "JAVA"));
System.out.println("[JAVAPlaygroundLogger] ERROR: " + msg);
addLog(new LogEntry("ERROR", msg, "JAVA"));
log.debug("[JAVAPlaygroundLogger] ERROR: {}", msg);
}
/**

View File

@@ -139,21 +139,20 @@ public class JsScriptLoader {
try {
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
JarFile jarFile = new JarFile(jarPath);
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.startsWith(RESOURCE_PATH + "/") &&
entryName.endsWith(".js") &&
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
resourceFiles.add(entryName);
try (JarFile jarFile = new JarFile(jarPath)) {
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.startsWith(RESOURCE_PATH + "/") &&
entryName.endsWith(".js") &&
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
resourceFiles.add(entryName);
}
}
}
jarFile.close();
} catch (Exception e) {
log.debug("解析JAR包资源文件失败", e);
}

View File

@@ -109,9 +109,9 @@ public class FjTool extends PanBase {
// String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
static String token = null;
static String userId = null;
public static boolean authFlag = true;
static volatile String token = null;
static volatile String userId = null;
public static volatile boolean authFlag = true;
public FjTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
@@ -289,12 +289,14 @@ public class FjTool extends PanBase {
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
header0.set("appToken", token);
log.info("登录成功 token: {}", token);
MultiMap h0 = MultiMap.caseInsensitiveMultiMap();
h0.addAll(header0);
h0.set("appToken", token);
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.putHeaders(header0).send().onSuccess(res -> {
.putHeaders(h0).send().onSuccess(res -> {
if (asJson(res).getInteger("code") == 200) {
if (FjTool.userId == null) {
FjTool.userId = asJson(res).getJsonObject("map").getString("userId");
@@ -454,7 +456,10 @@ public class FjTool extends PanBase {
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId != null && !dirId.isEmpty()) {
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
if (uuidObj != null) {
uuid = uuidObj.toString();
}
parserDir(dirId, shareId, promise0);
return promise0.future();
}
@@ -495,7 +500,7 @@ public class FjTool extends PanBase {
JsonArray list;
try {
JsonObject jsonObject = asJson(res);
System.out.println(jsonObject.encodePrettily());
log.debug("目录列表: {}", jsonObject.encodePrettily());
list = jsonObject.getJsonArray("list");
} catch (Exception e) {
log.error("解析目录失败: {}", res.bodyAsString());
@@ -576,6 +581,10 @@ public class FjTool extends PanBase {
// 第二次请求
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
if (paramJson == null) {
promise.fail("缺少 paramJson 参数");
return promise.future();
}
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
.setTemplateParam("uuid", paramJson.getString("uuid"))

View File

@@ -0,0 +1,496 @@
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");
if (paramJson == null) {
parsePromise.fail("缺少 paramJson 参数");
return parsePromise.future();
}
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 (IllegalArgumentException 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 (IllegalArgumentException 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) {
}
}

View File

@@ -89,8 +89,8 @@ public class IzTool extends PanBase {
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
public static String token = null;
public static boolean authFlag = true;
public static volatile String token = null;
public static volatile boolean authFlag = true;
public Future<String> parse() {
@@ -101,8 +101,8 @@ public class IzTool extends PanBase {
// 检查并输出认证状态
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(10, token.length())) + "...)" : "未登录");
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
// 如果需要认证但还没有token先执行登录
if ((isTempAuth || authFlag) && token == null) {
@@ -118,7 +118,7 @@ public class IzTool extends PanBase {
// 登录失败,继续使用免登录模式
});
} else if (token != null) {
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(10, token.length())));
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(8, token.length())));
}
} else {
log.debug("文件解析无认证信息,使用免登录模式");
@@ -247,7 +247,7 @@ public class IzTool extends PanBase {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", header.get("appToken"))
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
@@ -263,12 +263,12 @@ public class IzTool extends PanBase {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", header.get("appToken"))
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
httpRequest.setTemplateParam("appToken", header.get("appToken"))
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}
@@ -311,8 +311,7 @@ public class IzTool extends PanBase {
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
header.set("appToken", token);
log.info("登录成功 token: {}", token);
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
promise1.complete();
} else {
// 检查是否为临时认证
@@ -463,7 +462,10 @@ public class IzTool extends PanBase {
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId != null && !dirId.isEmpty()) {
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
if (uuidObj != null) {
uuid = uuidObj.toString();
}
parserDir(dirId, shareId, promise);
return promise.future();
}

View File

@@ -88,8 +88,8 @@ public class IzToolWithAuth extends PanBase {
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
public static String token = null;
public static boolean authFlag = true;
public static volatile String token = null;
public static volatile boolean authFlag = true;
public Future<String> parse() {
@@ -216,7 +216,7 @@ public class IzToolWithAuth extends PanBase {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", header.get("appToken"))
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
@@ -232,12 +232,12 @@ public class IzToolWithAuth extends PanBase {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", header.get("appToken"))
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
httpRequest.setTemplateParam("appToken", header.get("appToken"))
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}
@@ -280,8 +280,7 @@ public class IzToolWithAuth extends PanBase {
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
header.set("appToken", token);
log.info("登录成功 token: {}", token);
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
promise1.complete();
} else {
// 检查是否为临时认证
@@ -432,7 +431,8 @@ public class IzToolWithAuth extends PanBase {
// 如果参数里的目录ID不为空则直接解析目录
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
if (dirId != null && !dirId.isEmpty()) {
uuid = shareLinkInfo.getOtherParam().get("uuid").toString();
Object uuidObj = shareLinkInfo.getOtherParam().get("uuid");
uuid = uuidObj != null ? uuidObj.toString() : null;
parserDir(dirId, shareId, promise);
return promise.future();
}
@@ -480,7 +480,7 @@ public class IzToolWithAuth extends PanBase {
requestDirList(id, shareId, tsEncode, promise);
})
.onSuccess(r -> {
log.info("目录解析登录成功token={}, 使用 VIP 模式", token != null ? token.substring(0, 10) + "..." : "null");
log.info("目录解析登录成功token={}, 使用 VIP 模式", token != null ? token.substring(0, Math.min(8, token.length())) + "..." : "null");
requestDirList(id, shareId, tsEncode, promise);
});
return;
@@ -627,7 +627,7 @@ public class IzToolWithAuth extends PanBase {
// 如果有 token使用 VIP 接口
if (StringUtils.isNotBlank(appToken)) {
log.debug("parseById 使用 VIP 接口, appToken={}", appToken.substring(0, Math.min(10, appToken.length())) + "...");
log.debug("parseById 使用 VIP 接口, appToken={}", appToken.substring(0, Math.min(8, appToken.length())) + "...");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.putHeaders(header)
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))

View File

@@ -14,6 +14,7 @@ import io.vertx.ext.web.client.WebClientSession;
import org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
import javax.script.ScriptException;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -81,8 +82,8 @@ public class LzTool extends PanBase {
}
private void doParser(String html, String pwd, String sUrl) {
// 检测是否为目录分享链接 (含 /s/、/b/ 路径段或 b0 开头的路径段)
if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b0[^/]+.*")) {
// 检测是否为目录分享链接 (含 /s/、/b/ 路径段或 b 开头的路径段)
if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b[^/]+.*")) {
fail("该链接为蓝奏云目录分享,请使用目录解析接口");
return;
}
@@ -107,7 +108,7 @@ public class LzTool extends PanBase {
try {
setFileInfo(html, shareLinkInfo);
} catch (Exception e) {
e.printStackTrace();
log.error("文件信息解析异常", e);
}
// 匹配iframe
Pattern compile = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
@@ -175,7 +176,7 @@ public class LzTool extends PanBase {
if (firstDot >= 0) {
domain = host.substring(firstDot); // e.g. ".lanzoum.com"
}
} catch (Exception ignored) {}
} catch (MalformedURLException ignored) {}
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(domain);
@@ -217,7 +218,7 @@ public class LzTool extends PanBase {
return;
}
Map<?, ?> signMap = (Map<?, ?>)obj.get("data");
String url0 = obj.get("url").toString();
String url0 = String.valueOf(obj.get("url"));
MultiMap map = MultiMap.caseInsensitiveMultiMap();
signMap.forEach((k, v) -> {
map.add((String) k, v.toString());
@@ -275,7 +276,7 @@ public class LzTool extends PanBase {
String h = du.getHost();
int dot = h.indexOf('.');
if (dot >= 0) downDomain = h.substring(dot);
} catch (Exception ignored) {}
} catch (MalformedURLException ignored) {}
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(downDomain);
@@ -290,12 +291,12 @@ public class LzTool extends PanBase {
if (location0 == null) {
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
} else {
setDateAndComplate(location0);
setDateAndComplete(location0);
}
}).onFailure(handleFail(downUrl));
return;
}
setDateAndComplate(location);
setDateAndComplete(location);
})
.onFailure(handleFail(downUrl));
} catch (Exception e) {
@@ -304,7 +305,7 @@ public class LzTool extends PanBase {
}).onFailure(handleFail(url));
}
private void setDateAndComplate(String location0) {
private void setDateAndComplete(String location0) {
// 分享时间 提取url中的时间戳格式lanzoui.com/abc/abc/yyyy/mm/dd/
String regex = "(\\d{4}/\\d{1,2}/\\d{1,2})";
Matcher matcher = Pattern.compile(regex).matcher(location0);
@@ -354,8 +355,8 @@ public class LzTool extends PanBase {
}
private void handleFileListParse(String html, String pwd, String sUrl, Promise<List<FileInfo>> promise) {
// 检测是否为文件分享链接 (不含 /s/、/b/ 路径段且不含 b0 开头的路径段)
if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b0[^/]+.*")) {
// 检测是否为文件分享链接 (不含 /s/、/b/ 路径段且不含 b 开头的路径段)
if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b[^/]+.*")) {
promise.fail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口");
return;
}

View File

@@ -86,10 +86,10 @@ public class MkgsTool extends PanBase {
// 查找并输出 hash 字段的值
if (matcher.find()) {
String hashValue = matcher.group(1); // 获取第一个捕获组
System.out.println(hashValue);
log.debug("hash: {}", hashValue);
client.getAbs(UriTemplate.of(API_URL)).setTemplateParam("hash", hashValue).send().onSuccess(res3 -> {
JsonObject jsonObject = asJson(res3);
System.out.println(jsonObject.encodePrettily());
log.debug("API response: {}", jsonObject.encodePrettily());
if (jsonObject.containsKey("url")) {
promise.complete(jsonObject.getString("url"));
} else {

View File

@@ -29,19 +29,19 @@ public class MkwTool extends PanBase {
clientSession.getAbs(shareUrl).send().onSuccess(result -> {
String cookie = result.headers().get("set-cookie");
if (!cookie.isEmpty()) {
if (cookie != null && !cookie.isEmpty()) {
String regex = "([A-Za-z0-9_]+)=([A-Za-z0-9]+)";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(cookie);
if (matcher.find()) {
System.out.println(matcher.group(1));
System.out.println(matcher.group(2));
log.debug("cookie key: {}", matcher.group(1));
log.debug("cookie value: {}", matcher.group(2));
var key = matcher.group(1);
var token = matcher.group(2);
String sign = JsExecUtils.getKwSign(token, key);
System.out.println(sign);
log.debug("sign: {}", sign);
clientSession.getAbs(UriTemplate.of(API_URL)).setTemplateParam("mid", shareLinkInfo.getShareKey())
.putHeader("Secret", sign).send().onSuccess(res -> {
JsonObject json = asJson(res);
@@ -54,7 +54,7 @@ public class MkwTool extends PanBase {
}
} catch (Exception e) {
e.printStackTrace();
log.error("解析失败", e);
fail("解析失败");
}
});

View File

@@ -21,6 +21,8 @@ public class P115Tool extends PanBase {
private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "share/skip_login_downurl";
private static final String DEFAULT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
private static final MultiMap header;
static {
@@ -49,9 +51,11 @@ public class P115Tool extends PanBase {
public Future<String> parse() {
// 第一次请求 获取文件信息
Object uaObj = shareLinkInfo.getOtherParam().get("UA");
String ua = uaObj != null ? uaObj.toString() : DEFAULT_UA;
client.getAbs(UriTemplate.of(FIRST_REQUEST_URL))
.putHeaders(header)
.putHeader("User-Agent", shareLinkInfo.getOtherParam().get("UA").toString())
.putHeader("User-Agent", ua)
.setTemplateParam("dataKey", shareLinkInfo.getShareKey())
.setTemplateParam("dataPwd", shareLinkInfo.getSharePassword())
.send().onSuccess(res -> {
@@ -68,7 +72,7 @@ public class P115Tool extends PanBase {
// share_code={dataKey}&receive_code={dataPwd}&file_id={file_id}
client.postAbs(SECOND_REQUEST_URL)
.putHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.putHeader("User-Agent", shareLinkInfo.getOtherParam().get("UA").toString())
.putHeader("User-Agent", ua)
.sendForm(MultiMap.caseInsensitiveMultiMap()
.set("share_code", shareLinkInfo.getShareKey())
.set("receive_code", shareLinkInfo.getSharePassword())

View File

@@ -85,7 +85,7 @@ public class PdbTool extends PanBase implements IPanTool {
})
.onFailure(handleFail());
} catch (Exception e) {
e.printStackTrace();
log.error("URL编码异常", e);
}
})

View File

@@ -99,7 +99,8 @@ public class PodTool extends PanBase {
Matcher matcher1 =
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\s\"]+)").matcher(body);
if (matcher1.find()) {
complete(matcher1.group("url"));
// 响应体是 JSON 文本URL 中的 '&' 被转义为 \u0026需要反转义
complete(unescapeJsonUnicode(matcher1.group("url")));
} else {
fail();
}
@@ -128,12 +129,40 @@ public class PodTool extends PanBase {
if (urlMatcher.find()) {
String url = urlMatcher.group("url");
System.out.println("URL: " + url);
log.debug("URL: {}", url);
return 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) {
// 正则表达式来匹配 inputElem.value 中的 Token
@@ -143,7 +172,7 @@ public class PodTool extends PanBase {
if (tokenMatcher.find()) {
String token = tokenMatcher.group(1);
System.out.println("Token: " + token);
log.debug("Token: {}***", token.length() > 4 ? token.substring(0, 4) : "***");
return token;
}
throw new RuntimeException("token匹配失败");
@@ -169,8 +198,8 @@ public class PodTool extends PanBase {
// 发送请求并处理响应
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(response -> {
System.out.println("Response Status Code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
log.debug("Response Status Code: {}", response.statusCode());
log.debug("Response Body: {}", response.body());
promise.complete(response.body());
return null;
});
@@ -228,4 +257,4 @@ public class PodTool extends PanBase {
return promise.future();
}
}
}

View File

@@ -74,9 +74,9 @@ public class QQTool extends PanBase {
});
// 调试匹配的情况
System.out.println("文件名称: " + filename);
System.out.println("文件大小: " + filesize);
System.out.println("文件直链: " + fileurl);
log.debug("文件名称: {}", filename);
log.debug("文件大小: {}", filesize);
log.debug("文件直链: {}", fileurl);
// 提交
promise.complete(fileurl.replace("\\x26", "&"));

View File

@@ -3,9 +3,11 @@ 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 cn.qaiu.util.HeaderUtils;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.slf4j.Logger;
@@ -13,27 +15,33 @@ import org.slf4j.LoggerFactory;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* QQ闪传 <br>
* 只能客户端上传 支持Android QQ 9.2.5, MACOS QQ 6.9.78可生成分享链接通过浏览器下载支持超大文件有效期默认7天暂时没找到续期方法。<br>
* 支持多文件、多级目录解析。通过 GetFileList API 获取文件列表BatchDownload API 获取下载直链。<br>
* 有效期默认7天。
*/
public class QQscTool extends PanBase {
Logger LOG = LoggerFactory.getLogger(QQscTool.class);
private static final String API_URL = "https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.qqntv2.richmedia.InnerProxy/BatchDownload";
private static final String BATCH_DOWNLOAD_API =
"https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.qqntv2.richmedia.InnerProxy/BatchDownload";
private static final MultiMap HEADERS = HeaderUtils.parseHeaders("""
private static final String GET_FILE_LIST_API =
"https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.file.FileFlashTrans/GetFileList";
private static final MultiMap BATCH_DOWNLOAD_HEADERS = HeaderUtils.parseHeaders("""
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
Cookie: uin=9000002; p_uin=9000002
DNT: 1
Origin: https://qfile.qq.com
Referer: https://qfile.qq.com/q/Xolxtv5b4O
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
@@ -46,86 +54,257 @@ public class QQscTool extends PanBase {
x-oidb: {"uint32_command":"0x9248", "uint32_service_type":"4"}
""");
private static final MultiMap GET_FILE_LIST_HEADERS = HeaderUtils.parseHeaders("""
Accept-Encoding: gzip, deflate
Cookie: uin=9000002; p_uin=9000002
Origin: https://qfile.qq.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0
content-type: application/json
x-oidb: {"uint32_command":"0x93d4", "uint32_service_type":"1"}
""");
public QQscTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@Override
public Future<String> parse() {
String jsonTemplate = """
{"req_head":{"agent":8},"download_info":[{"batch_id":"%s","scene":{"business_type":4,"app_type":22,"scene_type":5},"index_node":{"file_uuid":"%s"},"url_type":2,"download_scene":0}],"scene_type":103}
""";
client.getAbs(shareLinkInfo.getShareUrl()).send(result -> {
if (result.succeeded()) {
String htmlJs = result.result().bodyAsString();
LOG.debug("获取到的HTML内容: {}", htmlJs);
String fileUUID = getFileUUID(htmlJs);
String fileName = extractFileNameFromTitle(htmlJs);
if (fileName != null) {
LOG.info("提取到的文件名: {}", fileName);
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(fileName);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
} else {
LOG.warn("未能提取到文件名");
}
if (fileUUID != null) {
LOG.info("提取到的文件UUID: {}", fileUUID);
String formatted = jsonTemplate.formatted(fileUUID, fileUUID);
JsonObject entries = new JsonObject(formatted);
client.postAbs(API_URL)
.putHeaders(HEADERS)
.sendJsonObject(entries)
.onSuccess(result2 -> {
if (result2.statusCode() == 200) {
JsonObject body = asJson(result2);
LOG.debug("API响应内容: {}", body.encodePrettily());
// {
// "retcode": 0,
// "cost": 132,
// "message": "",
// "error": {
// "message": "",
// "code": 0
// },
// "data": {
// "download_rsp": [{
// 取 download_rsp
if (!body.containsKey("retcode") || body.getInteger("retcode") != 0) {
promise.fail("API请求失败错误信息: " + body.encodePrettily());
return;
}
JsonArray downloadRsp = body.getJsonObject("data").getJsonArray("download_rsp");
if (downloadRsp != null && !downloadRsp.isEmpty()) {
String url = downloadRsp.getJsonObject(0).getString("url");
if (fileName != null) {
url = url + "&filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8);
}
promise.complete(url);
} else {
promise.fail("API响应中缺少 download_rsp");
}
} else {
promise.fail("API请求失败状态码: " + result2.statusCode());
}
}).onFailure(e -> {
LOG.error("API请求异常", e);
promise.fail(e);
});
} else {
LOG.error("未能提取到文件UUID");
promise.fail("未能提取到文件UUID");
}
} else {
if (result.failed()) {
LOG.error("请求失败: {}", result.cause().getMessage());
promise.fail(result.cause());
return;
}
String html = result.result().bodyAsString();
String fileName = extractFileNameFromTitle(html);
if (fileName != null) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(fileName);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
}
// 尝试用 GetFileList API 获取第一个文件的下载链接
String filesetId = extractFilesetId(html);
if (filesetId != null) {
fetchFileList(filesetId, "").onSuccess(fileList -> {
for (int i = 0; i < fileList.size(); i++) {
JsonObject file = fileList.getJsonObject(i);
if (!file.getBoolean("is_dir", false)) {
String physicalId = file.getJsonObject("physical").getString("id");
String name = file.getString("name");
downloadFile(physicalId, name);
return;
}
}
promise.fail("未找到可下载的文件");
}).onFailure(e -> {
LOG.warn("GetFileList 失败,回退到旧解析方式: {}", e.getMessage());
parseLegacy(html, fileName);
});
} else {
parseLegacy(html, fileName);
}
});
return promise.future();
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> resultPromise = Promise.promise();
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
client.getAbs(shareLinkInfo.getShareUrl()).send(result -> {
if (result.failed()) {
resultPromise.fail(result.cause());
return;
}
String html = result.result().bodyAsString();
String filesetId = extractFilesetId(html);
if (filesetId == null) {
resultPromise.fail("无法从页面提取 filesetId");
return;
}
String parentId = dirId != null ? dirId : "";
fetchFileList(filesetId, parentId).onSuccess(fileList -> {
try {
List<FileInfo> list = new ArrayList<>();
String panType = shareLinkInfo.getType();
for (int i = 0; i < fileList.size(); i++) {
JsonObject file = fileList.getJsonObject(i);
FileInfo fileInfo = new FileInfo();
String name = file.getString("name");
String cliFileid = file.getString("cli_fileid");
boolean isDir = file.getBoolean("is_dir", false);
String sizeStr = file.getString("file_size");
fileInfo.setFileName(name)
.setFileId(cliFileid)
.setPanType(panType)
.setSizeStr(sizeStr);
if (isDir) {
fileInfo.setFileType("folder")
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s",
getDomainName(),
URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8),
cliFileid));
} else {
String physicalId = file.getJsonObject("physical").getString("id");
JsonObject paramJson = new JsonObject()
.put("fileId", physicalId)
.put("fileName", name)
.put("cliFileid", cliFileid);
String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setFileType("file")
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), panType, param));
}
list.add(fileInfo);
}
resultPromise.complete(list);
} catch (Exception e) {
resultPromise.fail(e);
}
}).onFailure(resultPromise::fail);
});
return resultPromise.future();
}
@Override
public Future<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
String fileId = paramJson.getString("fileId");
String fileName = paramJson.getString("fileName");
Promise<String> p = Promise.promise();
callBatchDownload(fileId, fileName, p);
return p.future();
}
// ========== 内部方法 ==========
/**
* 调用 BatchDownload API 获取单个文件的下载直链
*/
private void downloadFile(String physicalId, String fileName) {
callBatchDownload(physicalId, fileName, promise);
}
private void callBatchDownload(String physicalId, String fileName, Promise<String> p) {
String body = """
{"req_head":{"agent":8},"download_info":[{"batch_id":"%s","scene":{"business_type":4,"app_type":22,"scene_type":5},"index_node":{"file_uuid":"%s"},"url_type":2,"download_scene":0}],"scene_type":103}
""".formatted(physicalId, physicalId);
client.postAbs(BATCH_DOWNLOAD_API)
.putHeaders(BATCH_DOWNLOAD_HEADERS)
.sendJsonObject(new JsonObject(body))
.onSuccess(resp -> {
if (resp.statusCode() != 200) {
p.fail("BatchDownload 请求失败,状态码: " + resp.statusCode());
return;
}
JsonObject respBody = asJson(resp);
if (!respBody.containsKey("retcode") || respBody.getInteger("retcode") != 0) {
p.fail("BatchDownload 请求失败: " + respBody.encodePrettily());
return;
}
JsonArray downloadRsp = respBody.getJsonObject("data").getJsonArray("download_rsp");
if (downloadRsp == null || downloadRsp.isEmpty()) {
p.fail("BatchDownload 响应中缺少 download_rsp");
return;
}
String url = downloadRsp.getJsonObject(0).getString("url");
if (url != null && url.startsWith("&filename=")) {
p.fail("该文件已被和谐");
return;
}
if (fileName != null) {
url = url + "&filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8);
}
p.complete(url);
})
.onFailure(e -> {
LOG.error("BatchDownload 请求异常", e);
p.fail(e);
});
}
/**
* 调用 GetFileList API 获取指定目录下的文件列表
*/
private Future<JsonArray> fetchFileList(String filesetId, String parentId) {
Promise<JsonArray> p = Promise.promise();
JsonObject body = new JsonObject()
.put("fileset_id", filesetId)
.put("req_infos", new JsonArray()
.add(new JsonObject()
.put("parent_id", parentId)
.put("req_depth", 1)
.put("count", 50)
.put("filter_condition", new JsonObject().put("file_category", 0))
.put("sort_conditions", new JsonArray()
.add(new JsonObject()
.put("sort_field", 0)
.put("sort_order", 0)))))
.put("support_folder_status", true);
MultiMap headers = GET_FILE_LIST_HEADERS.set("Referer", shareLinkInfo.getShareUrl());
client.postAbs(GET_FILE_LIST_API)
.putHeaders(headers)
.sendJsonObject(body)
.onSuccess(resp -> {
if (resp.statusCode() != 200) {
p.fail("GetFileList 请求失败,状态码: " + resp.statusCode());
return;
}
JsonObject respBody = asJson(resp);
if (respBody.getInteger("retcode", -1) != 0) {
p.fail("GetFileList 请求失败: " + respBody.getString("message", "未知错误"));
return;
}
JsonArray fileLists = respBody.getJsonObject("data").getJsonArray("file_lists");
if (fileLists == null || fileLists.isEmpty()) {
p.fail("GetFileList 响应中缺少 file_lists");
return;
}
JsonArray fileList = fileLists.getJsonObject(0).getJsonArray("file_list");
p.complete(fileList != null ? fileList : new JsonArray());
})
.onFailure(e -> {
LOG.error("GetFileList 请求异常", e);
p.fail(e);
});
return p.future();
}
/**
* 从 HTML 的 __NUXT_DATA__ 中提取 fileset_id
*/
String extractFilesetId(String html) {
// Nuxt __NUXT_DATA__ 中 fileset_id 出现在缓存 key 的嵌套 JSON 中
// 直接匹配 fileset_id 后面最近的 UUID跳过转义引号、冒号等非hex字符
Pattern pattern = Pattern.compile(
"fileset_id[^a-f0-9]*([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})");
Matcher matcher = pattern.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
/**
* 旧版解析方式(兼容单文件链接,通过 HTML 字符串搜索提取 UUID
*/
private void parseLegacy(String html, String fileName) {
String fileUUID = getFileUUID(html);
if (fileUUID == null) {
promise.fail("未能提取到文件UUID");
return;
}
LOG.info("使用旧版解析提取到的文件UUID: {}", fileUUID);
downloadFile(fileUUID, fileName);
}
String getFileUUID(String htmlJs) {
String keyword = "\"download_limit_status\"";
String marker = "},\"";
@@ -140,32 +319,23 @@ public class QQscTool extends PanBase {
String extracted = htmlJs.substring(quoteStart, quoteEnd);
LOG.debug("提取结果: {}", extracted);
return extracted;
} else {
LOG.error("未找到结束引号: {}", marker);
}
} else {
LOG.error("未找到标记: {} 在关键字: {} 之后", marker, keyword);
}
} else {
LOG.error("未找到关键字: {}", keyword);
}
return null;
}
public static String extractFileNameFromTitle(String content) {
// 匹配<title>和</title>之间的内容
Pattern pattern = Pattern.compile("<title>(.*?)</title>");
Matcher matcher = pattern.matcher(content);
if (matcher.find()) {
String fullTitle = matcher.group(1);
// 按 "" 分割,取前半部分
int sepIndex = fullTitle.indexOf("");
if (sepIndex != -1) {
return fullTitle.substring(0, sepIndex);
}
return fullTitle; // 如果没有分隔符,就返回全部
return fullTitle;
}
return null;
}
}

View File

@@ -3,10 +3,11 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import io.vertx.core.Future;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public class QQwTool extends QQTool {
@@ -16,54 +17,51 @@ public class QQwTool extends QQTool {
@Override
public Future<String> parse() {
client.getAbs(shareLinkInfo.getShareUrl()).send().onSuccess(res -> {
String html = res.bodyAsString();
Map<String, String> stringStringMap = extractVariables(html);
String url = stringStringMap.get("url");
String fn = stringStringMap.get("filename");
String size = stringStringMap.get("filesize");
String createBy = stringStringMap.get("nick");
FileInfo fileInfo = new FileInfo().setFileName(fn).setSize(Long.parseLong(size)).setCreateBy(createBy);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
if (url != null) {
String url302 = url.replace("\\x26", "&");
promise.complete(url302);
String k = shareLinkInfo.getShareKey();
String postBody = "f=json&k=" + URLEncoder.encode(k, StandardCharsets.UTF_8);
/*
clientNoRedirects.getAbs(url302).send().onSuccess(res2 -> {
MultiMap headers = res2.headers();
if (headers.contains("Location")) {
promise.complete(headers.get("Location"));
} else {
fail("找不到重定向URL");
client.postAbs("https://wx.mail.qq.com/s")
.putHeader("Content-Type", "application/x-www-form-urlencoded")
.putHeader("Accept", "application/json, text/plain, */*")
.putHeader("Referer", shareLinkInfo.getShareUrl())
.putHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0")
.sendBuffer(Buffer.buffer(postBody))
.onSuccess(res -> {
try {
JsonObject data = asJson(res);
JsonObject head = data.getJsonObject("head");
if (head == null || head.getInteger("ret", -1) != 0) {
String msg = head != null ? head.getString("msg", "未知错误") : "未知错误";
fail("API错误: " + msg);
return;
}
JsonObject body = data.getJsonObject("body");
if (body == null) {
fail("文件信息为空");
return;
}
String url = body.getString("url");
String fn = body.getString("name", "");
long size = body.getLong("size", 0L);
if (url == null || url.isEmpty()) {
fail("分享链接解析失败, 可能是链接失效");
return;
}
FileInfo fileInfo = new FileInfo().setFileName(fn).setSize(size);
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
String url302 = url.replace("\\x26", "&");
complete(url302);
} catch (Exception e) {
fail(e, "解析响应失败");
}
}).onFailure(handleFail());
*/
} else {
fail("分享链接解析失败, 可能是链接失效");
}
}).onFailure(handleFail());
return promise.future();
}
private Map<String, String> extractVariables(String jsCode) {
Map<String, String> variables = new HashMap<>();
// 正则表达式匹配 var 变量定义
String regex = "\\s+var\\s+(\\w+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^;\\r\\n]*))";
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(jsCode);
while (m.find()) {
String name = m.group(1);
String value = m.group(2) != null ? m.group(2)
: m.group(3) != null ? m.group(3)
: m.group(4);
variables.put(name, value);
}
return variables;
}
}

View File

@@ -67,11 +67,7 @@ public class WsTool extends PanBase {
String filepid = asJson(res2).getJsonObject("data").getString("ufileid"); // 文件夹pid
String filebid = asJson(res2).getJsonObject("data").getString("boxid"); // 文件夹bid
// 调试输出文件夹信息
System.out.println("文件夹期限: " + filetime);
System.out.println("文件夹大小: " + filesize);
System.out.println("文件夹pid: " + filepid);
System.out.println("文件夹bid: " + filebid);
log.debug("文件夹期限: {}, 大小: {}, pid: {}, bid: {}", filetime, filesize, filepid, filebid);
// 获取文件信息
httpClient.postAbs(SHARE_URL_API + "ufile/list").putHeaders(headers)
@@ -97,9 +93,7 @@ public class WsTool extends PanBase {
String filefid = asJson(res3).getJsonObject("data")
.getJsonArray("fileList").getJsonObject(0).getString("fid"); // 文件fid
// 调试输出文件信息
System.out.println("文件名称: " + filename);
System.out.println("文件fid: " + filefid);
log.debug("文件名称: {}, fid: {}", filename, filefid);
// 检查文件是否失效
httpClient.postAbs(SHARE_URL_API + "dl/sign").putHeaders(headers)
@@ -114,8 +108,7 @@ public class WsTool extends PanBase {
// 获取直链
String fileurl = asJson(res4).getJsonObject("data").getString("url");
// 调试输出文件直链
System.out.println("文件直链: " + fileurl);
log.debug("文件直链: {}", fileurl);
if (!fileurl.equals("")) {
promise.complete(URLDecoder.decode(fileurl, StandardCharsets.UTF_8));

View File

@@ -14,7 +14,6 @@ import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.HexFormat;
import java.util.Random;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -299,7 +298,7 @@ public class AESUtils {
//length用户要求产生字符串的长度
public static String getRandomString(int length){
String str="abcdefghijklmnopqrstuvwxyz0123456789";
Random random=new Random();
SecureRandom random=new SecureRandom();
StringBuilder sb=new StringBuilder();
for(int i=0;i<length;i++){
int number=random.nextInt(36);

View File

@@ -33,6 +33,9 @@ public class CommonUtils {
public static Map<String, String> getURLParams(String url) throws MalformedURLException {
URL fullUrl = new URL(url);
String query = fullUrl.getQuery();
if (query == null || query.isEmpty()) {
return new HashMap<>();
}
String[] params = query.split("&");
Map<String, String> map = new HashMap<>();
for (String param : params) {

View File

@@ -5,6 +5,8 @@ import io.vertx.core.Vertx;
import io.vertx.core.http.impl.headers.HeadersMultiMap;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.client.WebClientSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
@@ -15,6 +17,8 @@ import java.util.ArrayList;
import java.util.List;
public class IpExtractor {
private static final Logger log = LoggerFactory.getLogger(IpExtractor.class);
public static void main(String[] args) throws InterruptedException {
@@ -42,9 +46,9 @@ public class IpExtractor {
WebClient client = WebClient.create(Vertx.vertx());
WebClientSession webClientSession = WebClientSession.create(client);
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res->{
System.out.println(res.toString());
log.debug("response: {}", res.toString());
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res2->{
System.out.println(res2.toString());
log.debug("response2: {}", res2.toString());
});
});

View File

@@ -21,11 +21,11 @@ import static cn.qaiu.util.AESUtils.encrypt;
*/
public class JsExecUtils {
private static final Invocable inv;
private static final ScriptEngineManager ENGINE_MANAGER = new ScriptEngineManager();
// 初始化脚本引擎
static {
ScriptEngineManager engineManager = new ScriptEngineManager();
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
try {
engine.eval(JsContent.ye123);
@@ -45,12 +45,11 @@ public class JsExecUtils {
}
/**
* 调用执行蓝奏云js文件
* 调用执行蓝奏云js文件每次动态JS代码无法复用引擎
*/
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
NoSuchMethodException {
ScriptEngineManager engineManager = new ScriptEngineManager();
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
engine.eval(JsContent.lz + "\n" + jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数
@@ -63,12 +62,11 @@ public class JsExecUtils {
/**
* 调用执行蓝奏云js文件
* 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例)
*/
public static Object executeOtherJs(String jsText, String funName, Object ... args) throws ScriptException,
NoSuchMethodException {
ScriptEngineManager engineManager = new ScriptEngineManager();
ScriptEngine engine = engineManager.getEngineByName("JavaScript"); // 得到脚本引擎
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
engine.eval(jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数

View File

@@ -8,15 +8,19 @@ import io.vertx.core.http.impl.headers.HeadersMultiMap;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.client.WebClientSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ReqIpUtil {
public static String BASE_URL = "https://ip.ihuan.me";
public static String BASE_URL_TEMPLATE = BASE_URL + "/{path}";
private static final Logger log = LoggerFactory.getLogger(ReqIpUtil.class);
public static final String BASE_URL = "https://ip.ihuan.me";
public static final String BASE_URL_TEMPLATE = BASE_URL + "/{path}";
// GET https://ip.ihuan.me/mouse.do -> $("input[name='key']").val("30b4975b5547fed806bd2b9caa18485a");
public static String PATH1 = "mouse.do";
public static final String PATH1 = "mouse.do";
public static String PATH2 = "tqdl.html";
public static final String PATH2 = "tqdl.html";
// 创建请求头Map
static MultiMap headers = new HeadersMultiMap();
@@ -58,15 +62,15 @@ public class ReqIpUtil {
void next(AsyncResult<HttpResponse<Buffer>> response) {
if (response.failed()) {
response.cause().printStackTrace();
log.error("请求失败", response.cause());
} else {
HttpResponse<Buffer> res = response.result();
System.out.println("Received response with status code " + res.statusCode());
System.out.println("Body: " + res.body());
log.debug("Received response with status code {}", res.statusCode());
log.debug("Body: {}", res.body());
webClientSession.getAbs(BASE_URL_TEMPLATE).setTemplateParam("path", PATH1)
.putHeaders(headers) // 将请求头Map添加到请求中
.send(response2 -> {
System.out.println(response2.result().bodyAsString());
log.debug("response2: {}", response2.result().bodyAsString());
});
}

View File

@@ -2,6 +2,9 @@ package cn.qaiu.util;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
@@ -10,6 +13,8 @@ import java.util.Map;
public class URLUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(URLUtil.class);
private final Map<String, String> queryParams = new HashMap<>();
// 构造函数传入URL并解析参数
@@ -31,7 +36,7 @@ public class URLUtil {
}
}
} catch (Exception e) {
e.printStackTrace();
LOGGER.error("URL解析失败: {}", url, e);
}
}

View File

@@ -1,105 +1,105 @@
// ==UserScript==
// @name Fetch API示例解析器
// @type fetch_demo
// @displayName Fetch演示
// @description 演示如何在ES5环境中使用fetch API和async/await
// @match https?://example\.com/s/(?<KEY>\w+)
// @author QAIU
// @version 1.0.0
// ==/UserScript==
// // ==UserScript==
// // @name Fetch API示例解析器
// // @type fetch_demo
// // @displayName Fetch演示
// // @description 演示如何在ES5环境中使用fetch API和async/await
// // @match https?://example\.com/s/(?<KEY>\w+)
// // @author QAIU
// // @version 1.0.0
// // ==/UserScript==
// 使用require导入类型定义仅用于IDE类型提示
var types = require('./types');
/** @typedef {types.ShareLinkInfo} ShareLinkInfo */
/** @typedef {types.JsHttpClient} JsHttpClient */
/** @typedef {types.JsLogger} JsLogger */
// // 使用require导入类型定义仅用于IDE类型提示
// var types = require('./types');
// /** @typedef {types.ShareLinkInfo} ShareLinkInfo */
// /** @typedef {types.JsHttpClient} JsHttpClient */
// /** @typedef {types.JsLogger} JsLogger */
/**
* 演示使用fetch API的解析器
* 注意虽然源码中使用了ES6+语法async/await但在浏览器中会被编译为ES5
*
* @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
* @param {JsHttpClient} http - HTTP客户端传统方式
* @param {JsLogger} logger - 日志对象
* @returns {string} 下载链接
*/
function parse(shareLinkInfo, http, logger) {
logger.info("=== Fetch API Demo ===");
// /**
// * 演示使用fetch API的解析器
// * 注意虽然源码中使用了ES6+语法async/await但在浏览器中会被编译为ES5
// *
// * @param {ShareLinkInfo} shareLinkInfo - 分享链接信息
// * @param {JsHttpClient} http - HTTP客户端传统方式
// * @param {JsLogger} logger - 日志对象
// * @returns {string} 下载链接
// */
// function parse(shareLinkInfo, http, logger) {
// logger.info("=== Fetch API Demo ===");
// 方式1使用传统的http对象同步
logger.info("方式1: 使用传统http对象");
var response1 = http.get("https://httpbin.org/get");
logger.info("状态码: " + response1.statusCode());
// // 方式1使用传统的http对象同步
// logger.info("方式1: 使用传统http对象");
// var response1 = http.get("https://httpbin.org/get");
// logger.info("状态码: " + response1.statusCode());
// 方式2使用fetch API基于Promise
logger.info("方式2: 使用fetch API");
// // 方式2使用fetch API基于Promise
// logger.info("方式2: 使用fetch API");
// 注意在ES5环境中我们需要手动处理Promise
// 这个示例展示了如何在ES5中使用fetch
var fetchPromise = fetch("https://httpbin.org/get");
// // 注意在ES5环境中我们需要手动处理Promise
// // 这个示例展示了如何在ES5中使用fetch
// var fetchPromise = fetch("https://httpbin.org/get");
// 等待Promise完成同步等待模拟
var result = null;
var error = null;
// // 等待Promise完成同步等待模拟
// var result = null;
// var error = null;
fetchPromise
.then(function(response) {
logger.info("Fetch响应状态: " + response.status);
return response.text();
})
.then(function(text) {
logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
result = "https://example.com/download/demo.file";
})
['catch'](function(err) {
logger.error("Fetch失败: " + err.message);
error = err;
});
// fetchPromise
// .then(function(response) {
// logger.info("Fetch响应状态: " + response.status);
// return response.text();
// })
// .then(function(text) {
// logger.info("Fetch响应内容: " + text.substring(0, 100) + "...");
// result = "https://example.com/download/demo.file";
// })
// ['catch'](function(err) {
// logger.error("Fetch失败: " + err.message);
// error = err;
// });
// 简单的等待循环(实际场景中不推荐,这里仅作演示)
var timeout = 5000; // 5秒超时
var start = Date.now();
while (result === null && error === null && (Date.now() - start) < timeout) {
// 等待Promise完成
java.lang.Thread.sleep(10);
}
// // 简单的等待循环(实际场景中不推荐,这里仅作演示)
// var timeout = 5000; // 5秒超时
// var start = Date.now();
// while (result === null && error === null && (Date.now() - start) < timeout) {
// // 等待Promise完成
// java.lang.Thread.sleep(10);
// }
if (error !== null) {
throw error;
}
// if (error !== null) {
// throw error;
// }
if (result === null) {
throw new Error("Fetch超时");
}
// if (result === null) {
// throw new Error("Fetch超时");
// }
return result;
}
// return result;
// }
/**
* 演示POST请求
*/
function demonstratePost(logger) {
logger.info("=== 演示POST请求 ===");
// /**
// * 演示POST请求
// */
// function demonstratePost(logger) {
// logger.info("=== 演示POST请求 ===");
var postPromise = fetch("https://httpbin.org/post", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
key: "value",
demo: true
})
});
// var postPromise = fetch("https://httpbin.org/post", {
// method: "POST",
// headers: {
// "Content-Type": "application/json"
// },
// body: JSON.stringify({
// key: "value",
// demo: true
// })
// });
postPromise
.then(function(response) {
return response.json();
})
.then(function(data) {
logger.info("POST响应: " + JSON.stringify(data));
})
['catch'](function(err) {
logger.error("POST失败: " + err.message);
});
}
// postPromise
// .then(function(response) {
// return response.json();
// })
// .then(function(data) {
// logger.info("POST响应: " + JSON.stringify(data));
// })
// ['catch'](function(err) {
// logger.error("POST失败: " + err.message);
// });
// }

View File

@@ -8,6 +8,8 @@ import cn.qaiu.parser.customjs.JsParserExecutor;
import cn.qaiu.WebClientVertxInit;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.HashMap;
@@ -22,15 +24,26 @@ import java.util.Map;
*/
public class BaiduPhotoParserTest {
private Vertx vertx;
@Before
public void setUp() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
}
@After
public void tearDown() {
if (vertx != null) {
vertx.close();
}
}
@Test
public void testBaiduPhotoParserRegistration() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
// 检查是否加载了百度相册解析器
CustomParserConfig config = CustomParserRegistry.get("baidu_photo");
assert config != null : "百度相册解析器未加载";
@@ -44,11 +57,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoFileShareExecution() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建解析器 - 测试文件分享链接
IPanTool tool = ParserCreate.fromType("baidu_photo")
@@ -76,11 +85,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoFolderShareExecution() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建解析器 - 测试文件夹分享链接
IPanTool tool = ParserCreate.fromType("baidu_photo")
@@ -108,11 +113,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoParserFileList() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
IPanTool tool = ParserCreate.fromType("baidu_photo")
// 分享key PPgOEodBVE
@@ -166,11 +167,7 @@ public class BaiduPhotoParserTest {
public void testBaiduPhotoParserById() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建ShareLinkInfo
Map<String, Object> otherParam = new HashMap<>();

View File

@@ -7,6 +7,8 @@ import cn.qaiu.parser.custom.CustomParserRegistry;
import cn.qaiu.parser.customjs.JsParserExecutor;
import cn.qaiu.WebClientVertxInit;
import io.vertx.core.Vertx;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.HashMap;
@@ -21,15 +23,26 @@ import java.util.Map;
*/
public class JsParserTest {
private Vertx vertx;
@Before
public void setUp() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
}
@After
public void tearDown() {
if (vertx != null) {
vertx.close();
}
}
@Test
public void testJsParserRegistration() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
// 检查是否加载了JavaScript解析器
CustomParserConfig config = CustomParserRegistry.get("demo_js");
assert config != null : "JavaScript解析器未加载";
@@ -43,11 +56,7 @@ public class JsParserTest {
public void testJsParserExecution() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建解析器
IPanTool tool = ParserCreate.fromType("demo_js")
@@ -74,11 +83,7 @@ public class JsParserTest {
public void testJsParserFileList() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建解析器
IPanTool tool = ParserCreate.fromType("demo_js")
@@ -114,11 +119,7 @@ public class JsParserTest {
public void testJsParserById() {
// 清理注册表
CustomParserRegistry.clear();
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
try {
// 创建ShareLinkInfo
Map<String, Object> otherParam = new HashMap<>();

View File

@@ -129,15 +129,205 @@ public class PanDomainTemplateTest {
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
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 =
Arrays.stream(PanDomainTemplate.values()).map(PanDomainTemplate::getRegex).collect(Collectors.toSet());

View File

@@ -7,6 +7,8 @@ import cn.qaiu.parser.ParserCreate;
import cn.qaiu.parser.custom.CustomParserConfig;
import cn.qaiu.parser.custom.CustomParserRegistry;
import io.vertx.core.Vertx;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -16,18 +18,29 @@ import org.slf4j.LoggerFactory;
* 测试fetch API和Promise polyfill功能
*/
public class JsFetchBridgeTest {
private static final Logger log = LoggerFactory.getLogger(JsFetchBridgeTest.class);
private Vertx vertx;
@Before
public void setUp() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
}
@After
public void tearDown() {
if (vertx != null) {
vertx.close();
}
}
@Test
public void testFetchPolyfillLoaded() {
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
// 清理注册表
CustomParserRegistry.clear();
// 创建一个简单的解析器配置
String jsCode = """
// 测试Promise是否可用
@@ -83,13 +96,9 @@ public class JsFetchBridgeTest {
@Test
public void testPromiseBasicUsage() {
// 初始化Vertx
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
// 清理注册表
CustomParserRegistry.clear();
String jsCode = """
function parse(shareLinkInfo, http, logger) {
logger.info("测试Promise基本用法");

View File

@@ -11,7 +11,7 @@
## 测试配置
### 小飞机网盘 ✅
- **用户名**: 15764091073
- **用户名**: 15x
- **URL**: https://share.feijipan.com/s/ZWYoZ31c
- **文件**: 资源.rar (1.13 GB)
- **认证方式**: username/password
@@ -32,7 +32,7 @@
```
=== 测试小飞机网盘解析(带认证)===
分享链接: https://share.feijipan.com/s/ZWYoZ31c
用户名: 15764091073
用户名: 15x
密码: ******
开始解析...

View File

@@ -0,0 +1,329 @@
// ==FetchRuntime==
// @name Fetch API Polyfill for ES5
// @description Fetch API and Promise implementation for ES5 JavaScript engines
// @version 1.0.0
// @author QAIU
// ==============
/**
* Simple Promise implementation compatible with ES5
* Supports basic Promise functionality needed for fetch API
*/
function SimplePromise(executor) {
var state = 'pending';
var value;
var handlers = [];
var self = this;
function resolve(result) {
if (state !== 'pending') return;
state = 'fulfilled';
value = result;
handlers.forEach(handle);
handlers = [];
}
function reject(err) {
if (state !== 'pending') return;
state = 'rejected';
value = err;
handlers.forEach(handle);
handlers = [];
}
function handle(handler) {
if (state === 'pending') {
handlers.push(handler);
} else {
setTimeout(function() {
if (state === 'fulfilled' && typeof handler.onFulfilled === 'function') {
try {
var result = handler.onFulfilled(value);
if (result && typeof result.then === 'function') {
result.then(handler.resolve, handler.reject);
} else {
handler.resolve(result);
}
} catch (e) {
handler.reject(e);
}
}
if (state === 'rejected' && typeof handler.onRejected === 'function') {
try {
var result = handler.onRejected(value);
if (result && typeof result.then === 'function') {
result.then(handler.resolve, handler.reject);
} else {
handler.resolve(result);
}
} catch (e) {
handler.reject(e);
}
} else if (state === 'rejected' && !handler.onRejected) {
handler.reject(value);
}
}, 0);
}
}
this.then = function(onFulfilled, onRejected) {
return new SimplePromise(function(resolveNext, rejectNext) {
handle({
onFulfilled: onFulfilled,
onRejected: onRejected,
resolve: resolveNext,
reject: rejectNext
});
});
};
this['catch'] = function(onRejected) {
return this.then(null, onRejected);
};
this['finally'] = function(onFinally) {
return this.then(
function(value) {
return SimplePromise.resolve(onFinally()).then(function() {
return value;
});
},
function(reason) {
return SimplePromise.resolve(onFinally()).then(function() {
throw reason;
});
}
);
};
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
// Static methods
SimplePromise.resolve = function(value) {
if (value && typeof value.then === 'function') {
return value;
}
return new SimplePromise(function(resolve) {
resolve(value);
});
};
SimplePromise.reject = function(reason) {
return new SimplePromise(function(resolve, reject) {
reject(reason);
});
};
SimplePromise.all = function(promises) {
return new SimplePromise(function(resolve, reject) {
var results = [];
var remaining = promises.length;
if (remaining === 0) {
resolve(results);
return;
}
function handleResult(index, value) {
results[index] = value;
remaining--;
if (remaining === 0) {
resolve(results);
}
}
for (var i = 0; i < promises.length; i++) {
(function(index) {
var promise = promises[index];
if (promise && typeof promise.then === 'function') {
promise.then(
function(value) { handleResult(index, value); },
reject
);
} else {
handleResult(index, promise);
}
})(i);
}
});
};
SimplePromise.race = function(promises) {
return new SimplePromise(function(resolve, reject) {
if (promises.length === 0) {
// Per spec, Promise.race with empty array stays pending forever
return;
}
for (var i = 0; i < promises.length; i++) {
var promise = promises[i];
if (promise && typeof promise.then === 'function') {
promise.then(resolve, reject);
} else {
resolve(promise);
return;
}
}
});
};
// Make Promise global if not already defined
if (typeof Promise === 'undefined') {
var Promise = SimplePromise;
}
/**
* Response object that mimics the Fetch API Response
*/
function FetchResponse(jsHttpResponse) {
this._jsResponse = jsHttpResponse;
this.status = jsHttpResponse.statusCode();
this.ok = this.status >= 200 && this.status < 300;
// Map HTTP status codes to standard status text
var statusTexts = {
200: 'OK',
201: 'Created',
204: 'No Content',
301: 'Moved Permanently',
302: 'Found',
304: 'Not Modified',
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
408: 'Request Timeout',
409: 'Conflict',
410: 'Gone',
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout'
};
this.statusText = statusTexts[this.status] || (this.ok ? 'OK' : 'Error');
this.headers = {
get: function(name) {
return jsHttpResponse.header(name);
},
has: function(name) {
return jsHttpResponse.header(name) !== null;
},
entries: function() {
var headerMap = jsHttpResponse.headers();
var entries = [];
for (var key in headerMap) {
if (headerMap.hasOwnProperty(key)) {
entries.push([key, headerMap[key]]);
}
}
return entries;
}
};
}
FetchResponse.prototype.text = function() {
var body = this._jsResponse.body();
return SimplePromise.resolve(body || '');
};
FetchResponse.prototype.json = function() {
var self = this;
return this.text().then(function(text) {
try {
return JSON.parse(text);
} catch (e) {
throw new Error('Invalid JSON: ' + e.message);
}
});
};
FetchResponse.prototype.arrayBuffer = function() {
var bytes = this._jsResponse.bodyBytes();
return SimplePromise.resolve(bytes);
};
FetchResponse.prototype.blob = function() {
// Blob not supported in ES5, return bytes
return this.arrayBuffer();
};
/**
* Fetch API implementation using JavaFetch bridge
* @param {string} url - Request URL
* @param {Object} options - Fetch options (method, headers, body, etc.)
* @returns {Promise<FetchResponse>}
*/
function fetch(url, options) {
return new SimplePromise(function(resolve, reject) {
try {
// Parse options
options = options || {};
var method = (options.method || 'GET').toUpperCase();
var headers = options.headers || {};
var body = options.body;
// Prepare request options for JavaFetch
var requestOptions = {
method: method,
headers: {}
};
// Convert headers to simple object
if (headers) {
if (typeof headers.forEach === 'function') {
// Headers object
headers.forEach(function(value, key) {
requestOptions.headers[key] = value;
});
} else if (typeof headers === 'object') {
// Plain object
for (var key in headers) {
if (headers.hasOwnProperty(key)) {
requestOptions.headers[key] = headers[key];
}
}
}
}
// Add body if present
if (body !== undefined && body !== null) {
if (typeof body === 'string') {
requestOptions.body = body;
} else if (typeof body === 'object') {
// Assume JSON
requestOptions.body = JSON.stringify(body);
if (!requestOptions.headers['Content-Type'] && !requestOptions.headers['content-type']) {
requestOptions.headers['Content-Type'] = 'application/json';
}
}
}
// Call JavaFetch bridge
var jsHttpResponse = JavaFetch.fetch(url, requestOptions);
// Create Response object
var response = new FetchResponse(jsHttpResponse);
resolve(response);
} catch (e) {
reject(e);
}
});
}
// Export for global use
if (typeof window !== 'undefined') {
window.fetch = fetch;
window.Promise = Promise;
} else if (typeof global !== 'undefined') {
global.fetch = fetch;
global.Promise = Promise;
}

13
pom.xml
View File

@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.2.1</revision>
<revision>0.3.4</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
@@ -25,16 +25,17 @@
<packageDirectory>${project.basedir}/web-service/target/package</packageDirectory>
<!-- Vert.x 4.5.24 已包含安全修复,无需单独指定 Netty 版本 -->
<vertx.version>4.5.14</vertx.version>
<!-- Vert.x 4.5.27 包含安全修复,无需单独指定 Netty 版本 -->
<vertx.version>4.5.27</vertx.version>
<org.reflections.version>0.10.2</org.reflections.version>
<lombok.version>1.18.38</lombok.version>
<slf4j.version>2.0.16</slf4j.version>
<commons-lang3.version>3.18.0</commons-lang3.version>
<commons-beanutils2.version>2.0.0</commons-beanutils2.version>
<jackson.version>2.18.2</jackson.version>
<parserVersion>10.2.5</parserVersion>
<jackson.version>2.18.6</jackson.version>
<!-- Logback 最新稳定版 -->
<logback.version>1.5.18</logback.version>
<logback.version>1.5.32</logback.version>
<junit.version>4.13.2</junit.version>
</properties>
@@ -74,7 +75,7 @@
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.2.5</version>
<version>${parserVersion}</version>
</dependency>
</dependencies>
</dependencyManagement>

BIN
web-front/img/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -5,15 +5,15 @@
"scripts": {
"serve": "vue-cli-service serve",
"dev": "vue-cli-service serve",
"build": "vue-cli-service build && node scripts/compress-vs.js",
"build:no-compress": "vue-cli-service build",
"build": "node scripts/sync-version.js && vue-cli-service build && node scripts/compress-vs.js",
"build:no-compress": "node scripts/sync-version.js && vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@monaco-editor/loader": "^1.4.0",
"@vueuse/core": "^11.2.0",
"axios": "1.13.5",
"axios": "1.16.1",
"clipboard": "^2.0.11",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

View File

@@ -10,7 +10,9 @@
<meta name="description"
content="Netdisk fast download 网盘直链解析工具">
<!-- 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://s4.zstatic.net/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 迅雷 JS-SDK -->
<script src="//open.thunderurl.com/thunder-link.js"></script>
<style>
.page-loading-wrap {
padding: 120px;

View File

@@ -0,0 +1,23 @@
const fs = require('fs');
const path = require('path');
const pomPath = path.resolve(__dirname, '../../pom.xml');
const pkgPath = path.resolve(__dirname, '../package.json');
const pomContent = fs.readFileSync(pomPath, 'utf-8');
const match = pomContent.match(/<revision>([^<]+)<\/revision>/);
if (!match) {
console.error('sync-version: <revision> not found in root pom.xml');
process.exit(1);
}
const version = match[1];
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
if (pkg.version === version) {
console.log(`sync-version: package.json already at ${version}`);
process.exit(0);
}
pkg.version = version;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`sync-version: package.json ${pkg.version} -> ${version}`);

View File

@@ -36,7 +36,6 @@ if (item) {
const darkMode = ref(item)
watch(darkMode, (newValue) => {
console.log(`darkMode: ${newValue}`)
window.localStorage.setItem("darkMode", newValue);
// 发射主题变化事件

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -1,125 +0,0 @@
import axios from 'axios'
// 创建 axios 实例
const api = axios.create({
baseURL: process.env.VUE_APP_API_BASE_URL || 'http://localhost:6400',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
config => {
// 可以在这里添加认证token等
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
response => {
return response.data
},
error => {
console.error('API请求错误:', error)
if (error.response) {
// 服务器返回错误状态码
const message = error.response.data?.message || error.response.data?.error || '服务器错误'
return Promise.reject(new Error(message))
} else if (error.request) {
// 网络错误
return Promise.reject(new Error('网络连接失败,请检查网络设置'))
} else {
// 其他错误
return Promise.reject(new Error(error.message || '请求失败'))
}
}
)
// 客户端链接 API
export const clientLinksApi = {
/**
* 获取所有客户端下载链接
* @param {string} shareUrl - 分享链接
* @param {string} password - 提取码(可选)
* @returns {Promise} 客户端链接响应
*/
async getClientLinks(shareUrl, password = '') {
const params = new URLSearchParams()
params.append('url', shareUrl)
if (password) {
params.append('pwd', password)
}
return await api.get(`/v2/clientLinks?${params.toString()}`)
},
/**
* 获取指定类型的客户端下载链接
* @param {string} shareUrl - 分享链接
* @param {string} password - 提取码(可选)
* @param {string} clientType - 客户端类型
* @returns {Promise} 指定类型的客户端链接
*/
async getClientLink(shareUrl, password = '', clientType) {
const params = new URLSearchParams()
params.append('url', shareUrl)
if (password) {
params.append('pwd', password)
}
params.append('clientType', clientType)
return await api.get(`/v2/clientLink?${params.toString()}`)
}
}
// 其他 API如果需要的话
export const parserApi = {
/**
* 解析分享链接
* @param {string} shareUrl - 分享链接
* @param {string} password - 提取码(可选)
* @returns {Promise} 解析结果
*/
async parseLink(shareUrl, password = '') {
const params = new URLSearchParams()
params.append('url', shareUrl)
if (password) {
params.append('pwd', password)
}
return await api.get(`/v2/linkInfo?${params.toString()}`)
},
/**
* 获取文件列表
* @param {string} shareUrl - 分享链接
* @param {string} password - 提取码(可选)
* @param {string} dirId - 目录ID可选
* @param {string} uuid - UUID可选
* @returns {Promise} 文件列表
*/
async getFileList(shareUrl, password = '', dirId = '', uuid = '') {
const params = new URLSearchParams()
params.append('url', shareUrl)
if (password) {
params.append('pwd', password)
}
if (dirId) {
params.append('dirId', dirId)
}
if (uuid) {
params.append('uuid', uuid)
}
return await api.get(`/v2/getFileList?${params.toString()}`)
}
}
export default api

View File

@@ -0,0 +1,6 @@
/**
* 前端全局常量
*/
/** 预览服务基础 URL */
export const PREVIEW_BASE_URL = 'https://nfd-parser.github.io/nfd-preview/preview.html?src='

View File

@@ -0,0 +1,462 @@
/**
* 下载器服务 - 统一管理 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 APIGET /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 APIPOST /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.multicallgopeed 用 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'
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
}

View File

@@ -313,7 +313,6 @@ export async function loadTypesFromApi(monaco) {
cachedContent,
'file:///types.js'
);
console.log('从缓存加载types.js成功');
// 异步更新缓存
updateTypesJsCache();
return;
@@ -334,7 +333,6 @@ export async function loadTypesFromApi(monaco) {
typesJsContent,
'file:///types.js'
);
console.log('加载types.js成功并已缓存');
}
} catch (error) {
console.warn('加载types.js失败使用内置类型定义:', error);
@@ -350,7 +348,6 @@ async function updateTypesJsCache() {
if (response.ok) {
const typesJsContent = await response.text();
localStorage.setItem('playground_types_js', typesJsContent);
console.log('types.js缓存已更新');
}
} catch (error) {
console.warn('更新types.js缓存失败:', error);

View File

@@ -5,6 +5,15 @@ const axiosInstance = axios.create({
withCredentials: true // 重要允许跨域请求携带cookie
});
// 请求拦截器将存储的Token添加到Authorization请求头
axiosInstance.interceptors.request.use(config => {
const token = localStorage.getItem('playground_token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
/**
* 演练场API服务
*/
@@ -30,7 +39,12 @@ export const playgroundApi = {
async login(password) {
try {
const response = await axiosInstance.post('/v2/playground/login', { password });
return response.data;
const data = response.data;
// 登录成功时从响应中提取并保存Token
if ((data.code === 200 || data.success) && data.data?.token) {
localStorage.setItem('playground_token', data.data.token);
}
return data;
} catch (error) {
throw new Error(error.response?.data?.error || error.message || '登录失败');
}

View File

@@ -293,24 +293,6 @@ export default {
return clientConfig[type]?.downloadUrl || '#'
}
// 判断是否应该显示下载客户端按钮
const shouldShowDownloadButton = (type) => {
const os = getOSInfo()
switch (type) {
case 'CURL':
// cURL 在 Windows 上可能需要安装
return os === 'windows'
case 'ARIA2':
// Aria2 需要手动安装
return true
case 'THUNDER':
// 迅雷主要在 Windows 上使用
return os === 'windows'
default:
return false
}
}
// 获取操作系统信息
const getOSInfo = () => {
const userAgent = navigator.userAgent.toLowerCase()
@@ -369,7 +351,7 @@ export default {
copyToClipboard(link)
return
}
window.open(link, '_blank')
window.open(link, '_blank', 'noopener,noreferrer')
ElMessage.success('正在唤起迅雷下载')
break
@@ -383,13 +365,6 @@ export default {
}
}
// 下载客户端
const downloadClient = (type) => {
const url = getClientDownloadUrl(type)
window.open(url, '_blank')
ElMessage.success(`正在跳转到 ${getClientDisplayName(type)} 下载页面`)
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (!bytes) return '未知'
@@ -440,9 +415,7 @@ export default {
getTextareaRows,
goBack,
getClientLogo,
downloadClient,
handleImageError,
shouldShowDownloadButton,
getClientSupportsCookie,
goToAuthConfig
}

File diff suppressed because it is too large Load Diff

View File

@@ -653,22 +653,22 @@
<p>更多详细信息请参考 GitHub 仓库文档</p>
<ul>
<li>
<a href="https://github.com/qaiu/netdisk-fast-download/blob/main/parser/doc/JAVASCRIPT_PARSER_GUIDE.md" target="_blank" rel="noopener noreferrer">
<a :href="githubRepoUrl + '/blob/main/parser/doc/JAVASCRIPT_PARSER_GUIDE.md'" target="_blank" rel="noopener noreferrer">
JavaScript 解析器开发指南
</a>
</li>
<li>
<a href="https://github.com/qaiu/netdisk-fast-download/blob/main/parser/doc/CUSTOM_PARSER_GUIDE.md" target="_blank" rel="noopener noreferrer">
<a :href="githubRepoUrl + '/blob/main/parser/doc/CUSTOM_PARSER_GUIDE.md'" target="_blank" rel="noopener noreferrer">
自定义解析器扩展指南
</a>
</li>
<li>
<a href="https://github.com/qaiu/netdisk-fast-download/blob/main/parser/doc/CUSTOM_PARSER_QUICKSTART.md" target="_blank" rel="noopener noreferrer">
<a :href="githubRepoUrl + '/blob/main/parser/doc/CUSTOM_PARSER_QUICKSTART.md'" target="_blank" rel="noopener noreferrer">
快速开始教程
</a>
</li>
<li>
<a href="https://github.com/qaiu/netdisk-fast-download/blob/main/parser/README.md" target="_blank" rel="noopener noreferrer">
<a :href="githubRepoUrl + '/blob/main/parser/README.md'" target="_blank" rel="noopener noreferrer">
解析器模块文档
</a>
</li>
@@ -858,6 +858,7 @@ export default {
},
setup() {
const router = useRouter();
const githubRepoUrl = process.env.VUE_APP_GITHUB_REPO_URL;
// 语言常量
const LANGUAGE = {
@@ -1146,10 +1147,11 @@ function parseById(shareLinkInfo, http, logger) {
const isAuthed = res.data.authed || res.data.public;
authed.value = isAuthed;
// 如果后端session已失效清除localStorage
// 如果后端认证已失效清除localStorage中的认证信息
if (!isAuthed && savedAuth === 'true') {
localStorage.removeItem('playground_authed');
localStorage.removeItem('playground_auth_time');
localStorage.removeItem('playground_token');
}
return isAuthed;
@@ -1177,7 +1179,7 @@ function parseById(shareLinkInfo, http, logger) {
// 新窗口打开首页
const goHomeInNewWindow = () => {
window.open('/', '_blank');
window.open('/', '_blank', 'noopener,noreferrer');
};
// 检查是否有未保存的文件
@@ -1757,7 +1759,6 @@ function parseFileList(shareLinkInfo, http, logger) {
testParams.value.method
);
console.log('测试结果:', result);
testResult.value = result;
// 将日志添加到控制台
@@ -1819,10 +1820,8 @@ function parseFileList(shareLinkInfo, http, logger) {
loadingList.value = true;
try {
const result = await playgroundApi.getParserList();
console.log('获取解析器列表响应:', result);
// 检查响应格式
if (result.code === 200 || result.success) {
console.log('列表数据:', result.data);
parserList.value = result.data || [];
} else if (result.data && Array.isArray(result.data)) {
// 如果data直接是数组
@@ -1856,7 +1855,6 @@ function parseFileList(shareLinkInfo, http, logger) {
try {
const codeToPublish = currentCode.value;
const result = await playgroundApi.saveParser(codeToPublish);
console.log('保存解析器响应:', result);
// 检查响应格式
if (result.code === 200 || result.success) {
// 从响应或代码中提取type信息
@@ -2222,6 +2220,8 @@ curl "${baseUrl}/json/parser?url=${encodeURIComponent(exampleUrl)}"</pre>
}, 100);
};
let themeObserver = null;
onMounted(async () => {
// 初始化移动端检测
updateIsMobile();
@@ -2248,10 +2248,10 @@ curl "${baseUrl}/json/parser?url=${encodeURIComponent(exampleUrl)}"</pre>
const html = document.documentElement;
if (html && html.classList) {
try {
const observer = new MutationObserver(() => {
themeObserver = new MutationObserver(() => {
checkDarkMode();
});
observer.observe(html, {
themeObserver.observe(html, {
attributes: true,
attributeFilter: ['class', 'data-theme']
});
@@ -2268,9 +2268,11 @@ curl "${baseUrl}/json/parser?url=${encodeURIComponent(exampleUrl)}"</pre>
window.removeEventListener('resize', updateIsMobile);
// 移除页面关闭/刷新前的提示
window.removeEventListener('beforeunload', handleBeforeUnload);
themeObserver?.disconnect();
});
return {
githubRepoUrl,
LANGUAGE,
editorRef,
jsCode,

View File

@@ -32,7 +32,7 @@
<script>
import axios from 'axios'
import fileTypeUtils from '@/utils/fileTypeUtils'
import { previewBaseUrl } from '@/views/Home.vue'
import { PREVIEW_BASE_URL } from '@/utils/constants'
export default {
name: 'ShowFile',
@@ -44,7 +44,7 @@ export default {
downloadUrl: '',
shareUrl: '', // 添加原始分享链接
fileTypeUtils,
previewBaseUrl
previewBaseUrl: PREVIEW_BASE_URL
}
},
methods: {
@@ -73,7 +73,7 @@ export default {
this.parseResult = res.data
this.downloadUrl = res.data.data?.directLink
} catch (e) {
this.error = '解析失败'
this.error = e.response?.data?.msg || e.response?.data?.error || '解析失败'
} finally {
this.loading = false
}

View File

@@ -55,7 +55,7 @@ export default {
const res = await axios.get('/v2/getFileList', { params: { url: this.url } })
this.directoryData = res.data.data || []
} catch (e) {
this.error = '目录解析失败'
this.error = e.response?.data?.msg || e.response?.data?.error || '目录解析失败'
} finally {
this.loading = false
}

Some files were not shown because too many files have changed in this diff Show More