From 1ef6e120a892fe748a8b5402ce710a201228b41e Mon Sep 17 00:00:00 2001 From: yukaidi Date: Wed, 10 Jun 2026 21:18:44 +0800 Subject: [PATCH 1/7] ci: improve build and packaging workflow --- .github/workflows/maven.yml | 13 ++++++++++++- docker-entrypoint.sh | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 18710c2..450078c 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 2bc18db..7742770 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -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 From 46c6827edae9c220453093f15425bf9e54e96d25 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Wed, 10 Jun 2026 21:19:18 +0800 Subject: [PATCH 2/7] fix(core): harden proxy and routing lifecycle --- .../src/main/java/cn/qaiu/vx/core/Deploy.java | 64 +++- .../cn/qaiu/vx/core/base/BaseHttpApi.java | 83 ++++- .../handlerfactory/RouterHandlerFactory.java | 45 ++- .../java/cn/qaiu/vx/core/util/CommonUtil.java | 24 +- .../cn/qaiu/vx/core/util/FutureUtils.java | 40 ++- .../cn/qaiu/vx/core/util/ReflectionUtil.java | 12 +- .../cn/qaiu/vx/core/util/ResponseUtil.java | 23 +- .../cn/qaiu/vx/core/util/SharedDataUtil.java | 9 +- .../vx/core/verticle/HttpProxyVerticle.java | 322 ++++++++++++++---- .../core/verticle/ReverseProxyVerticle.java | 183 ++++++++-- .../qaiu/vx/core/verticle/RouterVerticle.java | 13 +- 11 files changed, 644 insertions(+), 174 deletions(-) diff --git a/core/src/main/java/cn/qaiu/vx/core/Deploy.java b/core/src/main/java/cn/qaiu/vx/core/Deploy.java index 5fb606f..3b3103f 100644 --- a/core/src/main/java/cn/qaiu/vx/core/Deploy.java +++ b/core/src/main/java/cn/qaiu/vx/core/Deploy.java @@ -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 handle; private Thread mainThread; + private final List preShutdownTasks = new CopyOnWriteArrayList<>(); + private final List 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 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); + } + } + } + /** * 部署失败 * diff --git a/core/src/main/java/cn/qaiu/vx/core/base/BaseHttpApi.java b/core/src/main/java/cn/qaiu/vx/core/base/BaseHttpApi.java index 0b8261c..d3d27a5 100644 --- a/core/src/main/java/cn/qaiu/vx/core/base/BaseHttpApi.java +++ b/core/src/main/java/cn/qaiu/vx/core/base/BaseHttpApi.java @@ -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 void doFireJsonResultResponse(RoutingContext ctx, JsonResult 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 void doFireJsonResultResponse(RoutingContext ctx, JsonResult jsonResult, int statusCode) { - if (!ctx.response().ended()) { + if (!isResponseDone(ctx)) { fireJsonResultResponse(ctx, jsonResult, statusCode); } handleAfterInterceptor(ctx, jsonResult.toJsonObject()); } default Set getAfterInterceptor() { + return AfterInterceptorHolder.INSTANCES; + } - Set> afterInterceptorClassSet = - reflections.getSubTypesOf(AfterInterceptor.class); - if (afterInterceptorClassSet == null) { - return null; + class AfterInterceptorHolder { + private static final Set INSTANCES = loadAfterInterceptors(); + + private static Set loadAfterInterceptors() { + Set> 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 clazz) { + HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class); + return sort == null || sort.value() >= 0; + } + + private static int compareOrder(Class left, Class right) { + return Integer.compare(order(left), order(right)); + } + + private static int order(Class clazz) { + HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class); + return sort == null ? 0 : sort.value(); + } + + private static AfterInterceptor newInterceptor(Class 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 = getAfterInterceptor(); - if (afterInterceptor != null) { - afterInterceptor.forEach(ai -> ai.handle(ctx, jsonObject)); + if (ctx.response().closed()) { + return; } - if (!ctx.response().ended()) { + Set 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(); + } + } diff --git a/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java b/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java index 92bb224..7d4e711 100644 --- a/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java +++ b/core/src/main/java/cn/qaiu/vx/core/handlerfactory/RouterHandlerFactory.java @@ -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> 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 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)); diff --git a/core/src/main/java/cn/qaiu/vx/core/util/CommonUtil.java b/core/src/main/java/cn/qaiu/vx/core/util/CommonUtil.java index 3f77baa..6bf4c8b 100644 --- a/core/src/main/java/cn/qaiu/vx/core/util/CommonUtil.java +++ b/core/src/main/java/cn/qaiu/vx/core/util/CommonUtil.java @@ -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 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); diff --git a/core/src/main/java/cn/qaiu/vx/core/util/FutureUtils.java b/core/src/main/java/cn/qaiu/vx/core/util/FutureUtils.java index f008994..dfc277f 100644 --- a/core/src/main/java/cn/qaiu/vx/core/util/FutureUtils.java +++ b/core/src/main/java/cn/qaiu/vx/core/util/FutureUtils.java @@ -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 getResult(Future 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 getResult(Future 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 getResult(Promise 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); + } } } diff --git a/core/src/main/java/cn/qaiu/vx/core/util/ReflectionUtil.java b/core/src/main/java/cn/qaiu/vx/core/util/ReflectionUtil.java index 8cb1b50..95290de 100644 --- a/core/src/main/java/cn/qaiu/vx/core/util/ReflectionUtil.java +++ b/core/src/main/java/cn/qaiu/vx/core/util/ReflectionUtil.java @@ -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 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(); } /** diff --git a/core/src/main/java/cn/qaiu/vx/core/util/ResponseUtil.java b/core/src/main/java/cn/qaiu/vx/core/util/ResponseUtil.java index e6f259a..9d298f9 100644 --- a/core/src/main/java/cn/qaiu/vx/core/util/ResponseUtil.java +++ b/core/src/main/java/cn/qaiu/vx/core/util/ResponseUtil.java @@ -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(); } } diff --git a/core/src/main/java/cn/qaiu/vx/core/util/SharedDataUtil.java b/core/src/main/java/cn/qaiu/vx/core/util/SharedDataUtil.java index 5181e42..a184caa 100644 --- a/core/src/main/java/cn/qaiu/vx/core/util/SharedDataUtil.java +++ b/core/src/main/java/cn/qaiu/vx/core/util/SharedDataUtil.java @@ -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 getLocalMap(String key) { @@ -24,7 +21,7 @@ public class SharedDataUtil { } public static LocalMap getLocalMapWithCast(String key) { - return sharedData.getLocalMap(key); + return shareData().getLocalMap(key); } public static JsonObject getJsonConfig(String key) { diff --git a/core/src/main/java/cn/qaiu/vx/core/verticle/HttpProxyVerticle.java b/core/src/main/java/cn/qaiu/vx/core/verticle/HttpProxyVerticle.java index c6fdfc3..3ddff9a 100644 --- a/core/src/main/java/cn/qaiu/vx/core/verticle/HttpProxyVerticle.java +++ b/core/src/main/java/cn/qaiu/vx/core/verticle/HttpProxyVerticle.java @@ -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 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 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 stopPromise) { + stopping = true; + Future 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 closeClients() { + Future httpClientClose = httpClient == null ? Future.succeededFuture() : httpClient.close(); + Future netClientClose = netClient == null ? Future.succeededFuture() : netClient.close(); + return Future.all(httpClientClose, netClientClose).mapEmpty(); } } diff --git a/core/src/main/java/cn/qaiu/vx/core/verticle/ReverseProxyVerticle.java b/core/src/main/java/cn/qaiu/vx/core/verticle/ReverseProxyVerticle.java index 7715758..7a59291 100644 --- a/core/src/main/java/cn/qaiu/vx/core/verticle/ReverseProxyVerticle.java +++ b/core/src/main/java/cn/qaiu/vx/core/verticle/ReverseProxyVerticle.java @@ -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 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 httpClientPool = new ConcurrentHashMap<>(); + private final Map httpClientPool = new ConcurrentHashMap<>(); + private final List 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 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 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> serverCloseFutures = new ArrayList<>(); + httpServers.forEach(server -> serverCloseFutures.add(server.close())); + Future serverCloseFuture = serverCloseFutures.isEmpty() + ? Future.succeededFuture() + : Future.all(serverCloseFutures).mapEmpty(); + + serverCloseFuture.onComplete(serverClose -> { + List> clientCloseFutures = new ArrayList<>(); + closeHttpClients(clientCloseFutures); + Future 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> 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 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> 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 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 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)) { diff --git a/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java b/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java index b85a484..6fae9e8 100644 --- a/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java +++ b/core/src/main/java/cn/qaiu/vx/core/verticle/RouterVerticle.java @@ -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 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())); } From b6b7f0d8b78e3fc2df8b19b7cf0432fd3c7b9569 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Wed, 10 Jun 2026 21:19:56 +0800 Subject: [PATCH 3/7] fix(parser): harden runtime resource handling --- .../main/java/cn/qaiu/WebClientVertxInit.java | 21 +- .../main/java/cn/qaiu/parser/IPanTool.java | 113 ++- .../src/main/java/cn/qaiu/parser/PanBase.java | 230 ++++-- .../java/cn/qaiu/parser/ParserCreate.java | 54 +- .../parser/custom/CustomParserRegistry.java | 12 +- .../cn/qaiu/parser/customjs/JsHttpClient.java | 681 ++++++++++++------ .../parser/customjs/JsParserExecutor.java | 285 ++++++-- .../parser/customjs/JsPlaygroundExecutor.java | 340 +++++++-- .../parser/customjs/JsPlaygroundLogger.java | 26 +- .../qaiu/parser/customjs/JsScriptLoader.java | 48 +- .../java/cn/qaiu/util/HttpResponseHelper.java | 63 +- .../main/java/cn/qaiu/util/IpExtractor.java | 12 +- .../main/java/cn/qaiu/util/JsExecUtils.java | 54 +- .../src/main/java/cn/qaiu/util/ReqIpUtil.java | 14 +- 14 files changed, 1498 insertions(+), 455 deletions(-) diff --git a/parser/src/main/java/cn/qaiu/WebClientVertxInit.java b/parser/src/main/java/cn/qaiu/WebClientVertxInit.java index 2dd0560..81419b7 100644 --- a/parser/src/main/java/cn/qaiu/WebClientVertxInit.java +++ b/parser/src/main/java/cn/qaiu/WebClientVertxInit.java @@ -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; } -} \ No newline at end of file +} diff --git a/parser/src/main/java/cn/qaiu/parser/IPanTool.java b/parser/src/main/java/cn/qaiu/parser/IPanTool.java index d635daf..706f8fe 100644 --- a/parser/src/main/java/cn/qaiu/parser/IPanTool.java +++ b/parser/src/main/java/cn/qaiu/parser/IPanTool.java @@ -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 parse(); + static Future closeAfter(IPanTool tool, Supplier> action) { + Promise promise = Promise.promise(); + AtomicBoolean cleanupDone = new AtomicBoolean(false); + ScheduledFuture cleanupTask = null; + try { + Future 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 void failOnVertxContext(Promise 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 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 客户端下载链接集合 */ default Map 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 timedJoin(Future 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); + } + } } diff --git a/parser/src/main/java/cn/qaiu/parser/PanBase.java b/parser/src/main/java/cn/qaiu/parser/PanBase.java index 7ea50c4..601534b 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanBase.java +++ b/parser/src/main/java/cn/qaiu/parser/PanBase.java @@ -34,25 +34,37 @@ import java.util.zip.GZIPInputStream; *

{网盘标识}Tool, 网盘标识不超过5个字符, 可以取网盘名称首字母缩写或拼音首字母,
* 音乐类型的解析以M开头, 例如网易云音乐Mne

*/ -public abstract class PanBase implements IPanTool { +public abstract class PanBase implements IPanTool, Closeable { protected Logger log = LoggerFactory.getLogger(this.getClass()); protected Promise 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 handleFail(String errorMsg) { - return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.fillInStackTrace()); + return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.getClass().getSimpleName() + ": " + t.getMessage()); } protected Handler 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 ""; + } + } + 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()); + } + } + } } diff --git a/parser/src/main/java/cn/qaiu/parser/ParserCreate.java b/parser/src/main/java/cn/qaiu/parser/ParserCreate.java index 2b743f7..618e5d6 100644 --- a/parser/src/main/java/cn/qaiu/parser/ParserCreate.java +++ b/parser/src/main/java/cn/qaiu/parser/ParserCreate.java @@ -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 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 diff --git a/parser/src/main/java/cn/qaiu/parser/custom/CustomParserRegistry.java b/parser/src/main/java/cn/qaiu/parser/custom/CustomParserRegistry.java index a978ed3..766d503 100644 --- a/parser/src/main/java/cn/qaiu/parser/custom/CustomParserRegistry.java +++ b/parser/src/main/java/cn/qaiu/parser/custom/CustomParserRegistry.java @@ -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(); } diff --git a/parser/src/main/java/cn/qaiu/parser/customjs/JsHttpClient.java b/parser/src/main/java/cn/qaiu/parser/customjs/JsHttpClient.java index 2ada6b0..fcd605c 100644 --- a/parser/src/main/java/cn/qaiu/parser/customjs/JsHttpClient.java +++ b/parser/src/main/java/cn/qaiu/parser/customjs/JsHttpClient.java @@ -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 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 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 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 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 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 mapData = (Map) 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 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 mapData = (Map) 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 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 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 mapData = (Map) 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 headersMap) { if (headersMap != null) { for (Map.Entry 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 data) { - return executeRequest(() -> { - HttpRequest 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 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 data) { - return executeRequest(() -> { - HttpRequest request = client.postAbs(url); - if (!headers.isEmpty()) { - request.putHeaders(headers); - } - - MultipartForm form = MultipartForm.create(); - - if (data != null) { - for (Map.Entry 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 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 requestRef = new AtomicReference<>(); + AtomicBoolean abandoned = new AtomicBoolean(false); try { - Promise> promise = Promise.promise(); - Future> 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 promise = Promise.promise(); - // 等待响应完成(使用配置的超时时间) - HttpResponse 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> 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 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 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 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 data) { + StringBuilder encoded = new StringBuilder(); + if (data != null) { + for (Map.Entry 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 data) { + String boundary = "----NetdiskJsHttpClientBoundary" + UUID.randomUUID().toString().replace("-", ""); + Buffer body = Buffer.buffer(); + if (data != null) { + for (Map.Entry 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 response; + private final int statusCode; + private final MultiMap headers; + private final Buffer body; + private final String statusMessage; + private final HttpResponse originalResponse; public JsHttpResponse(HttpResponse 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 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 headers() { - MultiMap responseHeaders = response.headers(); Map 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 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"); + } + } } diff --git a/parser/src/main/java/cn/qaiu/parser/customjs/JsParserExecutor.java b/parser/src/main/java/cn/qaiu/parser/customjs/JsParserExecutor.java index 7226e0b..af7bc02 100644 --- a/parser/src/main/java/cn/qaiu/parser/customjs/JsParserExecutor.java +++ b/parser/src/main/java/cn/qaiu/parser/customjs/JsParserExecutor.java @@ -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 Future executeBlockingWithPermit(String operation, Callable 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 Future withTimeout(Future executionFuture, String operation) { + Promise 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 parse() { jsLogger.info("开始执行JavaScript解析器: {}", config.getType()); - + // 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程 - return getExecutor().executeBlocking(() -> { + Future 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> parseFileList() { jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType()); - + // 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程 - return getExecutor().executeBlocking(() -> { + Future> 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 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 parseById() { jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType()); - + // 使用executeBlocking在工作线程上执行,避免阻塞EventLoop线程 - return getExecutor().executeBlocking(() -> { + Future 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 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; + } } diff --git a/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundExecutor.java b/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundExecutor.java index 19ae052..a36cc3a 100644 --- a/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundExecutor.java +++ b/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundExecutor.java @@ -21,20 +21,37 @@ import java.util.concurrent.*; * * @author QAIU */ -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 executeParseAsync() { Promise promise = Promise.promise(); - - // 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告 - CompletableFuture executionFuture = CompletableFuture.supplyAsync(() -> { + + final CompletableFuture 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> executeParseFileListAsync() { Promise> promise = Promise.promise(); - - // 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告 - CompletableFuture> executionFuture = CompletableFuture.supplyAsync(() -> { + + final CompletableFuture> 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 executeParseByIdAsync() { Promise promise = Promise.promise(); - - // 使用独立的ExecutorService执行,避免Vert.x的BlockedThreadChecker输出警告 - CompletableFuture executionFuture = CompletableFuture.supplyAsync(() -> { + + final CompletableFuture 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 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()); + } + } + } } diff --git a/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundLogger.java b/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundLogger.java index f442e64..bd635c0 100644 --- a/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundLogger.java +++ b/parser/src/main/java/cn/qaiu/parser/customjs/JsPlaygroundLogger.java @@ -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 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(); + } + } } diff --git a/parser/src/main/java/cn/qaiu/parser/customjs/JsScriptLoader.java b/parser/src/main/java/cn/qaiu/parser/customjs/JsScriptLoader.java index a3d665f..522e7b9 100644 --- a/parser/src/main/java/cn/qaiu/parser/customjs/JsScriptLoader.java +++ b/parser/src/main/java/cn/qaiu/parser/customjs/JsScriptLoader.java @@ -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); + } } diff --git a/parser/src/main/java/cn/qaiu/util/HttpResponseHelper.java b/parser/src/main/java/cn/qaiu/util/HttpResponseHelper.java index 73f784c..33d9bce 100644 --- a/parser/src/main/java/cn/qaiu/util/HttpResponseHelper.java +++ b/parser/src/main/java/cn/qaiu/util/HttpResponseHelper.java @@ -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(); } diff --git a/parser/src/main/java/cn/qaiu/util/IpExtractor.java b/parser/src/main/java/cn/qaiu/util/IpExtractor.java index 186a8cd..ceb0460 100644 --- a/parser/src/main/java/cn/qaiu/util/IpExtractor.java +++ b/parser/src/main/java/cn/qaiu/util/IpExtractor.java @@ -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()); }); diff --git a/parser/src/main/java/cn/qaiu/util/JsExecUtils.java b/parser/src/main/java/cn/qaiu/util/JsExecUtils.java index 453a294..0abd79a 100644 --- a/parser/src/main/java/cn/qaiu/util/JsExecUtils.java +++ b/parser/src/main/java/cn/qaiu/util/JsExecUtils.java @@ -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) { diff --git a/parser/src/main/java/cn/qaiu/util/ReqIpUtil.java b/parser/src/main/java/cn/qaiu/util/ReqIpUtil.java index bfaab33..d6717fa 100644 --- a/parser/src/main/java/cn/qaiu/util/ReqIpUtil.java +++ b/parser/src/main/java/cn/qaiu/util/ReqIpUtil.java @@ -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 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()); From 0103841fb5f8eaaaf7949aef1e05e593ce0d2f4a Mon Sep 17 00:00:00 2001 From: yukaidi Date: Wed, 10 Jun 2026 21:20:40 +0800 Subject: [PATCH 4/7] fix(parser): harden built-in pan parsers --- .../cn/qaiu/parser/PanDomainTemplate.java | 8 +- .../main/java/cn/qaiu/parser/impl/CeTool.java | 5 +- .../main/java/cn/qaiu/parser/impl/CtTool.java | 723 ++++++++++++++---- .../main/java/cn/qaiu/parser/impl/FcTool.java | 10 +- .../main/java/cn/qaiu/parser/impl/FjTool.java | 7 +- .../java/cn/qaiu/parser/impl/GenShortUrl.java | 15 +- .../cn/qaiu/parser/impl/IzSelectorTool.java | 10 + .../main/java/cn/qaiu/parser/impl/IzTool.java | 79 +- .../cn/qaiu/parser/impl/IzToolWithAuth.java | 65 +- .../main/java/cn/qaiu/parser/impl/LzTool.java | 444 +++++++---- .../java/cn/qaiu/parser/impl/MkgsTool.java | 7 +- .../java/cn/qaiu/parser/impl/MkwTool.java | 66 +- .../java/cn/qaiu/parser/impl/P360Tool.java | 9 +- .../java/cn/qaiu/parser/impl/PcxTool.java | 42 +- .../java/cn/qaiu/parser/impl/PdbTool.java | 3 +- .../java/cn/qaiu/parser/impl/PodTool.java | 99 ++- .../java/cn/qaiu/parser/impl/PvyyTool.java | 7 +- .../java/cn/qaiu/parser/impl/QQscTool.java | 16 +- 18 files changed, 1199 insertions(+), 416 deletions(-) diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index f66b4fc..a9334ad 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -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)?/" + - "(?[0-9a-zA-Z_-]+)(\\?p=(?\\w+))?"), + compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" + + "(?[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?\\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/" + - "(?[0-9a-zA-Z_-]+)(\\?p=(?\\w+))?"), + compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" + + "(?[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?\\w+)(?:&[^#]*)?|[^#]*))?"), "https://ctfile.com/d/{shareKey}", CtTool.class), // https://www.vyuyun.com/s/QMa6ie?password=I4KG7H diff --git a/parser/src/main/java/cn/qaiu/parser/impl/CeTool.java b/parser/src/main/java/cn/qaiu/parser/impl/CeTool.java index 09f9956..7daac1f 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/CeTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/CeTool.java @@ -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); } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/CtTool.java b/parser/src/main/java/cn/qaiu/parser/impl/CtTool.java index b075513..c1e2c61 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/CtTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/CtTool.java @@ -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(">([^<]+)"); - 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("]*>([^<]+)", 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 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 bufferHttpRequest1 = clientSession.getAbs(UriTemplate.of(API1)) + HttpRequest 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 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 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 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 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 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> parseFileList() { Promise> 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 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 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 fileList, + Promise> 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 fileList, + Promise> listPromise, String panType, String domainName, + String shareUrl, String pwd, io.vertx.ext.web.client.HttpResponse 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 parseById() { + Object paramValue = shareLinkInfo.getOtherParam().get("paramJson"); + if (!(paramValue instanceof JsonObject)) { + Promise parsePromise = Promise.promise(); + parsePromise.fail(baseMsg() + " 缺少下载参数paramJson"); + return parsePromise.future(); + } + JsonObject paramJson = (JsonObject) paramValue; + if (!applyFileParam(shareLinkInfo, paramJson)) { + Promise 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 withCtAjaxHeaders(HttpRequest 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> listPromise, String message) { + if (!listPromise.future().isComplete()) { + listPromise.fail(message); + } + } + + private static void failListPromise(Promise> listPromise, Throwable throwable) { + if (!listPromise.future().isComplete()) { + listPromise.fail(throwable); + } + } + + private static void completeListPromise(Promise> listPromise, List 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; + } + } } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/FcTool.java b/parser/src/main/java/cn/qaiu/parser/impl/FcTool.java index fb356da..886bc80 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/FcTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/FcTool.java @@ -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; diff --git a/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java b/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java index 6b991b4..d3092f4 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/FjTool.java @@ -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; diff --git a/parser/src/main/java/cn/qaiu/parser/impl/GenShortUrl.java b/parser/src/main/java/cn/qaiu/parser/impl/GenShortUrl.java index 45baacc..c814f10 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/GenShortUrl.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/GenShortUrl.java @@ -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); } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java b/parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java index 7e3b731..0abfe23 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java @@ -52,4 +52,14 @@ public class IzSelectorTool implements IPanTool { public Future parseById() { return selectedTool.parseById(); } + + @Override + public ShareLinkInfo getShareLinkInfo() { + return selectedTool.getShareLinkInfo(); + } + + @Override + public void close() { + IPanTool.closeQuietly(selectedTool); + } } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java b/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java index c135845..6565fd4 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java @@ -443,16 +443,75 @@ public class IzTool extends PanBase { } } - private void down(HttpResponse 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 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 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> parseFileList() { Promise> promise = Promise.promise(); diff --git a/parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java b/parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java index 074d506..f8829aa 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java @@ -414,11 +414,70 @@ public class IzToolWithAuth extends PanBase { private void down(HttpResponse 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 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) + "..."; } // 目录解析 diff --git a/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java b/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java index cfa3b2f..9f0f2f6 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/LzTool.java @@ -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(">文件大小:(.*?)
|\"n_filesize\">大小:(.*?)"); + private static final Pattern SHARE_USER_PATTERN = Pattern.compile(">分享用户:(.*?)|获取(.*?)的文件|\"user-name\">(.*?)
(.*?)|class=\"n_box_des\">(.*?)"); + private static final Pattern FILE_ID_PATTERN = Pattern.compile("\\?f=(.*?)&|fid = (.*?);"); + private static final Pattern CREATE_TIME_PATTERN = Pattern.compile(">上传时间:(.*?)<"); + 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 = ""; 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 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> 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> 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 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 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(">文件大小:(.*?)
|\"n_filesize\">大小:(.*?)")); - String createBy = CommonUtils.extract(html, Pattern.compile(">分享用户:(.*?)|获取(.*?)的文件|\"user-name\">(.*?)
(.*?)|class=\"n_box_des\">(.*?)")); + 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(">上传时间:(.*?)<")); + 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)); diff --git a/parser/src/main/java/cn/qaiu/parser/impl/MkgsTool.java b/parser/src/main/java/cn/qaiu/parser/impl/MkgsTool.java index cb01165..df8c754 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/MkgsTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/MkgsTool.java @@ -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()) { diff --git a/parser/src/main/java/cn/qaiu/parser/impl/MkwTool.java b/parser/src/main/java/cn/qaiu/parser/impl/MkwTool.java index 5686703..bb52c6c 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/MkwTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/MkwTool.java @@ -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(); } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/P360Tool.java b/parser/src/main/java/cn/qaiu/parser/impl/P360Tool.java index 8a9d596..895170b 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/P360Tool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/P360Tool.java @@ -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 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 { diff --git a/parser/src/main/java/cn/qaiu/parser/impl/PcxTool.java b/parser/src/main/java/cn/qaiu/parser/impl/PcxTool.java index 852456d..0ba4484 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/PcxTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/PcxTool.java @@ -14,6 +14,25 @@ import java.util.regex.Pattern; */ public class PcxTool extends PanBase { + private static final Pattern TITLE_PATTERN = + Pattern.compile("([^<]+)"); + private static final Pattern FILENAME_INPUT_PATTERN = + Pattern.compile("标签或文件名input - String fileName = extractByRegex(html, "([^<]+)"); + String fileName = extractByRegex(html, TITLE_PATTERN); if (fileName == null) { - fileName = extractByRegex(html, " * 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=(?[^!]+)!(?[^&]+).+&redeem=(?.+).*"); + private static final Pattern DOWNLOAD_URL_IN_RESPONSE_PATTERN = + Pattern.compile("\"downloadUrl\":\"(?https?://[^\\s\"]+)"); + private static final Pattern ACTION_URL_PATTERN = + Pattern.compile("'action'.+(?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\":\"(?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'.+(?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 sendHttpRequest2(String token, String redeem) { Promise 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 sendHttpRequest(String url, String token) { - // 创建一个 WorkerExecutor 用于异步执行阻塞的 HTTP 请求 - WorkerExecutor executor = WebClientVertxInit.get().createSharedWorkerExecutor("http-client-worker"); - Promise 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 response = client.send(request, HttpResponse.BodyHandlers.ofString()); + // 发起请求并获取响应(使用共享的 HttpClient) + HttpResponse 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(); } -} \ No newline at end of file + + 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; + } + } + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/impl/PvyyTool.java b/parser/src/main/java/cn/qaiu/parser/impl/PvyyTool.java index 1665ba9..1a47dec 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/PvyyTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/PvyyTool.java @@ -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 parse() { - // 请求downcode - WebClient.create(WebClientVertxInit.get()) - .getAbs(api + shareLinkInfo.getShareKey()) + // 请求downcode - 使用父类的共享 WebClient 而非创建新实例 + client.getAbs(api + shareLinkInfo.getShareKey()) .send() .onSuccess(res -> { if (res.statusCode() == 200) { diff --git a/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java b/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java index a92534d..ef57912 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java @@ -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("(.*?)"); + 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("(.*?)"); - Matcher matcher = pattern.matcher(content); + Matcher matcher = TITLE_PATTERN.matcher(content); if (matcher.find()) { String fullTitle = matcher.group(1); int sepIndex = fullTitle.indexOf("|"); From cbb442ceb6d8aeece15776336b18290e381b7c91 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Wed, 10 Jun 2026 21:21:52 +0800 Subject: [PATCH 5/7] fix(web): harden cache and API services --- .../src/main/java/cn/qaiu/lz/AppMain.java | 109 ++++--- .../cn/qaiu/lz/common/cache/CacheManager.java | 223 +++++++++++-- .../interceptorImpl/DefaultInterceptor.java | 58 ++-- .../common/interceptorImpl/RateLimiter.java | 71 +++- .../qaiu/lz/web/config/PlaygroundConfig.java | 42 ++- .../cn/qaiu/lz/web/controller/ParserApi.java | 55 +++- .../qaiu/lz/web/controller/PlaygroundApi.java | 303 +++++++++++------- .../cn/qaiu/lz/web/controller/ServerApi.java | 51 ++- .../cn/qaiu/lz/web/service/DbService.java | 10 + .../lz/web/service/impl/CacheServiceImpl.java | 61 +++- .../lz/web/service/impl/DbServiceImpl.java | 135 +++++--- .../lz/web/service/impl/ShoutServiceImpl.java | 116 +++++++ web-service/src/main/resources/app-dev.yml | 8 +- web-service/src/main/resources/logback.xml | 8 +- 14 files changed, 935 insertions(+), 315 deletions(-) diff --git a/web-service/src/main/java/cn/qaiu/lz/AppMain.java b/web-service/src/main/java/cn/qaiu/lz/AppMain.java index d60090c..c3e0df0 100644 --- a/web-service/src/main/java/cn/qaiu/lz/AppMain.java +++ b/web-service/src/main/java/cn/qaiu/lz/AppMain.java @@ -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) { - // 先注册 ShutdownHook(JVM 逆序执行,先注册的后执行) - // 确保关闭顺序: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语法"); } } } diff --git a/web-service/src/main/java/cn/qaiu/lz/common/cache/CacheManager.java b/web-service/src/main/java/cn/qaiu/lz/common/cache/CacheManager.java index 947670d..df447ab 100644 --- a/web-service/src/main/java/cn/qaiu/lz/common/cache/CacheManager.java +++ b/web-service/src/main/java/cn/qaiu/lz/common/cache/CacheManager.java @@ -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 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 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 updateTotalByField(String shareKey, CacheTotalField field) { + if (isOversizedShareKey(shareKey)) { + LOGGER.warn("缓存key过长,跳过统计写入: length={}, prefix={}", + shareKey.length(), previewShareKey(shareKey)); + return Future.succeededFuture(0); + } + Promise promise = Promise.promise(); String fieldLower = field.name().toLowerCase(); String sql; @@ -179,6 +209,12 @@ public class CacheManager { } public Future 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 cleanupExpiredCache() { - String sql = "DELETE FROM cache_link_info WHERE expiration > 0 AND expiration < #{now}"; - Map params = new HashMap<>(); - params.put("now", System.currentTimeMillis()); Promise 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 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 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 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> 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) + "..."; + } + } diff --git a/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/DefaultInterceptor.java b/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/DefaultInterceptor.java index 907df26..b1e1100 100644 --- a/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/DefaultInterceptor.java +++ b/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/DefaultInterceptor.java @@ -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 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()); + }); } } diff --git a/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/RateLimiter.java b/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/RateLimiter.java index fa3937e..74c91dd 100644 --- a/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/RateLimiter.java +++ b/web-service/src/main/java/cn/qaiu/lz/common/interceptorImpl/RateLimiter.java @@ -17,12 +17,19 @@ public class RateLimiter { private static final Map 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; diff --git a/web-service/src/main/java/cn/qaiu/lz/web/config/PlaygroundConfig.java b/web-service/src/main/java/cn/qaiu/lz/web/config/PlaygroundConfig.java index 2049bc5..2b03220 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/config/PlaygroundConfig.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/config/PlaygroundConfig.java @@ -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 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); + } } } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java index 5c6f493..cb925c6 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java @@ -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 parse(HttpServerRequest request, String pwd, String auth) { Promise 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> 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); diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/PlaygroundApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/PlaygroundApi.java index 09ae694..71f956c 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/PlaygroundApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/PlaygroundApi.java @@ -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 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 logEntries = executor.getLogs(); - log.debug("获取到 {} 条日志记录", logEntries.size()); - - List 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 logEntries = executor.getLogs(); + List 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 logEntries = executor.getLogs(); - List 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(); + } } diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java index 6d612aa..282d444 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ServerApi.java @@ -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 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 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 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 parseKey(HttpServerResponse response, HttpServerRequest request, String type, String key) { Promise 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()) { diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java b/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java index a996b4a..3b8ddfe 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/DbService.java @@ -25,6 +25,11 @@ public interface DbService extends BaseAsyncService { */ Future getPlaygroundParserList(); + /** + * 获取启动时需要注册的已启用演练场解析器 + */ + Future getEnabledPlaygroundParsersForLoad(); + /** * 保存演练场解析器 */ @@ -45,6 +50,11 @@ public interface DbService extends BaseAsyncService { */ Future getPlaygroundParserCount(); + /** + * 检查演练场解析器类型是否已存在 + */ + Future playgroundParserTypeExists(String type, Long excludeId); + /** * 根据ID获取演练场解析器 */ diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java index 8dbd0c1..8b8a303 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java @@ -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<>()); + } + + // 生成下载命令(aria2、curl) + generateDownloadCommands(result); } - // 生成下载命令(aria2、curl) - 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 headers = (Map) 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 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 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()); // 检查是否有临时认证参数 diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java index 4d8584a..dae80f3 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/DbServiceImpl.java @@ -85,33 +85,18 @@ public class DbServiceImpl implements DbService { public Future getPlaygroundParserList() { JDBCPool client = JDBCPoolInit.instance().getPool(); Promise 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 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 getEnabledPlaygroundParsersForLoad() { + JDBCPool client = JDBCPoolInit.instance().getPool(); + Promise 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 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 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 playgroundParserTypeExists(String type, Long excludeId) { + JDBCPool client = JDBCPoolInit.instance().getPool(); + Promise 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 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 diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/ShoutServiceImpl.java b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/ShoutServiceImpl.java index d1ed531..618857b 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/ShoutServiceImpl.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/ShoutServiceImpl.java @@ -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 submitMessage(String content, String host) { + registerCleanup(); Promise promise = Promise.promise(); String code = generateRandomCode(); // 判断一下当前code是否存在消息 @@ -50,6 +62,7 @@ public class ShoutServiceImpl implements ShoutService { @Override public Future retrieveMessage(String code) { + registerCleanup(); Promise 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> 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(); diff --git a/web-service/src/main/resources/app-dev.yml b/web-service/src/main/resources/app-dev.yml index b5e3892..4a7f80c 100644 --- a/web-service/src/main/resources/app-dev.yml +++ b/web-service/src/main/resources/app-dev.yml @@ -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 diff --git a/web-service/src/main/resources/logback.xml b/web-service/src/main/resources/logback.xml index af6dda5..86adaf2 100644 --- a/web-service/src/main/resources/logback.xml +++ b/web-service/src/main/resources/logback.xml @@ -37,10 +37,10 @@ - - 0 + + 20 - 256 + 512 @@ -55,7 +55,7 @@ - + From 6ae5d998e6a3b92a26654db5230f50d93ace9921 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Wed, 10 Jun 2026 21:22:29 +0800 Subject: [PATCH 6/7] fix(frontend): improve directory and timestamp display --- web-front/public/list.html | 65 +++++++++++++++---- web-front/src/components/DirectoryTree.vue | 75 +++++++++++++++++++--- web-front/src/parserUrl1.js | 4 +- web-front/src/utils/monacoTypes.js | 12 ++-- 4 files changed, 129 insertions(+), 27 deletions(-) diff --git a/web-front/public/list.html b/web-front/public/list.html index 0701d73..2917a9e 100644 --- a/web-front/public/list.html +++ b/web-front/public/list.html @@ -511,6 +511,7 @@ files.forEach(file => { const item = document.createElement('div'); + const metaText = fileMetaText(file); if (file.fileType === 'folder') { // 文件夹 @@ -520,9 +521,7 @@
${file.fileName || '未命名文件夹'}
-
- ${file.sizeStr || '0B'} · ${formatDate(file.createTime)} -
+ ${metaText ? `
${metaText}
` : ''} `; folderCount++; @@ -541,9 +540,7 @@
${file.fileName}
-
- ${file.sizeStr || '0B'} · ${formatDate(file.createTime)} -
+ ${metaText ? `
${metaText}
` : ''} `; 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 ''; } } - \ No newline at end of file + diff --git a/web-front/src/components/DirectoryTree.vue b/web-front/src/components/DirectoryTree.vue index 53767bd..c907f4d 100644 --- a/web-front/src/components/DirectoryTree.vue +++ b/web-front/src/components/DirectoryTree.vue @@ -32,8 +32,8 @@
{{ file.fileName }}
-
- {{ formatDate(file.createTime) }} +
+ {{ fileMetaText(file) }}
@@ -168,7 +168,8 @@

类型: {{ getFileTypeClass(selectedNode) }}

大小: {{ selectedNode.sizeStr || '0B' }}

-

创建时间: {{ formatDate(selectedNode.createTime) }}

+

创建时间: {{ formatDate(selectedNode.createTime) }}

+

更新时间: {{ formatDate(selectedNode.updateTime) }}

@@ -244,7 +245,8 @@

类型: {{ getFileTypeClass(selectedNode) }}

大小: {{ selectedNode.sizeStr || '0B' }}

-

创建时间: {{ formatDate(selectedNode.createTime) }}

+

创建时间: {{ formatDate(selectedNode.createTime) }}

+

更新时间: {{ formatDate(selectedNode.updateTime) }}

@@ -314,8 +316,9 @@

{{ selectedFile?.fileName || '未命名文件' }}

- 大小: {{ selectedFile?.sizeStr || '0B' }}
- 创建时间: {{ formatDate(selectedFile?.createTime) }} +

@@ -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') }, diff --git a/web-front/src/parserUrl1.js b/web-front/src/parserUrl1.js index 6b97e37..7c58aa2 100644 --- a/web-front/src/parserUrl1.js +++ b/web-front/src/parserUrl1.js @@ -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: '城通网盘', diff --git a/web-front/src/utils/monacoTypes.js b/web-front/src/utils/monacoTypes.js index 833d043..ed11305 100644 --- a/web-front/src/utils/monacoTypes.js +++ b/web-front/src/utils/monacoTypes.js @@ -83,9 +83,9 @@ function registerTypeDefinitions(monaco) { clearHeaders(): JsHttpClient; getHeaders(): Record; setTimeout(seconds: number): JsHttpClient; - sendForm(data: Record): JsHttpResponse; + sendForm(url: string, data: Record): JsHttpResponse; sendMultipartForm(url: string, data: Record): 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 From 0edc0573477c3940dbed5e120d98a268cf0e8205 Mon Sep 17 00:00:00 2001 From: yukaidi Date: Wed, 10 Jun 2026 21:23:05 +0800 Subject: [PATCH 7/7] docs: update parser and custom script docs --- parser/doc/JAVASCRIPT_PARSER_GUIDE.md | 6 +++--- parser/doc/README.md | 2 +- parser/doc/security/DOS_FIX_V2.md | 2 +- parser/doc/security/NASHORN_LIMITATIONS.md | 2 +- parser/src/main/resources/custom-parsers/README.md | 4 ++-- parser/src/main/resources/custom-parsers/types.js | 4 ++-- parser/src/main/resources/logback.xml | 6 +++--- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/parser/doc/JAVASCRIPT_PARSER_GUIDE.md b/parser/doc/JAVASCRIPT_PARSER_GUIDE.md index f5b3686..82877a0 100644 --- a/parser/doc/JAVASCRIPT_PARSER_GUIDE.md +++ b/parser/doc/JAVASCRIPT_PARSER_GUIDE.md @@ -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? diff --git a/parser/doc/README.md b/parser/doc/README.md index 15d8c69..3703fec 100644 --- a/parser/doc/README.md +++ b/parser/doc/README.md @@ -68,7 +68,7 @@ List files = tool.parseFileListSync(); ``` 要点: -- 必须先 WebClientVertxInit.init(Vertx);若未显式初始化,内部将懒加载 Vertx.vertx(),建议显式注入以统一生命周期。 +- 必须先 WebClientVertxInit.init(Vertx);未初始化时会直接报错,避免解析器偷偷创建第二个 Vert.x 实例。 - 支持三种同步方法: - `parseSync()`: 解析单个文件下载链接 - `parseFileListSync()`: 解析文件列表 diff --git a/parser/doc/security/DOS_FIX_V2.md b/parser/doc/security/DOS_FIX_V2.md index 20b1a53..c5aaf54 100644 --- a/parser/doc/security/DOS_FIX_V2.md +++ b/parser/doc/security/DOS_FIX_V2.md @@ -17,7 +17,7 @@ this.temporaryExecutor = WebClientVertxInit.get().createSharedWorkerExecutor( "playground-temp-" + System.currentTimeMillis(), 1, // 每个请求只需要1个线程 - 10000000000L // 设置非常长的超时,避免被vertx强制中断 + 10000000000L // 设置非常长的超时,避免触发Vert.x阻塞线程告警 ); // 执行完成或超时后关闭 diff --git a/parser/doc/security/NASHORN_LIMITATIONS.md b/parser/doc/security/NASHORN_LIMITATIONS.md index 0904268..5fb3f75 100644 --- a/parser/doc/security/NASHORN_LIMITATIONS.md +++ b/parser/doc/security/NASHORN_LIMITATIONS.md @@ -106,7 +106,7 @@ executionFuture.toCompletionStage() ### 长期方案(需大量工作) 1. **迁移到GraalVM JavaScript引擎** - 支持CPU时间限制 - - 可以强制中断 + - 相比Nashorn更容易实现受控取消 - 更好的性能 - 但需要额外依赖 diff --git a/parser/src/main/resources/custom-parsers/README.md b/parser/src/main/resources/custom-parsers/README.md index 1d87bfa..1da547b 100644 --- a/parser/src/main/resources/custom-parsers/README.md +++ b/parser/src/main/resources/custom-parsers/README.md @@ -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 diff --git a/parser/src/main/resources/custom-parsers/types.js b/parser/src/main/resources/custom-parsers/types.js index 5162b5f..d1a75c0 100644 --- a/parser/src/main/resources/custom-parsers/types.js +++ b/parser/src/main/resources/custom-parsers/types.js @@ -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解码(静态方法) */ diff --git a/parser/src/main/resources/logback.xml b/parser/src/main/resources/logback.xml index f7f57a9..df9e958 100644 --- a/parser/src/main/resources/logback.xml +++ b/parser/src/main/resources/logback.xml @@ -37,10 +37,10 @@ - - 0 + + 20 - 256 + 512