Compare commits

..
15 Commits
Author SHA1 Message Date
q 5e20ed5050 fix: 123网盘(YE)支持authorization别名配置并修复auth临时参数登录被覆盖问题, 更新README认证说明, bump version to 0.3.7
引用 #201 #198 #116
说明: 123解析仅限于本地部署(可Windows一键运行 run.bat),公共/云端服务因风控限制不保证可用
2026-07-18 12:28:00 +08:00
qaiu 91f4fcaa76 更新 README.md
oosmetrics不可用
2026-07-11 06:28:53 +08:00
qaiu 0754fe20f1 更新 README.md 2026-07-10 16:41:58 +08:00
qaiu 034fd20d3b 更新 README.md 2026-07-10 16:41:06 +08:00
q 24bf98bca9 fix: support 123pan redirected share links 2026-07-06 08:50:18 +08:00
qaiu 275f6b9e6d 更新 README.md 2026-06-23 17:10:09 +08:00
qaiu 5db9e422f4 更新 README.md 2026-06-11 12:03:44 +08:00
qaiu 705b6ba4b7 Merge pull request #195 from yukaidi1220/codex/full-main-clean
Clean up fork changes for upstream review
2026-06-10 21:32:21 +08:00
yukaidi 0edc057347 docs: update parser and custom script docs 2026-06-10 21:23:05 +08:00
yukaidi 6ae5d998e6 fix(frontend): improve directory and timestamp display 2026-06-10 21:22:29 +08:00
yukaidi cbb442ceb6 fix(web): harden cache and API services 2026-06-10 21:21:52 +08:00
yukaidi 0103841fb5 fix(parser): harden built-in pan parsers 2026-06-10 21:20:40 +08:00
yukaidi b6b7f0d8b7 fix(parser): harden runtime resource handling 2026-06-10 21:19:56 +08:00
yukaidi 46c6827eda fix(core): harden proxy and routing lifecycle 2026-06-10 21:19:18 +08:00
yukaidi 1ef6e120a8 ci: improve build and packaging workflow 2026-06-10 21:18:44 +08:00
77 changed files with 4988 additions and 1819 deletions
+12 -1
View File
@@ -5,6 +5,7 @@ permissions:
packages: write
on:
workflow_dispatch:
push:
tags:
- '*'
@@ -103,6 +104,16 @@ jobs:
- name: 设置 Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 生成 Docker 标签
id: docker_tag
shell: bash
run: |
tag="$(printf '%s' "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_.-]+/-/g; s/^-+//; s/-+$//')"
if [ -z "$tag" ]; then
tag="snapshot"
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: 构建并推送 Docker 镜像
uses: docker/build-push-action@v5
with:
@@ -110,7 +121,7 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:${{ steps.docker_tag.outputs.tag }}
ghcr.io/${{ github.repository }}:latest
+40 -9
View File
@@ -6,8 +6,10 @@ QQ交流群:1017480890
<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://github.com/QAIU/netdisk-fast-download">
<img src="https://img.shields.io/github/stars/QAIU/netdisk-fast-download?style=flat&logo=github" alt="GitHub Stars">
</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>
@@ -16,10 +18,6 @@ QQ交流群:1017480890
![alt text](web-front/img/image.png)
## 国内镜像
本项目同步托管于 **AtomGit**,国内访问更流畅:👉 [https://atomgit.com/QAIU/netdisk-fast-download](https://atomgit.com/QAIU/netdisk-fast-download)
## 介绍
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
@@ -40,12 +38,12 @@ curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&
```shell
wget -O bilibili.mp4 "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
```
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FTk1F2kGQ&name=bilibili.mp4&ext=mp4):
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4):
```
### 调用演示站下载:
https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234
https://lz.qaiu.top/parser?url=https://www.ilanzou.com/s/CDx6xKbT&pwd=1234
### 调用演示站预览:
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FTk1F2kGQ&name=bilibili.mp4&ext=mp4
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4
```
@@ -71,7 +69,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
- [移动云云空间-ec](https://www.ecpan.cn/web)
- [小飞机网盘-fj](https://www.feijipan.com/)
- [亿方云-fc](https://www.fangcloud.com/)
- [123云盘-ye](https://www.123pan.com/)
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
- ~[115网盘(失效)-p115](https://115.com/)~
- [文叔叔-ws](https://www.wenshushu.cn/)
- [联想乐云-le](https://lecloud.lenovo.com/)
@@ -178,6 +176,7 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
**使用示例**
```
@@ -198,6 +197,38 @@ GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
- 目的:防止客户端伪造失败计数请求
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
#### `auth` 临时认证参数 与 `auths` 静态配置认证 的区别
本项目存在两种互相独立的认证配置方式,作用范围不同,不要混淆:
| 方式 | 配置位置 | 生效范围 | 适用场景 |
|------|---------|---------|---------|
| `auth` 临时认证参数 | 请求 URL 上的 `auth` 查询参数 | **仅当次请求**,优先级高于 app-dev.yml 中的静态配置 | 调用方按用户临时提供的账号/Cookie/token 解析,不同请求可携带不同认证信息 |
| `auths` 静态配置认证 | `app-dev.yml``auths.<网盘标识>` 节点 | **服务端长期生效**,所有未携带 `auth` 参数的请求都会复用 | 部署方自己长期配置一份账号,供所有请求默认使用 |
以 123网盘(`ye`)为例,`app-dev.yml` 中支持以下几种写法(三选一即可):
```yaml
auths:
ye:
username: 你的123网盘账号
password: 你的123网盘密码
```
```yaml
auths:
ye:
token: 已登录后获取的 Authorization/AccessToken
```
```yaml
auths:
ye:
authorization: 已登录后获取的 Authorization/AccessToken # 与 token 等价,二选一
```
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
### 特殊说明
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
+55 -9
View File
@@ -21,6 +21,8 @@ import java.nio.file.Path;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.LockSupport;
import static cn.qaiu.vx.core.util.ConfigConstant.*;
@@ -45,11 +47,25 @@ public final class Deploy {
private Handler<JsonObject> handle;
private Thread mainThread;
private final List<Runnable> preShutdownTasks = new CopyOnWriteArrayList<>();
private final List<Runnable> postShutdownTasks = new CopyOnWriteArrayList<>();
public static Deploy instance() {
return INSTANCE;
}
public void addPreShutdownTask(Runnable task) {
if (task != null) {
preShutdownTasks.add(task);
}
}
public void addPostShutdownTask(Runnable task) {
if (task != null) {
postShutdownTasks.add(task);
}
}
/**
*
* @param args 启动参数
@@ -133,9 +149,16 @@ public final class Deploy {
customConfig = globalConfig.getJsonObject(CUSTOM);
JsonObject vertxConfig = globalConfig.getJsonObject(VERTX);
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE);
var vertxOptions = vertxConfigELPS == 0 ?
new VertxOptions() : new VertxOptions(vertxConfig);
JsonObject vertxOptionsConfig = vertxConfig.copy();
if (vertxOptionsConfig.getInteger(EVENT_LOOP_POOL_SIZE, 0) == 0) {
vertxOptionsConfig.remove(EVENT_LOOP_POOL_SIZE);
}
if (vertxOptionsConfig.getInteger("workerPoolSize", 0) == 0) {
vertxOptionsConfig.remove("workerPoolSize");
}
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE, 0);
var vertxOptions = vertxOptionsConfig.isEmpty() ?
new VertxOptions() : new VertxOptions(vertxOptionsConfig);
// vertxOptions.setAddressResolverOptions(
// new AddressResolverOptions().
@@ -151,12 +174,16 @@ public final class Deploy {
// 注册 ShutdownHook,确保进程退出时优雅关闭资源
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOGGER.info("JVM shutting down, closing Vert.x...");
LOGGER.info("JVM shutting down...");
runShutdownTasks("before Vert.x close", preShutdownTasks);
try {
LOGGER.info("Closing Vert.x...");
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);
} finally {
runShutdownTasks("after Vert.x close", postShutdownTasks);
}
}));
//配置保存在共享数据中
@@ -165,24 +192,25 @@ public final class Deploy {
localMap.put(GLOBAL_CONFIG, globalConfig);
localMap.put(CUSTOM_CONFIG, customConfig);
localMap.put(SERVER, globalConfig.getJsonObject(SERVER));
var future0 = vertx.createSharedWorkerExecutor("other-handle")
.executeBlocking(() -> {
WorkerExecutor otherHandleExecutor = vertx.createSharedWorkerExecutor("other-handle");
var future0 = otherHandleExecutor.executeBlocking(() -> {
handle.handle(globalConfig);
return "Other handle complete";
});
future0.onSuccess(res -> {
otherHandleExecutor.close();
LOGGER.info(res);
// 部署 路由、异步service、反向代理 服务
var future1 = vertx.deployVerticle(RouterVerticle.class, getWorkDeploymentOptions("Router"));
var future2 = vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"));
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy", 1));
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
if (jsonObject != null) {
genPwd(jsonObject);
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy"));
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy", 1));
future4.onSuccess(LOGGER::info);
future4.onFailure(e -> LOGGER.error("Other handle error", e));
Future.all(future1, future2, future3, future4)
@@ -194,7 +222,10 @@ public final class Deploy {
.onFailure(this::deployVerticalFailed);
}
}).onFailure(e -> LOGGER.error("Other handle error", e));
}).onFailure(e -> {
otherHandleExecutor.close();
LOGGER.error("Other handle error", e);
});
}
private static void genPwd(JsonObject jsonObject) {
@@ -211,6 +242,21 @@ public final class Deploy {
jsonObject.getString("password"));
LOGGER.info("==============server info================");
}
private static void runShutdownTasks(String stage, List<Runnable> tasks) {
if (tasks.isEmpty()) {
return;
}
LOGGER.info("Running {} shutdown tasks: {}", stage, tasks.size());
for (Runnable task : tasks) {
try {
task.run();
} catch (Exception e) {
LOGGER.warn("Shutdown task failed at stage {}", stage, e);
}
}
}
/**
* 部署失败
*
@@ -1,14 +1,20 @@
package cn.qaiu.vx.core.base;
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
import cn.qaiu.vx.core.interceptor.AfterInterceptor;
import cn.qaiu.vx.core.model.JsonResult;
import cn.qaiu.vx.core.util.CommonUtil;
import cn.qaiu.vx.core.util.ReflectionUtil;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import static cn.qaiu.vx.core.util.ResponseUtil.*;
@@ -22,9 +28,10 @@ public interface BaseHttpApi {
// 需要扫描注册的Router路径
Reflections reflections = ReflectionUtil.getReflections();
Logger LOGGER = LoggerFactory.getLogger(BaseHttpApi.class);
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
if (!ctx.response().ended()) {
if (!isResponseDone(ctx)) {
fireJsonObjectResponse(ctx, jsonObject);
}
handleAfterInterceptor(ctx, jsonObject);
@@ -32,14 +39,14 @@ public interface BaseHttpApi {
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult) {
if (!ctx.response().ended()) {
if (!isResponseDone(ctx)) {
fireJsonResultResponse(ctx, jsonResult);
}
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
if (!ctx.response().ended()) {
if (!isResponseDone(ctx)) {
fireJsonObjectResponse(ctx, jsonObject, statusCode);
}
handleAfterInterceptor(ctx, jsonObject);
@@ -47,30 +54,78 @@ public interface BaseHttpApi {
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
if (!ctx.response().ended()) {
if (!isResponseDone(ctx)) {
fireJsonResultResponse(ctx, jsonResult, statusCode);
}
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default Set<AfterInterceptor> getAfterInterceptor() {
return AfterInterceptorHolder.INSTANCES;
}
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
reflections.getSubTypesOf(AfterInterceptor.class);
if (afterInterceptorClassSet == null) {
return null;
class AfterInterceptorHolder {
private static final Set<AfterInterceptor> INSTANCES = loadAfterInterceptors();
private static Set<AfterInterceptor> loadAfterInterceptors() {
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
reflections.getSubTypesOf(AfterInterceptor.class);
if (afterInterceptorClassSet == null || afterInterceptorClassSet.isEmpty()) {
return Collections.emptySet();
}
return afterInterceptorClassSet.stream()
.filter(AfterInterceptorHolder::isEnabled)
.sorted(AfterInterceptorHolder::compareOrder)
.map(AfterInterceptorHolder::newInterceptor)
.filter(Objects::nonNull)
.collect(Collectors.collectingAndThen(
Collectors.toCollection(LinkedHashSet::new),
Collections::unmodifiableSet));
}
private static boolean isEnabled(Class<? extends AfterInterceptor> clazz) {
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
return sort == null || sort.value() >= 0;
}
private static int compareOrder(Class<? extends AfterInterceptor> left, Class<? extends AfterInterceptor> right) {
return Integer.compare(order(left), order(right));
}
private static int order(Class<? extends AfterInterceptor> clazz) {
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
return sort == null ? 0 : sort.value();
}
private static AfterInterceptor newInterceptor(Class<? extends AfterInterceptor> clazz) {
try {
return ReflectionUtil.newWithNoParam(clazz);
} catch (Exception e) {
LOGGER.warn("AfterInterceptor 初始化失败,已跳过: {}", clazz.getName(), e);
return null;
}
}
return CommonUtil.sortClassSet(afterInterceptorClassSet);
}
default void handleAfterInterceptor(RoutingContext ctx, JsonObject jsonObject) {
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
if (afterInterceptor != null) {
afterInterceptor.forEach(ai -> ai.handle(ctx, jsonObject));
if (ctx.response().closed()) {
return;
}
if (!ctx.response().ended()) {
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
afterInterceptor.forEach(ai -> {
try {
ai.handle(ctx, jsonObject);
} catch (Exception e) {
LOGGER.warn("AfterInterceptor 执行失败: {}", ai.getClass().getName(), e);
}
});
if (!isResponseDone(ctx)) {
fireTextResponse(ctx, "handleAfterInterceptor: response not end");
}
}
default boolean isResponseDone(RoutingContext ctx) {
return ctx.response().ended() || ctx.response().closed();
}
}
@@ -34,6 +34,7 @@ import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -96,7 +97,9 @@ public class RouterHandlerFactory implements BaseHttpApi {
mainRouter.route().handler(CorsHandler.create().addRelativeOrigin(".*").allowCredentials(true).allowedMethods(httpMethods));
// 配置文件上传路径
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
mainRouter.route().handler(BodyHandler.create()
.setUploadsDirectory("uploads")
.setBodyLimit(2L * 1024 * 1024));
// 拦截器
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
@@ -175,7 +178,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
route.handler(TimeoutHandler.create(SharedDataUtil.getCustomConfig().getInteger(ROUTE_TIME_OUT)));
route.handler(ResponseTimeHandler.create());
route.handler(ctx -> handlerMethod(instance, method, ctx)).failureHandler(ctx -> {
if (ctx.response().ended()) return;
if (isResponseDone(ctx)) return;
// 超时处理器状态码503
if (ctx.statusCode() == 503 || ctx.failure() == null) {
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员"), 503);
@@ -394,26 +397,34 @@ public class RouterHandlerFactory implements BaseHttpApi {
if (data instanceof JsonResult jsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
}
if (data instanceof JsonObject) {
} else if (data instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) data));
} else if (data instanceof Future) { // 处理异步响应
((Future<?>) data).onSuccess(res -> {
if (res instanceof JsonResult jsonResult) {
doFireJsonResultResponse(ctx, jsonResult, jsonResult.getCode());
Future<?> responseFuture = (Future<?>) data;
AtomicReference<RoutingContext> ctxRef = new AtomicReference<>(ctx);
ctx.addEndHandler(v -> ctxRef.set(null));
responseFuture.onComplete(ar -> {
RoutingContext responseCtx = ctxRef.getAndSet(null);
if (responseCtx == null || isResponseDone(responseCtx)) {
return;
}
if (res instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) res));
} else if (res != null) {
doFireJsonResultResponse(ctx, JsonResult.data(res));
if (ar.succeeded()) {
Object res = ar.result();
if (res instanceof JsonResult jsonResult) {
doFireJsonResultResponse(responseCtx, jsonResult, jsonResult.getCode());
} else if (res instanceof JsonObject) {
doFireJsonObjectResponse(responseCtx, ((JsonObject) res));
} else if (res != null) {
doFireJsonResultResponse(responseCtx, JsonResult.data(res));
} else {
doFireJsonResultResponse(responseCtx, JsonResult.data(null));
}
} else {
doFireJsonResultResponse(ctx, JsonResult.data(null));
Throwable e = ar.cause();
LOGGER.error("请求处理失败", e);
String msg = e != null && e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(responseCtx, JsonResult.error(msg), 500);
}
}).onFailure(e -> {
LOGGER.error("请求处理失败", e);
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
});
} else {
doFireJsonResultResponse(ctx, JsonResult.data(data));
@@ -17,6 +17,8 @@ import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -29,6 +31,16 @@ public class CommonUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(CommonUtil.class);
/** 正则表达式缓存,避免每次调用重新编译 */
private static final ConcurrentHashMap<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();
/**
* 获取预编译的 Pattern(带缓存)
*/
private static Pattern getCachedPattern(String regex) {
return PATTERN_CACHE.computeIfAbsent(regex, Pattern::compile);
}
/**
* 匹配正则list
*
@@ -39,7 +51,7 @@ public class CommonUtil {
public static boolean matchRegList(List<?> regList, String destStr) {
// 判断是否忽略
for (Object ignores : regList) {
if (destStr.matches(ignores.toString())) {
if (getCachedPattern(ignores.toString()).matcher(destStr).matches()) {
return true;
}
}
@@ -147,10 +159,12 @@ public class CommonUtil {
public static String getAppVersion() {
if (null == appVersion) {
Properties properties = new Properties();
try {
properties.load(CommonUtil.class.getClassLoader().getResourceAsStream("app.properties"));
if (!properties.isEmpty()) {
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
try (var is = CommonUtil.class.getClassLoader().getResourceAsStream("app.properties")) {
if (is != null) {
properties.load(is);
if (!properties.isEmpty()) {
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
}
}
} catch (IOException e) {
LOGGER.error("读取app.properties失败", e);
@@ -4,17 +4,41 @@ import io.vertx.core.Future;
import io.vertx.core.Promise;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
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);
}
}
/** 默认同步等待超时时间(秒) */
private static final long DEFAULT_TIMEOUT_SECONDS = 120;
public static <T> T getResult(Future<T> future) {
try {
return future.toCompletionStage().toCompletableFuture()
.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
} catch (TimeoutException e) {
throw new RuntimeException("等待Future超时(" + DEFAULT_TIMEOUT_SECONDS + "秒)", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
throw new RuntimeException(cause != null ? cause : e);
}
}
public static <T> T getResult(Promise<T> promise) {
return promise.future().toCompletionStage().toCompletableFuture().join();
try {
return promise.future().toCompletionStage().toCompletableFuture()
.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
} catch (TimeoutException e) {
throw new RuntimeException("等待Promise超时(" + DEFAULT_TIMEOUT_SECONDS + "秒)", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
throw new RuntimeException(cause != null ? cause : e);
}
}
}
@@ -24,6 +24,7 @@ import java.lang.reflect.Method;
import java.net.URL;
import java.text.ParseException;
import java.util.*;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -44,6 +45,12 @@ public final class ReflectionUtil {
// 缓存Reflections实例,避免重复扫描(每次扫描约35K+值,耗时1-3秒,占用大量内存)
private static final Map<String, Reflections> REFLECTIONS_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
// 预编译的类型匹配正则,避免每次请求重新编译
private static final Pattern BASIC_TYPE_PATTERN = Pattern.compile(
"^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|(Float)|(Double)|(String))$");
private static final Pattern BASIC_TYPE_ARRAY_PATTERN = Pattern.compile(
"^(boolean|char|byte|short|int|long|float|double|String)\\[]$");
/**
* 以默认配置的基础包路径获取反射器
*
@@ -234,8 +241,7 @@ public final class ReflectionUtil {
if (ctClass.isPrimitive() || "java.util.Date".equals(ctClass.getName())) {
return true;
}
return ctClass.getName().matches("^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|" +
"(Float)|(Double)|(String))$");
return BASIC_TYPE_PATTERN.matcher(ctClass.getName()).matches();
}
/**
@@ -246,7 +252,7 @@ public final class ReflectionUtil {
public static boolean isBasicTypeArray(CtClass ctClass) {
if (!ctClass.isArray()) {
return false;
} else return (ctClass.getName().matches("^(boolean|char|byte|short|int|long|float|double|String)\\[]$"));
} else return BASIC_TYPE_ARRAY_PATTERN.matcher(ctClass.getName()).matches();
}
/**
@@ -12,14 +12,21 @@ import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
public class ResponseUtil {
public static void redirect(HttpServerResponse response, String url) {
if (response.ended() || response.closed()) {
return;
}
response.putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
.putHeader("Referrer-Policy", "no-referrer")
.putHeader(HttpHeaders.LOCATION, url).setStatusCode(302).end();
}
public static void redirect(HttpServerResponse response, String url, Promise<?> promise) {
redirect(response, url);
promise.complete();
try {
redirect(response, url);
promise.tryComplete();
} catch (Throwable t) {
promise.tryFail(t);
}
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
@@ -31,12 +38,18 @@ public class ResponseUtil {
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
if (ctx.response().ended() || ctx.response().closed()) {
return;
}
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) {
if (ctx.ended() || ctx.closed()) {
return;
}
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(statusCode)
.end(jsonObject.encode());
@@ -55,10 +68,16 @@ public class ResponseUtil {
}
public static void fireTextResponse(RoutingContext ctx, String text) {
if (ctx.response().ended() || ctx.response().closed()) {
return;
}
ctx.response().putHeader(CONTENT_TYPE, "text/html; charset=utf-8").end(text);
}
public static void sendError(RoutingContext ctx, int statusCode) {
if (ctx.response().ended() || ctx.response().closed()) {
return;
}
ctx.response().setStatusCode(statusCode).end();
}
}
@@ -3,7 +3,6 @@ package cn.qaiu.vx.core.util;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.core.shareddata.LocalMap;
import io.vertx.core.shareddata.SharedData;
/**
* vertx 共享数据
@@ -13,10 +12,8 @@ import io.vertx.core.shareddata.SharedData;
*/
public class SharedDataUtil {
private static final SharedData sharedData = VertxHolder.getVertxInstance().sharedData();
public static SharedData shareData() {
return sharedData;
public static io.vertx.core.shareddata.SharedData shareData() {
return VertxHolder.getVertxInstance().sharedData();
}
public static LocalMap<String, Object> getLocalMap(String key) {
@@ -24,7 +21,7 @@ public class SharedDataUtil {
}
public static <T> LocalMap<String, T> getLocalMapWithCast(String key) {
return sharedData.getLocalMap(key);
return shareData().getLocalMap(key);
}
public static JsonObject getJsonConfig(String key) {
@@ -1,10 +1,13 @@
package cn.qaiu.vx.core.verticle;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Promise;
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;
@@ -24,13 +27,16 @@ public class HttpProxyVerticle extends AbstractVerticle {
private HttpClient httpClient;
private NetClient netClient;
private HttpServer httpServer;
private volatile boolean stopping = false;
private JsonObject proxyPreConf;
private JsonObject proxyServerConf;
@Override
public void start() {
public void start(io.vertx.core.Promise<Void> startPromise) {
stopping = false;
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");
@@ -41,7 +47,12 @@ public class HttpProxyVerticle extends AbstractVerticle {
}
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
HttpClientOptions httpClientOptions = new HttpClientOptions();
HttpClientOptions httpClientOptions = new HttpClientOptions()
.setMaxPoolSize(64)
.setMaxWaitQueueSize(256)
.setConnectTimeout(15000)
.setIdleTimeout(60)
.setKeepAlive(true);
if (proxyOptions != null) {
httpClientOptions.setProxyOptions(proxyOptions);
}
@@ -54,14 +65,14 @@ public class HttpProxyVerticle extends AbstractVerticle {
httpServerOptions.setClientAuth(ClientAuth.REQUIRED);
}
HttpServer server = vertx.createHttpServer();
server.requestHandler(this::handleClientRequest);
httpServer = vertx.createHttpServer(httpServerOptions);
httpServer.requestHandler(this::handleClientRequest);
// 初始化 NetClient,用于在 CONNECT 请求中建立 TCP 连接隧道
NetClientOptions netClientOptions = new NetClientOptions();
if (proxyOptions != null) {
httpClientOptions.setProxyOptions(proxyOptions);
netClientOptions.setProxyOptions(proxyOptions);
}
netClient = vertx.createNetClient(netClientOptions
@@ -69,16 +80,22 @@ public class HttpProxyVerticle extends AbstractVerticle {
.setTrustAll(true));
// 启动 HTTP 代理服务器
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()));
httpServer.listen(serverPort)
.onSuccess(res -> {
LOGGER.info("HTTP Proxy server started on port {}", serverPort);
startPromise.complete();
})
.onFailure(err -> {
LOGGER.error("Failed to start HTTP Proxy server: " + err.getMessage(), err);
closeClients().onComplete(close -> startPromise.fail(err));
});
}
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
private void handleConnectRequest(HttpServerRequest clientRequest) {
String[] uriParts = clientRequest.uri().split(":");
if (uriParts.length != 2) {
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid URI format");
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid URI format");
return;
}
@@ -88,45 +105,61 @@ public class HttpProxyVerticle extends AbstractVerticle {
try {
targetPort = Integer.parseInt(uriParts[1]);
} catch (NumberFormatException e) {
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid port");
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid port");
return;
}
clientRequest.pause();
// 通过 NetClient 连接目标服务器并创建隧道
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);
try {
netClient.connect(targetPort, targetHost)
.onSuccess(targetSocket -> {
// Upgrade client connection to NetSocket and implement bidirectional data flow
clientRequest.toNetSocket()
.onSuccess(clientSocket -> {
clientSocket.pipeTo(targetSocket)
.onFailure(err -> {
LOGGER.debug("CONNECT client -> target pipe closed", err);
closeTunnelSockets(clientSocket, targetSocket);
});
targetSocket.pipeTo(clientSocket)
.onFailure(err -> {
LOGGER.debug("CONNECT target -> client pipe closed", err);
closeTunnelSockets(clientSocket, targetSocket);
});
// 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");
});
// 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();
failClientRequestAndClose(clientRequest, 500, "Internal Server Error");
});
})
.onFailure(connectionAttempt -> {
LOGGER.warn("Failed to connect to target: {}", connectionAttempt.getMessage());
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to connect to target");
});
} catch (Exception e) {
LOGGER.warn("CONNECT 请求创建失败", e);
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to connect to target");
}
}
// 处理客户端的 HTTP 请求
private void handleClientRequest(HttpServerRequest clientRequest) {
if (stopping) {
failClientResponse(clientRequest.response(), 503, "Service Unavailable");
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();
failClientResponse(clientRequest.response(), 403, null);
return;
}
String[] split;
@@ -134,19 +167,19 @@ public class HttpProxyVerticle extends AbstractVerticle {
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();
failClientResponse(clientRequest.response(), 403, null);
return;
}
if (split.length <= 1) {
LOGGER.warn("Proxy-Authorization header format invalid: missing username:password separator");
clientRequest.response().setStatusCode(403).end();
failClientResponse(clientRequest.response(), 403, null);
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();
failClientResponse(clientRequest.response(), 403, null);
return;
}
}
@@ -165,40 +198,147 @@ public class HttpProxyVerticle extends AbstractVerticle {
// 获取目标主机
String hostHeader = clientRequest.getHeader("Host");
if (hostHeader == null) {
clientRequest.response().setStatusCode(400).end("Host header is missing");
failClientResponse(clientRequest.response(), 400, "Host header is missing");
return;
}
String targetHost = hostHeader.split(":")[0];
int targetPort = extractPortFromUrl(clientRequest.uri()); // 默认为 HTTP 的端口
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
HostAndPort target;
try {
target = parseHostHeader(hostHeader);
} catch (IllegalArgumentException e) {
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid Host header");
return;
}
String targetHost = target.host();
int targetPort = extractPortFromUrl(clientRequest.uri(), target.port()); // 默认为 HTTP 的端口
if (targetPort <= 0) {
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid target port");
return;
}
clientRequest.pause(); // 暂停客户端请求的读取,等上游请求创建完成
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
.onSuccess(request -> {
clientRequest.resume(); // 恢复客户端请求的读取
try {
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
.onSuccess(request -> {
// 逐个设置请求头
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
// 逐个设置请求头
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
request.response()
.onSuccess(response -> {
HttpServerResponse clientResponse = clientRequest.response();
if (clientResponse.ended() || clientResponse.closed()) {
response.resume();
return;
}
clientResponse.setStatusCode(response.statusCode());
clientResponse.headers().setAll(response.headers());
response.pipeTo(clientResponse)
.onFailure(err -> {
LOGGER.error("HTTP代理响应转发失败", err);
try {
response.request().reset();
} catch (Exception e) {
LOGGER.debug("HTTP代理上游响应已关闭", e);
}
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
});
})
.onFailure(err -> {
LOGGER.error("HTTP代理响应失败", err);
try {
request.reset();
} catch (Exception e) {
LOGGER.debug("HTTP代理上游请求已关闭", e);
}
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
});
// 将客户端请求的 body 转发给目标服务器
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 -> {
LOGGER.error("HTTP请求失败", err);
clientRequest.response().setStatusCode(502).end("Bad Gateway: Request failed");
});
clientRequest.pipeTo(request)
.onFailure(err -> {
LOGGER.error("HTTP代理请求转发失败", err);
try {
request.reset();
} catch (Exception e) {
LOGGER.debug("HTTP代理上游请求已关闭", e);
}
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Unable to reach target");
});
clientRequest.resume();
})
.onFailure(err -> {
LOGGER.error("HTTP请求失败", err);
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
});
} catch (Exception e) {
LOGGER.error("HTTP请求创建失败", e);
failClientRequestAndClose(clientRequest, 502, "Bad Gateway: Request failed");
}
}
private void failClientResponse(HttpServerResponse response, String message) {
failClientResponse(response, 502, message);
}
private void failClientResponse(HttpServerResponse response, int statusCode, String message) {
if (response.ended() || response.closed()) {
return;
}
try {
if (!response.headWritten()) {
response.setStatusCode(statusCode);
if (message == null) {
response.end();
} else {
response.end(message);
}
} else {
response.reset();
}
} catch (Exception e) {
LOGGER.debug("客户端响应已关闭,忽略代理错误响应", e);
}
}
private void failClientRequestAndClose(HttpServerRequest request, int statusCode, String message) {
HttpServerResponse response = request.response();
if (response.ended() || response.closed()) {
closeClientConnection(request);
return;
}
try {
if (!response.headWritten()) {
response.setStatusCode(statusCode);
Future<Void> endFuture = message == null ? response.end() : response.end(message);
endFuture.onComplete(v -> closeClientConnection(request));
} else {
response.reset();
closeClientConnection(request);
}
} catch (Exception e) {
LOGGER.debug("客户端响应已关闭,关闭代理连接", e);
closeClientConnection(request);
}
}
private void closeClientConnection(HttpServerRequest request) {
try {
request.connection().close();
} catch (Exception e) {
LOGGER.debug("关闭客户端代理连接失败", e);
}
}
private void closeTunnelSockets(NetSocket clientSocket, NetSocket targetSocket) {
try {
clientSocket.close();
} catch (Exception e) {
LOGGER.debug("关闭CONNECT客户端socket失败", e);
}
try {
targetSocket.close();
} catch (Exception e) {
LOGGER.debug("关闭CONNECT目标socket失败", e);
}
}
@@ -209,6 +349,10 @@ public class HttpProxyVerticle extends AbstractVerticle {
* @return 提取的端口号,如果没有指定端口,则返回默认端口
*/
public static int extractPortFromUrl(String urlString) {
return extractPortFromUrl(urlString, 80);
}
public static int extractPortFromUrl(String urlString, int defaultPort) {
try {
URI uri = new URI(urlString);
int port = uri.getPort();
@@ -217,7 +361,7 @@ public class HttpProxyVerticle extends AbstractVerticle {
if ("https".equalsIgnoreCase(uri.getScheme())) {
port = 443; // HTTPS 默认端口
} else {
port = 80; // HTTP 默认端口
port = defaultPort; // HTTP 默认端口
}
}
return port;
@@ -228,16 +372,48 @@ public class HttpProxyVerticle extends AbstractVerticle {
}
}
private HostAndPort parseHostHeader(String hostHeader) {
if (hostHeader.startsWith("[")) {
int end = hostHeader.indexOf(']');
if (end > 0) {
String host = hostHeader.substring(1, end);
int port = 80;
if (hostHeader.length() > end + 2 && hostHeader.charAt(end + 1) == ':') {
port = Integer.parseInt(hostHeader.substring(end + 2));
}
return new HostAndPort(host, port);
}
}
int lastColon = hostHeader.lastIndexOf(':');
if (lastColon > 0 && hostHeader.indexOf(':') == lastColon) {
return new HostAndPort(hostHeader.substring(0, lastColon), Integer.parseInt(hostHeader.substring(lastColon + 1)));
}
return new HostAndPort(hostHeader, 80);
}
private record HostAndPort(String host, int port) {
}
@Override
public void stop() {
// 停止 HTTP 客户端以释放资源
if (httpClient != null) {
httpClient.close();
}
if (netClient != null) {
netClient.close();
}
public void stop(Promise<Void> stopPromise) {
stopping = true;
Future<Void> serverClose = httpServer == null ? Future.succeededFuture() : httpServer.close();
serverClose.onComplete(serverResult -> closeClients().onComplete(clientResult -> {
if (serverResult.failed()) {
stopPromise.fail(serverResult.cause());
} else if (clientResult.failed()) {
stopPromise.fail(clientResult.cause());
} else {
stopPromise.complete();
}
}));
}
private Future<Void> closeClients() {
Future<Void> httpClientClose = httpClient == null ? Future.succeededFuture() : httpClient.close();
Future<Void> netClientClose = netClient == null ? Future.succeededFuture() : netClient.close();
return Future.all(httpClientClose, netClientClose).mapEmpty();
}
}
@@ -3,17 +3,20 @@ package cn.qaiu.vx.core.verticle;
import cn.qaiu.vx.core.util.*;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Handler;
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.http.HttpServerResponse;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.PemKeyCertOptions;
import io.vertx.ext.web.Route;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.StaticHandler;
import io.vertx.ext.web.proxy.handler.ProxyHandler;
import io.vertx.httpproxy.HttpProxy;
@@ -27,7 +30,9 @@ import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -45,10 +50,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(ReverseProxyVerticle.class);
private static final String PATH_PROXY_CONFIG = SharedDataUtil
.getJsonConfig(ConfigConstant.GLOBAL_CONFIG)
.getString("proxyConf");
private static final Future<JsonObject> CONFIG = ConfigUtil.readYamlConfig(PATH_PROXY_CONFIG);
private static final String DEFAULT_PATH_404 = "webroot/err/page404.html";
private static String serverName = "Vert.x-proxy-server"; //Server name in Http response header
@@ -58,26 +59,40 @@ public class ReverseProxyVerticle extends AbstractVerticle {
/**
* 【优化】HttpClient连接池,按host:port缓存复用,避免每个请求都创建新连接
*/
private final Map<String, HttpClient> httpClientPool = new ConcurrentHashMap<>();
private final Map<String, HttpClientEntry> httpClientPool = new ConcurrentHashMap<>();
private final List<HttpServer> httpServers = new ArrayList<>();
private volatile boolean stopping = false;
/**
* 连接池条目。HttpProxy 会持有这里的 HttpClient 引用,不能在路由仍可用时关闭。
*/
private static class HttpClientEntry {
final HttpClient client;
HttpClientEntry(HttpClient client) {
this.client = client;
}
}
/**
* 【优化】高并发场景下的HttpClient配置
*/
private static final int MAX_POOL_SIZE = 100; // 最大连接池大小
private static final int MAX_WAIT_QUEUE_SIZE = 500; // 最大等待队列大小
private static final int MAX_POOL_SIZE = 32; // 最大连接池大小
private static final int MAX_WAIT_QUEUE_SIZE = 128; // 最大等待队列大小
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管线化
private static final boolean PIPELINING = false; // 代理场景关闭管线化,避免慢响应堆积
@Override
public void start(Promise<Void> startPromise) {
CONFIG.onSuccess(this::handleProxyConfList).onFailure(e -> {
stopping = false;
String pathProxyConfig = SharedDataUtil
.getJsonConfig(ConfigConstant.GLOBAL_CONFIG)
.getString("proxyConf");
ConfigUtil.readYamlConfig(pathProxyConfig).onSuccess(config -> startProxyServers(config).onComplete(startPromise)).onFailure(e -> {
LOGGER.info("web代理配置已禁用,当前仅支持API调用");
startPromise.complete();
});
// createFileListener
startPromise.complete();
}
/**
@@ -85,16 +100,49 @@ public class ReverseProxyVerticle extends AbstractVerticle {
*/
@Override
public void stop(Promise<Void> stopPromise) {
LOGGER.info("Stopping ReverseProxyVerticle, closing {} HttpClient connections...", httpClientPool.size());
httpClientPool.values().forEach(client -> {
stopping = true;
LOGGER.info("Stopping ReverseProxyVerticle, closing {} servers and {} HttpClient connections...",
httpServers.size(), httpClientPool.size());
List<Future<Void>> serverCloseFutures = new ArrayList<>();
httpServers.forEach(server -> serverCloseFutures.add(server.close()));
Future<Void> serverCloseFuture = serverCloseFutures.isEmpty()
? Future.succeededFuture()
: Future.all(serverCloseFutures).mapEmpty();
serverCloseFuture.onComplete(serverClose -> {
List<Future<Void>> clientCloseFutures = new ArrayList<>();
closeHttpClients(clientCloseFutures);
Future<Void> clientCloseFuture = clientCloseFutures.isEmpty()
? Future.succeededFuture()
: Future.all(clientCloseFutures).mapEmpty();
clientCloseFuture.onComplete(clientClose -> {
if (serverClose.succeeded()) {
httpServers.clear();
}
if (clientClose.succeeded()) {
httpClientPool.clear();
}
if (serverClose.failed()) {
stopPromise.fail(serverClose.cause());
} else if (clientClose.failed()) {
stopPromise.fail(clientClose.cause());
} else {
stopPromise.complete();
}
});
});
}
private void closeHttpClients(List<Future<Void>> closeFutures) {
httpClientPool.values().forEach(entry -> {
try {
client.close();
closeFutures.add(entry.client.close());
} catch (Exception e) {
LOGGER.warn("Error closing HttpClient: {}", e.getMessage());
}
});
httpClientPool.clear();
stopPromise.complete();
}
/**
@@ -105,7 +153,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
*/
private HttpClient getOrCreateHttpClient(String host, int port) {
String key = host + ":" + port;
return httpClientPool.computeIfAbsent(key, k -> {
HttpClientEntry entry = httpClientPool.computeIfAbsent(key, k -> {
LOGGER.info("Creating new HttpClient for {}", key);
HttpClientOptions options = new HttpClientOptions()
.setMaxPoolSize(MAX_POOL_SIZE) // 连接池大小
@@ -116,15 +164,16 @@ public class ReverseProxyVerticle extends AbstractVerticle {
.setKeepAliveTimeout(120) // Keep-Alive超时120秒
.setPipelining(PIPELINING) // HTTP管线化
.setPipeliningLimit(10) // 管线化限制
.setDecompressionSupported(true) // 支持解压响应
.setDecompressionSupported(false) // 代理不解压,避免放大内存
.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);
return new HttpClientEntry(vertx.createHttpClient(options));
});
return entry.client;
}
/**
@@ -137,7 +186,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
*
* @param config proxy config
*/
private void handleProxyConfList(JsonObject config) {
private Future<Void> startProxyServers(JsonObject config) {
serverName = config.getString("server-name");
// 解析全局 trusted-proxies
JsonArray trustedArr = config.getJsonArray("trusted-proxies");
@@ -149,13 +198,15 @@ public class ReverseProxyVerticle extends AbstractVerticle {
});
}
JsonArray proxyConfList = config.getJsonArray("proxy");
List<Future<Void>> listenFutures = new ArrayList<>();
if (proxyConfList != null) {
proxyConfList.forEach(proxyConf -> {
if (proxyConf instanceof JsonObject) {
handleProxyConf((JsonObject) proxyConf);
listenFutures.add(handleProxyConf((JsonObject) proxyConf));
}
});
}
return listenFutures.isEmpty() ? Future.succeededFuture() : Future.all(listenFutures).mapEmpty();
}
/**
@@ -201,7 +252,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
*
* @param proxyConf 代理配置
*/
private void handleProxyConf(JsonObject proxyConf) {
private Future<Void> handleProxyConf(JsonObject proxyConf) {
// page404 path
if (proxyConf.containsKey(
@@ -226,6 +277,10 @@ public class ReverseProxyVerticle extends AbstractVerticle {
// Add Server name header
proxyRouter.route().handler(ctx -> {
if (stopping) {
sendProxyError(ctx, 503, "Service Unavailable");
return;
}
String realPath = ctx.request().uri();
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
@@ -234,7 +289,9 @@ public class ReverseProxyVerticle extends AbstractVerticle {
return;
}
ctx.response().putHeader("Server", serverName);
if (!ctx.response().ended() && !ctx.response().closed()) {
ctx.response().putHeader("Server", serverName);
}
ctx.next();
});
@@ -250,15 +307,19 @@ public class ReverseProxyVerticle extends AbstractVerticle {
// Send page404 page
proxyRouter.errorHandler(404, ctx -> {
ctx.response().sendFile(proxyConf.getString("page404"));
sendNotFoundPage(ctx, proxyConf.getString("page404"));
});
proxyRouter.errorHandler(500, this::handleProxyFailure);
HttpServer server = getHttpsServer(proxyConf);
server.requestHandler(proxyRouter);
Integer port = proxyConf.getInteger("listen");
LOGGER.info("proxy server start on {} port", port);
server.listen(port);
return server.listen(port)
.onSuccess(s -> httpServers.add(s))
.onFailure(e -> LOGGER.error("proxy server start failed on {} port", port, e))
.mapEmpty();
}
private HttpServer getHttpsServer(JsonObject proxyConf) {
@@ -267,7 +328,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法
.setCompressionSupported(true) // 启用压缩
.setAcceptBacklog(50000) // 增加积压队列到50000
.setAcceptBacklog(1024) // 限制积压队列,避免小容器内存膨胀
.setIdleTimeout(120) // 空闲超时120秒
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
@@ -303,6 +364,67 @@ public class ReverseProxyVerticle extends AbstractVerticle {
return vertx.createHttpServer(httpServerOptions);
}
private void addProxyHandler(Route route, HttpProxy httpProxy) {
Handler<RoutingContext> proxyHandler = ProxyHandler.create(httpProxy);
route.handler(ctx -> {
try {
proxyHandler.handle(ctx);
} catch (Throwable t) {
LOGGER.error("反向代理处理异常", t);
ctx.fail(t);
}
}).failureHandler(this::handleProxyFailure);
}
private void handleProxyFailure(RoutingContext ctx) {
Throwable failure = ctx.failure();
if (failure != null) {
LOGGER.error("反向代理路由失败", failure);
}
int statusCode = ctx.statusCode() > 0 ? ctx.statusCode() : 502;
if (statusCode < 400) {
statusCode = 502;
}
sendProxyError(ctx, statusCode, "Bad Gateway");
}
private void sendNotFoundPage(RoutingContext ctx, String page404) {
HttpServerResponse response = ctx.response();
if (response.ended() || response.closed()) {
return;
}
try {
if (response.headWritten()) {
response.reset();
return;
}
response.sendFile(page404)
.onFailure(e -> {
LOGGER.warn("发送代理 404 页面失败: {}", page404, e);
sendProxyError(ctx, 404, "404 not found");
});
} catch (Exception e) {
LOGGER.warn("发送代理 404 页面异常: {}", page404, e);
sendProxyError(ctx, 404, "404 not found");
}
}
private void sendProxyError(RoutingContext ctx, int statusCode, String message) {
HttpServerResponse response = ctx.response();
if (response.ended() || response.closed()) {
return;
}
try {
if (!response.headWritten()) {
response.setStatusCode(statusCode).end(message);
} else {
response.reset();
}
} catch (Exception e) {
LOGGER.debug("代理响应已关闭,忽略错误响应", e);
}
}
/**
* 处理静态资源配置
*
@@ -391,15 +513,14 @@ 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));
addProxyHandler(route, httpProxy);
} else {
// 配置 /api/, / => 请求 /api/test 代理后 /test
// 配置 /api/, /xxx => 请求 /api/test 代理后 /xxx/test
final String path0 = path;
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
addProxyHandler(proxyRouter.route(originPath0 + "*"), httpProxy);
proxyRouter.route(path0 + "*").handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(path0)) {
@@ -22,21 +22,22 @@ 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 JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
private HttpServer server;
private Router router;
private int port;
private JsonObject globalConfig;
static {
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
JacksonConfig.nothing();
LOGGER.info("To start listening to port {} ......", port);
}
@Override
public void start(Promise<Void> startPromise) {
port = SharedDataUtil.getValueForServerConfig("port");
globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
LOGGER.info("To start listening to port {} ......", port);
// 端口是否占用
if (CommonUtil.isPortUsing(port)) {
throw new RuntimeException("Start fail: the '" + port + "' port is already in use...");
@@ -64,7 +65,7 @@ public class RouterVerticle extends AbstractVerticle {
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
server = vertx.createHttpServer(options);
server.requestHandler(router).webSocketHandler(s->{}).listen()
server.requestHandler(router).listen()
.onSuccess(s -> startPromise.complete())
.onFailure(e -> startPromise.fail(e.getCause()));
}
+5 -1
View File
@@ -6,4 +6,8 @@ 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
DEFAULT_JVM_OPTS="-Xmx${JVM_XMX:-512M} -Xss${JVM_XSS:-512k} -XX:MaxDirectMemorySize=${JVM_MAX_DIRECT_MEMORY:-256M} -DNFD_LOG_LEVEL=${NFD_LOG_LEVEL:-info} -DNFD_PLAYGROUND_ENABLED=${NFD_PLAYGROUND_ENABLED:-false}"
if [ -n "${JVM_MAX_METASPACE:-}" ]; then
DEFAULT_JVM_OPTS="$DEFAULT_JVM_OPTS -XX:MaxMetaspaceSize=$JVM_MAX_METASPACE"
fi
exec java ${DEFAULT_JVM_OPTS} ${JVM_OPTS} -Duser.timezone=${TZ:-Asia/Shanghai} -jar /app/netdisk-fast-download.jar
+3 -3
View File
@@ -240,13 +240,13 @@ var encoded = JsHttpClient.urlEncode("hello world"); // "hello%20world"
var decoded = JsHttpClient.urlDecode("hello%20world"); // "hello world"
// 发送简单表单数据
var formResponse = http.sendForm({
var formResponse = http.sendForm("https://api.example.com/login", {
username: "user",
password: "pass"
});
// 发送JSON数据
var jsonResponse = http.sendJson({
var jsonResponse = http.sendJson("https://api.example.com/submit", {
name: "test",
value: 123
});
@@ -637,7 +637,7 @@ A: 使用 `shareLinkInfo.getSharePassword()` 方法。
### Q: 如何处理需要登录的网盘?
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm()` 发送登录表单。
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm(url, data)` 发送登录表单。
### Q: 如何解析复杂的HTML
+1 -1
View File
@@ -68,7 +68,7 @@ List<FileInfo> files = tool.parseFileListSync();
```
要点:
- 必须先 WebClientVertxInit.init(Vertx)若未显式初始化,内部将懒加载 Vertx.vertx(),建议显式注入以统一生命周期
- 必须先 WebClientVertxInit.init(Vertx)未初始化时会直接报错,避免解析器偷偷创建第二个 Vert.x 实例
- 支持三种同步方法:
- `parseSync()`: 解析单个文件下载链接
- `parseFileListSync()`: 解析文件列表
+1 -1
View File
@@ -17,7 +17,7 @@
this.temporaryExecutor = WebClientVertxInit.get().createSharedWorkerExecutor(
"playground-temp-" + System.currentTimeMillis(),
1, // 每个请求只需要1个线程
10000000000L // 设置非常长的超时,避免被vertx强制中断
10000000000L // 设置非常长的超时,避免触发Vert.x阻塞线程告警
);
// 执行完成或超时后关闭
+1 -1
View File
@@ -106,7 +106,7 @@ executionFuture.toCompletionStage()
### 长期方案(需大量工作)
1. **迁移到GraalVM JavaScript引擎**
- 支持CPU时间限制
- 可以强制中断
- 相比Nashorn更容易实现受控取消
- 更好的性能
- 但需要额外依赖
@@ -7,12 +7,15 @@ import org.slf4j.LoggerFactory;
import cn.qaiu.parser.custom.CustomParserRegistry;
public class WebClientVertxInit {
private Vertx vertx = null;
private volatile Vertx vertx = null;
private static final WebClientVertxInit INSTANCE = new WebClientVertxInit();
private static final Logger log = LoggerFactory.getLogger(WebClientVertxInit.class);
public static void init(Vertx vx) {
public static synchronized void init(Vertx vx) {
if (vx == null) {
throw new IllegalArgumentException("Vertx instance must not be null");
}
INSTANCE.vertx = vx;
// 自动加载JavaScript解析器脚本
@@ -23,18 +26,10 @@ public class WebClientVertxInit {
}
}
public static Vertx get() {
public static synchronized Vertx get() {
if (INSTANCE.vertx == null) {
log.info("getVertx: Vertx实例不存在, 创建Vertx实例.");
INSTANCE.vertx = Vertx.vertx();
// 如果Vertx实例是新创建的,也尝试加载JavaScript脚本
try {
CustomParserRegistry.autoLoadJsScripts();
} catch (Exception e) {
log.warn("自动加载JavaScript解析器脚本失败", e);
}
throw new IllegalStateException("Vertx实例未初始化,请先调用 WebClientVertxInit.init(vertx)");
}
return INSTANCE.vertx;
}
}
}
@@ -1,5 +1,6 @@
package cn.qaiu.parser;//package cn.qaiu.lz.common.parser;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
@@ -7,10 +8,26 @@ import cn.qaiu.parser.clientlink.ClientLinkType;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import java.util.function.Supplier;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
public interface IPanTool {
public interface IPanTool extends AutoCloseable {
/** 同步等待超时时间(秒) */
long SYNC_TIMEOUT_SECONDS = 120;
ScheduledExecutorService CLOSE_AFTER_SCHEDULER = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "pan-tool-close-after");
t.setDaemon(true);
return t;
});
/**
* 解析文件
@@ -18,8 +35,72 @@ public interface IPanTool {
*/
Future<String> parse();
static <T> Future<T> closeAfter(IPanTool tool, Supplier<Future<T>> action) {
Promise<T> promise = Promise.promise();
AtomicBoolean cleanupDone = new AtomicBoolean(false);
ScheduledFuture<?> cleanupTask = null;
try {
Future<T> future = action.get();
if (future == null) {
closeQuietly(tool);
return Future.failedFuture("解析器返回空 Future");
}
cleanupTask = CLOSE_AFTER_SCHEDULER.schedule(() -> {
if (cleanupDone.compareAndSet(false, true)) {
closeQuietly(tool);
failOnVertxContext(promise, "解析超时(" + SYNC_TIMEOUT_SECONDS + "秒)");
}
}, SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
ScheduledFuture<?> scheduledCleanupTask = cleanupTask;
future.onComplete(ar -> {
scheduledCleanupTask.cancel(false);
if (!cleanupDone.compareAndSet(false, true)) {
return;
}
closeQuietly(tool);
if (ar.succeeded()) {
promise.tryComplete(ar.result());
} else {
promise.tryFail(ar.cause());
}
});
return promise.future();
} catch (Throwable t) {
if (cleanupTask != null) {
cleanupTask.cancel(false);
}
closeQuietly(tool);
return Future.failedFuture(t);
}
}
private static <T> void failOnVertxContext(Promise<T> promise, String message) {
try {
WebClientVertxInit.get().runOnContext(ignored -> promise.tryFail(message));
} catch (Exception ignored) {
promise.tryFail(message);
}
}
static void closeQuietly(IPanTool tool) {
if (tool == null) {
return;
}
try {
tool.close();
} catch (Exception ignored) {
// ignore cleanup failures
}
}
static void shutdownCloseAfterScheduler() {
CLOSE_AFTER_SCHEDULER.shutdownNow();
}
default String parseSync() {
return parse().toCompletionStage().toCompletableFuture().join();
return timedJoin(parse());
}
/**
@@ -33,7 +114,7 @@ public interface IPanTool {
}
default List<FileInfo> parseFileListSync() {
return parseFileList().toCompletionStage().toCompletableFuture().join();
return timedJoin(parseFileList());
}
/**
@@ -47,7 +128,7 @@ public interface IPanTool {
}
default String parseByIdSync() {
return parseById().toCompletionStage().toCompletableFuture().join();
return timedJoin(parseById());
}
/**
@@ -126,7 +207,7 @@ public interface IPanTool {
* @return Map<ClientLinkType, String> 客户端下载链接集合
*/
default Map<ClientLinkType, String> parseWithClientLinksSync() {
return parseWithClientLinks().toCompletionStage().toCompletableFuture().join();
return timedJoin(parseWithClientLinks());
}
/**
@@ -137,4 +218,26 @@ public interface IPanTool {
default ShareLinkInfo getShareLinkInfo() {
return null;
}
@Override
default void close() {
// default no-op
}
/**
* 带超时的同步等待工具方法,替代无超时的 join()
*/
private static <T> T timedJoin(Future<T> future) {
try {
return future.toCompletionStage().toCompletableFuture()
.get(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
} catch (TimeoutException e) {
throw new RuntimeException("同步等待超时(" + SYNC_TIMEOUT_SECONDS + "秒)", e);
} catch (java.util.concurrent.ExecutionException e) {
throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
}
}
}
+188 -42
View File
@@ -34,25 +34,37 @@ import java.util.zip.GZIPInputStream;
* <p>{网盘标识}Tool, 网盘标识不超过5个字符, 可以取网盘名称首字母缩写或拼音首字母, <br>
* 音乐类型的解析以M开头, 例如网易云音乐Mne</p>
*/
public abstract class PanBase implements IPanTool {
public abstract class PanBase implements IPanTool, Closeable {
protected Logger log = LoggerFactory.getLogger(this.getClass());
protected Promise<String> promise = Promise.promise();
private static final int MAX_COMPRESSED_RESPONSE_BYTES = 8 * 1024 * 1024;
private static final int MAX_DECOMPRESSED_RESPONSE_CHARS = 16 * 1024 * 1024;
private static final int MAX_ERROR_BODY_CHARS = 4096;
/**
* 共享的 WebClient 配置(设置超时避免连接无限期占用)
*/
private static final WebClientOptions SHARED_OPTIONS = new WebClientOptions()
.setConnectTimeout(10000) // 连接超时 10 秒
.setIdleTimeout(30) // 空闲超时 30 秒
.setIdleTimeoutUnit(java.util.concurrent.TimeUnit.SECONDS);
private static final Object SHARED_CLIENT_LOCK = new Object();
/**
* 共享的 WebClient 实例(线程安全,避免每请求创建导致资源泄漏)
*/
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));
private static volatile WebClient sharedClient;
private static volatile WebClient sharedClientNoRedirects;
private static volatile WebClient sharedClientDisableUA;
private static volatile boolean sharedClientsShutdown = false;
/**
* Http client (默认使用共享实例,代理模式下使用独立实例)
*/
protected WebClient client = SHARED_CLIENT;
protected WebClient client = sharedClient();
/**
* Http client session (会话管理, 带cookie请求, 每实例独立)
@@ -62,14 +74,25 @@ public abstract class PanBase implements IPanTool {
/**
* Http client 不自动跳转
*/
protected WebClient clientNoRedirects = SHARED_CLIENT_NO_REDIRECTS;
protected WebClient clientNoRedirects = sharedClientNoRedirects();
/**
* Http client disable UserAgent
*/
protected WebClient clientDisableUA = SHARED_CLIENT_DISABLE_UA;
protected WebClient clientDisableUA = sharedClientDisableUA();
protected ShareLinkInfo shareLinkInfo;
/**
* 标记是否为代理模式(代理模式创建的 WebClient 需要手动关闭)
*/
private boolean isProxyMode = false;
/**
* 代理模式下创建的独立 WebClient 实例(需要在 close 时释放)
*/
private WebClient proxyClient = null;
private WebClient proxyClientNoRedirects = null;
/**
@@ -86,6 +109,7 @@ public abstract class PanBase implements IPanTool {
public PanBase(ShareLinkInfo shareLinkInfo) {
this.shareLinkInfo = shareLinkInfo;
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
this.isProxyMode = true;
JsonObject proxy = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
ProxyOptions proxyOptions = new ProxyOptions()
.setType(ProxyType.valueOf(proxy.getString("type").toUpperCase()))
@@ -97,22 +121,86 @@ public abstract class PanBase implements IPanTool {
if (StringUtils.isNotEmpty(proxy.getString("password"))) {
proxyOptions.setPassword(proxy.getString("password"));
}
this.client = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions()
// 代理模式下创建独立的 WebClient 实例(应用超时配置)
this.proxyClient = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions(SHARED_OPTIONS)
.setUserAgentEnabled(false)
.setProxyOptions(proxyOptions));
this.proxyClientNoRedirects = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions(SHARED_OPTIONS).setFollowRedirects(false)
.setUserAgentEnabled(false)
.setProxyOptions(proxyOptions));
this.client = proxyClient;
this.clientSession = WebClientSession.create(client);
this.clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setFollowRedirects(false)
.setUserAgentEnabled(false)
.setProxyOptions(proxyOptions));
this.clientNoRedirects = proxyClientNoRedirects;
}
}
protected PanBase() {
}
private static WebClient sharedClient() {
synchronized (SHARED_CLIENT_LOCK) {
if (sharedClientsShutdown) {
throw new IllegalStateException("共享 WebClient 已关闭");
}
if (sharedClient == null) {
sharedClient = WebClient.create(WebClientVertxInit.get(), new WebClientOptions(SHARED_OPTIONS));
}
return sharedClient;
}
}
private static WebClient sharedClientNoRedirects() {
synchronized (SHARED_CLIENT_LOCK) {
if (sharedClientsShutdown) {
throw new IllegalStateException("共享 WebClient 已关闭");
}
if (sharedClientNoRedirects == null) {
sharedClientNoRedirects = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions(SHARED_OPTIONS).setFollowRedirects(false));
}
return sharedClientNoRedirects;
}
}
private static WebClient sharedClientDisableUA() {
synchronized (SHARED_CLIENT_LOCK) {
if (sharedClientsShutdown) {
throw new IllegalStateException("共享 WebClient 已关闭");
}
if (sharedClientDisableUA == null) {
sharedClientDisableUA = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions(SHARED_OPTIONS).setUserAgentEnabled(false));
}
return sharedClientDisableUA;
}
}
public static void shutdownSharedClients() {
synchronized (SHARED_CLIENT_LOCK) {
sharedClientsShutdown = true;
closeSharedClient(sharedClient, "shared WebClient");
closeSharedClient(sharedClientNoRedirects, "shared WebClientNoRedirects");
closeSharedClient(sharedClientDisableUA, "shared WebClientDisableUA");
sharedClient = null;
sharedClientNoRedirects = null;
sharedClientDisableUA = null;
}
}
private static void closeSharedClient(WebClient client, String name) {
if (client == null) {
return;
}
try {
client.close();
} catch (Exception e) {
LoggerFactory.getLogger(PanBase.class).warn("关闭 {} 失败: {}", name, e.getMessage());
}
}
protected String baseMsg() {
if (shareLinkInfo.getShareUrl() != null) {
return shareLinkInfo.getPanName() + "-" + shareLinkInfo.getType() + ": url=" + shareLinkInfo.getShareUrl();
@@ -137,16 +225,19 @@ public abstract class PanBase implements IPanTool {
return;
}
String s = String.format(errorMsg.replaceAll("\\{}", "%s"), args);
log.error("解析异常: " + s, t.fillInStackTrace());
promise.fail(baseMsg() + ": 解析异常: " + s + " -> " + t);
// 只记录异常消息和类型,不调用 fillInStackTrace 避免产生巨大栈信息
log.error("解析异常: {} - {}: {}", s, t.getClass().getSimpleName(), t.getMessage());
// 只传递异常消息,不传递完整异常对象,减少内存占用
String failMsg = baseMsg() + ": 解析异常: " + s + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage();
promise.fail(failMsg);
} catch (Exception e) {
log.error("ErrorMsg format fail. The parameter has been discarded", e);
log.error("解析异常: " + errorMsg, t.fillInStackTrace());
log.error("解析异常: {} - {}: {}", errorMsg, t.getClass().getSimpleName(), t.getMessage());
if (promise.future().isComplete()) {
log.warn("ErrorMsg format. Promise 已经完成, 无法再次失败: {}", errorMsg);
return;
}
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t);
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage());
}
}
@@ -186,7 +277,7 @@ public abstract class PanBase implements IPanTool {
* @return Handler
*/
protected Handler<Throwable> handleFail(String errorMsg) {
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.fillInStackTrace());
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.getClass().getSimpleName() + ": " + t.getMessage());
}
protected Handler<Throwable> handleFail() {
@@ -205,28 +296,22 @@ public abstract class PanBase implements IPanTool {
String contentEncoding = res.getHeader("Content-Encoding");
try {
if ("gzip".equalsIgnoreCase(contentEncoding)) {
// 如果是gzip压缩的响应体,解压
return new JsonObject(decompressGzip((Buffer) res.body()));
// 如果是gzip压缩的响应体,解压(只解压一次,缓存结果)
String decompressed = decompressGzip((Buffer) res.body());
return new JsonObject(decompressed);
} else {
return res.bodyAsJsonObject();
}
} catch (Exception e) {
if ("gzip".equalsIgnoreCase(contentEncoding)) {
// 如果是gzip压缩的响应体,解压
try {
log.error(decompressGzip((Buffer) res.body()));
fail(decompressGzip((Buffer) res.body()));
//throw new RuntimeException("响应不是JSON格式");
} catch (IOException ex) {
log.error("响应gzip解压失败");
fail("响应gzip解压失败: {}", ex.getMessage());
//throw new RuntimeException("响应gzip解压失败", ex);
}
// gzip解压失败,记录错误
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
} else {
log.error("解析失败: json格式异常: {}", res.bodyAsString());
fail("解析失败: json格式异常: {}", res.bodyAsString());
//throw new RuntimeException("解析失败: json格式异常");
String bodyPreview = responseBodyPreview(res);
log.error("解析失败: json格式异常: {}", bodyPreview);
fail("解析失败: json格式异常: {}", bodyPreview);
}
return JsonObject.of();
}
@@ -289,11 +374,15 @@ public abstract class PanBase implements IPanTool {
if (iterator.hasNext()) {
PanDomainTemplate next = iterator.next();
log.debug("规则不匹配, 处理解析器转发: {} -> {}", shareLinkInfo.getPanName(), next.getDisplayName());
ParserCreate.fromType(next.name())
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
.createTool()
.parse()
.onComplete(promise);
try {
IPanTool nextTool = ParserCreate.fromType(next.name())
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
.createTool();
IPanTool.closeAfter(nextTool, nextTool::parse)
.onComplete(promise);
} catch (Exception e) {
fail(e, "转发到下一个解析器失败: {}", next.getDisplayName());
}
} else {
fail("error: 没有下一个解析处理器");
}
@@ -309,6 +398,12 @@ public abstract class PanBase implements IPanTool {
* @throws IOException IOException
*/
private String decompressGzip(Buffer compressedData) throws IOException {
if (compressedData == null) {
return "";
}
if (compressedData.length() > MAX_COMPRESSED_RESPONSE_BYTES) {
throw new IOException("gzip响应体过大: " + compressedData.length() + " bytes");
}
try (ByteArrayInputStream bais = new ByteArrayInputStream(compressedData.getBytes());
GZIPInputStream gzis = new GZIPInputStream(bais);
InputStreamReader isr = new InputStreamReader(gzis, StandardCharsets.UTF_8);
@@ -317,12 +412,39 @@ public abstract class PanBase implements IPanTool {
char[] buffer = new char[4096];
int n;
while ((n = isr.read(buffer)) != -1) {
writer.write(buffer, 0, n);
writeLimited(writer, buffer, n);
}
return writer.toString();
}
}
private void writeLimited(StringWriter writer, char[] buffer, int len) throws IOException {
if (writer.getBuffer().length() + len > MAX_DECOMPRESSED_RESPONSE_CHARS) {
throw new IOException("gzip解压后响应体过大");
}
writer.write(buffer, 0, len);
}
private String responseBodyPreview(HttpResponse<?> res) {
if (res == null || res.body() == null) {
return "";
}
try {
if (res.body() instanceof Buffer body) {
int length = Math.min(body.length(), MAX_ERROR_BODY_CHARS);
String preview = new String(body.getBytes(0, length), StandardCharsets.UTF_8);
return body.length() > length ? preview + "...(truncated " + body.length() + " bytes)" : preview;
}
String text = res.bodyAsString();
if (text == null || text.length() <= MAX_ERROR_BODY_CHARS) {
return text;
}
return text.substring(0, MAX_ERROR_BODY_CHARS) + "...(truncated " + text.length() + " chars)";
} catch (Exception e) {
return "<body preview failed: " + e.getMessage() + ">";
}
}
protected String getDomainName(){
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
}
@@ -331,4 +453,28 @@ public abstract class PanBase implements IPanTool {
public ShareLinkInfo getShareLinkInfo() {
return shareLinkInfo;
}
/**
* 关闭代理模式下创建的 WebClient 资源
* 非代理模式使用共享实例,不需要关闭
*/
@Override
public void close() {
if (isProxyMode) {
try {
if (proxyClient != null) {
proxyClient.close();
}
} catch (Exception e) {
log.warn("关闭代理 WebClient 失败: {}", e.getMessage());
}
try {
if (proxyClientNoRedirects != null) {
proxyClientNoRedirects.close();
}
} catch (Exception e) {
log.warn("关闭代理 WebClientNoRedirects 失败: {}", e.getMessage());
}
}
}
}
@@ -208,7 +208,7 @@ public enum PanDomainTemplate {
123795.com
*/
YE("123网盘",
compile("https://www\\.(" +
compile("https://(?:[a-zA-Z\\d-]+\\.)*(" +
"123254\\.com|" +
"123957\\.com|" +
"123295\\.com|" +
@@ -232,7 +232,7 @@ public enum PanDomainTemplate {
"123635\\.com|" +
"123242\\.com|" +
"123795\\.com" +
")/s/(?<KEY>[a-zA-Z0-9_-]+)(?:\\.html)?"),
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[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
@@ -247,14 +247,14 @@ public enum PanDomainTemplate {
"https://cowtransfer.com/s/{shareKey}",
CowTool.class),
CT("城通网盘",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
"https://ctfile.com/file/{shareKey}",
CtTool.class),
// https://url94.ctfile.com/d/64115194-164803691-48508c?p=7609&d=164803691&fk=decb36
CTD("城通网盘-目录",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" +
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" +
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
"https://ctfile.com/d/{shareKey}",
CtTool.class),
// https://www.vyuyun.com/s/QMa6ie?password=I4KG7H
@@ -9,6 +9,8 @@ import org.apache.commons.lang3.StringUtils;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.EnumSet;
import java.util.Set;
import java.util.regex.Matcher;
import static cn.qaiu.parser.PanDomainTemplate.KEY;
@@ -23,6 +25,9 @@ import static cn.qaiu.parser.PanDomainTemplate.PWD;
* Create at 2024/9/15 14:10
*/
public class ParserCreate {
private static final Set<PanDomainTemplate> GENERIC_BUILT_IN_PARSERS =
EnumSet.of(PanDomainTemplate.CE, PanDomainTemplate.KD, PanDomainTemplate.OTHER);
private final PanDomainTemplate panDomainTemplate;
private final ShareLinkInfo shareLinkInfo;
@@ -132,12 +137,12 @@ public class ParserCreate {
if (StringUtils.isNotEmpty(pwd)) {
shareLinkInfo.setSharePassword(pwd);
}
standardUrl = standardUrl.replace("{pwd}", pwd);
standardUrl = standardUrl.replace("{pwd}", StringUtils.defaultString(pwd));
} catch (IllegalStateException | IllegalArgumentException ignored) {}
shareLinkInfo.setShareUrl(shareUrl);
shareLinkInfo.setShareKey(shareKey);
if (!(panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal())) {
if (!isGenericBuiltInParser(panDomainTemplate)) {
shareLinkInfo.setStandardUrl(standardUrl);
}
return this;
@@ -197,7 +202,7 @@ public class ParserCreate {
}
// 内置解析器处理
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
if (isGenericBuiltInParser(panDomainTemplate)) {
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
String[] s = shareKey.split("_");
String standardUrl = "https://" + String.join("/", s);
@@ -247,9 +252,19 @@ public class ParserCreate {
return this;
}
// 根据分享链接获取PanDomainTemplate实例(优先匹配自定义解析器)
// 根据分享链接获取PanDomainTemplate实例
public synchronized static ParserCreate fromShareUrl(String shareUrl) {
// 优先查找支持正则匹配的自定义解析器
if (StringUtils.isBlank(shareUrl)) {
throw new IllegalArgumentException("shareUrl不能为空");
}
shareUrl = shareUrl.trim();
ParserCreate builtInParser = fromBuiltInShareUrl(shareUrl, false);
if (builtInParser != null) {
return builtInParser;
}
// 明确内置解析器未命中时,再查找支持正则匹配的自定义解析器
for (CustomParserConfig customConfig : CustomParserRegistry.getAll().values()) {
if (customConfig.supportsFromShareUrl()) {
Matcher matcher = customConfig.getMatchPattern().matcher(shareUrl);
@@ -295,22 +310,35 @@ public class ParserCreate {
}
}
}
// 查找内置解析器
// 最后再走 Cloudreve/可道云/其他网盘这类泛化兜底,避免抢走自定义解析器
builtInParser = fromBuiltInShareUrl(shareUrl, true);
if (builtInParser != null) {
return builtInParser;
}
throw new IllegalArgumentException("Unsupported share URL");
}
private static ParserCreate fromBuiltInShareUrl(String shareUrl, boolean genericOnly) {
for (PanDomainTemplate panDomainTemplate : PanDomainTemplate.values()) {
boolean genericParser = isGenericBuiltInParser(panDomainTemplate);
if (genericOnly != genericParser) {
continue;
}
if (panDomainTemplate.getPattern().matcher(shareUrl).matches()) {
ShareLinkInfo shareLinkInfo = ShareLinkInfo.newBuilder()
.type(panDomainTemplate.name().toLowerCase())
.panName(panDomainTemplate.getDisplayName())
.shareUrl(shareUrl).build();
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
if (isGenericBuiltInParser(panDomainTemplate)) {
shareLinkInfo.setStandardUrl(shareUrl);
}
ParserCreate parserCreate = new ParserCreate(panDomainTemplate, shareLinkInfo);
return parserCreate.normalizeShareLink();
}
}
throw new IllegalArgumentException("Unsupported share URL");
return null;
}
// 根据type获取枚举实例(优先查找自定义解析器)
@@ -353,7 +381,7 @@ public class ParserCreate {
// 自定义解析器处理
if (isCustomParser) {
path = this.shareLinkInfo.getType() + "/" + this.shareLinkInfo.getShareKey();
} else if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
} else if (isGenericBuiltInParser(panDomainTemplate)) {
// 处理Cloudreve(ce)类: pan.huang1111.cn_s_wDz5TK _ -> /
path = this.shareLinkInfo.getType() + "/"
+ this.shareLinkInfo.getShareUrl()
@@ -381,7 +409,11 @@ public class ParserCreate {
public CustomParserConfig getCustomParserConfig() {
return customParserConfig;
}
private static boolean isGenericBuiltInParser(PanDomainTemplate panDomainTemplate) {
return GENERIC_BUILT_IN_PARSERS.contains(panDomainTemplate);
}
/**
* 获取内置解析器模板(仅当isCustomParser为false时有效)
* @return 内置解析器模板,如果是自定义解析器则返回null
@@ -0,0 +1,53 @@
package cn.qaiu.parser;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Parser token cache keyed by parser type and account identity.
*/
public final class TokenCache {
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
private TokenCache() {
}
public static String key(String type, String accountId) {
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
}
public static void putToken(String key, String token) {
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
return;
}
TOKENS.put(key, token);
}
public static String getToken(String key) {
if (StringUtils.isBlank(key)) {
return null;
}
if (isExpired(key)) {
TOKENS.remove(key);
EXPIRES.remove(key);
return null;
}
return TOKENS.get(key);
}
public static void putExpire(String key, long expireTimeMillis) {
if (StringUtils.isBlank(key)) {
return;
}
EXPIRES.put(key, expireTimeMillis);
}
public static boolean isExpired(String key) {
Long expireTimeMillis = EXPIRES.get(key);
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
}
}
@@ -21,6 +21,7 @@ import java.util.concurrent.ConcurrentHashMap;
public class CustomParserRegistry {
private static final Logger log = LoggerFactory.getLogger(CustomParserRegistry.class);
private static final int MAX_CUSTOM_PARSERS = Integer.getInteger("parser.custom.maxRegistrySize", 256);
/**
* 存储自定义解析器配置的Map,key为类型标识,value为配置对象
@@ -33,7 +34,7 @@ public class CustomParserRegistry {
* @param config 解析器配置
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
*/
public static void register(CustomParserConfig config) {
public static synchronized void register(CustomParserConfig config) {
if (config == null) {
throw new IllegalArgumentException("config不能为空");
}
@@ -59,6 +60,11 @@ public class CustomParserRegistry {
"类型标识 '" + type + "' 已被注册,请先注销或使用其他标识"
);
}
if (CUSTOM_PARSERS.size() >= MAX_CUSTOM_PARSERS) {
throw new IllegalArgumentException(
"自定义解析器数量已达到上限(" + MAX_CUSTOM_PARSERS + "个),请先注销不需要的解析器"
);
}
CUSTOM_PARSERS.put(type, config);
log.info("注册自定义解析器成功: {} ({})", config.getDisplayName(), type);
@@ -171,7 +177,7 @@ public class CustomParserRegistry {
* @param type 解析器类型标识
* @return 是否注销成功
*/
public static boolean unregister(String type) {
public static synchronized boolean unregister(String type) {
if (type == null || type.trim().isEmpty()) {
return false;
}
@@ -213,7 +219,7 @@ public class CustomParserRegistry {
/**
* 清空所有自定义解析器
*/
public static void clear() {
public static synchronized void clear() {
CUSTOM_PARSERS.clear();
}
@@ -2,19 +2,21 @@ package cn.qaiu.parser.customjs;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.util.HttpResponseHelper;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpClientOptions;
import io.vertx.core.http.HttpClientRequest;
import io.vertx.core.http.HttpClientResponse;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.http.RequestOptions;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.ProxyOptions;
import io.vertx.core.net.ProxyType;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.client.WebClientOptions;
import io.vertx.ext.web.client.WebClientSession;
import io.vertx.ext.web.multipart.MultipartForm;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -27,8 +29,13 @@ import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
/**
@@ -39,11 +46,60 @@ import java.util.regex.Pattern;
* Create at 2025/10/17
*/
public class JsHttpClient {
private static final Logger log = LoggerFactory.getLogger(JsHttpClient.class);
private final WebClient client;
private final WebClientSession clientSession;
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
private static final int MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
private static final int MAX_HEADER_COUNT = 64;
private static final int MAX_HEADER_VALUE_LENGTH = 4096;
private static final int MAX_TIMEOUT_SECONDS = 120;
private static final int MAX_REDIRECTS = 5;
private static final String DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
private static final Object SHARED_CLIENT_LOCK = new Object();
// 共享 HttpClient 实例(非代理模式),懒加载避免类初始化阶段抢跑 Vert.x。
private static volatile HttpClient sharedClient;
private static volatile boolean sharedClientShutdown = false;
/**
* 关闭共享 HttpClient(应用关闭时调用)
*/
public static void shutdownSharedClient() {
synchronized (SHARED_CLIENT_LOCK) {
sharedClientShutdown = true;
if (sharedClient != null) {
sharedClient.close();
sharedClient = null;
}
}
}
private static HttpClient sharedClient() {
synchronized (SHARED_CLIENT_LOCK) {
ensureSharedClientAvailable();
if (sharedClient == null) {
sharedClient = WebClientVertxInit.get().createHttpClient(
new HttpClientOptions()
.setConnectTimeout(10000)
.setIdleTimeout(30)
.setIdleTimeoutUnit(TimeUnit.SECONDS)
.setMaxPoolSize(64));
}
return sharedClient;
}
}
private static void ensureSharedClientAvailable() {
if (sharedClientShutdown) {
throw new IllegalStateException("共享 JavaScript HttpClient 已关闭");
}
}
private final HttpClient client;
private final boolean ownClient; // 标记是否为自建 client(需要 close
private final AtomicBoolean closed = new AtomicBoolean(false);
private final Object requestLock = new Object();
private final Set<HttpClientRequest> activeRequests = ConcurrentHashMap.newKeySet();
private MultiMap headers;
private int timeoutSeconds = 30; // 默认超时时间30秒
@@ -61,11 +117,12 @@ public class JsHttpClient {
};
public JsHttpClient() {
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());
this.clientSession = WebClientSession.create(client);
ensureSharedClientAvailable();
this.client = sharedClient();
this.ownClient = false;
this.headers = MultiMap.caseInsensitiveMultiMap();
// 设置默认的Accept-Encoding头以支持压缩响应
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
// 设置默认的User-Agent头
this.headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
// 设置默认的Accept-Language头
@@ -77,31 +134,35 @@ public class JsHttpClient {
* @param proxyConfig 代理配置JsonObject,包含type、host、port、username、password
*/
public JsHttpClient(JsonObject proxyConfig) {
ensureSharedClientAvailable();
if (proxyConfig != null && proxyConfig.containsKey("type")) {
ProxyOptions proxyOptions = new ProxyOptions()
.setType(ProxyType.valueOf(proxyConfig.getString("type").toUpperCase()))
.setHost(proxyConfig.getString("host"))
.setPort(proxyConfig.getInteger("port"));
if (StringUtils.isNotEmpty(proxyConfig.getString("username"))) {
proxyOptions.setUsername(proxyConfig.getString("username"));
}
if (StringUtils.isNotEmpty(proxyConfig.getString("password"))) {
proxyOptions.setPassword(proxyConfig.getString("password"));
}
this.client = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions()
.setUserAgentEnabled(false)
this.client = WebClientVertxInit.get().createHttpClient(
new HttpClientOptions()
.setConnectTimeout(10000)
.setIdleTimeout(30)
.setIdleTimeoutUnit(TimeUnit.SECONDS)
.setMaxPoolSize(16)
.setProxyOptions(proxyOptions));
this.clientSession = WebClientSession.create(client);
this.ownClient = true;
} else {
this.client = WebClient.create(WebClientVertxInit.get());
this.clientSession = WebClientSession.create(client);
this.client = sharedClient();
this.ownClient = false;
}
this.headers = MultiMap.caseInsensitiveMultiMap();
// 设置默认的Accept-Encoding头以支持压缩响应
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
// 设置默认的User-Agent头
this.headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
// 设置默认的Accept-Language头
@@ -183,13 +244,7 @@ public class JsHttpClient {
*/
public JsHttpResponse get(String url) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.send();
});
return executeRequest(HttpMethod.GET, url, null, false);
}
/**
@@ -198,16 +253,25 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse getWithRedirect(String url) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
String currentUrl = url;
for (int redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
validateUrlSecurity(currentUrl);
JsHttpResponse response = executeRequest(HttpMethod.GET, currentUrl, null, false);
if (!isRedirectStatus(response.statusCode())) {
return response;
}
// 设置跟随重定向
request.followRedirects(true);
return request.send();
});
if (redirectCount == MAX_REDIRECTS) {
throw new RuntimeException("重定向次数超过限制: " + MAX_REDIRECTS);
}
String location = response.header(HttpHeaders.LOCATION.toString());
if (StringUtils.isBlank(location)) {
throw new RuntimeException("重定向响应缺少Location头");
}
currentUrl = resolveRedirectUrl(currentUrl, location);
}
throw new RuntimeException("重定向处理失败");
}
/**
@@ -217,15 +281,7 @@ public class JsHttpClient {
*/
public JsHttpResponse getNoRedirect(String url) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
// 设置不跟随重定向
request.followRedirects(false);
return request.send();
});
return executeRequest(HttpMethod.GET, url, null, false);
}
/**
@@ -236,26 +292,7 @@ public class JsHttpClient {
*/
public JsHttpResponse post(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
return executeRequest(HttpMethod.POST, url, bodyFromData(data), false);
}
/**
@@ -266,26 +303,7 @@ public class JsHttpClient {
*/
public JsHttpResponse put(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.putAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
return executeRequest(HttpMethod.PUT, url, bodyFromData(data), false);
}
/**
@@ -294,13 +312,8 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse delete(String url) {
return executeRequest(() -> {
HttpRequest<Buffer> request = client.deleteAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.send();
});
validateUrlSecurity(url);
return executeRequest(HttpMethod.DELETE, url, null, false);
}
/**
@@ -310,26 +323,8 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse patch(String url, Object data) {
return executeRequest(() -> {
HttpRequest<Buffer> request = client.patchAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
validateUrlSecurity(url);
return executeRequest(HttpMethod.PATCH, url, bodyFromData(data), false);
}
/**
@@ -340,6 +335,12 @@ public class JsHttpClient {
*/
public JsHttpClient putHeader(String name, String value) {
if (name != null && value != null) {
if (headers.size() >= MAX_HEADER_COUNT && !headers.contains(name)) {
throw new IllegalArgumentException("请求头数量超过限制");
}
if (value.length() > MAX_HEADER_VALUE_LENGTH) {
throw new IllegalArgumentException("请求头过长: " + name);
}
headers.set(name, value);
}
return this;
@@ -353,9 +354,7 @@ public class JsHttpClient {
public JsHttpClient putHeaders(Map<String, String> headersMap) {
if (headersMap != null) {
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
headers.set(entry.getKey(), entry.getValue());
}
putHeader(entry.getKey(), entry.getValue());
}
}
return this;
@@ -380,7 +379,7 @@ public class JsHttpClient {
public JsHttpClient clearHeaders() {
headers.clear();
// 重新设置默认头
headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0");
headers.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6");
return this;
@@ -405,7 +404,7 @@ public class JsHttpClient {
*/
public JsHttpClient setTimeout(int seconds) {
if (seconds > 0) {
this.timeoutSeconds = seconds;
this.timeoutSeconds = Math.min(seconds, MAX_TIMEOUT_SECONDS);
}
return this;
}
@@ -450,19 +449,12 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse sendForm(Map<String, String> data) {
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs("");
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
MultiMap formData = MultiMap.caseInsensitiveMultiMap();
if (data != null) {
formData.addAll(data);
}
return request.sendForm(formData);
});
throw new IllegalArgumentException("sendForm(data) 缺少请求URL,请使用 post(url, data)");
}
public JsHttpResponse sendForm(String url, Map<String, String> data) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.POST, url, formBody(data), false);
}
/**
@@ -474,34 +466,8 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse sendMultipartForm(String url, Map<String, Object> data) {
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
MultipartForm form = MultipartForm.create();
if (data != null) {
for (Map.Entry<String, Object> entry : data.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (value instanceof String) {
form.attribute(key, (String) value);
} else if (value instanceof byte[]) {
form.binaryFileUpload(key, key, Buffer.buffer((byte[]) value), "application/octet-stream");
} else if (value instanceof Buffer) {
form.binaryFileUpload(key, key, (Buffer) value, "application/octet-stream");
} else if (value != null) {
// 其他类型转换为字符串
form.attribute(key, value.toString());
}
}
}
return request.sendMultipartForm(form);
});
validateUrlSecurity(url);
return executeRequest(HttpMethod.POST, url, multipartBody(data), false);
}
/**
@@ -510,44 +476,103 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse sendJson(Object data) {
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs("");
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.sendJson(data);
});
throw new IllegalArgumentException("sendJson(data) 缺少请求URL,请使用 post(url, data)");
}
public JsHttpResponse sendJson(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.POST, url, jsonBody(data), false);
}
/**
* 执行HTTP请求(同步)
*/
private JsHttpResponse executeRequest(RequestExecutor executor) {
private JsHttpResponse executeRequest(HttpMethod method, String url, RequestBody requestBody, boolean followRedirects) {
if (closed.get()) {
throw new IllegalStateException("HTTP客户端已关闭");
}
AtomicReference<HttpClientRequest> requestRef = new AtomicReference<>();
AtomicBoolean abandoned = new AtomicBoolean(false);
try {
Promise<HttpResponse<Buffer>> promise = Promise.promise();
Future<HttpResponse<Buffer>> future = executor.execute();
future.onComplete(result -> {
if (result.succeeded()) {
promise.complete(result.result());
} else {
promise.fail(result.cause());
}
}).onFailure(e -> log.error("HTTP请求失败", e));
Promise<JsHttpResponse> promise = Promise.promise();
// 等待响应完成(使用配置的超时时间)
HttpResponse<Buffer> response = promise.future().toCompletionStage()
RequestOptions options = new RequestOptions()
.setMethod(method)
.setAbsoluteURI(url)
.setFollowRedirects(followRedirects)
.setTimeout(TimeUnit.SECONDS.toMillis(timeoutSeconds))
.setHeaders(MultiMap.caseInsensitiveMultiMap().setAll(headers));
client.request(options).onComplete(ar -> {
if (ar.failed()) {
promise.tryFail(ar.cause());
return;
}
HttpClientRequest request = ar.result();
synchronized (requestLock) {
if (closed.get() || abandoned.get()) {
request.reset();
promise.tryFail("HTTP客户端已关闭");
return;
}
activeRequests.add(request);
requestRef.set(request);
request.exceptionHandler(e -> {
finishRequest(request);
promise.tryFail(e);
});
request.response().onComplete(responseAr -> {
if (responseAr.succeeded()) {
collectResponse(request, responseAr.result(), promise);
} else {
finishRequest(request);
promise.tryFail(responseAr.cause());
}
});
if (closed.get() || abandoned.get()) {
request.reset();
finishRequest(request);
promise.tryFail("HTTP客户端已关闭");
return;
}
if (requestBody == null || requestBody.body() == null) {
request.end().onFailure(e -> {
finishRequest(request);
promise.tryFail(e);
});
} else {
request.headers().set(HttpHeaders.CONTENT_LENGTH, String.valueOf(requestBody.body().length()));
if (StringUtils.isNotEmpty(requestBody.contentType())) {
request.headers().set(HttpHeaders.CONTENT_TYPE, requestBody.contentType());
}
request.end(requestBody.body()).onFailure(e -> {
finishRequest(request);
promise.tryFail(e);
});
}
}
});
return promise.future().toCompletionStage()
.toCompletableFuture()
.get(timeoutSeconds, TimeUnit.SECONDS);
return new JsHttpResponse(response);
} catch (TimeoutException e) {
// RequestOptions timeout 通常会先触发;这里再兜底,避免等待线程返回后请求还在后台下载。
String errorMsg = "HTTP请求超时(" + timeoutSeconds + "秒)";
abandoned.set(true);
synchronized (requestLock) {
abortRequest(requestRef);
}
log.error(errorMsg, e);
throw new RuntimeException(errorMsg, e);
} catch (Exception e) {
abandoned.set(true);
synchronized (requestLock) {
abortRequest(requestRef);
}
String errorMsg = e.getMessage();
if (errorMsg == null || errorMsg.trim().isEmpty()) {
errorMsg = e.getClass().getSimpleName();
@@ -559,13 +584,196 @@ public class JsHttpClient {
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
}
}
/**
* 请求执行器接口
*/
@FunctionalInterface
private interface RequestExecutor {
Future<HttpResponse<Buffer>> execute();
private static boolean isRedirectStatus(int statusCode) {
return statusCode == 301 || statusCode == 302 || statusCode == 303
|| statusCode == 307 || statusCode == 308;
}
private String resolveRedirectUrl(String currentUrl, String location) {
try {
URI redirectUri = new URI(currentUrl).resolve(location.trim());
String scheme = redirectUri.getScheme();
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new SecurityException("🔒 安全拦截: 重定向协议不被允许");
}
String redirectUrl = redirectUri.toString();
validateUrlSecurity(redirectUrl);
return redirectUrl;
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("解析重定向地址失败: " + e.getMessage(), e);
}
}
private void collectResponse(HttpClientRequest request, HttpClientResponse response, Promise<JsHttpResponse> promise) {
Buffer body = Buffer.buffer();
AtomicBoolean done = new AtomicBoolean(false);
String contentLengthHeader = response.getHeader(HttpHeaders.CONTENT_LENGTH.toString());
if (StringUtils.isNumeric(contentLengthHeader)) {
long contentLength = Long.parseLong(contentLengthHeader);
if (contentLength > MAX_RESPONSE_BODY_BYTES) {
done.set(true);
request.reset();
finishRequest(request);
promise.tryFail("响应体过大: " + contentLength + " bytes");
return;
}
}
response.exceptionHandler(e -> {
if (done.compareAndSet(false, true)) {
finishRequest(request);
promise.tryFail(e);
}
});
response.handler(chunk -> {
if (done.get()) {
return;
}
if (body.length() + chunk.length() > MAX_RESPONSE_BODY_BYTES) {
if (done.compareAndSet(false, true)) {
request.reset();
finishRequest(request);
promise.tryFail("响应体过大: " + (body.length() + chunk.length()) + " bytes");
}
return;
}
body.appendBuffer(chunk);
});
response.endHandler(v -> {
if (done.compareAndSet(false, true)) {
finishRequest(request);
promise.tryComplete(new JsHttpResponse(
response.statusCode(),
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
body,
response.statusMessage(),
null
));
}
});
response.resume();
}
private void finishRequest(HttpClientRequest request) {
if (request != null) {
activeRequests.remove(request);
}
}
private void abortRequest(AtomicReference<HttpClientRequest> requestRef) {
HttpClientRequest request = requestRef.get();
if (request != null) {
try {
request.reset();
} finally {
finishRequest(request);
}
}
}
private RequestBody bodyFromData(Object data) {
if (data == null) {
return null;
}
if (data instanceof String str) {
return plainTextBody(str);
}
if (data instanceof Buffer buffer) {
return limitedBody(buffer, null);
}
if (data instanceof byte[] bytes) {
return limitedBody(Buffer.buffer(bytes), null);
}
if (data instanceof Map<?, ?> map) {
Map<String, String> formMap = new HashMap<>();
map.forEach((key, value) -> {
if (key != null && value != null) {
formMap.put(String.valueOf(key), String.valueOf(value));
}
});
return formBody(formMap);
}
return jsonBody(data);
}
private RequestBody plainTextBody(String data) {
return limitedBody(Buffer.buffer(data, StandardCharsets.UTF_8.name()), null);
}
private RequestBody jsonBody(Object data) {
Buffer body = data == null ? Buffer.buffer() : Buffer.buffer(Json.encode(data), StandardCharsets.UTF_8.name());
return limitedBody(body, "application/json; charset=utf-8");
}
private RequestBody formBody(Map<String, String> data) {
StringBuilder encoded = new StringBuilder();
if (data != null) {
for (Map.Entry<String, String> entry : data.entrySet()) {
if (encoded.length() > 0) {
encoded.append('&');
}
encoded.append(urlEncode(entry.getKey()));
encoded.append('=');
encoded.append(urlEncode(entry.getValue()));
}
}
return limitedBody(Buffer.buffer(encoded.toString(), StandardCharsets.UTF_8.name()),
"application/x-www-form-urlencoded; charset=utf-8");
}
private RequestBody multipartBody(Map<String, Object> data) {
String boundary = "----NetdiskJsHttpClientBoundary" + UUID.randomUUID().toString().replace("-", "");
Buffer body = Buffer.buffer();
if (data != null) {
for (Map.Entry<String, Object> entry : data.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (key == null || value == null) {
continue;
}
appendAscii(body, "--" + boundary + "\r\n");
if (value instanceof byte[] bytes) {
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
body.appendBytes(bytes);
appendAscii(body, "\r\n");
} else if (value instanceof Buffer buffer) {
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
body.appendBuffer(buffer);
appendAscii(body, "\r\n");
} else {
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key) + "\"\r\n\r\n");
body.appendString(String.valueOf(value), StandardCharsets.UTF_8.name());
appendAscii(body, "\r\n");
}
ensureRequestBodyLimit(body);
}
}
appendAscii(body, "--" + boundary + "--\r\n");
return limitedBody(body, "multipart/form-data; boundary=" + boundary);
}
private static void appendAscii(Buffer body, String value) {
body.appendString(value, StandardCharsets.US_ASCII.name());
}
private static String escapeMultipart(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static RequestBody limitedBody(Buffer body, String contentType) {
ensureRequestBodyLimit(body);
return new RequestBody(body, contentType);
}
private record RequestBody(Buffer body, String contentType) {
}
/**
@@ -573,10 +781,29 @@ public class JsHttpClient {
*/
public static class JsHttpResponse {
private final HttpResponse<Buffer> response;
private final int statusCode;
private final MultiMap headers;
private final Buffer body;
private final String statusMessage;
private final HttpResponse<Buffer> originalResponse;
public JsHttpResponse(HttpResponse<Buffer> response) {
this.response = response;
this(
response.statusCode(),
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
response.body(),
response.statusMessage(),
response
);
}
public JsHttpResponse(int statusCode, MultiMap headers, Buffer body, String statusMessage,
HttpResponse<Buffer> originalResponse) {
this.statusCode = statusCode;
this.headers = headers == null ? MultiMap.caseInsensitiveMultiMap() : headers;
this.body = body == null ? Buffer.buffer() : body;
this.statusMessage = statusMessage;
this.originalResponse = originalResponse;
}
/**
@@ -584,7 +811,7 @@ public class JsHttpClient {
* @return 响应体字符串
*/
public String body() {
return HttpResponseHelper.asText(response);
return HttpResponseHelper.asText(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
}
/**
@@ -593,7 +820,7 @@ public class JsHttpClient {
*/
public Object json() {
try {
JsonObject jsonObject = HttpResponseHelper.asJson(response);
JsonObject jsonObject = HttpResponseHelper.asJson(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
if (jsonObject == null || jsonObject.isEmpty()) {
return null;
}
@@ -611,7 +838,7 @@ public class JsHttpClient {
* @return 状态码
*/
public int statusCode() {
return response.statusCode();
return statusCode;
}
/**
@@ -620,7 +847,7 @@ public class JsHttpClient {
* @return 头值
*/
public String header(String name) {
return response.getHeader(name);
return headers.get(name);
}
/**
@@ -628,10 +855,9 @@ public class JsHttpClient {
* @return 响应头Map
*/
public Map<String, String> headers() {
MultiMap responseHeaders = response.headers();
Map<String, String> result = new HashMap<>();
for (String name : responseHeaders.names()) {
result.put(name, responseHeaders.get(name));
for (String name : headers.names()) {
result.put(name, headers.get(name));
}
return result;
}
@@ -649,8 +875,14 @@ public class JsHttpClient {
* 获取原始响应对象
* @return HttpResponse对象
*/
@Deprecated
public HttpResponse<Buffer> getOriginalResponse() {
return response;
if (originalResponse == null) {
throw new UnsupportedOperationException(
"流式HTTP客户端不再保留原始Vert.x HttpResponse,请使用statusCode/header/headers/body/bodyBytes方法"
);
}
return originalResponse;
}
/**
@@ -658,11 +890,8 @@ public class JsHttpClient {
* @return 响应体字节数组
*/
public byte[] bodyBytes() {
Buffer buffer = response.body();
if (buffer == null) {
return new byte[0];
}
return buffer.getBytes();
ensureResponseBodyLimit(body);
return body.getBytes();
}
/**
@@ -670,20 +899,46 @@ public class JsHttpClient {
* @return 响应体大小(字节)
*/
public long bodySize() {
Buffer buffer = response.body();
if (buffer == null) {
return 0;
}
return buffer.length();
return body.length();
}
public String statusMessage() {
return statusMessage;
}
}
/**
* 关闭 WebClient 释放连接池资源
* 关闭 HttpClient 释放连接池资源
* 仅关闭自建的 client(代理模式),共享实例不关闭
*/
public void close() {
if (client != null) {
if (!closed.compareAndSet(false, true)) {
return;
}
synchronized (requestLock) {
for (HttpClientRequest request : activeRequests) {
try {
request.reset();
} catch (Exception e) {
log.debug("重置 JavaScript HTTP 请求失败: {}", e.getMessage());
}
}
activeRequests.clear();
}
if (ownClient && client != null) {
client.close();
}
}
private static void ensureResponseBodyLimit(Buffer buffer) {
if (buffer != null && buffer.length() > MAX_RESPONSE_BODY_BYTES) {
throw new IllegalArgumentException("响应体过大: " + buffer.length() + " bytes");
}
}
private static void ensureRequestBodyLimit(Buffer buffer) {
if (buffer != null && buffer.length() > MAX_REQUEST_BODY_BYTES) {
throw new IllegalArgumentException("请求体过大: " + buffer.length() + " bytes");
}
}
}
@@ -6,6 +6,7 @@ import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.IPanTool;
import cn.qaiu.parser.custom.CustomParserConfig;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.WorkerExecutor;
import io.vertx.core.json.JsonObject;
import org.openjdk.nashorn.api.scripting.NashornScriptEngineFactory;
@@ -20,6 +21,13 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
/**
@@ -35,16 +43,40 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
private static volatile WorkerExecutor EXECUTOR;
private static final Object EXECUTOR_LOCK = new Object();
private static volatile boolean executorShutdown = false;
private static String FETCH_RUNTIME_JS = null;
/** 安全网调度器:当 onComplete 未触发时,延迟强制释放资源 */
private static final ScheduledExecutorService CLEANUP_SCHEDULER =
Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "js-parser-cleanup-safety");
t.setDaemon(true);
return t;
});
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
private static final int MAX_RESULT_STRING_LENGTH = 1024 * 1024;
private static final int MAX_FILE_LIST_SIZE = 1000;
private static final int MAX_FILE_FIELD_LENGTH = 4096;
private static final int MAX_CONCURRENT_EXECUTIONS =
Math.max(1, Integer.getInteger("parser.custom.js.maxConcurrentExecutions", 32));
private static final Semaphore EXECUTION_PERMITS = new Semaphore(MAX_CONCURRENT_EXECUTIONS);
private static volatile String FETCH_RUNTIME_JS = null;
private final CustomParserConfig config;
private final ShareLinkInfo shareLinkInfo;
private final ScriptEngine engine;
private volatile ScriptEngine engine;
private final Object engineLock = new Object();
private final JsHttpClient httpClient;
private final JsLogger jsLogger;
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
private final JsFetchBridge fetchBridge;
/** 标记是否已释放,防止重复关闭 */
private final AtomicBoolean closed = new AtomicBoolean(false);
private final Object lifecycleLock = new Object();
private volatile boolean running = false;
private volatile boolean closeRequested = false;
/** 安全网定时任务句柄,正常完成时取消 */
private volatile ScheduledFuture<?> safetyCleanupFuture = null;
public JsParserExecutor(ShareLinkInfo shareLinkInfo, CustomParserConfig config) {
this.config = config;
@@ -60,7 +92,6 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
this.jsLogger = new JsLogger("JsParser-" + config.getType());
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
this.fetchBridge = new JsFetchBridge(httpClient);
this.engine = initEngine();
}
/**
@@ -112,6 +143,7 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
if (engine == null) {
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
}
this.engine = engine;
// 注入Java对象到JavaScript环境
engine.put("http", httpClient);
@@ -146,45 +178,124 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
throw new RuntimeException("JavaScript引擎初始化失败: " + e.getMessage(), e);
}
}
private ScriptEngine engine() {
ScriptEngine current = engine;
if (current != null) {
return current;
}
synchronized (engineLock) {
if (closed.get()) {
throw new IllegalStateException("JavaScript解析器已关闭");
}
if (engine == null) {
engine = initEngine();
}
return engine;
}
}
private void beginExecution() {
synchronized (lifecycleLock) {
if (closed.get() || closeRequested) {
throw new IllegalStateException("JavaScript解析器已关闭");
}
if (running) {
throw new IllegalStateException("JavaScript解析器已在运行");
}
running = true;
}
}
private void finishExecution() {
synchronized (lifecycleLock) {
running = false;
if (closeRequested) {
doClose();
}
}
}
/**
* 释放资源(ScriptEngine 和 HttpClient),避免内存泄漏
* 幂等:可安全多次调用
*/
@Override
public void close() {
synchronized (lifecycleLock) {
closeRequested = true;
cancelSafetyCleanup();
if (running || closed.get()) {
closeExternalResources();
return;
}
doClose();
}
}
private void doClose() {
if (!closed.compareAndSet(false, true)) return;
closeRequested = false;
closeExternalResources();
cleanupEngine();
}
private void closeExternalResources() {
if (httpClient != null) {
httpClient.close();
}
// 清除 ScriptEngine 持有的 Java 对象引用,帮助 GC 回收
}
private void cleanupEngine() {
// 清除 ScriptEngine 持有的所有引用和内部状态,帮助 GC 回收
if (engine != null) {
engine.put("http", null);
engine.put("logger", null);
engine.put("shareLinkInfo", null);
engine.put("JavaFetch", null);
try {
engine.put("http", null);
engine.put("logger", null);
engine.put("shareLinkInfo", null);
engine.put("JavaFetch", null);
// 彻底清除 ENGINE_SCOPE bindings,释放 JS AST、编译函数、闭包等运行时状态
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
if (bindings != null) {
bindings.clear();
}
} catch (Exception e) {
log.warn("清理 ScriptEngine bindings 失败: {}", e.getMessage());
}
}
}
private void cancelSafetyCleanup() {
// 取消安全网定时任务(如果正常完成则无需再触发)
if (safetyCleanupFuture != null) {
safetyCleanupFuture.cancel(false);
safetyCleanupFuture = null;
}
}
/**
* 关闭全局 WorkerExecutor(应在应用关闭时调用)
* 关闭全局 WorkerExecutor 和清理调度器(应在应用关闭时调用)
*/
public static void shutdownExecutor() {
synchronized (EXECUTOR_LOCK) {
executorShutdown = true;
if (EXECUTOR != null) {
EXECUTOR.close();
EXECUTOR = null;
log.info("JsParserExecutor WorkerExecutor 已关闭");
}
}
CLEANUP_SCHEDULER.shutdown();
}
/**
* 获取或创建 WorkerExecutor(懒加载)
*/
private static WorkerExecutor getExecutor() {
if (EXECUTOR != null) {
return EXECUTOR;
}
synchronized (EXECUTOR_LOCK) {
if (executorShutdown) {
throw new IllegalStateException("JavaScript解析器 WorkerExecutor 已关闭");
}
if (EXECUTOR == null) {
EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
}
@@ -192,98 +303,159 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
}
}
private <T> Future<T> executeBlockingWithPermit(String operation, Callable<T> blockingCode) {
if (!EXECUTION_PERMITS.tryAcquire()) {
String message = "JavaScript " + operation + " 执行并发已满,请稍后重试";
jsLogger.error(message);
close();
return Future.failedFuture(message);
}
try {
return getExecutor().executeBlocking(() -> {
boolean executionStarted = false;
try {
beginExecution();
executionStarted = true;
return blockingCode.call();
} finally {
if (executionStarted) {
finishExecution();
}
EXECUTION_PERMITS.release();
}
});
} catch (Throwable e) {
EXECUTION_PERMITS.release();
close();
return Future.failedFuture(e);
}
}
private <T> Future<T> withTimeout(Future<T> executionFuture, String operation) {
Promise<T> promise = Promise.promise();
try {
safetyCleanupFuture = CLEANUP_SCHEDULER.schedule(() -> {
if (promise.tryFail("JavaScript " + operation + " 执行超时(" + EXECUTION_TIMEOUT_SECONDS + "秒)")) {
jsLogger.error("{} 执行超时,已停止外部HTTP资源;ScriptEngine将在执行线程退出后清理", operation);
close();
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("安全网调度失败: {}", e.getMessage());
}
executionFuture.onComplete(ar -> {
cancelSafetyCleanup();
if (ar.succeeded()) {
promise.tryComplete(ar.result());
} else {
promise.tryFail(ar.cause());
}
close();
});
return promise.future();
}
@Override
public Future<String> parse() {
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
return getExecutor().executeBlocking(() -> {
Future<String> executionFuture = executeBlockingWithPermit("parse", () -> {
ScriptEngine engine = engine();
// 直接调用全局parse函数
Object parseFunction = engine.get("parse");
if (parseFunction == null) {
throw new RuntimeException("JavaScript代码中未找到parse函数");
}
if (parseFunction instanceof ScriptObjectMirror parseMirror) {
Object result = parseMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
if (result instanceof String) {
jsLogger.info("解析成功: {}", result);
return (String) result;
String resultText = limitResultString((String) result, "parse");
jsLogger.info("解析成功,结果长度: {}", resultText.length());
return resultText;
} else {
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
result != null ? result.getClass().getSimpleName() : "null");
throw new RuntimeException("parse方法返回值类型错误");
}
} else {
throw new RuntimeException("parse函数类型错误");
}
}).onComplete(ar -> close());
});
return withTimeout(executionFuture, "parse");
}
@Override
public Future<List<FileInfo>> parseFileList() {
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
return getExecutor().executeBlocking(() -> {
Future<List<FileInfo>> executionFuture = executeBlockingWithPermit("parseFileList", () -> {
ScriptEngine engine = engine();
// 直接调用全局parseFileList函数
Object parseFileListFunction = engine.get("parseFileList");
if (parseFileListFunction == null) {
throw new RuntimeException("JavaScript代码中未找到parseFileList函数");
}
// 调用parseFileList方法
if (parseFileListFunction instanceof ScriptObjectMirror parseFileListMirror) {
Object result = parseFileListMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
if (result instanceof ScriptObjectMirror resultMirror) {
List<FileInfo> fileList = convertToFileInfoList(resultMirror);
jsLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
return fileList;
} else {
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
result != null ? result.getClass().getSimpleName() : "null");
throw new RuntimeException("parseFileList方法返回值类型错误");
}
} else {
throw new RuntimeException("parseFileList函数类型错误");
}
}).onComplete(ar -> close());
});
return withTimeout(executionFuture, "parseFileList");
}
@Override
public Future<String> parseById() {
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程
return getExecutor().executeBlocking(() -> {
Future<String> executionFuture = executeBlockingWithPermit("parseById", () -> {
ScriptEngine engine = engine();
// 直接调用全局parseById函数
Object parseByIdFunction = engine.get("parseById");
if (parseByIdFunction == null) {
throw new RuntimeException("JavaScript代码中未找到parseById函数");
}
// 调用parseById方法
if (parseByIdFunction instanceof ScriptObjectMirror parseByIdMirror) {
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
if (result instanceof String) {
jsLogger.info("按ID解析成功: {}", result);
return (String) result;
String resultText = limitResultString((String) result, "parseById");
jsLogger.info("按ID解析成功,结果长度: {}", resultText.length());
return resultText;
} else {
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
result != null ? result.getClass().getSimpleName() : "null");
throw new RuntimeException("parseById方法返回值类型错误");
}
} else {
throw new RuntimeException("parseById函数类型错误");
}
}).onComplete(ar -> close());
});
return withTimeout(executionFuture, "parseById");
}
/**
@@ -293,6 +465,9 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
List<FileInfo> fileList = new ArrayList<>();
if (resultMirror.isArray()) {
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
}
for (int i = 0; i < resultMirror.size(); i++) {
Object item = resultMirror.get(String.valueOf(i));
if (item instanceof ScriptObjectMirror) {
@@ -316,13 +491,13 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
// 设置基本字段
if (itemMirror.hasMember("fileName")) {
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
}
if (itemMirror.hasMember("fileId")) {
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
}
if (itemMirror.hasMember("fileType")) {
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
}
if (itemMirror.hasMember("size")) {
Object size = itemMirror.getMember("size");
@@ -331,16 +506,16 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
}
}
if (itemMirror.hasMember("sizeStr")) {
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
}
if (itemMirror.hasMember("createTime")) {
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
}
if (itemMirror.hasMember("updateTime")) {
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
}
if (itemMirror.hasMember("createBy")) {
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
}
if (itemMirror.hasMember("downloadCount")) {
Object downloadCount = itemMirror.getMember("downloadCount");
@@ -349,16 +524,16 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
}
}
if (itemMirror.hasMember("fileIcon")) {
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
}
if (itemMirror.hasMember("panType")) {
fileInfo.setPanType(itemMirror.getMember("panType").toString());
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
}
if (itemMirror.hasMember("parserUrl")) {
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
}
if (itemMirror.hasMember("previewUrl")) {
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
}
return fileInfo;
@@ -368,4 +543,22 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
return null;
}
}
private static String limitResultString(String value, String operation) {
if (value.length() > MAX_RESULT_STRING_LENGTH) {
throw new RuntimeException(operation + " 返回结果过大: " + value.length() + " 字符");
}
return value;
}
private static String limitField(Object value) {
if (value == null) {
return null;
}
String text = value.toString();
if (text.length() > MAX_FILE_FIELD_LENGTH) {
throw new RuntimeException("文件字段过长: " + text.length() + " 字符");
}
return text;
}
}
@@ -21,20 +21,37 @@ import java.util.concurrent.*;
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JsPlaygroundExecutor {
public class JsPlaygroundExecutor implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundExecutor.class);
// JavaScript执行超时时间(秒)
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
private static final int MAX_RESULT_STRING_LENGTH = 1024 * 1024;
private static final int MAX_FILE_LIST_SIZE = 1000;
private static final int MAX_FILE_FIELD_LENGTH = 4096;
private static final int TIMEOUT_LOG_RETAIN = 50;
// 使用独立的线程池,不受Vert.x的BlockedThreadChecker监控
private static final ExecutorService INDEPENDENT_EXECUTOR = Executors.newCachedThreadPool(r -> {
Thread thread = new Thread(r);
thread.setName("playground-independent-" + System.currentTimeMillis());
thread.setDaemon(true); // 设置为守护线程,服务关闭时自动清理
return thread;
});
// 使用有界线程池,防止线程无限增长导致内存溢出
private static final int POOL_MAX_THREADS = 16;
private static final int POOL_QUEUE_CAPACITY = 256;
private static final ExecutorService INDEPENDENT_EXECUTOR = new ThreadPoolExecutor(
4, POOL_MAX_THREADS, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(POOL_QUEUE_CAPACITY),
r -> {
Thread thread = new Thread(r);
thread.setName("playground-independent-" + thread.getId());
thread.setDaemon(true);
return thread;
},
(r, executor) -> {
// 拒绝策略:记录日志并抛出异常,避免阻塞 Vert.x EventLoop
log.warn("演练场线程池已满,拒绝任务。活跃线程: {}, 队列大小: {}",
((ThreadPoolExecutor) executor).getActiveCount(),
((ThreadPoolExecutor) executor).getQueue().size());
throw new java.util.concurrent.RejectedExecutionException("演练场线程池已满,请稍后重试");
}
);
// 超时调度线程池,用于处理超时中断
private static final ScheduledExecutorService TIMEOUT_SCHEDULER = Executors.newScheduledThreadPool(2, r -> {
@@ -43,14 +60,29 @@ public class JsPlaygroundExecutor {
thread.setDaemon(true);
return thread;
});
/**
* 关闭静态线程池(应在应用关闭时调用)
*/
public static void shutdownPools() {
INDEPENDENT_EXECUTOR.shutdown();
TIMEOUT_SCHEDULER.shutdown();
log.info("JsPlaygroundExecutor 线程池已关闭");
}
private final ShareLinkInfo shareLinkInfo;
private final String jsCode;
private final ScriptEngine engine;
private volatile ScriptEngine engine;
private final Object engineLock = new Object();
private final JsHttpClient httpClient;
private final JsPlaygroundLogger playgroundLogger;
private final JsShareLinkInfoWrapper shareLinkInfoWrapper;
private final JsFetchBridge fetchBridge;
/** 标记是否已释放,防止重复关闭 */
private volatile boolean closed = false;
private final Object lifecycleLock = new Object();
private volatile boolean running = false;
private volatile boolean closeRequested = false;
/**
* 创建演练场执行器
@@ -72,7 +104,6 @@ public class JsPlaygroundExecutor {
this.playgroundLogger = new JsPlaygroundLogger();
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
this.fetchBridge = new JsFetchBridge(httpClient);
this.engine = initEngine();
}
/**
@@ -89,6 +120,7 @@ public class JsPlaygroundExecutor {
if (engine == null) {
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
}
this.engine = engine;
// 注入Java对象到JavaScript环境
engine.put("http", httpClient);
@@ -133,9 +165,14 @@ public class JsPlaygroundExecutor {
*/
public Future<String> executeParseAsync() {
Promise<String> promise = Promise.promise();
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
final CompletableFuture<String> executionFuture;
try {
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
executionFuture = CompletableFuture.supplyAsync(() -> {
beginExecution();
try {
ScriptEngine engine = engine();
playgroundLogger.infoJava("开始执行parse方法");
try {
Object parseFunction = engine.get("parse");
@@ -151,8 +188,9 @@ public class JsPlaygroundExecutor {
log.debug("[JsPlaygroundExecutor] parse函数执行完成,当前日志数量: {}", playgroundLogger.size());
if (result instanceof String) {
playgroundLogger.infoJava("解析成功,返回结果: " + result);
return (String) result;
String resultText = limitResultString((String) result, "parse");
playgroundLogger.infoJava("解析成功,返回结果长度: " + resultText.length());
return resultText;
} else {
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
(result != null ? result.getClass().getSimpleName() : "null");
@@ -167,25 +205,27 @@ public class JsPlaygroundExecutor {
playgroundLogger.errorJava("执行parse方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
}, INDEPENDENT_EXECUTOR);
// 创建超时任务,强制取消执行
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true); // 强制中断执行线程
playgroundLogger.errorJava("执行超时,已强制中断");
log.warn("JavaScript执行超时,已强制取消");
} finally {
finishExecution();
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}, INDEPENDENT_EXECUTOR);
} catch (java.util.concurrent.RejectedExecutionException e) {
log.warn("演练场线程池已满,任务被拒绝");
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
return promise.future();
}
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parse");
// 处理执行结果
executionFuture.whenComplete((result, error) -> {
// 取消超时任务
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
playgroundLogger.errorJava(timeoutMsg);
log.error(timeoutMsg);
promise.fail(new RuntimeException(timeoutMsg));
@@ -197,10 +237,10 @@ public class JsPlaygroundExecutor {
promise.complete(result);
}
});
return promise.future();
}
/**
* 执行parseFileList方法(异步,带超时控制)
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
@@ -209,9 +249,14 @@ public class JsPlaygroundExecutor {
*/
public Future<List<FileInfo>> executeParseFileListAsync() {
Promise<List<FileInfo>> promise = Promise.promise();
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
CompletableFuture<List<FileInfo>> executionFuture = CompletableFuture.supplyAsync(() -> {
final CompletableFuture<List<FileInfo>> executionFuture;
try {
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
executionFuture = CompletableFuture.supplyAsync(() -> {
beginExecution();
try {
ScriptEngine engine = engine();
playgroundLogger.infoJava("开始执行parseFileList方法");
try {
Object parseFileListFunction = engine.get("parseFileList");
@@ -242,25 +287,27 @@ public class JsPlaygroundExecutor {
playgroundLogger.errorJava("执行parseFileList方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
}, INDEPENDENT_EXECUTOR);
// 创建超时任务,强制取消执行
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true); // 强制中断执行线程
playgroundLogger.errorJava("执行超时,已强制中断");
log.warn("JavaScript执行超时,已强制取消");
} finally {
finishExecution();
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}, INDEPENDENT_EXECUTOR);
} catch (java.util.concurrent.RejectedExecutionException e) {
log.warn("演练场线程池已满,任务被拒绝");
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
return promise.future();
}
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parseFileList");
// 处理执行结果
executionFuture.whenComplete((result, error) -> {
// 取消超时任务
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
playgroundLogger.errorJava(timeoutMsg);
log.error(timeoutMsg);
promise.fail(new RuntimeException(timeoutMsg));
@@ -272,10 +319,10 @@ public class JsPlaygroundExecutor {
promise.complete(result);
}
});
return promise.future();
}
/**
* 执行parseById方法(异步,带超时控制)
* 使用独立线程池,不受Vert.x BlockedThreadChecker监控
@@ -284,9 +331,14 @@ public class JsPlaygroundExecutor {
*/
public Future<String> executeParseByIdAsync() {
Promise<String> promise = Promise.promise();
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
final CompletableFuture<String> executionFuture;
try {
// 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告
executionFuture = CompletableFuture.supplyAsync(() -> {
beginExecution();
try {
ScriptEngine engine = engine();
playgroundLogger.infoJava("开始执行parseById方法");
try {
Object parseByIdFunction = engine.get("parseById");
@@ -300,8 +352,9 @@ public class JsPlaygroundExecutor {
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, playgroundLogger);
if (result instanceof String) {
playgroundLogger.infoJava("按ID解析成功: " + result);
return (String) result;
String resultText = limitResultString((String) result, "parseById");
playgroundLogger.infoJava("按ID解析成功,返回结果长度: " + resultText.length());
return resultText;
} else {
String errorMsg = "parseById方法返回值类型错误,期望String,实际: " +
(result != null ? result.getClass().getSimpleName() : "null");
@@ -316,25 +369,27 @@ public class JsPlaygroundExecutor {
playgroundLogger.errorJava("执行parseById方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
}, INDEPENDENT_EXECUTOR);
// 创建超时任务,强制取消执行
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true); // 强制中断执行线程
playgroundLogger.errorJava("执行超时,已强制中断");
log.warn("JavaScript执行超时,已强制取消");
} finally {
finishExecution();
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}, INDEPENDENT_EXECUTOR);
} catch (java.util.concurrent.RejectedExecutionException e) {
log.warn("演练场线程池已满,任务被拒绝");
close(); // 释放已创建的 ScriptEngine 和 HttpClient 资源
promise.fail(new RuntimeException("演练场线程池已满,请稍后重试", e));
return promise.future();
}
ScheduledFuture<?> timeoutTask = scheduleTimeout(executionFuture, "parseById");
// 处理执行结果
executionFuture.whenComplete((result, error) -> {
// 取消超时任务
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
playgroundLogger.errorJava(timeoutMsg);
log.error(timeoutMsg);
promise.fail(new RuntimeException(timeoutMsg));
@@ -346,7 +401,7 @@ public class JsPlaygroundExecutor {
promise.complete(result);
}
});
return promise.future();
}
@@ -373,6 +428,9 @@ public class JsPlaygroundExecutor {
List<FileInfo> fileList = new ArrayList<>();
if (resultMirror.isArray()) {
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
}
for (int i = 0; i < resultMirror.size(); i++) {
Object item = resultMirror.get(String.valueOf(i));
if (item instanceof ScriptObjectMirror) {
@@ -396,13 +454,13 @@ public class JsPlaygroundExecutor {
// 设置基本字段
if (itemMirror.hasMember("fileName")) {
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
}
if (itemMirror.hasMember("fileId")) {
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
}
if (itemMirror.hasMember("fileType")) {
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
}
if (itemMirror.hasMember("size")) {
Object size = itemMirror.getMember("size");
@@ -411,16 +469,16 @@ public class JsPlaygroundExecutor {
}
}
if (itemMirror.hasMember("sizeStr")) {
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
}
if (itemMirror.hasMember("createTime")) {
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
}
if (itemMirror.hasMember("updateTime")) {
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
}
if (itemMirror.hasMember("createBy")) {
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
}
if (itemMirror.hasMember("downloadCount")) {
Object downloadCount = itemMirror.getMember("downloadCount");
@@ -429,24 +487,154 @@ public class JsPlaygroundExecutor {
}
}
if (itemMirror.hasMember("fileIcon")) {
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
}
if (itemMirror.hasMember("panType")) {
fileInfo.setPanType(itemMirror.getMember("panType").toString());
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
}
if (itemMirror.hasMember("parserUrl")) {
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
}
if (itemMirror.hasMember("previewUrl")) {
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
}
return fileInfo;
} catch (Exception e) {
playgroundLogger.errorJava("转换FileInfo对象失败", e);
return null;
}
}
private static String limitResultString(String value, String operation) {
if (value.length() > MAX_RESULT_STRING_LENGTH) {
throw new RuntimeException(operation + " 返回结果过大: " + value.length() + " 字符");
}
return value;
}
private static String limitField(Object value) {
if (value == null) {
return null;
}
String text = value.toString();
if (text.length() > MAX_FILE_FIELD_LENGTH) {
throw new RuntimeException("文件字段过长: " + text.length() + " 字符");
}
return text;
}
private void beginExecution() {
synchronized (lifecycleLock) {
if (closed) {
throw new CancellationException("演练场执行器已关闭");
}
if (running) {
throw new IllegalStateException("演练场执行器已在运行");
}
running = true;
}
}
private ScriptEngine engine() {
ScriptEngine current = engine;
if (current != null) {
return current;
}
synchronized (engineLock) {
if (closed) {
throw new CancellationException("演练场执行器已关闭");
}
if (engine == null) {
engine = initEngine();
}
return engine;
}
}
private void finishExecution() {
synchronized (lifecycleLock) {
running = false;
if (closeRequested) {
doClose();
}
}
}
private ScheduledFuture<?> scheduleTimeout(CompletableFuture<?> executionFuture, String operation) {
// cancel(true) 只能请求中断,Nashorn 死循环不保证立即停止。
return TIMEOUT_SCHEDULER.schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true);
playgroundLogger.errorJava(operation + " 执行超时,已请求取消并停止外部HTTP资源");
forceCloseAfterTimeout();
log.warn("JavaScript {} 执行超时,已请求取消;Nashorn长循环可能继续占用线程,ScriptEngine将在执行线程退出后清理", operation);
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
private void forceCloseAfterTimeout() {
synchronized (lifecycleLock) {
closeRequested = true;
if (running || closed) {
closeExternalResources();
} else {
doClose();
}
}
playgroundLogger.trimToLast(TIMEOUT_LOG_RETAIN);
}
/**
* 释放资源(HttpClient 和 ScriptEngine),避免内存泄漏
* 幂等:可安全多次调用
*/
@Override
public void close() {
synchronized (lifecycleLock) {
closeRequested = true;
if (running || closed) {
closeExternalResources();
return;
}
doClose();
}
}
private void doClose() {
if (closed) return;
closed = true;
closeRequested = false;
closeExternalResources();
cleanupEngine();
log.debug("JsPlaygroundExecutor 资源已释放");
}
private void closeExternalResources() {
if (httpClient != null) {
httpClient.close();
}
}
private void cleanupEngine() {
// 清除 ScriptEngine 的所有 bindings,释放 JS 运行时引用
if (engine != null) {
try {
// 清除注入的 Java 对象引用
engine.put("http", null);
engine.put("logger", null);
engine.put("shareLinkInfo", null);
engine.put("JavaFetch", null);
// 清除所有 ENGINE_SCOPE bindings,包括 eval 加载的 JS 函数
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
if (bindings != null) {
bindings.clear();
}
} catch (Exception e) {
log.warn("清理 ScriptEngine bindings 失败: {}", e.getMessage());
}
}
}
}
@@ -20,6 +20,7 @@ public class JsPlaygroundLogger {
// 使用线程安全的列表
private static final int MAX_LOG_SIZE = 1000;
private static final int MAX_LOG_MESSAGE_LENGTH = 4096;
private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
/**
@@ -62,7 +63,11 @@ public class JsPlaygroundLogger {
if (obj == null) {
return "null";
}
return obj.toString();
String msg = obj.toString();
if (msg.length() <= MAX_LOG_MESSAGE_LENGTH) {
return msg;
}
return msg.substring(0, MAX_LOG_MESSAGE_LENGTH) + "...[truncated]";
}
/**
@@ -127,7 +132,7 @@ public class JsPlaygroundLogger {
public void error(Object message, Throwable throwable) {
String msg = toString(message);
if (throwable != null) {
msg = msg + ": " + throwable.getMessage();
msg = toString(msg + ": " + throwable.getMessage());
}
addLog(new LogEntry("ERROR", msg, "JS"));
log.debug("[JSPlaygroundLogger] ERROR: {}", msg);
@@ -167,9 +172,9 @@ public class JsPlaygroundLogger {
* 错误日志(带异常,供Java层调用)
*/
public void errorJava(String message, Throwable throwable) {
String msg = message;
String msg = toString(message);
if (throwable != null) {
msg = msg + ": " + throwable.getMessage();
msg = toString(msg + ": " + throwable.getMessage());
}
addLog(new LogEntry("ERROR", msg, "JAVA"));
log.debug("[JAVAPlaygroundLogger] ERROR: {}", msg);
@@ -197,4 +202,17 @@ public class JsPlaygroundLogger {
public void clear() {
logs.clear();
}
public void trimToLast(int maxEntries) {
if (maxEntries < 0) {
throw new IllegalArgumentException("maxEntries不能小于0");
}
synchronized (logs) {
int removeCount = logs.size() - maxEntries;
if (removeCount <= 0) {
return;
}
logs.subList(0, removeCount).clear();
}
}
}
@@ -31,6 +31,8 @@ public class JsScriptLoader {
private static final String RESOURCE_PATH = "custom-parsers";
private static final String EXTERNAL_PATH = "./custom-parsers";
private static final long MAX_SCRIPT_SIZE_BYTES = 128 * 1024;
private static final int MAX_EXTERNAL_SCRIPT_COUNT = 100;
// 系统属性配置的外部目录路径
private static final String EXTERNAL_PATH_PROPERTY = "parser.custom-parsers.path";
@@ -81,14 +83,16 @@ public class JsScriptLoader {
try {
InputStream inputStream = JsScriptLoader.class.getClassLoader()
.getResourceAsStream(resourceFile);
if (inputStream != null) {
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
configs.add(config);
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
log.debug("从资源目录加载脚本: {}", fileName);
try (inputStream) {
String jsCode = readResourceScript(inputStream, resourceFile);
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
configs.add(config);
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
log.debug("从资源目录加载脚本: {}", fileName);
}
}
} catch (Exception e) {
log.warn("加载资源脚本失败: {}", resourceFile, e);
@@ -207,8 +211,10 @@ public class JsScriptLoader {
paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".js"))
.filter(path -> !isExcludedFile(path.getFileName().toString()))
.limit(MAX_EXTERNAL_SCRIPT_COUNT)
.forEach(path -> {
try {
ensureScriptSize(path);
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
configs.add(config);
@@ -262,6 +268,7 @@ public class JsScriptLoader {
throw new IllegalArgumentException("文件不存在: " + filePath);
}
ensureScriptSize(path);
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
return JsScriptMetadataParser.parseScript(jsCode);
@@ -279,14 +286,16 @@ public class JsScriptLoader {
try {
InputStream inputStream = JsScriptLoader.class.getClassLoader()
.getResourceAsStream(resourcePath);
if (inputStream == null) {
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
}
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
return JsScriptMetadataParser.parseScript(jsCode);
try (inputStream) {
String jsCode = readResourceScript(inputStream, resourcePath);
return JsScriptMetadataParser.parseScript(jsCode);
}
} catch (IOException e) {
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
}
@@ -346,4 +355,19 @@ public class JsScriptLoader {
fileName.contains(".test.") ||
fileName.contains(".spec.");
}
private static void ensureScriptSize(Path path) throws IOException {
long size = Files.size(path);
if (size > MAX_SCRIPT_SIZE_BYTES) {
throw new IllegalArgumentException("JavaScript脚本超过128KB限制: " + path.getFileName());
}
}
private static String readResourceScript(InputStream inputStream, String name) throws IOException {
byte[] bytes = inputStream.readNBytes((int) MAX_SCRIPT_SIZE_BYTES + 1);
if (bytes.length > MAX_SCRIPT_SIZE_BYTES) {
throw new IllegalArgumentException("JavaScript资源脚本超过128KB限制: " + name);
}
return new String(bytes, StandardCharsets.UTF_8);
}
}
@@ -2,6 +2,7 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.IPanTool;
import cn.qaiu.parser.PanBase;
import io.vertx.core.Future;
import io.vertx.core.buffer.Buffer;
@@ -160,6 +161,7 @@ public class CeTool extends PanBase {
} catch (Exception e) {
log.debug("v3 share API解析失败: {}", e.getMessage());
}
tryV4ShareApi(baseUrl, key, pwd);
}).onFailure(t -> {
log.debug("v3 share API请求失败: {}", t.getMessage());
// 请求失败,尝试 v4 或下一个解析器
@@ -206,7 +208,8 @@ public class CeTool extends PanBase {
*/
private void delegateToCe4Tool() {
log.debug("检测到Cloudreve 4.x,转发到Ce4Tool处理");
new Ce4Tool(shareLinkInfo).parse().onComplete(promise);
Ce4Tool ce4Tool = new Ce4Tool(shareLinkInfo);
IPanTool.closeAfter(ce4Tool, ce4Tool::parse).onComplete(promise);
}
@@ -3,6 +3,7 @@ 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.FileSizeConverter;
import io.vertx.core.Future;
import io.vertx.core.Promise;
@@ -12,6 +13,10 @@ import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.uritemplate.UriTemplate;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -24,19 +29,22 @@ import java.util.regex.Pattern;
*/
public class CtTool extends PanBase {
private static final String API_URL_PREFIX = "https://webapi.ctfile.com";
private static final String SHARE_FILE_URL_PREFIX = "https://ctfile.com/file/";
private static final String AJAX_ACCEPT = "application/json, text/javascript, */*; q=0.01";
private static final String BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
private static final int FILE_LIST_PAGE_SIZE = 200;
private static final int MAX_FILE_LIST_PAGES = 50;
// https://webapi.ctfile.com/getfile.php?path=f&f=64115194-17569800420720-06c697&
// passcode=7609&r=0.6611183001986635&ref=&url=https%3A%2F%2Furl94.ctfile.com%2Ff%2F64115194-17569800420720-06c697%3Fp%3D7609
private static final String API1 = API_URL_PREFIX + "/getfile.php?path={path}" +
"&f={shareKey}&passcode={pwd}&r={rand}&ref=&url={url}";
// https://webapi.ctfile.com/get_file_url.php?uid=64115194&fid=17569800420720&folder_id=0&
// share_id=&file_chk=af5c8757a49cbc69a557eb3da59b246c&start_time=1780471868&wait_seconds=0&
// mb=0&app=0&acheck=1&verifycode=1780471868.2951fe63abedf36ec02f34ed5711ce70&rd=0.36350981353622636
private static final String API2 = API_URL_PREFIX + "/get_file_url.php?" +
"uid={uid}&fid={fid}&folder_id=0&share_id=&file_chk={file_chk}" +
"&start_time={start_time}&wait_seconds={wait_seconds}&mb=0&app=0&acheck=1" +
"&verifycode={verifycode}&rd={rand}";
// https://webapi.ctfile.com/get_down_url.php?uid=64115194&fid=17569800420720&
// file_chk=af5c8757a49cbc69a557eb3da59b246c&start_time=1780471868&wait_seconds=0&rd=0.36...
private static final String API2 = API_URL_PREFIX + "/get_down_url.php?" +
"uid={uid}&fid={fid}&file_chk={file_chk}" +
"&start_time={start_time}&wait_seconds={wait_seconds}&rd={rand}";
// https://webapi.ctfile.com/getdir.php?path=d&d=64115194-164803691-48508c&
// folder_id=164803691&fk=decb36&passcode=7609&r=0.23...&ref=&url=https://url94.ctfile.com/d/...
@@ -44,15 +52,22 @@ public class CtTool extends PanBase {
"&d={shareKey}&folder_id={folder_id}&fk={fk}&passcode={pwd}&r={rand}&ref=&url={url}";
// DataTables参数,用于获取目录文件列表
private static final String FILE_LIST_PARAMS = "&sEcho=1&iColumns=4&sColumns=%2C%2C%2C" +
"&iDisplayStart=0&iDisplayLength=500&mDataProp_0=0&mDataProp_1=1&mDataProp_2=2&mDataProp_3=3" +
private static final String FILE_LIST_PARAMS_TEMPLATE = "&sEcho=1&iColumns=4&sColumns=%2C%2C%2C" +
"&iDisplayStart={start}&iDisplayLength={length}" +
"&mDataProp_0=0&sSearch_0=&bRegex_0=false&bSearchable_0=true&bSortable_0=false" +
"&mDataProp_1=1&sSearch_1=&bRegex_1=false&bSearchable_1=true&bSortable_1=true" +
"&mDataProp_2=2&sSearch_2=&bRegex_2=false&bSearchable_2=true&bSortable_2=true" +
"&mDataProp_3=3&sSearch_3=&bRegex_3=false&bSearchable_3=true&bSortable_3=true" +
"&sSearch=&bRegex=false" +
"&iSortCol_0=3&sSortDir_0=desc&iSortingCols=1";
// 文件列表HTML解析正则
private static final Pattern FILE_ID_PATTERN = Pattern.compile("value=\"f(\\d+)\"");
private static final Pattern FILE_HREF_PATTERN = Pattern.compile("href=\"#/f/([^\"]+)\"");
private static final Pattern FILE_NAME_PATTERN = Pattern.compile(">([^<]+)</a>");
private static final Pattern FILE_ICON_PATTERN = Pattern.compile("alt=\"([^\"]+)\"");
private static final Pattern FILE_ID_PATTERN = Pattern.compile("value=[\"']f(\\d+)[\"']");
private static final Pattern FOLDER_ID_PATTERN = Pattern.compile("value=[\"']d(\\d+)[\"']");
private static final Pattern FILE_HREF_PATTERN = Pattern.compile("href=[\"']#/f/([^\"']+)[\"']");
private static final Pattern FILE_NAME_PATTERN = Pattern.compile("<a\\b[^>]*>([^<]+)</a>", Pattern.CASE_INSENSITIVE);
private static final Pattern FILE_ICON_PATTERN = Pattern.compile("alt=[\"']([^\"']+)[\"']");
private static final Pattern SUBDIR_PATTERN = Pattern.compile("load_subdir\\s*\\((\\d+)\\s*,\\s*['\"]([^'\"]+)['\"]\\)");
/**
* 子类重写此构造方法不需要添加额外逻辑
@@ -73,81 +88,105 @@ public class CtTool extends PanBase {
@Override
public Future<String> parse() {
final String shareKey = shareLinkInfo.getShareKey();
if (shareKey.indexOf('-') == -1) {
if (shareKey == null || shareKey.indexOf('-') == -1) {
fail("shareKey格式不正确找不到'-': {}", shareKey);
return promise.future();
}
String[] split = shareKey.split("-");
String uid = split[0], fid = split[1];
// 获取url path
int i1 = shareLinkInfo.getShareUrl().indexOf("com/");
int i2 = shareLinkInfo.getShareUrl().lastIndexOf("/");
String path = shareLinkInfo.getShareUrl().substring(i1 + 4, i2);
if (split.length < 2 || split[0].isBlank() || split[1].isBlank()) {
fail("shareKey格式不正确: {}", shareKey);
return promise.future();
}
String fallbackUid = split[0], fallbackFid = split[1];
String path = extractPath(shareLinkInfo.getShareUrl());
HttpRequest<Buffer> bufferHttpRequest1 = clientSession.getAbs(UriTemplate.of(API1))
HttpRequest<Buffer> bufferHttpRequest1 = withCtAjaxHeaders(clientSession.getAbs(UriTemplate.of(API1))
.setTemplateParam("path", path)
.setTemplateParam("shareKey", shareKey)
.setTemplateParam("pwd", shareLinkInfo.getSharePassword())
.setTemplateParam("rand", String.valueOf(Math.random()))
.setTemplateParam("url", shareLinkInfo.getShareUrl());
.setTemplateParam("url", shareLinkInfo.getShareUrl()), shareLinkInfo.getShareUrl());
bufferHttpRequest1
.send().onSuccess(res -> {
var resJson = asJson(res);
if (resJson.containsKey("file")) {
var fileJson = resJson.getJsonObject("file");
if (fileJson.containsKey("file_chk")) {
var file_chk = fileJson.getString("file_chk");
String startTime = fileJson.getValue("start_time").toString();
String waitSeconds = fileJson.getValue("wait_seconds").toString();
String verifycode = fileJson.getString("verifycode");
// 提取文件信息并存储
FileInfo fileInfo = new FileInfo()
.setFileName(fileJson.getString("file_name"))
.setFileId(String.valueOf(fileJson.getLong("file_id", 0L)))
.setSizeStr(fileJson.getString("file_size"))
.setCreateTime(fileJson.getString("file_time"))
.setCreateBy(fileJson.getString("username"))
.setFileType("file")
.setPanType(shareLinkInfo.getType());
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
HttpRequest<Buffer> bufferHttpRequest2 = clientSession.getAbs(UriTemplate.of(API2))
.setTemplateParam("uid", uid)
.setTemplateParam("fid", fid)
.setTemplateParam("file_chk", file_chk)
.setTemplateParam("start_time", startTime)
.setTemplateParam("wait_seconds", waitSeconds)
.setTemplateParam("verifycode", verifycode)
.setTemplateParam("rand", String.valueOf(Math.random()));
bufferHttpRequest2
.send().onSuccess(res2 -> {
JsonObject resJson2 = asJson(res2);
if (resJson2.containsKey("downurl")) {
String downloadUrl = resJson2.getString("downurl");
// 存储下载元数据,包括必要的请求头
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
headers.put("Referer", shareLinkInfo.getShareUrl());
// 使用新的 completeWithMeta 方法
completeWithMeta(downloadUrl, headers);
} else {
fail("解析失败, 可能分享已失效: json: {} 字段 {} 不存在", resJson2, "downurl");
}
}).onFailure(handleFail(bufferHttpRequest1.queryParams().toString()));
} else {
fail("解析失败, file_chk找不到, 可能分享已失效或者分享密码不对: {}", fileJson);
try {
var resJson = asJson(res);
if (resJson == null || resJson.isEmpty()) {
fail("解析失败, 上游返回空响应或非JSON响应");
return;
}
} else {
fail("解析失败, 文件信息为空, 可能分享已失效");
Object fileValue = resJson.getValue("file");
if (!(fileValue instanceof JsonObject)) {
fail("解析失败, 文件信息为空或格式错误, 可能分享已失效: {}", resJson);
return;
}
var fileJson = (JsonObject) fileValue;
String uid = resolveDownloadUid(fileJson, fallbackUid);
String fid = resolveDownloadFid(fileJson, fallbackFid);
String fileChk = fileJson.getString("file_chk");
String startTime = valueToString(fileJson.getValue("start_time"));
String waitSeconds = valueToString(fileJson.getValue("wait_seconds"));
if (uid.isBlank() || fid.isBlank() || fileChk == null || fileChk.isBlank()
|| startTime.isBlank() || waitSeconds.isBlank()) {
fail("解析失败, 下载参数不完整, 可能分享已失效或者分享密码不对: {}", fileJson);
return;
}
// 提取文件信息并存储
FileInfo fileInfo = new FileInfo()
.setFileName(fileJson.getString("file_name"))
.setFileId(fid)
.setSizeStr(fileJson.getString("file_size"))
.setCreateTime(fileJson.getString("file_time"))
.setCreateBy(fileJson.getString("username"))
.setFileType("file")
.setPanType(shareLinkInfo.getType());
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
HttpRequest<Buffer> bufferHttpRequest2 = withCtAjaxHeaders(clientSession.getAbs(UriTemplate.of(API2))
.setTemplateParam("uid", uid)
.setTemplateParam("fid", fid)
.setTemplateParam("file_chk", fileChk)
.setTemplateParam("start_time", startTime)
.setTemplateParam("wait_seconds", waitSeconds)
.setTemplateParam("rand", String.valueOf(Math.random())), shareLinkInfo.getShareUrl());
bufferHttpRequest2
.send().onSuccess(res2 -> handleDownloadUrlResponse(res2))
.onFailure(t -> fail("下载链接请求失败: {}", t.getMessage()));
} catch (Exception e) {
fail("解析失败: {}", e.getMessage());
}
}).onFailure(handleFail(bufferHttpRequest1.queryParams().toString()));
}).onFailure(t -> fail("文件信息请求失败: {}", t.getMessage()));
return promise.future();
}
private void handleDownloadUrlResponse(io.vertx.ext.web.client.HttpResponse<Buffer> res) {
try {
JsonObject resJson = asJson(res);
if (resJson == null || resJson.isEmpty()) {
fail("解析失败, 下载接口返回空响应或非JSON响应");
return;
}
String downloadUrl = resJson.getString("downurl");
if (downloadUrl == null || downloadUrl.isBlank()) {
fail("解析失败, 可能分享已失效: json: {} 字段 {} 不存在", resJson, "downurl");
return;
}
// 存储下载元数据,包括必要的请求头
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", BROWSER_UA);
if (shareLinkInfo.getShareUrl() != null && !shareLinkInfo.getShareUrl().isBlank()) {
headers.put("Referer", shareLinkInfo.getShareUrl());
}
// 使用新的 completeWithMeta 方法
completeWithMeta(downloadUrl, headers);
} catch (Exception e) {
fail("解析失败, 下载接口响应处理异常: {}", e.getMessage());
}
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> listPromise = Promise.promise();
@@ -157,122 +196,478 @@ public class CtTool extends PanBase {
final String pwd = shareLinkInfo.getSharePassword();
// shareKey格式: uid-folder_id-hash (例如 64115194-164803691-48508c)
if (shareKey == null) {
listPromise.fail(baseMsg() + " shareKey为空");
return listPromise.future();
}
String[] split = shareKey.split("-");
if (split.length < 2) {
listPromise.fail(baseMsg() + " shareKey格式不正确: " + shareKey);
return listPromise.future();
}
String folderId = split[1];
String path = extractPath(shareUrl);
Object dirId = shareLinkInfo.getOtherParam() == null ? null : shareLinkInfo.getOtherParam().get("dirId");
DirectoryContext directoryContext = resolveDirectoryContext(shareUrl, dirId);
// 从分享URL中提取fk参数
String fk = extractQueryParam(shareUrl, "fk");
// 从URL中提取path (例如从 "https://url94.ctfile.com/d/xxx?p=..." 中提取 "d")
int comIdx = shareUrl.indexOf("com/");
int qIdx = shareUrl.indexOf('?');
String pathAndKey = qIdx > 0 ? shareUrl.substring(comIdx + 4, qIdx) : shareUrl.substring(comIdx + 4);
int slashIdx = pathAndKey.indexOf('/');
String path = slashIdx > 0 ? pathAndKey.substring(0, slashIdx) : pathAndKey;
clientSession.getAbs(UriTemplate.of(API_GETDIR))
HttpRequest<Buffer> getDirRequest = withCtAjaxHeaders(clientSession.getAbs(UriTemplate.of(API_GETDIR))
.setTemplateParam("path", path)
.setTemplateParam("shareKey", shareKey)
.setTemplateParam("folder_id", folderId)
.setTemplateParam("fk", fk != null ? fk : "")
.setTemplateParam("folder_id", directoryContext.folderId)
.setTemplateParam("fk", directoryContext.folderKey)
.setTemplateParam("pwd", pwd != null ? pwd : "")
.setTemplateParam("rand", String.valueOf(Math.random()))
.setTemplateParam("url", shareUrl)
.send().onSuccess(res -> {
var resJson = asJson(res);
if (!resJson.containsKey("file")) {
listPromise.fail(baseMsg() + " 目录解析失败: " + resJson.encode());
return;
}
var dirInfo = resJson.getJsonObject("file");
String fileListRelUrl = dirInfo.getString("url");
if (fileListRelUrl == null) {
listPromise.fail(baseMsg() + " 文件列表URL为空");
return;
}
.setTemplateParam("url", shareUrl), shareUrl);
String fileListUrl = API_URL_PREFIX + fileListRelUrl + FILE_LIST_PARAMS;
clientSession.getAbs(fileListUrl)
.send().onSuccess(res2 -> {
var listJson = asJson(res2);
JsonArray aaData = listJson.getJsonArray("aaData");
if (aaData == null) {
listPromise.fail(baseMsg() + " 文件列表为空");
return;
}
List<FileInfo> fileList = new ArrayList<>();
String panType = shareLinkInfo.getType();
for (int i = 0; i < aaData.size(); i++) {
var row = aaData.getJsonArray(i);
try {
String checkboxHtml = row.getString(0);
String nameCellHtml = row.getString(1);
String sizeStr = row.getString(2).trim();
getDirRequest.send().onSuccess(res -> {
try {
var resJson = asJson(res);
if (resJson == null || resJson.isEmpty()) {
failListPromise(listPromise, baseMsg() + " 目录解析失败: 上游返回空响应或非JSON响应");
return;
}
if (!resJson.containsKey("file")) {
failListPromise(listPromise, baseMsg() + " 目录解析失败: " + resJson.encode());
return;
}
Object dirInfoValue = resJson.getValue("file");
if (!(dirInfoValue instanceof JsonObject)) {
failListPromise(listPromise, baseMsg() + " 目录解析失败: file字段格式错误: " + resJson.encode());
return;
}
JsonObject dirInfo = (JsonObject) dirInfoValue;
Object fileListUrlValue = dirInfo.getValue("url");
String fileListRelUrl = fileListUrlValue instanceof String ? ((String) fileListUrlValue).trim() : "";
if (fileListRelUrl.isBlank()) {
failListPromise(listPromise, baseMsg() + " " + buildDirectoryFailureMessage(resJson, dirInfo));
return;
}
// 从checkbox HTML中提取文件ID
String fileId = null;
Matcher idMatcher = FILE_ID_PATTERN.matcher(checkboxHtml);
if (idMatcher.find()) fileId = idMatcher.group(1);
// 从文件名单元格HTML中提取临时分享key
String fileShareKey = null;
Matcher hrefMatcher = FILE_HREF_PATTERN.matcher(nameCellHtml);
if (hrefMatcher.find()) fileShareKey = hrefMatcher.group(1);
// 提取文件名
String fileName = null;
Matcher nameMatcher = FILE_NAME_PATTERN.matcher(nameCellHtml);
if (nameMatcher.find()) fileName = nameMatcher.group(1).trim();
// 提取文件图标/类型
String fileIcon = null;
Matcher iconMatcher = FILE_ICON_PATTERN.matcher(nameCellHtml);
if (iconMatcher.find()) fileIcon = iconMatcher.group(1);
if (fileName == null || fileShareKey == null) continue;
long sizeBytes = 0;
try {
sizeBytes = FileSizeConverter.convertToBytes(sizeStr);
} catch (Exception ignored) {}
FileInfo fileInfo = new FileInfo()
.setFileName(fileName)
.setFileId(fileId)
.setSizeStr(sizeStr)
.setSize(sizeBytes)
.setFileType(fileIcon)
.setFileIcon(fileIcon)
.setPanType(panType)
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), panType, fileShareKey));
fileList.add(fileInfo);
} catch (Exception e) {
log.warn("解析文件行失败: {}", e.getMessage());
}
}
listPromise.complete(fileList);
}).onFailure(listPromise::fail);
}).onFailure(listPromise::fail);
fetchFileListPage(toCtApiUrl(fileListRelUrl), 0, 0, new ArrayList<>(), listPromise,
shareLinkInfo.getType(), getDomainName(), shareUrl, pwd);
} catch (Exception e) {
failListPromise(listPromise, baseMsg() + " 目录解析失败: " + e.getMessage());
}
}).onFailure(t -> failListPromise(listPromise, t));
return listPromise.future();
}
private String extractQueryParam(String url, String paramName) {
if (url == null) return null;
private void fetchFileListPage(String fileListBaseUrl, int start, int pageIndex, List<FileInfo> fileList,
Promise<List<FileInfo>> listPromise, String panType, String domainName,
String shareUrl, String pwd) {
try {
if (pageIndex >= MAX_FILE_LIST_PAGES) {
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: 分页超过最大限制 " + MAX_FILE_LIST_PAGES
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
return;
}
String fileListUrl = appendQueryParams(fileListBaseUrl,
buildFileListParams(start, FILE_LIST_PAGE_SIZE) + "&_=" + System.currentTimeMillis());
withCtAjaxHeaders(clientSession.getAbs(fileListUrl), shareUrl)
.send()
.onSuccess(res -> handleFileListPageResponse(fileListBaseUrl, start, pageIndex, fileList,
listPromise, panType, domainName, shareUrl, pwd, res))
.onFailure(t -> failListPromise(listPromise, t));
} catch (Exception e) {
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: " + e.getMessage()
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
}
}
private void handleFileListPageResponse(String fileListBaseUrl, int start, int pageIndex, List<FileInfo> fileList,
Promise<List<FileInfo>> listPromise, String panType, String domainName,
String shareUrl, String pwd, io.vertx.ext.web.client.HttpResponse<Buffer> res) {
try {
var listJson = asJson(res);
if (listJson == null || listJson.isEmpty()) {
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: 上游返回空响应或非JSON响应"
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
return;
}
Object aaDataValue = listJson.getValue("aaData");
if (!(aaDataValue instanceof JsonArray)) {
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: aaData为空: " + listJson.encode());
return;
}
JsonArray aaData = (JsonArray) aaDataValue;
for (int i = 0; i < aaData.size(); i++) {
try {
Object rowValue = aaData.getValue(i);
if (!(rowValue instanceof JsonArray)) {
log.warn("城通文件列表行格式错误: {}", rowValue);
continue;
}
FileInfo fileInfo = parseFileListRow((JsonArray) rowValue, panType,
domainName, shareUrl, pwd);
if (fileInfo != null) {
fileList.add(fileInfo);
}
} catch (Exception e) {
log.warn("解析文件行失败: {}", e.getMessage());
}
}
int nextStart = start + aaData.size();
int total = parseFileListTotal(listJson);
if (isUnexpectedEmptyFileListPage(start, aaData.size(), total)) {
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: 上游返回空分页"
+ " (start=" + start + ", total=" + total + ")");
return;
}
if (shouldFetchNextFileListPage(start, aaData.size(), total)) {
fetchFileListPage(fileListBaseUrl, nextStart, pageIndex + 1, fileList,
listPromise, panType, domainName, shareUrl, pwd);
} else {
completeListPromise(listPromise, fileList);
}
} catch (Exception e) {
failListPromise(listPromise, baseMsg() + " 文件列表解析失败: " + e.getMessage()
+ " (start=" + start + ", length=" + FILE_LIST_PAGE_SIZE + ")");
}
}
@Override
public Future<String> parseById() {
Object paramValue = shareLinkInfo.getOtherParam().get("paramJson");
if (!(paramValue instanceof JsonObject)) {
Promise<String> parsePromise = Promise.promise();
parsePromise.fail(baseMsg() + " 缺少下载参数paramJson");
return parsePromise.future();
}
JsonObject paramJson = (JsonObject) paramValue;
if (!applyFileParam(shareLinkInfo, paramJson)) {
Promise<String> parsePromise = Promise.promise();
parsePromise.fail(baseMsg() + " 下载参数id为空");
return parsePromise.future();
}
return parse();
}
static boolean applyFileParam(ShareLinkInfo shareLinkInfo, JsonObject paramJson) {
String fileShareKey = paramJson.getString("id");
if (fileShareKey == null || fileShareKey.isBlank()) {
return false;
}
shareLinkInfo.setSharePassword(paramJson.getString("pwd", ""));
shareLinkInfo.setShareKey(fileShareKey);
shareLinkInfo.setShareUrl(SHARE_FILE_URL_PREFIX + fileShareKey);
shareLinkInfo.setStandardUrl(SHARE_FILE_URL_PREFIX + fileShareKey);
return true;
}
static String resolveDownloadUid(JsonObject fileJson, String fallbackUid) {
return firstNonBlank(valueToString(fileJson.getValue("userid")), fallbackUid);
}
static String resolveDownloadFid(JsonObject fileJson, String fallbackFid) {
return firstNonBlank(valueToString(fileJson.getValue("file_id")), fallbackFid);
}
private HttpRequest<Buffer> withCtAjaxHeaders(HttpRequest<Buffer> request, String shareUrl) {
request.putHeader("User-Agent", BROWSER_UA)
.putHeader("Accept", AJAX_ACCEPT)
.putHeader("X-Requested-With", "XMLHttpRequest");
if (shareUrl != null && !shareUrl.isBlank()) {
request.putHeader("Referer", shareUrl);
}
String origin = extractOrigin(shareUrl);
if (!origin.isBlank()) {
request.putHeader("Origin", origin);
}
return request;
}
static FileInfo parseFileListRow(JsonArray row, String panType, String domainName, String shareUrl, String pwd) {
if (row == null || row.size() < 2) {
return null;
}
String checkboxHtml = rowString(row, 0);
String nameCellHtml = rowString(row, 1);
String sizeStr = rowString(row, 2).trim();
String dateStr = rowString(row, 3).trim();
if (nameCellHtml.isBlank()) {
return null;
}
String fileName = matchFirst(FILE_NAME_PATTERN, nameCellHtml);
String fileIcon = matchFirst(FILE_ICON_PATTERN, nameCellHtml);
if (fileName == null || fileName.isBlank()) {
return null;
}
Matcher subdirMatcher = SUBDIR_PATTERN.matcher(nameCellHtml);
boolean hasSubdirCall = subdirMatcher.find();
if (hasSubdirCall || "folder".equalsIgnoreCase(fileIcon)) {
String folderId = hasSubdirCall ? subdirMatcher.group(1) : null;
String folderKey = hasSubdirCall ? subdirMatcher.group(2) : "";
if (folderId == null) {
folderId = matchFirst(FOLDER_ID_PATTERN, checkboxHtml);
}
if (folderId == null || folderId.isBlank()) {
return null;
}
String dirId = folderId + ":" + folderKey;
FileInfo fileInfo = new FileInfo()
.setFileName(fileName.trim())
.setFileId(folderId)
.setSize(0L)
.setSizeStr(sizeStr.isBlank() ? "0B" : sizeStr)
.setFileType("folder")
.setFileIcon(fileIcon)
.setPanType(panType)
.setParserUrl(buildFolderParserUrl(domainName, shareUrl, dirId, pwd));
if (!dateStr.isBlank()) {
fileInfo.setCreateTime(dateStr).setUpdateTime(dateStr);
}
return fileInfo;
}
String fileShareKey = matchFirst(FILE_HREF_PATTERN, nameCellHtml);
if (fileShareKey == null || fileShareKey.isBlank()) {
return null;
}
String fileId = matchFirst(FILE_ID_PATTERN, checkboxHtml);
JsonObject paramJson = new JsonObject()
.put("id", fileShareKey)
.put("fileName", fileName.trim())
.put("pwd", pwd == null ? "" : pwd);
String param = CommonUtils.urlBase64Encode(paramJson.encode());
long sizeBytes = 0;
try {
sizeBytes = sizeStr.isBlank() ? 0 : FileSizeConverter.convertToBytes(sizeStr);
} catch (Exception ignored) {
}
FileInfo fileInfo = new FileInfo()
.setFileName(fileName.trim())
.setFileId(fileId)
.setSizeStr(sizeStr)
.setSize(sizeBytes)
.setFileType(fileIcon != null ? fileIcon : "file")
.setFileIcon(fileIcon)
.setPanType(panType)
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
domainName, panType, param));
if (!dateStr.isBlank()) {
fileInfo.setCreateTime(dateStr).setUpdateTime(dateStr);
}
return fileInfo;
}
private static String buildFolderParserUrl(String domainName, String shareUrl, String dirId, String pwd) {
String url = String.format("%s/v2/getFileList?url=%s&dirId=%s",
domainName, urlEncode(shareUrl), urlEncode(dirId));
if (pwd != null && !pwd.isBlank()) {
url += "&pwd=" + urlEncode(pwd);
}
return url;
}
static String extractQueryParam(String url, String paramName) {
if (url == null || paramName == null) return null;
int qIdx = url.indexOf('?');
if (qIdx < 0) return null;
String query = url.substring(qIdx + 1);
int fragmentIdx = query.indexOf('#');
if (fragmentIdx >= 0) {
query = query.substring(0, fragmentIdx);
}
for (String param : query.split("&")) {
int eqIdx = param.indexOf('=');
if (eqIdx > 0 && param.substring(0, eqIdx).equals(paramName)) {
return param.substring(eqIdx + 1);
if (eqIdx > 0 && urlDecode(param.substring(0, eqIdx)).equals(paramName)) {
return urlDecode(param.substring(eqIdx + 1));
}
}
return null;
}
static String extractPath(String shareUrl) {
if (shareUrl == null) {
return "";
}
int comIdx = shareUrl.indexOf("com/");
if (comIdx < 0) {
return "";
}
int pathStart = comIdx + 4;
int pathEnd = shareUrl.indexOf('/', pathStart);
if (pathEnd < 0) {
pathEnd = shareUrl.indexOf('?', pathStart);
}
if (pathEnd < 0) {
pathEnd = shareUrl.length();
}
return shareUrl.substring(pathStart, pathEnd);
}
static String extractFolderKey(String shareUrl) {
return trimToEmpty(extractQueryParam(shareUrl, "fk"));
}
static DirectoryContext resolveDirectoryContext(String shareUrl, Object dirIdObj) {
String dirId = dirIdObj == null ? "" : urlDecode(String.valueOf(dirIdObj).trim());
if (!dirId.isBlank()) {
String[] split = dirId.split(":", 2);
return new DirectoryContext(trimToDefault(split[0], "undefined"),
split.length > 1 ? trimToEmpty(split[1]) : "");
}
String queryFolderId = firstNonBlank(extractQueryParam(shareUrl, "folder_id"), extractQueryParam(shareUrl, "d"));
String queryFk = extractFolderKey(shareUrl);
if (!queryFolderId.isBlank() || !queryFk.isBlank()) {
return new DirectoryContext(trimToDefault(queryFolderId, "undefined"), queryFk);
}
return new DirectoryContext("undefined", "");
}
static String buildDirectoryFailureMessage(JsonObject resJson, JsonObject dirInfo) {
String code = valueToString(resJson.getValue("code"));
String message = valueToString(dirInfo.getValue("message"));
if (message != null && !message.isBlank()) {
return "目录解析失败: " + message + " (code=" + code + ")";
}
if ("423".equals(code)) {
return "目录解析失败: 需要访问密码或该分享受限 (code=423)";
}
return "目录解析失败: 文件列表URL为空, 上游响应: " + resJson.encode();
}
static String buildFileListParams(int start, int length) {
return FILE_LIST_PARAMS_TEMPLATE
.replace("{start}", String.valueOf(Math.max(0, start)))
.replace("{length}", String.valueOf(Math.max(1, length)));
}
static int parseFileListTotal(JsonObject listJson) {
int displayTotal = parseInteger(listJson.getValue("iTotalDisplayRecords"), -1);
return displayTotal >= 0 ? displayTotal : parseInteger(listJson.getValue("iTotalRecords"), -1);
}
static boolean shouldFetchNextFileListPage(int start, int rowCount, int total) {
if (rowCount <= 0) {
return false;
}
int fetchedThrough = start + rowCount;
return total < 0 ? rowCount >= FILE_LIST_PAGE_SIZE : fetchedThrough < total;
}
static boolean isUnexpectedEmptyFileListPage(int start, int rowCount, int total) {
return total >= 0 && start < total && rowCount <= 0;
}
private static int parseInteger(Object value, int defaultValue) {
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value == null) {
return defaultValue;
}
try {
return Integer.parseInt(value.toString());
} catch (NumberFormatException e) {
return defaultValue;
}
}
private static void failListPromise(Promise<List<FileInfo>> listPromise, String message) {
if (!listPromise.future().isComplete()) {
listPromise.fail(message);
}
}
private static void failListPromise(Promise<List<FileInfo>> listPromise, Throwable throwable) {
if (!listPromise.future().isComplete()) {
listPromise.fail(throwable);
}
}
private static void completeListPromise(Promise<List<FileInfo>> listPromise, List<FileInfo> fileList) {
if (!listPromise.future().isComplete()) {
listPromise.complete(fileList);
}
}
private static String toCtApiUrl(String url) {
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
return API_URL_PREFIX + url;
}
private static String appendQueryParams(String url, String params) {
String normalizedParams = params != null && params.startsWith("&") ? params.substring(1) : params;
return url + (url.contains("?") ? "&" : "?") + normalizedParams;
}
private static String rowString(JsonArray row, int index) {
if (row == null || index >= row.size()) {
return "";
}
return valueToString(row.getValue(index));
}
private static String valueToString(Object value) {
return value == null ? "" : value.toString();
}
private static String matchFirst(Pattern pattern, String text) {
if (text == null) {
return null;
}
Matcher matcher = pattern.matcher(text);
return matcher.find() ? matcher.group(1) : null;
}
private static String firstNonBlank(String first, String second) {
return !trimToEmpty(first).isBlank() ? trimToEmpty(first) : trimToEmpty(second);
}
private static String trimToDefault(String value, String defaultValue) {
String result = trimToEmpty(value);
return result.isBlank() ? defaultValue : result;
}
private static String trimToEmpty(String value) {
return value == null ? "" : value.trim();
}
private static String urlEncode(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private static String urlDecode(String value) {
if (value == null) {
return "";
}
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
} catch (Exception e) {
return value;
}
}
private static String extractOrigin(String shareUrl) {
try {
URI uri = URI.create(shareUrl);
if (uri.getScheme() == null || uri.getHost() == null) {
return "";
}
String origin = uri.getScheme() + "://" + uri.getHost();
return uri.getPort() > 0 ? origin + ":" + uri.getPort() : origin;
} catch (Exception e) {
return "";
}
}
static final class DirectoryContext {
final String folderId;
final String folderKey;
DirectoryContext(String folderId, String folderKey) {
this.folderId = folderId;
this.folderKey = folderKey;
}
}
}
@@ -25,6 +25,10 @@ public class FcTool extends PanBase {
private static final String DOWN_REQUEST_URL = "https://v2.fangcloud.cn/apps/files/download?file_id={fid}" +
"&scenario=share&unique_name={uname}";
// 静态编译的正则表达式,避免每次调用都重新编译
private static final Pattern REQUEST_TOKEN_PATTERN = Pattern.compile("name=\"requesttoken\"\\s+value=\"([a-zA-Z0-9_+=]+)\"");
private static final Pattern TYPED_ID_PATTERN = Pattern.compile("id=\"typed_id\"\\s+value=\"file_(\\d+)\"");
public FcTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@@ -41,8 +45,7 @@ public class FcTool extends PanBase {
if (StringUtils.isNotEmpty(pwd)) {
// 获取requesttoken
String html = res.bodyAsString();
Pattern compile = Pattern.compile("name=\"requesttoken\"\\s+value=\"([a-zA-Z0-9_+=]+)\"");
Matcher matcher = compile.matcher(html);
Matcher matcher = REQUEST_TOKEN_PATTERN.matcher(html);
if (!matcher.find()) {
fail(SHARE_URL_PREFIX + " 未匹配到加密分享的密码输入页面的requesttoken");
return;
@@ -71,8 +74,7 @@ public class FcTool extends PanBase {
WebClientSession sClient) {
// 从HTML中找到文件id
String html = res.bodyAsString();
Pattern compile = Pattern.compile("id=\"typed_id\"\\s+value=\"file_(\\d+)\"");
Matcher matcher = compile.matcher(html);
Matcher matcher = TYPED_ID_PATTERN.matcher(html);
if (!matcher.find()) {
fail(SHARE_URL_PREFIX + " 未匹配到文件id(typed_id)");
return;
@@ -169,8 +169,13 @@ public class FjTool extends PanBase {
// 文件Id
JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0);
JsonArray fileListArray = fileInfo.getJsonArray("fileList");
if (fileListArray == null || fileListArray.isEmpty()) {
fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo);
return;
}
// 如果是目录返回目录ID
JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0);
JsonObject fileList = fileListArray.getJsonObject(0);
if (fileList.getInteger("fileType") == 2) {
promise.complete(fileList.getInteger("folderId").toString());
return;
@@ -31,10 +31,12 @@ public class GenShortUrl extends PanBase {
private static final String WRAPPER_URL = "https://www.so.com/link?m=ewgUSYiFWXIoTybC3fJH8YoJy8y10iRquo6cazgINwWjTn3HvVJ92TrCJu0PmMUR0RMDfOAucP3wa4G8j64SrhNH9Z0Cr0PEyn9ASuvpkUGmAjjUEGJkO5%2BIDGWVrEkPHsL7UsoKO6%2BlT%2BD6r&ccc=";
private static final String MID = "5095144728824883"; // 微博的mid
private static final Pattern SHORT_URL_PATTERN = Pattern.compile("(https?)://t.cn/\\w+");
private static final Pattern COMMENT_ID_PATTERN = Pattern.compile("comment_id=\"(\\d+)\"");
private static final MultiMap HEADER = HeadersMultiMap.headers()
.add("Content-Type", "application/x-www-form-urlencoded")
.add("Referer", "https://www.weibo.com")
.add("Content-Type", "application/x-www-form-urlencoded")
.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36");
Cookie cookie = new DefaultCookie("SUB", "_2A25KJE5vDeRhGeRJ6lsR9SjJzDuIHXVpWM-nrDV8PUJbkNAbLVPlkW1NUmJm3GjYtRHBsHdMUKafkdTL_YheMEmu");
@@ -64,11 +66,12 @@ public class GenShortUrl extends PanBase {
String shortUrl = extractShortUrl(comment);
if (shortUrl != null) {
log.info("生成的短链:{}", shortUrl);
// 先完成 promise,返回短链
promise.complete(shortUrl);
// 异步清理评论(best-effort,不影响结果)
String commentId = extractCommentId(comment);
if (commentId != null) {
deleteComment(commentId);
} else {
promise.fail("未能提取评论ID");
}
} else {
promise.fail("未能生成短链");
@@ -103,8 +106,7 @@ public class GenShortUrl extends PanBase {
}
private String extractShortUrl(String comment) {
Pattern pattern = Pattern.compile("(https?)://t.cn/\\w+");
Matcher matcher = pattern.matcher(comment);
Matcher matcher = SHORT_URL_PATTERN.matcher(comment);
if (matcher.find()) {
return matcher.group(0);
}
@@ -112,8 +114,7 @@ public class GenShortUrl extends PanBase {
}
private String extractCommentId(String comment) {
Pattern pattern = Pattern.compile("comment_id=\"(\\d+)\"");
Matcher matcher = pattern.matcher(comment);
Matcher matcher = COMMENT_ID_PATTERN.matcher(comment);
if (matcher.find()) {
return matcher.group(1);
}
@@ -52,4 +52,14 @@ public class IzSelectorTool implements IPanTool {
public Future<String> parseById() {
return selectedTool.parseById();
}
@Override
public ShareLinkInfo getShareLinkInfo() {
return selectedTool.getShareLinkInfo();
}
@Override
public void close() {
IPanTool.closeQuietly(selectedTool);
}
}
@@ -443,16 +443,75 @@ public class IzTool extends PanBase {
}
}
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) {
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误");
return;
}
promise.complete(headers.get("Location"));
}
// 目录解析
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
String location = headers.get("Location");
if (StringUtils.isBlank(location)) {
fail("{}", buildMissingLocationMessage(res2));
return;
}
promise.complete(location);
}
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
message.append(", HTTP ").append(response.statusCode());
String body = null;
try {
body = asText(response);
} catch (Exception e) {
body = "<响应体读取失败: " + e.getMessage() + ">";
}
if (StringUtils.isNotBlank(body)) {
try {
JsonObject json = new JsonObject(body);
String upstreamMsg = json.getString("msg");
Object code = json.getValue("code");
if (StringUtils.isNotBlank(upstreamMsg)) {
message.append(", 上游返回: ").append(upstreamMsg);
if (code != null) {
message.append(" (code=").append(code).append(")");
}
} else {
message.append(", 响应体: ").append(previewBody(body));
}
} catch (Exception ignored) {
message.append(", 响应体: ").append(previewBody(body));
}
} else {
message.append(", 响应体为空");
}
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
if (fileName != null) {
message.append(", 文件: ").append(fileName);
}
if (fileSize != null) {
message.append(", 大小: ").append(fileSize);
}
if (!hasConfiguredAuth()) {
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
}
return message.toString();
}
private boolean hasConfiguredAuth() {
Object authObj = shareLinkInfo.getOtherParam().get("auths");
if (!(authObj instanceof MultiMap auths)) {
return false;
}
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
}
private String previewBody(String body) {
int maxLength = 500;
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
}
// 目录解析
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> promise = Promise.promise();
@@ -414,11 +414,70 @@ public class IzToolWithAuth extends PanBase {
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) {
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误");
String location = headers.get("Location");
if (StringUtils.isBlank(location)) {
fail("{}", buildMissingLocationMessage(res2));
return;
}
promise.complete(headers.get("Location"));
promise.complete(location);
}
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
message.append(", HTTP ").append(response.statusCode());
String body = null;
try {
body = asText(response);
} catch (Exception e) {
body = "<响应体读取失败: " + e.getMessage() + ">";
}
if (StringUtils.isNotBlank(body)) {
try {
JsonObject json = new JsonObject(body);
String upstreamMsg = json.getString("msg");
Object code = json.getValue("code");
if (StringUtils.isNotBlank(upstreamMsg)) {
message.append(", 上游返回: ").append(upstreamMsg);
if (code != null) {
message.append(" (code=").append(code).append(")");
}
} else {
message.append(", 响应体: ").append(previewBody(body));
}
} catch (Exception ignored) {
message.append(", 响应体: ").append(previewBody(body));
}
} else {
message.append(", 响应体为空");
}
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
if (fileName != null) {
message.append(", 文件: ").append(fileName);
}
if (fileSize != null) {
message.append(", 大小: ").append(fileSize);
}
if (!hasConfiguredAuth()) {
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
}
return message.toString();
}
private boolean hasConfiguredAuth() {
Object authObj = shareLinkInfo.getOtherParam().get("auths");
if (!(authObj instanceof MultiMap auths)) {
return false;
}
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
}
private String previewBody(String body) {
int maxLength = 500;
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
}
// 目录解析
@@ -15,6 +15,8 @@ import org.openjdk.nashorn.api.scripting.ScriptObjectMirror;
import javax.script.ScriptException;
import java.net.MalformedURLException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -31,6 +33,20 @@ public class LzTool extends PanBase {
WebClientSession webClientSession = WebClientSession.create(clientNoRedirects);
public static final String SHARE_URL_PREFIX = "https://w1.lanzn.com/";
// 静态编译的正则表达式,避免每次调用都重新编译
private static final Pattern FILE_NAME_PATTERN = Pattern.compile("padding: 56px 0px 20px 0px;\">(.*?)<|filenajax\">(.*?)<");
private static final Pattern FILE_SIZE_PATTERN = Pattern.compile(">文件大小:</span>(.*?)<br>|\"n_filesize\">大小:(.*?)</div>");
private static final Pattern SHARE_USER_PATTERN = Pattern.compile(">分享用户:</span><font>(.*?)</font>|获取<span>(.*?)</span>的文件|\"user-name\">(.*?)</");
private static final Pattern DESCRIPTION_PATTERN = Pattern.compile("(?s)文件描述:</span><br>(.*?)</td>|class=\"n_box_des\">(.*?)</div>");
private static final Pattern FILE_ID_PATTERN = Pattern.compile("\\?f=(.*?)&|fid = (.*?);");
private static final Pattern CREATE_TIME_PATTERN = Pattern.compile(">上传时间:</span>(.*?)<");
private static final Pattern URL_DATE_PATTERN = Pattern.compile("(\\d{4}/\\d{1,2}/\\d{1,2})");
private static final Pattern ARG1_PATTERN = Pattern.compile("var arg1='([^']+)'");
private static final Pattern IFRAME_SRC_PATTERN = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
private static final Pattern RELATIVE_TIME_PATTERN = Pattern.compile("^(\\d+|几)\\s*(分钟|小时)前$");
private static final Pattern DATE_PATTERN = Pattern.compile("^(\\d{4})\\s*[-/年]\\s*(\\d{1,2})\\s*[-/月]\\s*(\\d{1,2})\\s*日?$");
private static final Pattern MONTH_DAY_PATTERN = Pattern.compile("^(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*日?$");
MultiMap headers0 = HeaderUtils.parseHeaders("""
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
@@ -62,19 +78,30 @@ public class LzTool extends PanBase {
client.getAbs(sUrl)
.putHeaders(headers0)
.send().onSuccess(res -> {
String html = asText(res);
if (html.contains("var arg1='")) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(html, sUrl);
webClientSession.getAbs(sUrl)
.putHeaders(headers0)
.send().onSuccess(res2 -> {
String html2 = asText(res2);
doParser(html2, pwd, sUrl);
});
try {
String html = asText(res);
if (hasAcwArg1(html)) {
webClientSession = WebClientSession.create(clientNoRedirects);
if (!setCookie(html, sUrl)) {
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
return;
}
webClientSession.getAbs(sUrl)
.putHeaders(headers0)
.send().onSuccess(res2 -> {
try {
String html2 = asText(res2);
doParser(html2, pwd, sUrl);
} catch (Exception e) {
fail("蓝奏云页面响应处理异常: {}", e.getMessage());
}
}).onFailure(handleFail(sUrl));
} else {
doParser(html, pwd, sUrl);
} else {
doParser(html, pwd, sUrl);
}
} catch (Exception e) {
fail("蓝奏云页面响应处理异常: {}", e.getMessage());
}
}).onFailure(handleFail(sUrl));
@@ -82,22 +109,41 @@ public class LzTool extends PanBase {
}
private void doParser(String html, String pwd, String sUrl) {
if (html == null || html.isBlank()) {
fail("蓝奏云页面响应为空");
return;
}
if (isShareCancelledPage(html)) {
fail("分享已失效或文件已取消分享");
return;
}
// 检测是否为目录分享链接 (含 /s/、/b/ 路径段或 b 开头的路径段)
if (sUrl.matches(".*/(s|b)/[^/]+.*") || sUrl.matches(".*/b[^/]+.*")) {
fail("该链接为蓝奏云目录分享,请使用目录解析接口");
return;
}
// 若仍是校验页 (parse()中cookie域名与实际URL不匹配时会出现), 重试一次
if (html.contains("var arg1='")) {
if (hasAcwArg1(html)) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(html, sUrl);
if (!setCookie(html, sUrl)) {
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
return;
}
webClientSession.getAbs(sUrl).putHeaders(headers0).send().onSuccess(res -> {
String html2 = asText(res);
if (html2.contains("var arg1='")) {
fail("蓝奏云反爬校验失败,请稍后重试");
return;
try {
String html2 = asText(res);
if (isShareCancelledPage(html2)) {
fail("分享已失效或文件已取消分享");
return;
}
if (hasAcwArg1(html2)) {
fail("蓝奏云反爬校验失败,请稍后重试");
return;
}
doParserInternal(html2, pwd, sUrl);
} catch (Exception e) {
fail("蓝奏云页面响应处理异常: {}", e.getMessage());
}
doParserInternal(html2, pwd, sUrl);
}).onFailure(handleFail(sUrl));
return;
}
@@ -105,14 +151,21 @@ public class LzTool extends PanBase {
}
private void doParserInternal(String html, String pwd, String sUrl) {
if (html == null || html.isBlank()) {
fail("蓝奏云页面响应为空");
return;
}
if (isShareCancelledPage(html)) {
fail("分享已失效或文件已取消分享");
return;
}
try {
setFileInfo(html, shareLinkInfo);
} catch (Exception e) {
log.error("文件信息解析异常", e);
}
// 匹配iframe
Pattern compile = Pattern.compile("src=\"(/fn\\?[a-zA-Z\\d_+/=]{16,})\"");
Matcher matcher = compile.matcher(html);
Matcher matcher = IFRAME_SRC_PATTERN.matcher(html);
// 没有Iframe说明是加密分享, 匹配sign通过密码请求下载页面
if (!matcher.find()) {
try {
@@ -126,46 +179,64 @@ public class LzTool extends PanBase {
// 没有密码
String iframePath = matcher.group(1);
String absoluteURI = SHARE_URL_PREFIX + iframePath;
webClientSession.getAbs(absoluteURI).putHeaders(headers0).send().onSuccess(res2 -> {
String html2 = asText(res2);
String jsText = getJsText(html2);
if (jsText == null) {
headers0.add("Referer", absoluteURI);
setCookie(html2, absoluteURI);
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
String html3 = asText(res3);
String jsText3 = getJsText(html3);
if (jsText3 != null) {
try {
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText3, null);
getDownURL(sUrl, scriptObjectMirror);
} catch (ScriptException | NoSuchMethodException e) {
fail(e, "引擎执行失败");
}
} else {
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效");
}
});
} else {
try {
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null);
getDownURL(sUrl, scriptObjectMirror);
} catch (ScriptException | NoSuchMethodException e) {
fail(e, "js引擎执行失败");
// 创建局部副本,避免修改实例字段导致累积
MultiMap headersCopy = MultiMap.caseInsensitiveMultiMap().addAll(headers0);
headersCopy.add("Referer", absoluteURI);
webClientSession.getAbs(absoluteURI).putHeaders(headersCopy).send().onSuccess(res2 -> {
try {
String html2 = asText(res2);
if (isShareCancelledPage(html2)) {
fail("分享已失效或文件已取消分享");
return;
}
String jsText = getJsText(html2);
if (jsText == null) {
if (!setCookie(html2, absoluteURI)) {
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
return;
}
webClientSession.getAbs(absoluteURI).send().onSuccess(res3 -> {
try {
String html3 = asText(res3);
if (isShareCancelledPage(html3)) {
fail("分享已失效或文件已取消分享");
return;
}
String jsText3 = getJsText(html3);
if (jsText3 != null) {
try {
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText3, null);
getDownURL(sUrl, scriptObjectMirror);
} catch (ScriptException | NoSuchMethodException e) {
fail(e, "引擎执行失败");
}
} else {
fail(SHARE_URL_PREFIX + iframePath + " -> " + sUrl + ": 获取失败0, 可能分享已失效");
}
} catch (Exception e) {
fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage());
}
}).onFailure(handleFail(absoluteURI));
} else {
try {
ScriptObjectMirror scriptObjectMirror = JsExecUtils.executeDynamicJs(jsText, null);
getDownURL(sUrl, scriptObjectMirror);
} catch (ScriptException | NoSuchMethodException e) {
fail(e, "js引擎执行失败");
}
}
} catch (Exception e) {
fail("蓝奏云 iframe 响应处理异常: {}", e.getMessage());
}
}).onFailure(handleFail(SHARE_URL_PREFIX));
}
}
private void setCookie(String html, String url) {
int beginIndex = html.indexOf("arg1='") + 6;
int endIndex = html.indexOf("';", beginIndex);
if (beginIndex < 6 || endIndex == -1 || endIndex <= beginIndex) {
fail("蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
return;
private boolean setCookie(String html, String url) {
String arg1 = extractAcwArg1(html);
if (arg1 == null) {
return false;
}
String arg1 = html.substring(beginIndex, endIndex);
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
// 从 URL 中动态提取域名(如 lanzoum.com, lanzoux.com 等)
String domain = ".lanzn.com"; // 默认兜底
@@ -184,6 +255,7 @@ public class LzTool extends PanBase {
nettyCookie.setSecure(false);
nettyCookie.setHttpOnly(false);
webClientSession.cookieStore().put(nettyCookie);
return true;
}
private String getJsByPwd(String pwd, String html, String subText) {
@@ -201,6 +273,9 @@ public class LzTool extends PanBase {
}
private String getJsText(String html) {
if (html == null) {
return null;
}
String jsTagStart = "<script type=\"text/javascript\">";
String jsTagEnd = "</script>";
int index = html.lastIndexOf(jsTagStart);
@@ -209,9 +284,38 @@ public class LzTool extends PanBase {
}
int startPos = index + jsTagStart.length();
int endPos = html.indexOf(jsTagEnd, startPos);
if (endPos <= startPos) {
return null;
}
return html.substring(startPos, endPos).replaceAll("<!--.*-->", "");
}
static String extractAcwArg1(String html) {
if (html == null) {
return null;
}
int beginIndex = html.indexOf("arg1='");
if (beginIndex < 0) {
return null;
}
beginIndex += 6;
int endIndex = html.indexOf("';", beginIndex);
if (endIndex <= beginIndex) {
return null;
}
return html.substring(beginIndex, endIndex);
}
static boolean isShareCancelledPage(String html) {
return html != null
&& ((html.contains("来晚啦") && html.contains("取消分享"))
|| (html.contains("class=\"off\"") && html.contains("取消分享")));
}
private static boolean hasAcwArg1(String html) {
return html != null && html.contains("var arg1='");
}
private void getDownURL(String key, Map<String, ?> obj) {
if (obj == null) {
fail("需要访问密码");
@@ -225,7 +329,7 @@ public class LzTool extends PanBase {
});
MultiMap headers = HeaderUtils.parseHeaders("""
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br, zstd
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cache-Control: no-cache
Connection: keep-alive
@@ -261,42 +365,57 @@ public class LzTool extends PanBase {
headers.remove("Referer");
webClientSession.getAbs(downUrl).putHeaders(headers).send()
.onSuccess(res3 -> {
String location = res3.headers().get("Location");
if (location == null) {
String text = asText(res3);
// 使用cookie 再请求一次
headers.add("Referer", downUrl);
int beginIndex = text.indexOf("arg1='") + 6;
String arg1 = text.substring(beginIndex, text.indexOf("';", beginIndex));
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
// 从 downUrl 中动态提取域名
String downDomain = ".lanrar.com";
try {
java.net.URL du = new java.net.URL(downUrl);
String h = du.getHost();
int dot = h.indexOf('.');
if (dot >= 0) downDomain = h.substring(dot);
} catch (MalformedURLException ignored) {}
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(downDomain);
nettyCookie.setPath("/");
nettyCookie.setSecure(false);
nettyCookie.setHttpOnly(false);
WebClientSession webClientSession2 = WebClientSession.create(clientNoRedirects);
webClientSession2.cookieStore().put(nettyCookie);
webClientSession2.getAbs(downUrl).putHeaders(headers).send()
.onSuccess(res4 -> {
String location0 = res4.headers().get("Location");
if (location0 == null) {
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
} else {
setDateAndComplete(location0);
}
}).onFailure(handleFail(downUrl));
return;
try {
String location = res3.headers().get("Location");
if (location == null) {
String text = asText(res3);
if (isShareCancelledPage(text)) {
fail(downUrl + " -> 分享已失效或文件已取消分享");
return;
}
// 使用cookie 再请求一次
headers.add("Referer", downUrl);
String arg1 = extractAcwArg1(text);
if (arg1 == null) {
fail(downUrl + " -> 蓝奏云反爬 arg1 Cookie 解析失败,可能分享已失效");
return;
}
String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1);
// 从 downUrl 中动态提取域名
String downDomain = ".lanrar.com";
try {
java.net.URL du = new java.net.URL(downUrl);
String h = du.getHost();
int dot = h.indexOf('.');
if (dot >= 0) downDomain = h.substring(dot);
} catch (MalformedURLException ignored) {}
// 创建一个 Cookie 并放入 CookieStore
DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2);
nettyCookie.setDomain(downDomain);
nettyCookie.setPath("/");
nettyCookie.setSecure(false);
nettyCookie.setHttpOnly(false);
WebClientSession webClientSession2 = WebClientSession.create(clientNoRedirects);
webClientSession2.cookieStore().put(nettyCookie);
webClientSession2.getAbs(downUrl).putHeaders(headers).send()
.onSuccess(res4 -> {
try {
String location0 = res4.headers().get("Location");
if (location0 == null) {
fail(downUrl + " -> 直链获取失败2, 可能分享已失效");
} else {
setDateAndComplete(location0);
}
} catch (Exception e) {
fail("蓝奏云直链二次响应处理异常: {}", e.getMessage());
}
}).onFailure(handleFail(downUrl));
return;
}
setDateAndComplete(location);
} catch (Exception e) {
fail("蓝奏云直链响应处理异常: {}", e.getMessage());
}
setDateAndComplete(location);
})
.onFailure(handleFail(downUrl));
} catch (Exception e) {
@@ -307,10 +426,9 @@ public class LzTool extends PanBase {
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);
Matcher matcher = URL_DATE_PATTERN.matcher(location0);
if (matcher.find()) {
String dateStr = matcher.group().replace("/", "-");
String dateStr = parseLanzouFileTime(matcher.group());
((FileInfo)shareLinkInfo.getOtherParam().get("fileInfo")).setCreateTime(dateStr);
}
promise.complete(location0);
@@ -338,26 +456,45 @@ public class LzTool extends PanBase {
String pwd = shareLinkInfo.getSharePassword();
webClientSession.getAbs(sUrl).send().onSuccess(res -> {
String html = asText(res);
// 检查是否需要 cookie 验证
if (html.contains("var arg1='")) {
webClientSession = WebClientSession.create(clientNoRedirects);
setCookie(html, sUrl);
// 重新请求
webClientSession.getAbs(sUrl).send().onSuccess(res2 -> {
handleFileListParse(asText(res2), pwd, sUrl, promise);
}).onFailure(err -> promise.fail(err));
return;
try {
String html = asText(res);
// 检查是否需要 cookie 验证
if (hasAcwArg1(html)) {
webClientSession = WebClientSession.create(clientNoRedirects);
if (!setCookie(html, sUrl)) {
promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
return;
}
// 重新请求
webClientSession.getAbs(sUrl).send().onSuccess(res2 -> {
try {
handleFileListParse(asText(res2), pwd, sUrl, promise);
} catch (Exception e) {
promise.tryFail(e);
}
}).onFailure(promise::tryFail);
return;
}
handleFileListParse(html, pwd, sUrl, promise);
} catch (Exception e) {
promise.tryFail(e);
}
handleFileListParse(html, pwd, sUrl, promise);
}).onFailure(err -> promise.fail(err));
}).onFailure(promise::tryFail);
return promise.future();
}
private void handleFileListParse(String html, String pwd, String sUrl, Promise<List<FileInfo>> promise) {
if (html == null || html.isBlank()) {
promise.tryFail(baseMsg() + "蓝奏云页面响应为空");
return;
}
if (isShareCancelledPage(html)) {
promise.tryFail(baseMsg() + "分享已失效或文件已取消分享");
return;
}
// 检测是否为文件分享链接 (不含 /s/、/b/ 路径段且不含 b 开头的路径段)
if (!sUrl.matches(".*/(s|b)/[^/]+.*") && !sUrl.matches(".*/b[^/]+.*")) {
promise.fail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口");
promise.tryFail(baseMsg() + "该链接为蓝奏云文件分享,请使用文件解析接口");
return;
}
try {
@@ -371,28 +508,43 @@ public class LzTool extends PanBase {
String url = SHARE_URL_PREFIX + "filemoreajax.php?file=" + data.get("fid");
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res2 -> {
String resBody = asText(res2);
// 再次检查是否需要 cookie 验证
if (resBody.contains("var arg1='")) {
setCookie(resBody, url);
// 重新请求
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> {
handleFileListResponse(asText(res3), promise);
}).onFailure(err -> promise.fail(err));
return;
try {
String resBody = asText(res2);
// 再次检查是否需要 cookie 验证
if (hasAcwArg1(resBody)) {
if (!setCookie(resBody, url)) {
promise.tryFail(baseMsg() + "蓝奏云反爬 arg1 Cookie 解析失败,页面内容异常");
return;
}
// 重新请求
webClientSession.postAbs(url).putHeaders(headers).sendForm(map).onSuccess(res3 -> {
try {
handleFileListResponse(asText(res3), promise);
} catch (Exception e) {
promise.tryFail(e);
}
}).onFailure(promise::tryFail);
return;
}
handleFileListResponse(resBody, promise);
} catch (Exception e) {
promise.tryFail(e);
}
handleFileListResponse(resBody, promise);
}).onFailure(err -> promise.fail(err));
}).onFailure(promise::tryFail);
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
promise.fail(e);
promise.tryFail(e);
}
}
private void handleFileListResponse(String responseBody, Promise<List<FileInfo>> promise) {
try {
if (responseBody == null || responseBody.isBlank()) {
promise.tryFail(baseMsg() + "蓝奏云文件列表响应为空");
return;
}
JsonObject fileListJson = new JsonObject(responseBody);
if (fileListJson.getInteger("zt") != 1) {
promise.fail(baseMsg() + fileListJson.getString("info"));
promise.tryFail(baseMsg() + fileListJson.getString("info"));
return;
}
List<FileInfo> list = new ArrayList<>();
@@ -423,7 +575,7 @@ public class LzTool extends PanBase {
String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setFileName(fileName)
.setFileId(id)
.setCreateTime(fileJson.getString("time"))
.setCreateTime(parseLanzouFileTime(fileJson.getString("time")))
.setFileType(fileJson.getString("icon"))
.setSizeStr(fileJson.getString("size"))
.setSize(sizeNum)
@@ -436,10 +588,46 @@ public class LzTool extends PanBase {
});
promise.complete(list);
} catch (Exception e) {
promise.fail(e);
promise.tryFail(e);
}
}
private static String parseLanzouFileTime(String timeText) {
if (timeText == null || timeText.isBlank()) {
return timeText;
}
String normalized = timeText.trim().replaceAll("\\s+", " ");
Matcher matcher = RELATIVE_TIME_PATTERN.matcher(normalized);
if (matcher.matches()) {
int amount = "".equals(matcher.group(1)) ? 1 : Integer.parseInt(matcher.group(1));
String unit = matcher.group(2);
LocalDateTime time = LocalDateTime.now();
if ("小时".equals(unit)) {
time = time.minusHours(amount);
} else {
time = time.minusMinutes(amount);
}
return time.toLocalDate().toString();
}
matcher = DATE_PATTERN.matcher(normalized);
if (matcher.matches()) {
return LocalDate.of(
Integer.parseInt(matcher.group(1)),
Integer.parseInt(matcher.group(2)),
Integer.parseInt(matcher.group(3))
).toString();
}
matcher = MONTH_DAY_PATTERN.matcher(normalized);
if (matcher.matches()) {
return LocalDate.of(
LocalDate.now().getYear(),
Integer.parseInt(matcher.group(1)),
Integer.parseInt(matcher.group(2))
).toString();
}
return normalized;
}
@Override
public Future<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
@@ -455,13 +643,13 @@ public class LzTool extends PanBase {
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
try {
// 提取文件名
String fileName = CommonUtils.extract(html, Pattern.compile("padding: 56px 0px 20px 0px;\">(.*?)<|filenajax\">(.*?)<"));
String sizeStr = CommonUtils.extract(html, Pattern.compile(">文件大小:</span>(.*?)<br>|\"n_filesize\">大小:(.*?)</div>"));
String createBy = CommonUtils.extract(html, Pattern.compile(">分享用户:</span><font>(.*?)</font>|获取<span>(.*?)</span>的文件|\"user-name\">(.*?)</"));
String description = CommonUtils.extract(html, Pattern.compile("(?s)文件描述:</span><br>(.*?)</td>|class=\"n_box_des\">(.*?)</div>"));
String fileName = CommonUtils.extract(html, FILE_NAME_PATTERN);
String sizeStr = CommonUtils.extract(html, FILE_SIZE_PATTERN);
String createBy = CommonUtils.extract(html, SHARE_USER_PATTERN);
String description = CommonUtils.extract(html, DESCRIPTION_PATTERN);
// String icon = CommonUtils.extract(html, Pattern.compile("class=\"n_file_icon\" src=\"(.*?)\""));
String fileId = CommonUtils.extract(html, Pattern.compile("\\?f=(.*?)&|fid = (.*?);"));
String createTime = CommonUtils.extract(html, Pattern.compile(">上传时间:</span>(.*?)<"));
String fileId = CommonUtils.extract(html, FILE_ID_PATTERN);
String createTime = CommonUtils.extract(html, CREATE_TIME_PATTERN);
try {
fileInfo.setFileName(fileName)
.setCreateBy(createBy)
@@ -469,7 +657,7 @@ public class LzTool extends PanBase {
.setDescription(description)
.setFileType("file")
.setFileId(fileId)
.setCreateTime(createTime);
.setCreateTime(parseLanzouFileTime(createTime));
if (sizeStr != null && !sizeStr.isBlank()) {
long bytes = FileSizeConverter.convertToBytes(sizeStr);
fileInfo.setSize(bytes).setSizeStr(FileSizeConverter.convertToReadableSize(bytes));
@@ -21,6 +21,8 @@ public class MkgsTool extends PanBase {
public static final String API_URL = "https://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash}";
private static final Pattern HASH_PATTERN = Pattern.compile("\"hash\"\\s*:\\s*\"([A-F0-9]+)\"");
private static final MultiMap headers = MultiMap.caseInsensitiveMultiMap();
static {
// 设置 User-Agent
@@ -78,10 +80,7 @@ public class MkgsTool extends PanBase {
protected void downUrl(String locationURL) {
client.getAbs(locationURL).putHeaders(headers).send().onSuccess(res2->{
String body = res2.bodyAsString();
// 正则表达式匹配 hash 字段
String regex = "\"hash\"\s*:\s*\"([A-F0-9]+)\"";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(body);
Matcher matcher = HASH_PATTERN.matcher(body);
// 查找并输出 hash 字段的值
if (matcher.find()) {
@@ -19,6 +19,8 @@ public class MkwTool extends PanBase {
public static final String API_URL = "https://www.kuwo.cn/api/v1/www/music/playUrl?mid={mid}&type=music&httpsStatus=1&reqId=&plat=web_www&from=";
private static final Pattern COOKIE_PATTERN = Pattern.compile("([A-Za-z0-9_]+)=([A-Za-z0-9]+)");
public MkwTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
@@ -29,39 +31,41 @@ public class MkwTool extends PanBase {
clientSession.getAbs(shareUrl).send().onSuccess(result -> {
String cookie = result.headers().get("set-cookie");
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()) {
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);
log.debug("sign: {}", sign);
clientSession.getAbs(UriTemplate.of(API_URL)).setTemplateParam("mid", shareLinkInfo.getShareKey())
.putHeader("Secret", sign).send().onSuccess(res -> {
JsonObject json = asJson(res);
log.debug(json.encodePrettily());
try {
if (json.getInteger("code") == 200) {
complete(json.getJsonObject("data").getString("url"));
} else {
fail("链接已失效/需要VIP");
}
} catch (Exception e) {
log.error("解析失败", e);
fail("解析失败");
}
});
}
if (cookie == null || cookie.isEmpty()) {
fail("未获取到 cookie,无法继续解析");
return;
}
});
Matcher matcher = COOKIE_PATTERN.matcher(cookie);
if (!matcher.find()) {
fail("cookie 格式不匹配");
return;
}
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);
log.debug("sign: {}", sign);
clientSession.getAbs(UriTemplate.of(API_URL)).setTemplateParam("mid", shareLinkInfo.getShareKey())
.putHeader("Secret", sign).send().onSuccess(res -> {
JsonObject json = asJson(res);
log.debug(json.encodePrettily());
try {
if (json.getInteger("code") == 200) {
complete(json.getJsonObject("data").getString("url"));
} else {
fail("链接已失效/需要VIP");
}
} catch (Exception e) {
log.error("解析失败", e);
fail("解析失败");
}
}).onFailure(handleFail("获取下载链接失败"));
}).onFailure(handleFail("请求分享页面失败"));
return promise.future();
}
@@ -16,6 +16,9 @@ import java.util.regex.Pattern;
* 下载链接需要Referer: https://link.yunpan.com/
*/
public class P360Tool extends PanBase {
private static final Pattern NID_PATTERN = Pattern.compile("\"nid\": \"([^\"]+)\"");
public P360Tool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@@ -43,9 +46,7 @@ public class P360Tool extends PanBase {
clientSession.getAbs(url)
.send()
.onSuccess(res -> {
// find "nid": "17402043311959599"
Pattern compile = Pattern.compile("\"nid\": \"([^\"]+)\"");
Matcher matcher = compile.matcher(res.bodyAsString());
Matcher matcher = NID_PATTERN.matcher(res.bodyAsString());
AtomicReference<String> nid = new AtomicReference<>();
if (matcher.find()) {
nid.set(matcher.group(1));
@@ -69,7 +70,7 @@ public class P360Tool extends PanBase {
clientSession.getAbs(url)
.send()
.onSuccess(res3 -> {
Matcher matcher1 = compile.matcher(res3.bodyAsString());
Matcher matcher1 = NID_PATTERN.matcher(res3.bodyAsString());
if (matcher1.find()) {
nid.set(matcher1.group(1));
} else {
@@ -14,6 +14,25 @@ import java.util.regex.Pattern;
*/
public class PcxTool extends PanBase {
private static final Pattern TITLE_PATTERN =
Pattern.compile("<title>([^<]+)</title>");
private static final Pattern FILENAME_INPUT_PATTERN =
Pattern.compile("<input id=\"filename\" type=\"hidden\" value=\"([^\"]+)\"");
private static final Pattern FILESIZE_PATTERN =
Pattern.compile("['\"]filesize['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
private static final Pattern SUFFIX_PATTERN =
Pattern.compile("['\"]suffix['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
private static final Pattern OBJECT_ID_PATTERN =
Pattern.compile("['\"]objectId['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
private static final Pattern CREATOR_PATTERN =
Pattern.compile("['\"]creator['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
private static final Pattern UPLOAD_DATE_PATTERN =
Pattern.compile("['\"]uploadDate['\"]\\s*:\\s*(\\d+)");
private static final Pattern THUMBNAIL_PATTERN =
Pattern.compile("['\"]thumbnail['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
private static final Pattern DOWNLOAD_PATTERN =
Pattern.compile("['\"]download['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
public PcxTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@@ -44,9 +63,7 @@ public class PcxTool extends PanBase {
* 从HTML中提取download链接
*/
private String extractDownloadUrl(String html) {
// 匹配 'download': 'https://xxx' 或 "download": "https://xxx"
Pattern pattern = Pattern.compile("['\"]download['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
Matcher matcher = pattern.matcher(html);
Matcher matcher = DOWNLOAD_PATTERN.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
@@ -61,13 +78,13 @@ public class PcxTool extends PanBase {
FileInfo fileInfo = new FileInfo();
// 提取文件名:从<title>标签或文件名input
String fileName = extractByRegex(html, "<title>([^<]+)</title>");
String fileName = extractByRegex(html, TITLE_PATTERN);
if (fileName == null) {
fileName = extractByRegex(html, "<input id=\"filename\" type=\"hidden\" value=\"([^\"]+)\"");
fileName = extractByRegex(html, FILENAME_INPUT_PATTERN);
}
// 提取文件大小:'filesize': 'xxx' 或 "filesize": "xxx"
String fileSizeStr = extractByRegex(html, "['\"]filesize['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
String fileSizeStr = extractByRegex(html, FILESIZE_PATTERN);
Long fileSize = null;
if (fileSizeStr != null) {
try {
@@ -76,19 +93,19 @@ public class PcxTool extends PanBase {
}
// 提取文件类型/后缀:'suffix': 'xxx' 或 "suffix": "xxx"
String suffix = extractByRegex(html, "['\"]suffix['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
String suffix = extractByRegex(html, SUFFIX_PATTERN);
// 提取objectId(文件ID):'objectId': 'xxx' 或 "objectId": "xxx"
String objectId = extractByRegex(html, "['\"]objectId['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
String objectId = extractByRegex(html, OBJECT_ID_PATTERN);
// 提取创建者:'creator': 'xxx' 或 "creator": "xxx"
String creator = extractByRegex(html, "['\"]creator['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
String creator = extractByRegex(html, CREATOR_PATTERN);
// 提取上传时间:'uploadDate': timestamp
String uploadDate = extractByRegex(html, "['\"]uploadDate['\"]\\s*:\\s*(\\d+)");
String uploadDate = extractByRegex(html, UPLOAD_DATE_PATTERN);
// 提取缩略图:'thumbnail': 'xxx' 或 "thumbnail": "xxx"
String thumbnail = extractByRegex(html, "['\"]thumbnail['\"]\\s*:\\s*['\"]([^'\"]+)['\"]");
String thumbnail = extractByRegex(html, THUMBNAIL_PATTERN);
// 设置文件信息
if (fileName != null) {
@@ -141,8 +158,7 @@ public class PcxTool extends PanBase {
/**
* 使用正则表达式提取内容
*/
private String extractByRegex(String text, String regex) {
Pattern pattern = Pattern.compile(regex);
private String extractByRegex(String text, Pattern pattern) {
Matcher matcher = pattern.matcher(text);
if (matcher.find()) {
return matcher.group(1);
@@ -28,6 +28,7 @@ public class PdbTool extends PanBase implements IPanTool {
private static final String API_URL =
"https://www.dropbox.com/sharing/fetch_user_content_link";
static final String COOKIE_KEY = "__Host-js_csrf=";
private static final Pattern CSRF_TOKEN_PATTERN = Pattern.compile(COOKIE_KEY + "([\\w-]+);");
public PdbTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
@@ -47,7 +48,7 @@ public class PdbTool extends PanBase implements IPanTool {
fail("cookie未找到");
return;
}
Matcher matcher = Pattern.compile(COOKIE_KEY + "([\\w-]+);").matcher(collect.get(0));
Matcher matcher = CSRF_TOKEN_PATTERN.matcher(collect.get(0));
String _t;
if (matcher.find()) {
_t = matcher.group(1);
@@ -23,6 +23,16 @@ import java.util.regex.Pattern;
*/
public class PodTool extends PanBase {
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
private static final java.time.Duration REQUEST_TIMEOUT = java.time.Duration.ofSeconds(30);
// 静态共享的 JDK HttpClient 实例,避免每次调用创建新实例
private static final HttpClient SHARED_HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(10))
.build();
private static volatile WorkerExecutor SHARED_WORKER_EXECUTOR;
private static volatile boolean workerExecutorShutdown = false;
/*
* https://1drv.ms/w/s!Alg0feQmCv2rnRFd60DQOmMa-Oh_?e=buaRtp --302->
* https://api.onedrive.com/v1.0/drives/abfd0a26e47d3458/items/ABFD0A26E47D3458!3729?authkey=!AF3rQNA6Yxr46H8
@@ -45,6 +55,13 @@ public class PodTool extends PanBase {
private static final Pattern redirectUrlRegex =
Pattern.compile("resid=(?<cid1>[^!]+)!(?<cid2>[^&]+).+&redeem=(?<redeem>.+).*");
private static final Pattern DOWNLOAD_URL_IN_RESPONSE_PATTERN =
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\\s\"]+)");
private static final Pattern ACTION_URL_PATTERN =
Pattern.compile("'action'.+(?<url>https://.+)'\\)");
private static final Pattern TOKEN_PATTERN =
Pattern.compile("inputElem\\.value\\s*=\\s*'([^']+)'");
public PodTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@@ -97,7 +114,7 @@ public class PodTool extends PanBase {
sendHttpRequest(url, token).onSuccess(body -> {
Matcher matcher1 =
Pattern.compile("\"downloadUrl\":\"(?<url>https?://[^\s\"]+)").matcher(body);
DOWNLOAD_URL_IN_RESPONSE_PATTERN.matcher(body);
if (matcher1.find()) {
// 响应体是 JSON 文本,URL 中的 '&' 被转义为 \u0026,需要反转义
complete(unescapeJsonUnicode(matcher1.group("url")));
@@ -121,11 +138,7 @@ public class PodTool extends PanBase {
}
private String matcherUrl(String html) {
// 正则表达式来匹配 URL
String urlRegex = "'action'.+(?<url>https://.+)'\\)";
Pattern urlPattern = Pattern.compile(urlRegex);
Matcher urlMatcher = urlPattern.matcher(html);
Matcher urlMatcher = ACTION_URL_PATTERN.matcher(html);
if (urlMatcher.find()) {
String url = urlMatcher.group("url");
@@ -165,10 +178,7 @@ public class PodTool extends PanBase {
private String matcherToken(String html) {
// 正则表达式来匹配 inputElem.value 中的 Token
String tokenRegex = "inputElem\\.value\\s*=\\s*'([^']+)'";
Pattern tokenPattern = Pattern.compile(tokenRegex);
Matcher tokenMatcher = tokenPattern.matcher(html);
Matcher tokenMatcher = TOKEN_PATTERN.matcher(html);
if (tokenMatcher.find()) {
String token = tokenMatcher.group(1);
@@ -180,11 +190,8 @@ public class PodTool extends PanBase {
public Future<String> sendHttpRequest2(String token, String redeem) {
Promise<String> promise = Promise.promise();
// 构造 HttpClient
HttpClient client = HttpClient.newHttpClient();
// 构造请求的 URI 和头部信息
// https://onedrive.live.com/redir?cid=abfd0a26e47d3458&resid=ABFD0A26E47D3458!4465&ithint=file%2cxlsx&e=Ao2uSU&migratedtospo=true&redeem=aHR0cHM6Ly8xZHJ2Lm1zL3gvYy9hYmZkMGEyNmU0N2QzNDU4L0VWZzBmZVFtQ3YwZ2dLdHhFUUFBQUFBQlRQRWVDMTZfZk1EYk5FTjhEdTRta1E_ZT1BbzJ1U1U
String url = ("https://my.microsoftpersonalcontent.com/_api/v2.0/shares/u!%s/driveItem?$select=content" +
".downloadUrl").formatted(redeem);
String authorizationHeader = "Badger " + token;
@@ -192,15 +199,20 @@ public class PodTool extends PanBase {
// 构建请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(REQUEST_TIMEOUT)
.header("Authorization", authorizationHeader)
.build();
// 发送请求并处理响应
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
// 发送请求并处理响应(使用共享的 HttpClient
SHARED_HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
.thenApply(response -> {
log.debug("Response Status Code: {}", response.statusCode());
log.debug("Response Body: {}", response.body());
promise.complete(response.body());
promise.complete(toLimitedString(response.body()));
return null;
})
.exceptionally(e -> {
log.error("sendHttpRequest2 请求失败: {}", e.getMessage());
promise.fail(e);
return null;
});
@@ -208,18 +220,13 @@ public class PodTool extends PanBase {
}
public Future<String> sendHttpRequest(String url, String token) {
// 创建一个 WorkerExecutor 用于异步执行阻塞的 HTTP 请求
WorkerExecutor executor = WebClientVertxInit.get().createSharedWorkerExecutor("http-client-worker");
Promise<String> promise = Promise.promise();
executor.executeBlocking(() -> {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = null;
getWorkerExecutor().executeBlocking(() -> {
try {
// 构造请求
request = HttpRequest.newBuilder()
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI(url))
.timeout(REQUEST_TIMEOUT)
.header("accept", "text/html,application/xhtml+xml,application/xml;q=0.9," +
"image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;" +
"v=b3;q=0.7")
@@ -244,17 +251,49 @@ public class PodTool extends PanBase {
.POST(HttpRequest.BodyPublishers.ofString("badger_token=" + token))
.build();
// 发起请求并获取响应
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
// 发起请求并获取响应(使用共享的 HttpClient
HttpResponse<byte[]> response = SHARED_HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofByteArray());
// 返回响应体
promise.complete(response.body());
promise.complete(toLimitedString(response.body()));
return null;
} catch (URISyntaxException | IOException | InterruptedException e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
throw new RuntimeException(e);
}
});
}).onFailure(promise::fail);
return promise.future();
}
}
private static String toLimitedString(byte[] body) {
if (body.length > MAX_RESPONSE_BODY_BYTES) {
throw new IllegalArgumentException("OneDrive响应体过大: " + body.length + " bytes");
}
return new String(body, java.nio.charset.StandardCharsets.UTF_8);
}
private static WorkerExecutor getWorkerExecutor() {
synchronized (PodTool.class) {
if (workerExecutorShutdown) {
throw new IllegalStateException("OneDrive WorkerExecutor 已关闭");
}
if (SHARED_WORKER_EXECUTOR == null) {
SHARED_WORKER_EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("http-client-worker", 8);
}
return SHARED_WORKER_EXECUTOR;
}
}
public static void shutdownWorkerExecutor() {
synchronized (PodTool.class) {
workerExecutorShutdown = true;
if (SHARED_WORKER_EXECUTOR != null) {
SHARED_WORKER_EXECUTOR.close();
SHARED_WORKER_EXECUTOR = null;
}
}
}
}
@@ -1,6 +1,5 @@
package cn.qaiu.parser.impl;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
@@ -11,7 +10,6 @@ import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import io.vertx.core.json.pointer.JsonPointer;
import io.vertx.ext.web.client.WebClient;
import io.vertx.uritemplate.UriTemplate;
import java.util.List;
@@ -53,9 +51,8 @@ public class PvyyTool extends PanBase {
@Override
public Future<String> parse() {
// 请求downcode
WebClient.create(WebClientVertxInit.get())
.getAbs(api + shareLinkInfo.getShareKey())
// 请求downcode - 使用父类的共享 WebClient 而非创建新实例
client.getAbs(api + shareLinkInfo.getShareKey())
.send()
.onSuccess(res -> {
if (res.statusCode() == 200) {
@@ -63,6 +63,11 @@ public class QQscTool extends PanBase {
x-oidb: {"uint32_command":"0x93d4", "uint32_service_type":"1"}
""");
private static final Pattern FILESET_ID_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})");
private static final Pattern TITLE_PATTERN = Pattern.compile("<title>(.*?)</title>");
public QQscTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@@ -247,7 +252,9 @@ public class QQscTool extends PanBase {
.put("sort_order", 0)))))
.put("support_folder_status", true);
MultiMap headers = GET_FILE_LIST_HEADERS.set("Referer", shareLinkInfo.getShareUrl());
// 创建局部副本,避免修改静态 MultiMap 导致并发污染
MultiMap headers = MultiMap.caseInsensitiveMultiMap().addAll(GET_FILE_LIST_HEADERS)
.set("Referer", shareLinkInfo.getShareUrl());
client.postAbs(GET_FILE_LIST_API)
.putHeaders(headers)
@@ -283,9 +290,7 @@ public class QQscTool extends PanBase {
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);
Matcher matcher = FILESET_ID_PATTERN.matcher(html);
if (matcher.find()) {
return matcher.group(1);
}
@@ -326,8 +331,7 @@ public class QQscTool extends PanBase {
}
public static String extractFileNameFromTitle(String content) {
Pattern pattern = Pattern.compile("<title>(.*?)</title>");
Matcher matcher = pattern.matcher(content);
Matcher matcher = TITLE_PATTERN.matcher(content);
if (matcher.find()) {
String fullTitle = matcher.group(1);
int sepIndex = fullTitle.indexOf("");
File diff suppressed because it is too large Load Diff
@@ -17,16 +17,35 @@ import java.util.zip.InflaterInputStream;
public class HttpResponseHelper {
static Logger LOGGER = LoggerFactory.getLogger(HttpResponseHelper.class);
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
private static final int MAX_DECOMPRESSED_CHARS = 16 * 1024 * 1024;
// -------------------- 公共方法 --------------------
public static String asText(HttpResponse<?> res) {
String encoding = res.getHeader(HttpHeaders.CONTENT_ENCODING.toString());
try {
Buffer body = toBuffer(res);
return asText(body, encoding);
} catch (IllegalArgumentException | UnsupportedOperationException e) {
throw e;
} catch (Exception e) {
LOGGER.error("asText: {}", e.getMessage(), e);
return null;
}
}
public static String asText(Buffer body, String encoding) {
try {
if (body == null) {
return "";
}
ensureBodyLimit(body);
if (encoding == null || "identity".equalsIgnoreCase(encoding)) {
return body.toString(StandardCharsets.UTF_8);
}
return decompress(body, encoding);
} catch (IllegalArgumentException | UnsupportedOperationException e) {
throw e;
} catch (Exception e) {
LOGGER.error("asText: {}", e.getMessage(), e);
return null;
@@ -36,6 +55,29 @@ public class HttpResponseHelper {
public static JsonObject asJson(HttpResponse<?> res) {
try {
String text = asText(res);
return parseJsonText(text);
} catch (IllegalArgumentException | UnsupportedOperationException e) {
throw e;
} catch (Exception e) {
LOGGER.error("asJson: {}", e.getMessage(), e);
return JsonObject.of();
}
}
public static JsonObject asJson(Buffer body, String encoding) {
try {
String text = asText(body, encoding);
return parseJsonText(text);
} catch (IllegalArgumentException | UnsupportedOperationException e) {
throw e;
} catch (Exception e) {
LOGGER.error("asJson: {}", e.getMessage(), e);
return JsonObject.of();
}
}
private static JsonObject parseJsonText(String text) {
try {
if (text != null) {
return new JsonObject(text);
} else {
@@ -53,13 +95,26 @@ public class HttpResponseHelper {
return res.body() instanceof Buffer ? (Buffer) res.body() : Buffer.buffer(res.bodyAsString());
}
private static void ensureBodyLimit(Buffer body) {
if (body != null && body.length() > MAX_RESPONSE_BODY_BYTES) {
throw new IllegalArgumentException("响应体过大: " + body.length() + " bytes");
}
}
private static void writeLimited(StringWriter writer, char[] buffer, int len) throws IOException {
if (writer.getBuffer().length() + len > MAX_DECOMPRESSED_CHARS) {
throw new IOException("解压后响应体过大");
}
writer.write(buffer, 0, len);
}
// -------------------- 通用解压分发 --------------------
private static String decompress(Buffer compressed, String encoding) throws IOException {
return switch (encoding.toLowerCase()) {
case "gzip" -> decompressGzip(compressed);
case "deflate" -> decompressDeflate(compressed);
case "br" -> decompressBrotli(compressed);
case "zstd" -> compressed.toString(StandardCharsets.UTF_8); // 暂时返回原始内容
case "zstd" -> throw new UnsupportedOperationException("不支持的 Content-Encoding: zstd");
default -> throw new UnsupportedOperationException("不支持的 Content-Encoding: " + encoding);
};
}
@@ -74,7 +129,7 @@ public class HttpResponseHelper {
char[] buffer = new char[4096];
int n;
while ((n = isr.read(buffer)) != -1) {
writer.write(buffer, 0, n);
writeLimited(writer, buffer, n);
}
return writer.toString();
}
@@ -99,7 +154,7 @@ public class HttpResponseHelper {
char[] buffer = new char[4096];
int n;
while ((n = isr.read(buffer)) != -1) {
writer.write(buffer, 0, n);
writeLimited(writer, buffer, n);
}
return writer.toString();
}
@@ -115,7 +170,7 @@ public class HttpResponseHelper {
char[] buffer = new char[4096];
int n;
while ((n = isr.read(buffer)) != -1) {
writer.write(buffer, 0, n);
writeLimited(writer, buffer, n);
}
return writer.toString();
}
@@ -1,7 +1,7 @@
package cn.qaiu.util;
import cn.qaiu.WebClientVertxInit;
import io.vertx.core.MultiMap;
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;
@@ -19,6 +19,10 @@ import java.util.List;
public class IpExtractor {
private static final Logger log = LoggerFactory.getLogger(IpExtractor.class);
// 使用共享的 Vertx 实例,避免每次调用创建新实例导致资源泄漏
private static final WebClient SHARED_CLIENT = WebClient.create(WebClientVertxInit.get());
private static final WebClientSession SHARED_SESSION = WebClientSession.create(SHARED_CLIENT);
public static void main(String[] args) throws InterruptedException {
@@ -43,11 +47,9 @@ public class IpExtractor {
headers.add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
headers.add("Content-Type", "application/x-www-form-urlencoded");
WebClient client = WebClient.create(Vertx.vertx());
WebClientSession webClientSession = WebClientSession.create(client);
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res->{
SHARED_SESSION.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res->{
log.debug("response: {}", res.toString());
webClientSession.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res2->{
SHARED_SESSION.getAbs("https://ip.ihuan.me").putHeaders(headers).send().onSuccess(res2->{
log.debug("response2: {}", res2.toString());
});
@@ -46,34 +46,62 @@ public class JsExecUtils {
/**
* 调用执行蓝奏云js文件(每次动态JS代码,无法复用引擎)
* 注意:使用后清理引擎引用,帮助 GC 回收 Nashorn 引擎内部资源
*/
public static ScriptObjectMirror executeDynamicJs(String jsText, String funName) throws ScriptException,
NoSuchMethodException {
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
engine.eval(JsContent.lz + "\n" + jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数
if (StringUtils.isNotEmpty(funName)) {
inv.invokeFunction(funName);
try {
engine.eval(JsContent.lz + "\n" + jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数
if (StringUtils.isNotEmpty(funName)) {
inv.invokeFunction(funName);
}
return (ScriptObjectMirror) engine.get("signObj");
} finally {
// 清理引擎持有的引用,帮助 GC 回收
clearEngineBindings(engine);
}
return (ScriptObjectMirror) engine.get("signObj");
}
/**
* 调用执行js文件(使用缓存的 ScriptEngineManager 创建新引擎实例)
* 注意:使用后清理引擎引用,帮助 GC 回收 Nashorn 引擎内部资源
*/
public static Object executeOtherJs(String jsText, String funName, Object ... args) throws ScriptException,
NoSuchMethodException {
ScriptEngine engine = ENGINE_MANAGER.getEngineByName("JavaScript"); // 得到脚本引擎
engine.eval(jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数
if (StringUtils.isNotEmpty(funName)) {
return inv.invokeFunction(funName, args);
try {
engine.eval(jsText);
Invocable inv = (Invocable) engine;
//调用js中的函数
if (StringUtils.isNotEmpty(funName)) {
return inv.invokeFunction(funName, args);
}
throw new ScriptException("funName is null");
} finally {
// 清理引擎持有的引用,帮助 GC 回收
clearEngineBindings(engine);
}
}
/**
* 清理 ScriptEngine 的 bindings,帮助 GC 回收 Nashorn 引擎资源
*/
private static void clearEngineBindings(ScriptEngine engine) {
try {
if (engine != null) {
// 清理全局 bindings
var bindings = engine.getBindings(javax.script.ScriptContext.ENGINE_SCOPE);
if (bindings != null) {
bindings.clear();
}
}
} catch (Exception ignored) {
// 清理失败不影响主流程
}
throw new ScriptException("funName is null");
}
public static String getKwSign(String s, String pwd) {
@@ -1,8 +1,8 @@
package cn.qaiu.util;
import cn.qaiu.WebClientVertxInit;
import io.vertx.core.AsyncResult;
import io.vertx.core.MultiMap;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.impl.headers.HeadersMultiMap;
import io.vertx.ext.web.client.HttpResponse;
@@ -47,15 +47,13 @@ public class ReqIpUtil {
}
Vertx vertx = Vertx.vertx();
WebClient webClient = WebClient.create(vertx);
// 发送GET请求
WebClientSession webClientSession = WebClientSession.create(webClient);
// 使用共享的 Vertx 实例和 WebClient,避免每次创建新实例导致资源泄漏
private static final WebClient WEB_CLIENT = WebClient.create(WebClientVertxInit.get());
private static final WebClientSession WEB_CLIENT_SESSION = WebClientSession.create(WEB_CLIENT);
public void exec() {
webClientSession.getAbs(BASE_URL)
WEB_CLIENT_SESSION.getAbs(BASE_URL)
.putHeaders(headers) // 将请求头Map添加到请求中
.send(this::next);
}
@@ -67,7 +65,7 @@ public class ReqIpUtil {
HttpResponse<Buffer> res = response.result();
log.debug("Received response with status code {}", res.statusCode());
log.debug("Body: {}", res.body());
webClientSession.getAbs(BASE_URL_TEMPLATE).setTemplateParam("path", PATH1)
WEB_CLIENT_SESSION.getAbs(BASE_URL_TEMPLATE).setTemplateParam("path", PATH1)
.putHeaders(headers) // 将请求头Map添加到请求中
.send(response2 -> {
log.debug("response2: {}", response2.result().bodyAsString());
@@ -0,0 +1,84 @@
package cn.qaiu.util;
import org.apache.commons.lang3.StringUtils;
import java.util.HashMap;
import java.util.Map;
/**
* 123分享链接子域工具:将分享 key 的前半段解码为数字 uid。
*/
public final class YeShareHostUtil {
private static final String CODE62 = "Tvd3hHA9QEkom14xpfaBJIMwgFYGPXn2sWCNORDr80KuUSl7bZcetizL5q6yVj";
private static final long MAX_SAFE_INTEGER = 9007199254740991L;
private static final Map<Character, Integer> DECODE_MAP = new HashMap<>();
static {
for (int i = 0; i < CODE62.length(); i++) {
DECODE_MAP.put(CODE62.charAt(i), i);
}
}
private YeShareHostUtil() {
}
public static String getNumericSubdomainIdByShareKey(String shareKey) {
String normalized = normalizeShareKey(shareKey);
if (StringUtils.isBlank(normalized)) {
return "";
}
int split = normalized.indexOf('-');
if (split <= 0) {
return "";
}
String encodedUid = normalized.substring(0, split);
Long uid = decodeBase62LittleEndian(encodedUid);
return uid == null ? "" : String.valueOf(uid);
}
public static String normalizeShareKey(String shareKey) {
if (StringUtils.isBlank(shareKey)) {
return "";
}
String key = shareKey.trim();
int queryIndex = key.indexOf('?');
if (queryIndex >= 0) {
key = key.substring(0, queryIndex);
}
int slashIndex = key.lastIndexOf('/');
if (slashIndex >= 0 && slashIndex < key.length() - 1) {
key = key.substring(slashIndex + 1);
}
if (key.endsWith(".html")) {
key = key.substring(0, key.length() - 5);
}
return key;
}
private static Long decodeBase62LittleEndian(String value) {
if (StringUtils.isBlank(value)) {
return null;
}
long result = 0L;
for (int i = 0; i < value.length(); i++) {
Integer digit = DECODE_MAP.get(value.charAt(i));
if (digit == null) {
return null;
}
double weighted = digit * Math.pow(62, i);
if (!Double.isFinite(weighted)) {
return null;
}
long next = result + (long) weighted;
if (next <= 0 || next > MAX_SAFE_INTEGER) {
return null;
}
result = next;
}
if (result <= 0) {
return null;
}
return result;
}
}
@@ -134,8 +134,8 @@ HTTP客户端对象:
http.get(url) // GET请求
http.post(url, data) // POST请求
http.putHeader(name, value) // 设置请求头
http.sendForm(data) // 发送表单数据
http.sendJson(data) // 发送JSON数据
http.sendForm(url, data) // 发送表单数据
http.sendJson(url, data) // 发送JSON数据
```
### JsHttpResponse
@@ -89,9 +89,9 @@ var java;
* @property {function(): JsHttpClient} clearHeaders - 清空所有请求头(保留默认头)
* @property {function(): Object} getHeaders - 获取所有请求头
* @property {function(number): JsHttpClient} setTimeout - 设置请求超时时间(秒)
* @property {function(Object): JsHttpResponse} sendForm - 发送简单表单数据
* @property {function(string, Object): JsHttpResponse} sendForm - 发送简单表单数据
* @property {function(string, Object): JsHttpResponse} sendMultipartForm - 发送multipart表单数据(仅支持文本字段)
* @property {function(any): JsHttpResponse} sendJson - 发送JSON数据
* @property {function(string, any): JsHttpResponse} sendJson - 发送JSON数据
* @property {function(string): string} urlEncode - URL编码(静态方法)
* @property {function(string): string} urlDecode - URL解码(静态方法)
*/
+3 -3
View File
@@ -37,10 +37,10 @@
<!-- 将文件输出设置成异步输出 -->
<appender name="ASYNC-FILE" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUGINFO级别日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 队列剩余 20% 时开始丢弃 TRACE/DEBUG/INFO 级别日志,避免阻塞调用线程 -->
<discardingThreshold>20</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>256</queueSize>
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="FILE"/>
</appender>
@@ -1,6 +1,7 @@
package cn.qaiu.parser;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.util.YeShareHostUtil;
import org.junit.Test;
import java.util.Arrays;
@@ -157,6 +158,26 @@ public class PanDomainTemplateTest {
assertEquals("somekey", m5.group("KEY"));
}
@Test
public void testYeRedirectPatternAndUidDecode() {
Pattern yePattern = PanDomainTemplate.YE.getPattern();
Matcher oldUrl = yePattern.matcher("https://www.123pan.com/s/lN7UVv-pbYJ");
assertTrue("YE should match old 123pan share URL", oldUrl.find());
assertEquals("lN7UVv-pbYJ", oldUrl.group("KEY"));
Matcher redirectedUrl = yePattern.matcher("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ");
assertTrue("YE should match redirected mshare URL", redirectedUrl.find());
assertEquals("lN7UVv-pbYJ", redirectedUrl.group("KEY"));
Matcher htmlUrl = yePattern.matcher("https://www.123278.com/s/lN7UVv-pbYJ.html?pwd=abcd");
assertTrue("YE should match html URL with query", htmlUrl.find());
assertEquals("lN7UVv-pbYJ", htmlUrl.group("KEY"));
assertEquals("1813382308", YeShareHostUtil.getNumericSubdomainIdByShareKey("lN7UVv-pbYJ"));
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ?pwd=abcd"));
}
@Test
public void testLePatternFix() {
Pattern lePattern = PanDomainTemplate.LE.getPattern();
+1 -1
View File
@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.3.4</revision>
<revision>0.3.7</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nfd-web",
"version": "0.2.1",
"version": "0.3.7",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
+54 -11
View File
@@ -511,6 +511,7 @@
files.forEach(file => {
const item = document.createElement('div');
const metaText = fileMetaText(file);
if (file.fileType === 'folder') {
// 文件夹
@@ -520,9 +521,7 @@
<i class="fas fa-folder"></i>
</div>
<div class="item-name">${file.fileName || '未命名文件夹'}</div>
<div class="item-meta">
${file.sizeStr || '0B'} · ${formatDate(file.createTime)}
</div>
${metaText ? `<div class="item-meta">${metaText}</div>` : ''}
`;
folderCount++;
@@ -541,9 +540,7 @@
<i class="fas ${fileTypeInfo.icon}"></i>
</div>
<div class="item-name">${file.fileName}</div>
<div class="item-meta">
${file.sizeStr || '0B'} · ${formatDate(file.createTime)}
</div>
${metaText ? `<div class="item-meta">${metaText}</div>` : ''}
`;
fileCount++;
@@ -675,19 +672,65 @@
renderBreadcrumb();
}
// 文件元信息
function fileMetaText(file) {
const parts = [];
if (file.fileType !== 'folder') {
parts.push(file.sizeStr || '0B');
}
const dateText = formatDate(file.createTime);
if (dateText) {
parts.push(dateText);
}
return parts.join(' · ');
}
function hasValidTime(value) {
if (value === null || value === undefined) return false;
if (typeof value !== 'string') return true;
const trimmedValue = value.trim();
return trimmedValue !== '' && trimmedValue !== 'null' && trimmedValue !== 'undefined';
}
function formatDateOnly(yearValue, monthValue, dayValue) {
const year = Number(yearValue);
const month = Number(monthValue);
const day = Number(dayValue);
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return '';
}
return `${yearValue}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
// 格式化日期
function formatDate(dateString) {
if (!dateString) return '未知日期';
if (!hasValidTime(dateString)) return '';
try {
const date = new Date(dateString);
const value = typeof dateString === 'string' ? dateString.trim() : dateString;
if (typeof value === 'string') {
const dateOnly = value.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
if (dateOnly) {
return formatDateOnly(dateOnly[1], dateOnly[2], dateOnly[3]);
}
const cnDateOnly = value.match(/^(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日$/);
if (cnDateOnly) {
return formatDateOnly(cnDateOnly[1], cnDateOnly[2], cnDateOnly[3]);
}
}
const date = new Date(value);
return isNaN(date.getTime())
? '未知日期'
? ''
: `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
} catch {
return '未知日期';
return '';
}
}
</script>
</body>
</html>
</html>
+67 -8
View File
@@ -32,8 +32,8 @@
<i :class="getFileIcon(file)"></i>
</div>
<div class="file-name">{{ file.fileName }}</div>
<div class="file-meta">
<template v-if="file.fileType !== 'folder'">{{ file.sizeStr || '0B' }} · </template>{{ formatDate(file.createTime) }}
<div v-if="fileMetaText(file)" class="file-meta">
{{ fileMetaText(file) }}
</div>
</div>
<div v-if="!loading && (!currentFileList || currentFileList.length === 0)" class="empty-state">
@@ -168,7 +168,8 @@
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.createTime">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div>
<div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
@@ -244,7 +245,8 @@
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.createTime">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div>
<div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
@@ -314,8 +316,9 @@
<div class="file-dialog-content">
<p><strong>{{ selectedFile?.fileName || '未命名文件' }}</strong></p>
<p class="file-info">
大小: {{ selectedFile?.sizeStr || '0B' }}<br>
创建时间: {{ formatDate(selectedFile?.createTime) }}
<template v-for="(line, index) in selectedFileInfoLines" :key="index">
{{ line }}<br v-if="index < selectedFileInfoLines.length - 1">
</template>
</p>
</div>
@@ -444,6 +447,19 @@ export default {
if (this.batchProgress.failed > 0) return 'exception'
if (this.batchProgress.current >= this.batchProgress.total && this.batchProgress.total > 0) return 'success'
return ''
},
selectedFileInfoLines() {
if (!this.selectedFile) return []
const lines = [`大小: ${this.selectedFile.sizeStr || '0B'}`]
const createTime = this.formatDate(this.selectedFile.createTime)
const updateTime = this.formatDate(this.selectedFile.updateTime)
if (createTime) {
lines.push(`创建时间: ${createTime}`)
}
if (updateTime) {
lines.push(`更新时间: ${updateTime}`)
}
return lines
}
},
watch: {
@@ -815,11 +831,54 @@ export default {
document.body.removeChild(a)
}
},
hasValidTime(value) {
if (value === null || value === undefined) return false
if (typeof value !== 'string') return true
const trimmedValue = value.trim()
return trimmedValue !== '' && trimmedValue !== 'null' && trimmedValue !== 'undefined'
},
formatDateOnly(yearValue, monthValue, dayValue) {
const year = Number(yearValue)
const month = Number(monthValue)
const day = Number(dayValue)
const date = new Date(year, month - 1, day)
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return ''
}
return `${yearValue}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
},
formatDate(timestamp) {
if (!timestamp) return '未知时间'
const date = new Date(timestamp)
if (!this.hasValidTime(timestamp)) return ''
const value = typeof timestamp === 'string' ? timestamp.trim() : timestamp
if (typeof value === 'string') {
const dateOnly = value.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/)
if (dateOnly) {
return this.formatDateOnly(dateOnly[1], dateOnly[2], dateOnly[3])
}
const cnDateOnly = value.match(/^(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日$/)
if (cnDateOnly) {
return this.formatDateOnly(cnDateOnly[1], cnDateOnly[2], cnDateOnly[3])
}
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString('zh-CN')
},
fileMetaText(file) {
const parts = []
if (file.fileType !== 'folder') {
parts.push(file.sizeStr || '0B')
}
const timeText = this.formatDate(file.createTime)
if (timeText) {
parts.push(timeText)
}
return parts.join(' · ')
},
checkTheme() {
this.isDarkTheme = document.documentElement.classList.contains('dark')
},
+2 -2
View File
@@ -238,8 +238,8 @@
storage: 'hash'
},
'ctfile': {
reg: /((?:https?:\/\/)?(?:[a-zA-Z\d-.]+)?(?:ctfile|545c|u062|ghpym|474b)\.com\/\w+\/[a-zA-Z\d-]+)/,
host: /(?:[a-zA-Z\d-.]+)?(?:ctfile|545c|u062|474b)\.com/,
reg: /((?:https?:\/\/)?(?:[a-zA-Z\d-.]+)?(?:ctfile|545c|u062|ghpym|474b)\.com\/(?:f(?:ile)?|d)\/[a-zA-Z\d_-]+\/?(?:\?[^#\s]*)?)/,
host: /(?:[a-zA-Z\d-.]+)?(?:ctfile|545c|u062|ghpym|474b)\.com/,
input: ['#passcode'],
button: ['.card-body button'],
name: '城通网盘',
+6 -6
View File
@@ -83,9 +83,9 @@ function registerTypeDefinitions(monaco) {
clearHeaders(): JsHttpClient;
getHeaders(): Record<string, string>;
setTimeout(seconds: number): JsHttpClient;
sendForm(data: Record<string, any>): JsHttpResponse;
sendForm(url: string, data: Record<string, any>): JsHttpResponse;
sendMultipartForm(url: string, data: Record<string, any>): JsHttpResponse;
sendJson(data: any): JsHttpResponse;
sendJson(url: string, data: any): JsHttpResponse;
urlEncode(str: string): string;
urlDecode(str: string): string;
}
@@ -244,17 +244,17 @@ function registerCompletionProvider(monaco) {
range
},
{
label: 'http.sendForm(data)',
label: 'http.sendForm(url, data)',
kind: monaco.languages.CompletionItemKind.Method,
insertText: 'http.sendForm(${1:data})',
insertText: 'http.sendForm(${1:url}, ${2:data})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: '发送表单数据',
range
},
{
label: 'http.sendJson(data)',
label: 'http.sendJson(url, data)',
kind: monaco.languages.CompletionItemKind.Method,
insertText: 'http.sendJson(${1:data})',
insertText: 'http.sendJson(${1:url}, ${2:data})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: '发送JSON数据',
range
@@ -3,9 +3,11 @@ package cn.qaiu.lz;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.db.pool.JDBCPoolInit;
import cn.qaiu.lz.common.cache.CacheConfigLoader;
import cn.qaiu.lz.common.cache.CacheManager;
import cn.qaiu.lz.common.interceptorImpl.RateLimiter;
import cn.qaiu.lz.web.config.PlaygroundConfig;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.lz.web.service.impl.ShoutServiceImpl;
import cn.qaiu.parser.custom.CustomParserConfig;
import cn.qaiu.parser.custom.CustomParserRegistry;
import cn.qaiu.parser.customjs.JsScriptMetadataParser;
@@ -20,6 +22,7 @@ import io.vertx.core.json.jackson.DatabindCodec;
import io.vertx.core.shareddata.LocalMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.DateFormatUtils;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -38,22 +41,45 @@ import static cn.qaiu.vx.core.util.ConfigConstant.LOCAL;
public class AppMain {
public static void main(String[] args) {
// 先注册 ShutdownHookJVM 逆序执行先注册的后执行
// 确保关闭顺序Vert.x -> JDBCPoolInit -> JsParserExecutor
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
JDBCPoolInit.instance().close();
} catch (Exception e) {
// ignore
}
try {
cn.qaiu.parser.customjs.JsParserExecutor.shutdownExecutor();
} catch (Exception e) {
// ignore
}
}));
applyRuntimeLogLevelOverride();
Deploy deploy = Deploy.instance();
// 先阻断应用级定时任务再让 Vert.x 停入口和 verticle
deploy.addPreShutdownTask(CacheManager::cancelPeriodicCleanup);
deploy.addPreShutdownTask(ShoutServiceImpl::cancelCleanup);
// Vert.x 停完后再关数据库和解析器共享资源避免请求还在路上就先关底层 client
deploy.addPostShutdownTask(() -> JDBCPoolInit.instance().close());
deploy.addPostShutdownTask(cn.qaiu.parser.customjs.JsParserExecutor::shutdownExecutor);
deploy.addPostShutdownTask(cn.qaiu.parser.customjs.JsPlaygroundExecutor::shutdownPools);
deploy.addPostShutdownTask(cn.qaiu.parser.customjs.JsHttpClient::shutdownSharedClient);
deploy.addPostShutdownTask(cn.qaiu.parser.PanBase::shutdownSharedClients);
deploy.addPostShutdownTask(cn.qaiu.parser.IPanTool::shutdownCloseAfterScheduler);
deploy.addPostShutdownTask(cn.qaiu.parser.impl.PodTool::shutdownWorkerExecutor);
// start
Deploy.instance().start(args, AppMain::exec);
deploy.start(args, AppMain::exec);
}
private static void applyRuntimeLogLevelOverride() {
String levelName = System.getProperty("NFD_LOG_LEVEL");
if (levelName == null || levelName.isBlank()) {
levelName = System.getenv("NFD_LOG_LEVEL");
}
if (levelName == null || levelName.isBlank()) {
return;
}
try {
var level = ch.qos.logback.classic.Level.toLevel(levelName, null);
if (level == null) {
log.warn("忽略无效的 NFD_LOG_LEVEL: {}", levelName);
return;
}
var logger = LoggerFactory.getLogger("cn.qaiu");
if (logger instanceof ch.qos.logback.classic.Logger logbackLogger) {
logbackLogger.setLevel(level);
log.info("cn.qaiu 日志级别已覆盖为 {}", level);
}
} catch (Exception e) {
log.warn("覆盖 cn.qaiu 日志级别失败: {}", e.getMessage());
}
}
/**
@@ -65,6 +91,8 @@ public class AppMain {
private static void exec(JsonObject jsonObject) {
WebClientVertxInit.init(VertxHolder.getVertxInstance());
DatabindCodec.mapper().registerModule(new JavaTimeModule());
// 演练场配置要先加载后续启动流程才能按开关决定是否注册动态解析器
PlaygroundConfig.loadFromJson(jsonObject);
// 限流
if (jsonObject.containsKey("rateLimit")) {
JsonObject rateLimit = jsonObject.getJsonObject("rateLimit");
@@ -108,7 +136,12 @@ public class AppMain {
} catch (Exception e) {
log.warn("读取代理配置失败,使用默认页面地址: {}", e.getMessage());
}
loadPlaygroundParsers(pageAddr);
if (PlaygroundConfig.getInstance().isEnabled()) {
loadPlaygroundParsers(pageAddr);
} else {
log.info("演练场功能已禁用,跳过加载演练场解析器");
log.info("服务已启动,可通过 {} 访问页面", pageAddr);
}
System.out.println("启动成功: \n本地服务地址: " + addr);
});
});
@@ -142,9 +175,6 @@ public class AppMain {
JsonObject auths = jsonObject.getJsonObject(ConfigConstant.AUTHS);
localMap.put(ConfigConstant.AUTHS, auths);
}
// 演练场配置
PlaygroundConfig.loadFromJson(jsonObject);
}
/**
@@ -153,34 +183,31 @@ public class AppMain {
private static void loadPlaygroundParsers(String accessAddr) {
DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
dbService.getPlaygroundParserList().onSuccess(result -> {
dbService.getEnabledPlaygroundParsersForLoad().onSuccess(result -> {
JsonArray parsers = result.getJsonArray("data");
if (parsers != null) {
int loadedCount = 0;
for (int i = 0; i < parsers.size(); i++) {
JsonObject parser = parsers.getJsonObject(i);
// 只注册已启用的解析器
if (parser.getBoolean("enabled", false)) {
try {
String jsCode = parser.getString("jsCode");
if (jsCode == null || jsCode.trim().isEmpty()) {
log.error("加载演练场解析器失败: {} - JavaScript代码为空", parser.getString("name"));
continue;
}
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
CustomParserRegistry.register(config);
loadedCount++;
log.info("已加载演练场解析器: {} ({})",
config.getDisplayName(), config.getType());
} catch (Exception e) {
String parserName = parser.getString("name");
String errorMsg = e.getMessage();
log.error("加载演练场解析器失败: {} - {}", parserName, errorMsg, e);
// 如果是require相关错误提供更详细的提示
if (errorMsg != null && errorMsg.contains("require")) {
log.error("提示:演练场解析器不支持CommonJS模块系统(require),请确保代码使用ES5.1语法");
}
try {
String jsCode = parser.getString("jsCode");
if (jsCode == null || jsCode.trim().isEmpty()) {
log.error("加载演练场解析器失败: {} - JavaScript代码为空", parser.getString("name"));
continue;
}
CustomParserConfig config = JsScriptMetadataParser.parseScript(jsCode);
CustomParserRegistry.register(config);
loadedCount++;
log.info("已加载演练场解析器: {} ({})",
config.getDisplayName(), config.getType());
} catch (Exception e) {
String parserName = parser.getString("name");
String errorMsg = e.getMessage();
log.error("加载演练场解析器失败: {} - {}", parserName, errorMsg, e);
// 如果是require相关错误提供更详细的提示
if (errorMsg != null && errorMsg.contains("require")) {
log.error("提示:演练场解析器不支持CommonJS模块系统(require),请确保代码使用ES5.1语法");
}
}
}
@@ -5,8 +5,10 @@ import cn.qaiu.db.pool.JDBCType;
import cn.qaiu.lz.web.model.CacheLinkInfo;
import cn.qaiu.lz.web.model.PanFileInfo;
import cn.qaiu.lz.web.model.PanFileInfoRowMapper;
import cn.qaiu.vx.core.util.VertxHolder;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.Row;
@@ -18,14 +20,24 @@ import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
public class CacheManager {
private static final int MAX_SHARE_KEY_LENGTH = 1024;
private final Pool jdbcPool = JDBCPoolInit.instance().getPool();
private final JDBCType jdbcType = JDBCPoolInit.instance().getType();
private static final Logger LOGGER = LoggerFactory.getLogger(CacheManager.class);
public Future<CacheLinkInfo> get(String cacheKey) {
if (isOversizedShareKey(cacheKey)) {
LOGGER.warn("缓存key过长,跳过缓存读取: length={}, prefix={}",
cacheKey.length(), previewShareKey(cacheKey));
return Future.succeededFuture(new CacheLinkInfo(JsonObject.of("cacheHit", false, "shareKey", cacheKey)));
}
String sql = "SELECT share_key as shareKey, direct_link as directLink, expiration FROM cache_link_info WHERE share_key = #{share_key}";
String sql2 = "SELECT * FROM pan_file_info WHERE share_key = #{share_key}";
Map<String, Object> params = new HashMap<>();
@@ -65,6 +77,17 @@ public class CacheManager {
// 插入或更新缓存数据
public void cacheShareLink(CacheLinkInfo cacheLinkInfo) {
if (cacheLinkInfo == null) {
LOGGER.warn("缓存信息为空,跳过缓存写入");
return;
}
if (isOversizedShareKey(cacheLinkInfo.getShareKey())) {
String shareKey = cacheLinkInfo.getShareKey();
LOGGER.warn("缓存key过长,跳过缓存写入: length={}, prefix={}",
shareKey.length(), previewShareKey(shareKey));
return;
}
String sql;
if (jdbcType == JDBCType.MySQL) {
sql = """
@@ -125,12 +148,19 @@ public class CacheManager {
}
}).onFailure(e -> LOGGER.error("文件信息插入失败", e));
}
});
})
.onFailure(e -> LOGGER.error("查询文件信息缓存失败: shareKey={}", cacheLinkInfo.getShareKey(), e));
}
}
// 写入网盘厂商API解析次数
public Future<Integer> updateTotalByField(String shareKey, CacheTotalField field) {
if (isOversizedShareKey(shareKey)) {
LOGGER.warn("缓存key过长,跳过统计写入: length={}, prefix={}",
shareKey.length(), previewShareKey(shareKey));
return Future.succeededFuture(0);
}
Promise<Integer> promise = Promise.promise();
String fieldLower = field.name().toLowerCase();
String sql;
@@ -179,6 +209,12 @@ public class CacheManager {
}
public Future<Integer> getShareKeyTotal(String shareKey, String name) {
if (isOversizedShareKey(shareKey)) {
LOGGER.warn("缓存key过长,跳过统计读取: length={}, prefix={}",
shareKey.length(), previewShareKey(shareKey));
return Future.succeededFuture(null);
}
String sql = """
SELECT `share_key`, SUM({total_name}) AS sum_num
FROM `api_statistics_info`
@@ -205,21 +241,56 @@ public class CacheManager {
/**
* 清理过期缓存记录防止数据库无限增长
* @return 删除的行数
* 包括
* 1. 清理 cache_link_info 中过期的记录
* 2. 清理 pan_file_info 中孤立的记录对应的 cache_link_info 已被删除
* @return 删除的总行数
*/
public Future<Integer> cleanupExpiredCache() {
String sql = "DELETE FROM cache_link_info WHERE expiration > 0 AND expiration < #{now}";
Map<String, Object> params = new HashMap<>();
params.put("now", System.currentTimeMillis());
Promise<Integer> promise = Promise.promise();
SqlTemplate.forUpdate(jdbcPool, sql)
long now = System.currentTimeMillis();
// 第一步清理 cache_link_info 中过期的记录
String sqlDeleteExpired = "DELETE FROM cache_link_info WHERE expiration > 0 AND expiration < #{now}";
Map<String, Object> params = new HashMap<>();
params.put("now", now);
SqlTemplate.forUpdate(jdbcPool, sqlDeleteExpired)
.execute(params)
.onSuccess(res -> {
int deleted = res.rowCount();
if (deleted > 0) {
LOGGER.info("清理过期缓存记录 {} 条", deleted);
int deletedCache = res.rowCount();
if (deletedCache > 0) {
LOGGER.info("清理过期缓存记录 {} 条", deletedCache);
}
promise.complete(deleted);
// 第二步清理 pan_file_info 中孤立的记录
// 使用 share_key 关联create_time 使用字符串格式比较yyyy-MM-dd HH:mm:ss
String sqlDeleteOrphans = """
DELETE FROM pan_file_info
WHERE share_key NOT IN (
SELECT DISTINCT share_key FROM cache_link_info WHERE share_key IS NOT NULL
)
AND (create_time IS NULL OR create_time < #{thresholdTime})
""";
Map<String, Object> orphanParams = new HashMap<>();
// 计算1天前的时间转换为 yyyy-MM-dd HH:mm:ss 格式
java.time.LocalDateTime thresholdTime = java.time.LocalDateTime.now().minusDays(1);
orphanParams.put("thresholdTime", thresholdTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
SqlTemplate.forUpdate(jdbcPool, sqlDeleteOrphans)
.execute(orphanParams)
.onSuccess(res2 -> {
int deletedOrphans = res2.rowCount();
if (deletedOrphans > 0) {
LOGGER.info("清理孤立文件信息记录 {} 条", deletedOrphans);
}
promise.complete(deletedCache + deletedOrphans);
})
.onFailure(e -> {
LOGGER.warn("清理孤立文件信息记录失败(不影响主流程)", e);
// 即使孤立记录清理失败也返回已删除的缓存记录数
promise.complete(deletedCache);
});
})
.onFailure(e -> {
LOGGER.error("清理过期缓存失败", e);
@@ -232,31 +303,121 @@ public class CacheManager {
* 注册定时清理过期缓存任务每小时执行一次
* 应在应用启动后调用
*/
private static volatile boolean cleanupRegistered = false;
private static final long CLEANUP_INTERVAL_MILLIS = 3600_000L;
private static final long CLEANUP_SHUTDOWN_WAIT_MILLIS = 5_000L;
private static final AtomicBoolean CLEANUP_REGISTERED = new AtomicBoolean(false);
private static final AtomicInteger CLEANUP_IN_FLIGHT = new AtomicInteger(0);
private static final Object CLEANUP_MONITOR = new Object();
private static volatile Long cleanupTimerId;
private static volatile Vertx cleanupVertx;
public static void registerPeriodicCleanup() {
if (cleanupRegistered) return;
if (!CLEANUP_REGISTERED.compareAndSet(false, true)) {
return;
}
try {
io.vertx.core.Vertx vertx = cn.qaiu.vx.core.util.VertxHolder.getVertxInstance();
if (vertx == null) {
LOGGER.warn("Vertx 未就绪,缓存定时清理任务延迟注册");
return;
}
cleanupRegistered = true;
vertx.setPeriodic(3600_000, 3600_000, id -> {
try {
new CacheManager().cleanupExpiredCache();
} catch (Exception e) {
LOGGER.warn("定时清理缓存任务跳过(数据库可能未就绪)", e);
}
});
Vertx vertx = VertxHolder.getVertxInstance();
cleanupVertx = vertx;
cleanupTimerId = vertx.setPeriodic(CLEANUP_INTERVAL_MILLIS, CLEANUP_INTERVAL_MILLIS,
id -> cleanupExpiredCacheSafely());
LOGGER.info("缓存定时清理任务已注册(每小时执行)");
} catch (Exception e) {
cleanupTimerId = null;
cleanupVertx = null;
CLEANUP_REGISTERED.set(false);
LOGGER.warn("注册缓存定时清理任务失败", e);
}
}
public static void cancelPeriodicCleanup() {
Long timerId = cleanupTimerId;
Vertx vertx = cleanupVertx;
cleanupTimerId = null;
cleanupVertx = null;
CLEANUP_REGISTERED.set(false);
if (timerId == null || vertx == null) {
waitForCleanupToFinish();
return;
}
try {
if (vertx.cancelTimer(timerId)) {
LOGGER.info("缓存定时清理任务已取消");
}
} catch (Exception e) {
LOGGER.warn("取消缓存定时清理任务失败", e);
}
waitForCleanupToFinish();
}
private static void cleanupExpiredCacheSafely() {
cleanupStarted();
boolean asyncCleanupStarted = false;
try {
if (!CLEANUP_REGISTERED.get()) {
return;
}
JDBCPoolInit poolInit = JDBCPoolInit.instance();
if (poolInit == null || poolInit.getPool() == null) {
LOGGER.debug("数据库连接池未就绪,跳过缓存定时清理");
return;
}
Future<Integer> cleanupFuture = new CacheManager().cleanupExpiredCache();
asyncCleanupStarted = true;
cleanupFuture.onComplete(ar -> {
if (ar.failed()) {
LOGGER.warn("定时清理缓存失败", ar.cause());
}
cleanupFinished();
});
} catch (Exception e) {
LOGGER.warn("定时清理缓存任务跳过(数据库可能正在关闭)", e);
} finally {
if (!asyncCleanupStarted) {
cleanupFinished();
}
}
}
private static void cleanupStarted() {
CLEANUP_IN_FLIGHT.incrementAndGet();
}
private static void cleanupFinished() {
if (CLEANUP_IN_FLIGHT.decrementAndGet() <= 0) {
synchronized (CLEANUP_MONITOR) {
CLEANUP_MONITOR.notifyAll();
}
}
}
private static void waitForCleanupToFinish() {
long deadline = System.currentTimeMillis() + CLEANUP_SHUTDOWN_WAIT_MILLIS;
synchronized (CLEANUP_MONITOR) {
while (CLEANUP_IN_FLIGHT.get() > 0) {
long waitMillis = deadline - System.currentTimeMillis();
if (waitMillis <= 0) {
LOGGER.warn("等待缓存定时清理结束超时,剩余任务数: {}", CLEANUP_IN_FLIGHT.get());
return;
}
try {
CLEANUP_MONITOR.wait(waitMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOGGER.warn("等待缓存定时清理结束被中断");
return;
}
}
}
}
public Future<Map<String, Integer>> getShareKeyTotal(String shareKey) {
if (isOversizedShareKey(shareKey)) {
LOGGER.warn("缓存key过长,跳过统计读取: length={}, prefix={}",
shareKey.length(), previewShareKey(shareKey));
return Future.succeededFuture(null);
}
String sql = """
SELECT `share_key`, SUM(cache_hit_total) AS hit_total, SUM(api_parser_total) AS parser_total
FROM `api_statistics_info`
@@ -287,4 +448,16 @@ public class CacheManager {
return promise.future();
}
private static boolean isOversizedShareKey(String shareKey) {
return shareKey != null && shareKey.length() > MAX_SHARE_KEY_LENGTH;
}
private static String previewShareKey(String shareKey) {
if (shareKey == null) {
return "";
}
int maxLength = 120;
return shareKey.length() <= maxLength ? shareKey : shareKey.substring(0, maxLength) + "...";
}
}
@@ -9,6 +9,10 @@ import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static cn.qaiu.vx.core.util.ConfigConstant.IGNORES_REG;
import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
@@ -19,7 +23,15 @@ import static io.vertx.core.http.HttpHeaders.CONTENT_TYPE;
@HandleSortFilter(1)
public class DefaultInterceptor implements BeforeInterceptor {
protected final JsonArray ignores = SharedDataUtil.getJsonArrayForCustomConfig(IGNORES_REG);
/** 预编译的忽略路径正则列表,避免每次请求重新编译 */
protected final List<Pattern> ignorePatterns;
public DefaultInterceptor() {
JsonArray ignores = SharedDataUtil.getJsonArrayForCustomConfig(IGNORES_REG);
this.ignorePatterns = ignores.stream()
.map(obj -> Pattern.compile(obj.toString()))
.collect(Collectors.toList());
}
@Override
public void handle(RoutingContext ctx) {
@@ -38,28 +50,30 @@ public class DefaultInterceptor implements BeforeInterceptor {
// limit: 1000
// # 限流的时间窗口(单位秒)
// timeWindow: 60
if (rateLimit.getBoolean("enable")) {
// 获取当前请求的路径
String path = ctx.request().path();
// 正则匹配路径
if (ignores.stream().anyMatch(ignore -> path.matches(ignore.toString()))) {
// 如果匹配到忽略的路径则不进行限流
doNext(ctx);
return;
}
RateLimiter.checkRateLimit(ctx.request())
.onSuccess(v -> {
// 继续执行下一个拦截器
doNext(ctx);
})
.onFailure(t -> {
// 限流失败返回错误响应
log.warn("Rate limit exceeded for path: {}", path);
ctx.response().putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
.setStatusCode(429)
.end(t.getMessage());
});
if (!rateLimit.getBoolean("enable", false)) {
doNext(ctx);
return;
}
// 获取当前请求的路径
String path = ctx.request().path();
// 正则匹配路径
if (ignorePatterns.stream().anyMatch(pattern -> pattern.matcher(path).matches())) {
// 如果匹配到忽略的路径则不进行限流
doNext(ctx);
return;
}
RateLimiter.checkRateLimit(ctx.request())
.onSuccess(v -> {
// 继续执行下一个拦截器
doNext(ctx);
})
.onFailure(t -> {
// 限流失败返回错误响应
log.warn("Rate limit exceeded for path: {}", path);
ctx.response().putHeader(CONTENT_TYPE, "text/html; charset=utf-8")
.setStatusCode(429)
.end(t.getMessage());
});
}
}
@@ -17,12 +17,19 @@ public class RateLimiter {
private static final Map<String, RequestInfo> ipRequestMap = new ConcurrentHashMap<>();
private static int MAX_REQUESTS = 10; // 最大请求次数
private static int MAX_ENTRIES = 10_000;
private static long TIME_WINDOW = 60 * 1000; // 时间窗口毫秒
private static String PATH_REG; // 限流路径正则
private static String PATH_REG = "/.*"; // 限流路径正则默认匹配所有路径
// 上次清理时间
private static volatile long lastCleanupTime = System.currentTimeMillis();
// 清理间隔30秒
private static final long CLEANUP_INTERVAL = 30 * 1000;
public static void init(JsonObject rateLimitConfig) {
MAX_REQUESTS = rateLimitConfig.getInteger("limit", 10);
MAX_ENTRIES = rateLimitConfig.getInteger("maxEntries", 10_000);
TIME_WINDOW = rateLimitConfig.getInteger("timeWindow", 60) * 1000L; // 转换为毫秒
PATH_REG = rateLimitConfig.getString("pathReg", "/.*");
log.info("RateLimiter initialized with max requests: {}, time window: {} ms, path regex: {}",
@@ -39,28 +46,37 @@ public class RateLimiter {
String ip = request.remoteAddress().host();
// 定期清理过期条目防止 Map 无限增长
if (ipRequestMap.size() > 1000) {
long now = System.currentTimeMillis();
ipRequestMap.entrySet().removeIf(entry -> now - entry.getValue().timestamp > TIME_WINDOW);
// 基于时间间隔的清理策略避免 Map 无限增长
long now = System.currentTimeMillis();
if (now - lastCleanupTime > CLEANUP_INTERVAL) {
cleanupExpiredEntries(now, false);
}
RequestInfo info = ipRequestMap.compute(ip, (key, requestInfo) -> {
long currentTime = System.currentTimeMillis();
if (requestInfo == null || currentTime - requestInfo.timestamp > TIME_WINDOW) {
// 初始化或重置计数器
return new RequestInfo(1, currentTime);
} else {
// 增加计数器
requestInfo.count.incrementAndGet();
return requestInfo;
RequestInfo info;
synchronized (RateLimiter.class) {
if (!ipRequestMap.containsKey(ip) && ipRequestMap.size() >= MAX_ENTRIES) {
cleanupExpiredEntries(now, true);
if (ipRequestMap.size() >= MAX_ENTRIES) {
promise.fail("限流记录过多,请稍后再试。");
return promise.future();
}
}
});
info = ipRequestMap.compute(ip, (key, requestInfo) -> {
if (requestInfo == null || now - requestInfo.timestamp > TIME_WINDOW) {
// 初始化或重置计数器
return new RequestInfo(1, now);
} else {
// 增加计数器
requestInfo.count.incrementAndGet();
return requestInfo;
}
});
}
if (info.count.get() > MAX_REQUESTS) {
// 超过限制
// 计算剩余时间
long remainingTime = TIME_WINDOW - (System.currentTimeMillis() - info.timestamp);
long remainingTime = TIME_WINDOW - (now - info.timestamp);
BigDecimal bigDecimal = BigDecimal.valueOf(remainingTime / 1000.0)
.setScale(2, RoundingMode.HALF_UP);
promise.fail("请求次数太多了,请" + bigDecimal + "秒后再试。");
@@ -71,6 +87,27 @@ public class RateLimiter {
return promise.future();
}
/**
* 清理过期的限流条目
* 使用 synchronized 避免并发清理
*/
private static synchronized void cleanupExpiredEntries(long now, boolean force) {
// 双重检查避免重复清理
if (!force && now - lastCleanupTime <= CLEANUP_INTERVAL) {
return;
}
lastCleanupTime = now;
int sizeBefore = ipRequestMap.size();
if (sizeBefore > 0) {
ipRequestMap.entrySet().removeIf(entry -> now - entry.getValue().timestamp > TIME_WINDOW);
int sizeAfter = ipRequestMap.size();
if (sizeBefore > 100 || sizeAfter != sizeBefore) {
log.debug("RateLimiter 清理过期条目: {} -> {}", sizeBefore, sizeAfter);
}
}
}
private static class RequestInfo {
final AtomicInteger count;
volatile long timestamp;
@@ -19,6 +19,7 @@ public class PlaygroundConfig {
/** Token有效期:24小时 */
private static final long TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000L;
private static final int MAX_TOKEN_COUNT = 1024;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
@@ -59,10 +60,10 @@ public class PlaygroundConfig {
/**
* 生成并存储一个新的认证Token同时清理过期Token
*/
public String generateToken() {
// 清理过期Token防止Map无限增长
public synchronized String generateToken() {
long now = System.currentTimeMillis();
validTokens.entrySet().removeIf(e -> now - e.getValue() > TOKEN_EXPIRY_MS);
cleanupExpiredTokens(now);
evictOldestTokensIfNeeded();
// 使用SecureRandom生成32字节的密码学安全Token
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);
@@ -78,16 +79,39 @@ public class PlaygroundConfig {
/**
* 校验Token是否合法且未过期
*/
public boolean validateToken(String token) {
public synchronized boolean validateToken(String token) {
if (token == null || token.isEmpty()) return false;
long now = System.currentTimeMillis();
cleanupExpiredTokens(now);
Long createdAt = validTokens.get(token);
if (createdAt == null) return false;
if (System.currentTimeMillis() - createdAt > TOKEN_EXPIRY_MS) {
if (now - createdAt > TOKEN_EXPIRY_MS) {
validTokens.remove(token);
return false;
}
return true;
}
private void cleanupExpiredTokens(long now) {
validTokens.entrySet().removeIf(e -> now - e.getValue() > TOKEN_EXPIRY_MS);
}
private void evictOldestTokensIfNeeded() {
while (validTokens.size() >= MAX_TOKEN_COUNT) {
String oldestToken = null;
long oldestTime = Long.MAX_VALUE;
for (Map.Entry<String, Long> entry : validTokens.entrySet()) {
if (entry.getValue() < oldestTime) {
oldestTime = entry.getValue();
oldestToken = entry.getKey();
}
}
if (oldestToken == null) {
return;
}
validTokens.remove(oldestToken);
}
}
/**
* 获取单例实例
@@ -125,5 +149,13 @@ public class PlaygroundConfig {
} else {
log.info("未找到playground配置,使用默认值: enabled=false, public=false");
}
String enabledOverride = System.getProperty("NFD_PLAYGROUND_ENABLED");
if (enabledOverride == null || enabledOverride.isBlank()) {
enabledOverride = System.getenv("NFD_PLAYGROUND_ENABLED");
}
if (enabledOverride != null && !enabledOverride.isBlank()) {
cfg.enabled = Boolean.parseBoolean(enabledOverride);
log.info("Playground enabled 已被 NFD_PLAYGROUND_ENABLED 覆盖为 {}", cfg.enabled);
}
}
}
@@ -13,6 +13,7 @@ import cn.qaiu.lz.web.model.LinkInfoResp;
import cn.qaiu.lz.web.model.StatisticsInfo;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.IPanTool;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.vx.core.annotaions.RouteHandler;
@@ -76,11 +77,30 @@ public class ParserApi {
private static final CacheManager cacheManager = new CacheManager();
private static final ServerApi serverApi = new ServerApi();
@RouteMapping(value = "/check/:type/:key", method = RouteMethod.GET)
public void check(HttpServerResponse response, String type, String key) {
response.putHeader("Content-Type", "text/plain; charset=utf-8")
.setStatusCode(200)
.end("ok");
}
@RouteMapping(value = "/check/:type/:key", method = RouteMethod.HEAD)
public void checkHead(HttpServerResponse response, String type, String key) {
response.putHeader("Content-Type", "text/plain; charset=utf-8")
.setStatusCode(200)
.end();
}
@RouteMapping(value = "/linkInfo", method = RouteMethod.GET)
public Future<LinkInfoResp> parse(HttpServerRequest request, String pwd, String auth) {
Promise<LinkInfoResp> promise = Promise.promise();
String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate = ParserCreate.fromShareUrl(url).setShareLinkInfoPwd(pwd);
ParserCreate parserCreate;
try {
parserCreate = ParserCreate.fromShareUrl(url).setShareLinkInfoPwd(pwd);
} catch (Exception e) {
return Future.failedFuture(e);
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
// 构建链接信息响应如果有 auth 参数则附加到链接中
@@ -141,7 +161,12 @@ public class ParserApi {
@RouteMapping("/getFileList")
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid) {
String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate = ParserCreate.fromShareUrl(url).setShareLinkInfoPwd(pwd);
ParserCreate parserCreate;
try {
parserCreate = ParserCreate.fromShareUrl(url).setShareLinkInfoPwd(pwd);
} catch (Exception e) {
return Future.failedFuture(e);
}
String linkPrefix = getLinkPrefix(request);
parserCreate.getShareLinkInfo().getOtherParam().put("domainName", linkPrefix);
parserCreate.getShareLinkInfo().getOtherParam().put("_requestOrigin", linkPrefix);
@@ -151,7 +176,8 @@ public class ParserApi {
if (StringUtils.isNotBlank(uuid)) {
parserCreate.getShareLinkInfo().getOtherParam().put("uuid", uuid);
}
return parserCreate.createTool().parseFileList();
IPanTool tool = parserCreate.createTool();
return IPanTool.closeAfter(tool, tool::parseFileList);
}
// 目录解析下载文件
@@ -174,7 +200,8 @@ public class ParserApi {
String linkPrefix = getLinkPrefix(request);
shareLinkInfo.getOtherParam().put("domainName", linkPrefix);
shareLinkInfo.getOtherParam().put("_requestOrigin", linkPrefix);
return parserCreate.createTool().parseById();
IPanTool tool = parserCreate.createTool();
return IPanTool.closeAfter(tool, tool::parseById);
}
@RouteMapping("/redirectUrl/:type/:param")
@@ -183,10 +210,9 @@ public class ParserApi {
getFileDownUrl(request, type, param)
.onSuccess(res -> {
ResponseUtil.redirect(response, res);
promise.complete();
ResponseUtil.redirect(response, res, promise);
})
.onFailure(t -> promise.fail(t.fillInStackTrace()));
.onFailure(promise::tryFail);
return promise.future();
}
@@ -212,7 +238,7 @@ public class ParserApi {
}
String previewURL = SharedDataUtil.getJsonStringForServerConfig("previewURL");
serverApi.parseKeyJson(request, type, key).onSuccess(res -> {
serverApi.parseKeyJsonForRedirect(request, type, key).onSuccess(res -> {
redirect(response, previewURL, res);
}).onFailure(e -> {
ResponseUtil.fireJsonResultResponse(response, JsonResult.error(e.toString()));
@@ -248,7 +274,7 @@ public class ParserApi {
}
String previewURL = SharedDataUtil.getJsonStringForServerConfig("previewURL");
serverApi.parseJson(request, pwd, null).onSuccess(res -> {
serverApi.parseJsonForRedirect(request, pwd, null).onSuccess(res -> {
redirect(response, previewURL, res);
}).onFailure(e -> {
ResponseUtil.fireJsonResultResponse(response, JsonResult.error(e.toString()));
@@ -264,10 +290,9 @@ public class ParserApi {
getFileDownUrl(request, type, param)
.onSuccess(res -> {
String url = viewPrefix + URLEncoder.encode(res, StandardCharsets.UTF_8);
ResponseUtil.redirect(response, url);
promise.complete();
ResponseUtil.redirect(response, url, promise);
})
.onFailure(t -> promise.fail(t.fillInStackTrace()));
.onFailure(promise::tryFail);
return promise.future();
}
@@ -320,7 +345,8 @@ public class ParserApi {
}
// 使用默认方法解析并生成客户端链接
parserCreate.createTool().parseWithClientLinks()
IPanTool tool = parserCreate.createTool();
IPanTool.closeAfter(tool, tool::parseWithClientLinks)
.onSuccess(clientLinks -> {
try {
ClientLinkResp response = buildClientLinkResponse(shareLinkInfo, clientLinks);
@@ -362,7 +388,8 @@ public class ParserApi {
URLParamUtil.addParam(parserCreate);
// 使用默认方法解析并生成客户端链接
parserCreate.createTool().parseWithClientLinks()
IPanTool tool = parserCreate.createTool();
IPanTool.closeAfter(tool, tool::parseWithClientLinks)
.onSuccess(clientLinks -> {
try {
String clientLink = extractClientLinkByType(clientLinks, clientType);
@@ -123,6 +123,10 @@ public class PlaygroundApi {
// 获取密码
JsonObject body = ctx.body().asJsonObject();
if (body == null) {
promise.complete(JsonResult.error("请求体不能为空").toJsonObject());
return promise.future();
}
String password = body.getString("password");
if (StringUtils.isBlank(password)) {
@@ -249,82 +253,84 @@ public class PlaygroundApi {
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
// 创建演练场执行器
JsPlaygroundExecutor executor = new JsPlaygroundExecutor(shareLinkInfo, jsCode);
final JsPlaygroundExecutor executor = new JsPlaygroundExecutor(shareLinkInfo, jsCode);
// 根据方法类型选择执行并异步处理结果
Future<Object> executionFuture;
switch (method) {
case "parse":
executionFuture = executor.executeParseAsync().map(r -> (Object) r);
break;
case "parseFileList":
executionFuture = executor.executeParseFileListAsync().map(r -> (Object) r);
break;
case "parseById":
executionFuture = executor.executeParseByIdAsync().map(r -> (Object) r);
break;
default:
promise.fail(new IllegalArgumentException("未知的方法类型: " + method));
return promise.future();
try {
switch (method) {
case "parse":
executionFuture = executor.executeParseAsync().map(r -> (Object) r);
break;
case "parseFileList":
executionFuture = executor.executeParseFileListAsync().map(r -> (Object) r);
break;
case "parseById":
executionFuture = executor.executeParseByIdAsync().map(r -> (Object) r);
break;
default:
executor.close();
promise.fail(new IllegalArgumentException("未知的方法类型: " + method));
return promise.future();
}
} catch (Exception ex) {
// 同步异常路径executor 已创建但 Future 未注册需要手动关闭
executor.close();
throw ex;
}
// 异步处理执行结果
executionFuture.onSuccess(result -> {
log.debug("执行成功,结果类型: {}, 结果值: {}",
result != null ? result.getClass().getSimpleName() : "null",
result);
// 获取日志
List<JsPlaygroundLogger.LogEntry> logEntries = executor.getLogs();
log.debug("获取到 {} 条日志记录", logEntries.size());
List<PlaygroundTestResp.LogEntry> respLogs = logEntries.stream()
.map(entry -> PlaygroundTestResp.LogEntry.builder()
.level(entry.getLevel())
.message(entry.getMessage())
.timestamp(entry.getTimestamp())
.source(entry.getSource()) // 使用日志条目的来源标识
.build())
.collect(Collectors.toList());
// 异步处理执行结果finally 中统一释放 executor避免响应构建异常导致资源泄漏
executionFuture.onComplete(ar -> {
try {
long executionTime = System.currentTimeMillis() - startTime;
List<JsPlaygroundLogger.LogEntry> logEntries = executor.getLogs();
List<PlaygroundTestResp.LogEntry> respLogs = logEntries.stream()
.map(entry -> PlaygroundTestResp.LogEntry.builder()
.level(entry.getLevel())
.message(entry.getMessage())
.timestamp(entry.getTimestamp())
.source(entry.getSource())
.build())
.collect(Collectors.toList());
long executionTime = System.currentTimeMillis() - startTime;
if (ar.succeeded()) {
Object result = ar.result();
log.debug("执行成功,结果类型: {}, 结果摘要: {}",
result != null ? result.getClass().getSimpleName() : "null",
summarizeResult(result));
// 构建响应
PlaygroundTestResp response = PlaygroundTestResp.builder()
.success(true)
.result(result)
.logs(respLogs)
.executionTime(executionTime)
.build();
PlaygroundTestResp response = PlaygroundTestResp.builder()
.success(true)
.result(result)
.logs(respLogs)
.executionTime(executionTime)
.build();
JsonObject jsonResponse = JsonObject.mapFrom(response);
log.debug("测试成功响应: {}", jsonResponse.encodePrettily());
promise.complete(jsonResponse);
}).onFailure(e -> {
long executionTime = System.currentTimeMillis() - startTime;
String errorMessage = e.getMessage();
log.debug("测试成功响应: success=true, logCount={}", respLogs.size());
promise.complete(JsonObject.mapFrom(response));
} else {
Throwable e = ar.cause();
String errorMessage = e == null ? "执行失败" : e.getMessage();
log.error("演练场执行失败", e);
log.error("演练场执行失败", e);
PlaygroundTestResp response = PlaygroundTestResp.builder()
.success(false)
.error(errorMessage)
.executionTime(executionTime)
.logs(respLogs)
.build();
// 尝试获取已有的日志
List<JsPlaygroundLogger.LogEntry> logEntries = executor.getLogs();
List<PlaygroundTestResp.LogEntry> respLogs = logEntries.stream()
.map(entry -> PlaygroundTestResp.LogEntry.builder()
.level(entry.getLevel())
.message(entry.getMessage())
.timestamp(entry.getTimestamp())
.source(entry.getSource()) // 使用日志条目的来源标识
.build())
.collect(Collectors.toList());
PlaygroundTestResp response = PlaygroundTestResp.builder()
.success(false)
.error(errorMessage)
.executionTime(executionTime)
.logs(respLogs)
.build();
promise.complete(JsonObject.mapFrom(response));
promise.complete(JsonObject.mapFrom(response));
}
} catch (Exception e) {
log.error("构建演练场响应失败", e);
promise.tryComplete(JsonObject.mapFrom(PlaygroundTestResp.builder()
.success(false)
.error("构建响应失败: " + e.getMessage())
.build()));
} finally {
executor.close();
}
});
} catch (Exception e) {
@@ -462,19 +468,7 @@ public class PlaygroundApi {
}
// 检查type是否已存在
dbService.getPlaygroundParserList().onSuccess(listResult -> {
var list = listResult.getJsonArray("data");
boolean exists = false;
if (list != null) {
for (int i = 0; i < list.size(); i++) {
var item = list.getJsonObject(i);
if (type.equals(item.getString("type"))) {
exists = true;
break;
}
}
}
dbService.playgroundParserTypeExists(type, null).onSuccess(exists -> {
if (exists) {
promise.complete(JsonResult.error("解析器类型 " + type + " 已存在,请使用其他类型标识").toJsonObject());
return;
@@ -493,25 +487,29 @@ public class PlaygroundApi {
parser.put("ip", getClientIp(ctx.request()));
parser.put("enabled", true);
try {
CustomParserRegistry.register(config);
} catch (Exception e) {
log.error("注册解析器失败", e);
promise.complete(JsonResult.error("保存失败,注册解析器失败: " + e.getMessage()).toJsonObject());
return;
}
dbService.savePlaygroundParser(parser).onSuccess(result -> {
// 保存成功后立即注册到解析器系统
try {
CustomParserRegistry.register(config);
log.info("已注册演练场解析器: {} ({})", displayName, type);
promise.complete(JsonResult.success("保存并注册成功").toJsonObject());
} catch (Exception e) {
log.error("注册解析器失败", e);
// 虽然注册失败但保存成功了返回警告
promise.complete(JsonResult.success(
"保存成功,但注册失败(重启服务后会自动加载): " + e.getMessage()
).toJsonObject());
if (!result.getBoolean("success", false)) {
CustomParserRegistry.unregister(type);
promise.complete(JsonResult.error(result.getString("msg", "保存失败")).toJsonObject());
return;
}
log.info("已注册演练场解析器: {} ({})", displayName, type);
promise.complete(JsonResult.success("保存并注册成功").toJsonObject());
}).onFailure(e -> {
CustomParserRegistry.unregister(type);
log.error("保存解析器失败", e);
promise.complete(JsonResult.error("保存失败: " + e.getMessage()).toJsonObject());
});
}).onFailure(e -> {
log.error("获取解析器列表失败", e);
log.error("检查解析器类型失败", e);
promise.complete(JsonResult.error("检查解析器失败: " + e.getMessage()).toJsonObject());
});
}).onFailure(e -> {
@@ -557,6 +555,11 @@ public class PlaygroundApi {
return promise.future();
}
if (jsCode.length() > MAX_CODE_LENGTH) {
promise.complete(JsonResult.error("代码长度超过限制(最大128KB),当前长度: " + jsCode.length() + " 字节").toJsonObject());
return promise.future();
}
// 解析元数据
try {
var config = JsScriptMetadataParser.parseScript(jsCode);
@@ -570,6 +573,7 @@ public class PlaygroundApi {
boolean enabled = body.getBoolean("enabled", true);
JsonObject parser = new JsonObject();
parser.put("type", type);
parser.put("name", name);
parser.put("displayName", displayName);
parser.put("description", description);
@@ -579,29 +583,73 @@ public class PlaygroundApi {
parser.put("jsCode", jsCode);
parser.put("enabled", enabled);
dbService.updatePlaygroundParser(id, parser).onSuccess(result -> {
// 更新成功后重新注册解析器
try {
if (enabled) {
// 先注销旧的如果存在
CustomParserRegistry.unregister(type);
// 重新注册新的
CustomParserRegistry.register(config);
log.info("已重新注册演练场解析器: {} ({})", displayName, type);
} else {
// 禁用时注销
CustomParserRegistry.unregister(type);
log.info("已注销演练场解析器: {}", type);
}
promise.complete(JsonResult.success("更新并重新注册成功").toJsonObject());
} catch (Exception e) {
log.error("重新注册解析器失败", e);
promise.complete(JsonResult.success(
"更新成功,但注册失败(重启服务后会自动加载): " + e.getMessage()
).toJsonObject());
dbService.getPlaygroundParserById(id).onSuccess(oldResult -> {
String oldType = null;
if (oldResult.getBoolean("success", false) && oldResult.getJsonObject("data") != null) {
oldType = oldResult.getJsonObject("data").getString("type");
} else {
promise.complete(JsonResult.error("解析器不存在").toJsonObject());
return;
}
final String oldRegisteredType = oldType;
dbService.playgroundParserTypeExists(type, id).onSuccess(exists -> {
if (exists) {
promise.complete(JsonResult.error("解析器类型 " + type + " 已被其他解析器使用").toJsonObject());
return;
}
var oldConfig = CustomParserRegistry.get(oldRegisteredType);
boolean removedOldType = false;
boolean registeredNewType = false;
try {
if (StringUtils.isNotBlank(oldRegisteredType)) {
removedOldType = CustomParserRegistry.unregister(oldRegisteredType);
}
if (enabled) {
CustomParserRegistry.register(config);
registeredNewType = true;
}
} catch (Exception e) {
if (removedOldType && oldConfig != null) {
try {
CustomParserRegistry.register(oldConfig);
} catch (Exception restoreError) {
log.warn("恢复旧解析器注册失败: {}", oldRegisteredType, restoreError);
}
}
log.error("预注册解析器失败", e);
promise.complete(JsonResult.error("更新失败,注册解析器失败: " + e.getMessage()).toJsonObject());
return;
}
final boolean shouldRollbackNewType = registeredNewType;
final boolean shouldRestoreOldType = removedOldType;
final var oldParserConfig = oldConfig;
dbService.updatePlaygroundParser(id, parser).onSuccess(result -> {
if (!result.getBoolean("success", false)) {
rollbackParserRegistration(type, oldRegisteredType, shouldRollbackNewType,
shouldRestoreOldType, oldParserConfig);
promise.complete(JsonResult.error(result.getString("msg", "更新失败")).toJsonObject());
return;
}
if (!enabled) {
log.info("已注销演练场解析器: {}", oldRegisteredType);
} else {
log.info("已重新注册演练场解析器: {} ({})", displayName, type);
}
promise.complete(JsonResult.success("更新并重新注册成功").toJsonObject());
}).onFailure(e -> {
rollbackParserRegistration(type, oldRegisteredType, shouldRollbackNewType,
shouldRestoreOldType, oldParserConfig);
log.error("更新解析器失败", e);
promise.complete(JsonResult.error("更新失败: " + e.getMessage()).toJsonObject());
});
}).onFailure(e -> {
log.error("检查解析器类型失败", e);
promise.complete(JsonResult.error("更新失败: " + e.getMessage()).toJsonObject());
});
}).onFailure(e -> {
log.error("更新解析器失败", e);
log.error("获取旧解析器信息失败", e);
promise.complete(JsonResult.error("更新失败: " + e.getMessage()).toJsonObject());
});
@@ -617,6 +665,24 @@ public class PlaygroundApi {
return promise.future();
}
private void rollbackParserRegistration(String newType, String oldType, boolean rollbackNewType,
boolean restoreOldType, cn.qaiu.parser.custom.CustomParserConfig oldConfig) {
if (rollbackNewType) {
try {
CustomParserRegistry.unregister(newType);
} catch (Exception rollbackError) {
log.warn("回滚新解析器注册失败: {}", newType, rollbackError);
}
}
if (restoreOldType && oldConfig != null) {
try {
CustomParserRegistry.register(oldConfig);
} catch (Exception restoreError) {
log.warn("恢复旧解析器注册失败: {}", oldType, restoreError);
}
}
}
/**
* 删除解析器
*/
@@ -695,5 +761,18 @@ public class PlaygroundApi {
}
return ip;
}
private String summarizeResult(Object result) {
if (result == null) {
return "null";
}
if (result instanceof String str) {
return "String(length=" + str.length() + ")";
}
if (result instanceof List<?> list) {
return "List(size=" + list.size() + ")";
}
return result.getClass().getSimpleName();
}
}
@@ -29,6 +29,8 @@ import lombok.extern.slf4j.Slf4j;
@RouteHandler("/")
public class ServerApi {
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
private final CacheService cacheService = AsyncServiceUtil.getAsyncServiceInstance(CacheService.class);
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
@@ -38,16 +40,15 @@ public class ServerApi {
String url = URLParamUtil.parserParams(request);
// 构建 otherParam包含 UA 和解码后的认证参数
JsonObject otherParam = buildOtherParam(request, auth);
JsonObject otherParam = buildOtherParam(request, auth, true);
cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam)
.onSuccess(res -> ResponseUtil.redirect(
response.putHeader("nfd-cache-hit", res.getCacheHit().toString())
.putHeader("nfd-cache-expires", res.getExpires()),
addCacheHeaders(response, res),
res.getDirectLink(), promise))
.onFailure(t -> {
recordDonatedAccountFailureIfNeeded(otherParam, t);
promise.fail(t.fillInStackTrace());
promise.tryFail(t);
});
return promise.future();
}
@@ -60,6 +61,13 @@ public class ServerApi {
.onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t));
}
public Future<CacheLinkInfo> parseJsonForRedirect(HttpServerRequest request, String pwd, String auth) {
String url = URLParamUtil.parserParams(request);
JsonObject otherParam = buildOtherParam(request, auth, true);
return cacheService.getCachedByShareUrlAndPwd(url, pwd, otherParam)
.onFailure(t -> recordDonatedAccountFailureIfNeeded(otherParam, t));
}
@RouteMapping(value = "/json/:type/:key", method = RouteMethod.GET)
public Future<CacheLinkInfo> parseKeyJson(HttpServerRequest request, String type, String key) {
String pwd = "";
@@ -72,6 +80,18 @@ public class ServerApi {
return cacheService.getCachedByShareKeyAndPwd(type, key, pwd, JsonObject.of("UA",request.headers().get("user-agent"), "_requestOrigin", origin));
}
public Future<CacheLinkInfo> parseKeyJsonForRedirect(HttpServerRequest request, String type, String key) {
String pwd = "";
if (key.contains("@")) {
String[] keys = key.split("@");
key = keys[0];
pwd = keys[1];
}
String origin = resolveOrigin(request);
return cacheService.getCachedByShareKeyAndPwd(type, key, pwd,
JsonObject.of("UA", request.headers().get("user-agent"), "_requestOrigin", origin, SKIP_CLIENT_LINKS, true));
}
@RouteMapping(value = "/:type/:key", method = RouteMethod.GET)
public Future<Void> parseKey(HttpServerResponse response, HttpServerRequest request, String type, String key) {
Promise<Void> promise = Promise.promise();
@@ -82,15 +102,23 @@ public class ServerApi {
pwd = keys[1];
}
String origin = resolveOrigin(request);
cacheService.getCachedByShareKeyAndPwd(type, key, pwd, JsonObject.of("UA",request.headers().get("user-agent"), "_requestOrigin", origin))
cacheService.getCachedByShareKeyAndPwd(type, key, pwd,
JsonObject.of("UA", request.headers().get("user-agent"), "_requestOrigin", origin, SKIP_CLIENT_LINKS, true))
.onSuccess(res -> ResponseUtil.redirect(
response.putHeader("nfd-cache-hit", res.getCacheHit().toString())
.putHeader("nfd-cache-expires", res.getExpires()),
addCacheHeaders(response, res),
res.getDirectLink(), promise))
.onFailure(t -> promise.fail(t.fillInStackTrace()));
.onFailure(promise::tryFail);
return promise.future();
}
private static HttpServerResponse addCacheHeaders(HttpServerResponse response, CacheLinkInfo cacheLinkInfo) {
if (response.ended() || response.closed()) {
return response;
}
return response.putHeader("nfd-cache-hit", cacheLinkInfo.getCacheHit().toString())
.putHeader("nfd-cache-expires", cacheLinkInfo.getExpires());
}
/**
* 解析请求来源地址支持反向代理
*/
@@ -114,7 +142,14 @@ public class ServerApi {
* @return JsonObject
*/
private JsonObject buildOtherParam(HttpServerRequest request, String auth) {
return buildOtherParam(request, auth, false);
}
private JsonObject buildOtherParam(HttpServerRequest request, String auth, boolean skipClientLinks) {
JsonObject otherParam = JsonObject.of("UA", request.headers().get("user-agent"), "_requestOrigin", resolveOrigin(request));
if (skipClientLinks) {
otherParam.put(SKIP_CLIENT_LINKS, true);
}
// 解码认证参数
if (auth != null && !auth.isEmpty()) {
@@ -25,6 +25,11 @@ public interface DbService extends BaseAsyncService {
*/
Future<JsonObject> getPlaygroundParserList();
/**
* 获取启动时需要注册的已启用演练场解析器
*/
Future<JsonObject> getEnabledPlaygroundParsersForLoad();
/**
* 保存演练场解析器
*/
@@ -45,6 +50,11 @@ public interface DbService extends BaseAsyncService {
*/
Future<Integer> getPlaygroundParserCount();
/**
* 检查演练场解析器类型是否已存在
*/
Future<Boolean> playgroundParserTypeExists(String type, Long excludeId);
/**
* 根据ID获取演练场解析器
*/
@@ -27,6 +27,8 @@ import java.util.Map;
@Slf4j
public class CacheServiceImpl implements CacheService {
private static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
private final CacheManager cacheManager = new CacheManager();
static {
@@ -62,10 +64,14 @@ public class CacheServiceImpl implements CacheService {
try {
tool = parserCreate.createTool();
} catch (Exception e) {
promise.fail(e.getCause().getCause());
Throwable cause = e;
while (cause.getCause() != null) {
cause = cause.getCause();
}
promise.fail(cause);
return;
}
tool.parse().onSuccess(redirectUrl -> {
IPanTool.closeAfter(tool, tool::parse).onSuccess(redirectUrl -> {
// 使用 effectiveCacheDuration
long expires = System.currentTimeMillis() + effectiveCacheDuration * 60 * 1000L;
result.setDirectLink(redirectUrl);
@@ -74,7 +80,7 @@ public class CacheServiceImpl implements CacheService {
result.setExpires(generateDate(expires));
// 调试日志检查解析器返回的otherParam
log.info("[解析完成] shareKey={}, otherParam.keys={}, hasFileInfo={}",
log.debug("[解析完成] shareKey={}, otherParam.keys={}, hasFileInfo={}",
cacheKey,
shareLinkInfo.getOtherParam().keySet(),
shareLinkInfo.getOtherParam().containsKey("fileInfo"));
@@ -90,17 +96,19 @@ public class CacheServiceImpl implements CacheService {
FileInfo fileInfo = (FileInfo) shareLinkInfo.getOtherParam().get("fileInfo");
result.setFileInfo(fileInfo);
cacheLinkInfo.setFileInfo(fileInfo);
log.info("[设置文件信息] shareKey={}, fileName={}, size={}",
log.debug("[设置文件信息] shareKey={}, fileName={}, size={}",
cacheKey, fileInfo.getFileName(), fileInfo.getSize());
} catch (Exception e) {
log.error("文件对象转换异常: shareKey={}", cacheKey, e);
}
} else {
log.warn("[文件信息缺失] 解析器未返回fileInfo: shareKey={}, otherParam.keys={}",
log.debug("[文件信息缺失] 解析器未返回fileInfo: shareKey={}, otherParam.keys={}",
cacheKey, shareLinkInfo.getOtherParam().keySet());
}
// 传递 downloadHeaders 并生成下载命令
processDownloadHeaders(shareLinkInfo, cacheLinkInfo, result);
if (shouldGenerateClientLinks(shareLinkInfo)) {
// 传递 downloadHeaders 并生成下载命令
processDownloadHeaders(shareLinkInfo, cacheLinkInfo, result);
}
promise.complete(result);
// 更新缓存
cacheManager.cacheShareLink(cacheLinkInfo);
@@ -110,19 +118,21 @@ public class CacheServiceImpl implements CacheService {
// 缓存命中生成过期时间并生成下载命令
result.setExpires(generateDate(result.getExpiration()));
// 初始化 otherParam如果为空
if (result.getOtherParam() == null) {
result.setOtherParam(new HashMap<>());
if (shouldGenerateClientLinks(shareLinkInfo)) {
// 初始化 otherParam如果为空
if (result.getOtherParam() == null) {
result.setOtherParam(new HashMap<>());
}
// 生成下载命令aria2curl
generateDownloadCommands(result);
}
// 生成下载命令aria2curl
generateDownloadCommands(result);
promise.complete(result);
cacheManager.updateTotalByField(cacheKey, CacheTotalField.CACHE_HIT_TOTAL)
.onFailure(e -> log.error("更新缓存命中计数失败: cacheKey={}", cacheKey, e));
}
}).onFailure(t -> promise.fail(t.fillInStackTrace()));
}).onFailure(promise::tryFail);
return promise.future();
}
@@ -131,6 +141,13 @@ public class CacheServiceImpl implements CacheService {
return DateFormatUtils.format(new Date(ts), "yyyy-MM-dd HH:mm:ss");
}
private boolean shouldGenerateClientLinks(ShareLinkInfo shareLinkInfo) {
if (shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
return true;
}
return !Boolean.TRUE.equals(shareLinkInfo.getOtherParam().get(SKIP_CLIENT_LINKS));
}
/**
* 处理下载请求头并生成下载命令
* shareLinkInfo 中提取 downloadHeaders传递到 cacheLinkInfo result
@@ -147,7 +164,7 @@ public class CacheServiceImpl implements CacheService {
Map<String, String> headers = (Map<String, String>) shareLinkInfo.getOtherParam().get("downloadHeaders");
if (headers != null) {
downloadHeaders = headers;
log.info("从shareLinkInfo提取downloadHeaders: shareKey={}, 请求头数量={}",
log.debug("从shareLinkInfo提取downloadHeaders: shareKey={}, 请求头数量={}",
cacheLinkInfo.getShareKey(), downloadHeaders.size());
}
}
@@ -255,14 +272,24 @@ public class CacheServiceImpl implements CacheService {
@Override
public Future<CacheLinkInfo> getCachedByShareKeyAndPwd(String type, String shareKey, String pwd, JsonObject otherParam) {
ParserCreate parserCreate = ParserCreate.fromType(type).shareKey(shareKey).setShareLinkInfoPwd(pwd);
ParserCreate parserCreate;
try {
parserCreate = ParserCreate.fromType(type).shareKey(shareKey).setShareLinkInfoPwd(pwd);
} catch (Exception e) {
return Future.failedFuture(e);
}
parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap());
return getAndSaveCachedShareLink(parserCreate);
}
@Override
public Future<CacheLinkInfo> getCachedByShareUrlAndPwd(String shareUrl, String pwd, JsonObject otherParam) {
ParserCreate parserCreate = ParserCreate.fromShareUrl(shareUrl).setShareLinkInfoPwd(pwd);
ParserCreate parserCreate;
try {
parserCreate = ParserCreate.fromShareUrl(shareUrl).setShareLinkInfoPwd(pwd);
} catch (Exception e) {
return Future.failedFuture(e);
}
parserCreate.getShareLinkInfo().getOtherParam().putAll(otherParam.getMap());
// 检查是否有临时认证参数
@@ -85,33 +85,18 @@ public class DbServiceImpl implements DbService {
public Future<JsonObject> getPlaygroundParserList() {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = "SELECT * FROM playground_parser ORDER BY create_time DESC";
String sql = """
SELECT id, name, type, display_name, description, author, version,
match_pattern, ip, create_time, update_time, enabled
FROM playground_parser
ORDER BY create_time DESC
LIMIT 100
""";
client.query(sql).execute().onSuccess(rows -> {
List<JsonObject> list = new ArrayList<>();
for (Row row : rows) {
JsonObject parser = new JsonObject();
parser.put("id", row.getLong("id"));
parser.put("name", row.getString("name"));
parser.put("type", row.getString("type"));
parser.put("displayName", row.getString("display_name"));
parser.put("description", row.getString("description"));
parser.put("author", row.getString("author"));
parser.put("version", row.getString("version"));
parser.put("matchPattern", row.getString("match_pattern"));
parser.put("jsCode", row.getString("js_code"));
parser.put("ip", row.getString("ip"));
// 将LocalDateTime转换为字符串格式避免序列化为数组
var createTime = row.getLocalDateTime("create_time");
if (createTime != null) {
parser.put("createTime", createTime.toString().replace("T", " "));
}
var updateTime = row.getLocalDateTime("update_time");
if (updateTime != null) {
parser.put("updateTime", updateTime.toString().replace("T", " "));
}
parser.put("enabled", row.getBoolean("enabled"));
list.add(parser);
list.add(toPlaygroundParserJson(row, false));
}
promise.complete(JsonResult.data(list).toJsonObject());
}).onFailure(e -> {
@@ -122,6 +107,33 @@ public class DbServiceImpl implements DbService {
return promise.future();
}
@Override
public Future<JsonObject> getEnabledPlaygroundParsersForLoad() {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = """
SELECT id, name, type, display_name, description, author, version,
match_pattern, js_code, ip, create_time, update_time, enabled
FROM playground_parser
WHERE enabled = TRUE
ORDER BY update_time DESC, create_time DESC
LIMIT 100
""";
client.query(sql).execute().onSuccess(rows -> {
List<JsonObject> list = new ArrayList<>();
for (Row row : rows) {
list.add(toPlaygroundParserJson(row, true));
}
promise.complete(JsonResult.data(list).toJsonObject());
}).onFailure(e -> {
log.error("getEnabledPlaygroundParsersForLoad failed", e);
promise.fail(e);
});
return promise.future();
}
@Override
public Future<JsonObject> savePlaygroundParser(JsonObject parser) {
JDBCPool client = JDBCPoolInit.instance().getPool();
@@ -164,13 +176,14 @@ public class DbServiceImpl implements DbService {
String sql = """
UPDATE playground_parser
SET name = ?, display_name = ?, description = ?, author = ?,
SET type = ?, name = ?, display_name = ?, description = ?, author = ?,
version = ?, match_pattern = ?, js_code = ?, update_time = NOW(), enabled = ?
WHERE id = ?
""";
client.preparedQuery(sql)
.execute(Tuple.of(
parser.getString("type"),
parser.getString("name"),
parser.getString("displayName"),
parser.getString("description"),
@@ -182,6 +195,10 @@ public class DbServiceImpl implements DbService {
id
))
.onSuccess(res -> {
if (res.rowCount() == 0) {
promise.complete(JsonResult.error("解析器不存在").toJsonObject());
return;
}
promise.complete(JsonResult.success("更新成功").toJsonObject());
})
.onFailure(e -> {
@@ -230,6 +247,27 @@ public class DbServiceImpl implements DbService {
return promise.future();
}
@Override
public Future<Boolean> playgroundParserTypeExists(String type, Long excludeId) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<Boolean> promise = Promise.promise();
String sql = excludeId == null
? "SELECT COUNT(*) as count FROM playground_parser WHERE type = ?"
: "SELECT COUNT(*) as count FROM playground_parser WHERE type = ? AND id <> ?";
Tuple params = excludeId == null ? Tuple.of(type) : Tuple.of(type, excludeId);
client.preparedQuery(sql).execute(params).onSuccess(rows -> {
Integer count = rows.iterator().next().getInteger("count");
promise.complete(count != null && count > 0);
}).onFailure(e -> {
log.error("playgroundParserTypeExists failed", e);
promise.fail(e);
});
return promise.future();
}
@Override
public Future<JsonObject> getPlaygroundParserById(Long id) {
JDBCPool client = JDBCPoolInit.instance().getPool();
@@ -242,28 +280,7 @@ public class DbServiceImpl implements DbService {
.onSuccess(rows -> {
if (rows.size() > 0) {
Row row = rows.iterator().next();
JsonObject parser = new JsonObject();
parser.put("id", row.getLong("id"));
parser.put("name", row.getString("name"));
parser.put("type", row.getString("type"));
parser.put("displayName", row.getString("display_name"));
parser.put("description", row.getString("description"));
parser.put("author", row.getString("author"));
parser.put("version", row.getString("version"));
parser.put("matchPattern", row.getString("match_pattern"));
parser.put("jsCode", row.getString("js_code"));
parser.put("ip", row.getString("ip"));
// 将LocalDateTime转换为字符串格式避免序列化为数组
var createTime = row.getLocalDateTime("create_time");
if (createTime != null) {
parser.put("createTime", createTime.toString().replace("T", " "));
}
var updateTime = row.getLocalDateTime("update_time");
if (updateTime != null) {
parser.put("updateTime", updateTime.toString().replace("T", " "));
}
parser.put("enabled", row.getBoolean("enabled"));
promise.complete(JsonResult.data(parser).toJsonObject());
promise.complete(JsonResult.data(toPlaygroundParserJson(row, true)).toJsonObject());
} else {
promise.fail("解析器不存在");
}
@@ -276,6 +293,32 @@ public class DbServiceImpl implements DbService {
return promise.future();
}
private JsonObject toPlaygroundParserJson(Row row, boolean includeJsCode) {
JsonObject parser = new JsonObject();
parser.put("id", row.getLong("id"));
parser.put("name", row.getString("name"));
parser.put("type", row.getString("type"));
parser.put("displayName", row.getString("display_name"));
parser.put("description", row.getString("description"));
parser.put("author", row.getString("author"));
parser.put("version", row.getString("version"));
parser.put("matchPattern", row.getString("match_pattern"));
if (includeJsCode) {
parser.put("jsCode", row.getString("js_code"));
}
parser.put("ip", row.getString("ip"));
var createTime = row.getLocalDateTime("create_time");
if (createTime != null) {
parser.put("createTime", createTime.toString().replace("T", " "));
}
var updateTime = row.getLocalDateTime("update_time");
if (updateTime != null) {
parser.put("updateTime", updateTime.toString().replace("T", " "));
}
parser.put("enabled", row.getBoolean("enabled"));
return parser;
}
// ========== 捐赠账号相关 ==========
@Override
@@ -3,9 +3,11 @@ package cn.qaiu.lz.web.service.impl;
import cn.qaiu.db.pool.JDBCPoolInit;
import cn.qaiu.lz.web.service.ShoutService;
import cn.qaiu.vx.core.annotaions.Service;
import cn.qaiu.vx.core.util.VertxHolder;
import cn.qaiu.vx.core.model.JsonResult;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.Tuple;
@@ -14,6 +16,8 @@ import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Random;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@Slf4j
@Service
@@ -21,10 +25,18 @@ import java.util.Random;
public class ShoutServiceImpl implements ShoutService {
private static final int CODE_LENGTH = 6;
private static final int EXPIRE_HOURS = 24;
private static final long CLEANUP_INTERVAL_MILLIS = 3600_000L;
private static final long CLEANUP_SHUTDOWN_WAIT_MILLIS = 5_000L;
private static final AtomicBoolean CLEANUP_REGISTERED = new AtomicBoolean(false);
private static final AtomicInteger CLEANUP_IN_FLIGHT = new AtomicInteger(0);
private static final Object CLEANUP_MONITOR = new Object();
private static volatile Long cleanupTimerId;
private static volatile Vertx cleanupVertx;
private final JDBCPool jdbcPool = JDBCPoolInit.instance().getPool();
@Override
public Future<String> submitMessage(String content, String host) {
registerCleanup();
Promise<String> promise = Promise.promise();
String code = generateRandomCode();
// 判断一下当前code是否存在消息
@@ -50,6 +62,7 @@ public class ShoutServiceImpl implements ShoutService {
@Override
public Future<JsonObject> retrieveMessage(String code) {
registerCleanup();
Promise<JsonObject> promise = Promise.promise();
String sql = "SELECT content FROM t_messages WHERE code = ? AND expire_time > NOW()";
@@ -79,6 +92,109 @@ public class ShoutServiceImpl implements ShoutService {
jdbcPool.preparedQuery(sql).execute(Tuple.of(code));
}
private static void registerCleanup() {
if (!CLEANUP_REGISTERED.compareAndSet(false, true)) {
return;
}
try {
Vertx vertx = VertxHolder.getVertxInstance();
cleanupVertx = vertx;
cleanupTimerId = vertx.setPeriodic(CLEANUP_INTERVAL_MILLIS, CLEANUP_INTERVAL_MILLIS,
id -> cleanupExpiredMessages());
cleanupExpiredMessages();
} catch (Exception e) {
cleanupTimerId = null;
cleanupVertx = null;
CLEANUP_REGISTERED.set(false);
log.warn("注册消息清理任务失败", e);
}
}
public static void cancelCleanup() {
Long timerId = cleanupTimerId;
Vertx vertx = cleanupVertx;
cleanupTimerId = null;
cleanupVertx = null;
CLEANUP_REGISTERED.set(false);
if (timerId == null || vertx == null) {
waitForCleanupToFinish();
return;
}
try {
if (vertx.cancelTimer(timerId)) {
log.info("消息定时清理任务已取消");
}
} catch (Exception e) {
log.warn("取消消息定时清理任务失败", e);
}
waitForCleanupToFinish();
}
private static void cleanupExpiredMessages() {
cleanupStarted();
boolean asyncCleanupStarted = false;
try {
if (!CLEANUP_REGISTERED.get()) {
return;
}
JDBCPoolInit poolInit = JDBCPoolInit.instance();
if (poolInit == null || poolInit.getPool() == null) {
log.debug("数据库连接池未就绪,跳过消息定时清理");
return;
}
JDBCPool pool = poolInit.getPool();
String sql = "DELETE FROM t_messages WHERE expire_time < NOW()";
Future<io.vertx.sqlclient.RowSet<io.vertx.sqlclient.Row>> cleanupFuture = pool.query(sql).execute();
asyncCleanupStarted = true;
cleanupFuture.onSuccess(res -> {
if (res.rowCount() > 0) {
log.info("清理过期消息 {} 条", res.rowCount());
}
})
.onFailure(e -> log.warn("清理过期消息失败", e))
.onComplete(ar -> cleanupFinished());
} catch (Exception e) {
log.warn("清理过期消息任务启动失败", e);
} finally {
if (!asyncCleanupStarted) {
cleanupFinished();
}
}
}
private static void cleanupStarted() {
CLEANUP_IN_FLIGHT.incrementAndGet();
}
private static void cleanupFinished() {
if (CLEANUP_IN_FLIGHT.decrementAndGet() <= 0) {
synchronized (CLEANUP_MONITOR) {
CLEANUP_MONITOR.notifyAll();
}
}
}
private static void waitForCleanupToFinish() {
long deadline = System.currentTimeMillis() + CLEANUP_SHUTDOWN_WAIT_MILLIS;
synchronized (CLEANUP_MONITOR) {
while (CLEANUP_IN_FLIGHT.get() > 0) {
long waitMillis = deadline - System.currentTimeMillis();
if (waitMillis <= 0) {
log.warn("等待消息定时清理结束超时,剩余任务数: {}", CLEANUP_IN_FLIGHT.get());
return;
}
try {
CLEANUP_MONITOR.wait(waitMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("等待消息定时清理结束被中断");
return;
}
}
}
}
private String generateRandomCode() {
Random random = new Random();
StringBuilder sb = new StringBuilder();
+4 -4
View File
@@ -17,7 +17,7 @@ proxyConf: server-proxy
# JS演练场配置
playground:
# 是否启用演练场,默认false不启用
enabled: true
enabled: false
# 公开模式,默认false需要密码访问,设为true则无需密码
public: false
# 访问密码,建议修改默认密码!
@@ -86,12 +86,12 @@ cache:
ct: 30 # 城通网盘
ec: 5 # 移动云空间
fc: # 亿方云
fj: 20 # 小飞机网盘
fj: 20 # 小飞机网盘 链接有效期约30分钟
fs: # 飞书云盘
iz: 20 # 蓝奏云优享
iz: 20 # 蓝奏云优享 链接有效期约30分钟
kd: # 可道云
le: 2879 # 联想乐云
lz: 20 # 蓝奏云
lz: 30 # 蓝奏云 链接有效期约45分钟
other: # 其他网盘
p115: 30 # 115网盘
pdb: # Dropbox
+4 -4
View File
@@ -37,10 +37,10 @@
<!-- 将文件输出设置成异步输出 -->
<appender name="ASYNC-FILE" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUGINFO级别日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 队列剩余 20% 时开始丢弃 TRACE/DEBUG/INFO 级别日志,避免阻塞调用线程 -->
<discardingThreshold>20</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>256</queueSize>
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="FILE"/>
</appender>
@@ -55,7 +55,7 @@
<logger name="io.netty" level="warn"/>
<logger name="io.vertx" level="info"/>
<logger name="com.zaxxer.hikari" level="info"/>
<logger name="cn.qaiu" level="debug"/>
<logger name="cn.qaiu" level="${NFD_LOG_LEVEL:-info}"/>
<root level="info">
<appender-ref ref="STDOUT"/>
<!-- <appender-ref ref="FILE"/>-->