Compare commits

..
14 Commits
Author SHA1 Message Date
q ca2444db65 Merge remote-tracking branch 'origin/copilot/add-new-py-tag' into feature/graalpy-parser 2026-01-12 09:31:43 +08:00
copilot-swe-agent[bot]andqaiu 81bb35ec29 docs: Add notes about using latest py tag version
- Add links to Releases page for finding latest py tag
- Add reminder to replace version number with latest
- Address code review feedback about hardcoded versions

Co-authored-by: qaiu <[email protected]>
2026-01-11 17:13:06 +00:00
copilot-swe-agent[bot]andqaiu 2e8b4f834a docs: Add branch notice at top of README
- Add clear notice indicating this is the Python parser support branch
- Explain difference between py tags and latest tag
- Provide link to main branch for users who want standard version

Co-authored-by: qaiu <[email protected]>
2026-01-11 17:11:29 +00:00
copilot-swe-agent[bot]andqaiu 1d289c7c97 docs: Update README with Python version Docker deployment instructions
- Add Python version (py tag) Docker deployment section
- Clarify that py branch uses py-tagged images
- Keep standard version (latest tag) instructions for reference
- Add notes about image size and Python features
- Specify manual version upgrades for py tags (no latest tag updates)

Co-authored-by: qaiu <[email protected]>
2026-01-11 17:10:25 +00:00
copilot-swe-agent[bot] 9b037031d4 Initial plan 2026-01-11 17:05:36 +00:00
copilot-swe-agent[bot] 1da2044a75 Initial plan 2026-01-11 16:28:08 +00:00
q 2fcf9cfab1 docs: 更新文档导航和解析器指南
- 添加演练场(Playground)文档导航区到主 README
- 新增 Python 解析器文档链接(开发指南、测试报告、LSP集成)
- 更新前端版本号至 0.1.9b19p
- 补充 Python 解析器 requests 库使用章节和官方文档链接
- 添加 JavaScript 和 Python 解析器的语言版本和官方文档
- 优化文档结构,分类为项目文档和外部资源
2026-01-11 22:35:45 +08:00
q b8eee2b8a7 docs: 创建Python解析器开发指南并更新相关文档
- 新增 PYTHON_PARSER_GUIDE.md 完整开发指南
- 更新 CUSTOM_PARSER_GUIDE.md 添加Python解析器引用
- 更新 parser/README.md 添加Python文档链接
2026-01-11 07:43:01 +08:00
q 0a0e2d69fa fix: 为PyPlaygroundLogger重写方法添加@HostAccess.Export注解 2026-01-11 07:31:13 +08:00
q 5883c9f7fd fix: 移除python-embedding依赖,只使用标准Polyglot API
- 移除pom.xml中的python-embedding依赖
- 修改PyContextPool使用标准Polyglot API创建Engine和Context
- 更新checkGraalPyAvailability方法使用标准API检查
- 测试验证通过
2026-01-11 07:27:10 +08:00
q 2e5e679cea fix: 增强PyContextPool Engine创建的兼容性并添加GraalPy测试 2026-01-11 07:25:32 +08:00
q 88d675fe95 fix: 添加缺失的python运行时pom依赖 2026-01-11 07:20:58 +08:00
q b179194753 fix: 修复GraalPy依赖并添加Context池化和完整单元测试
- 修复parser pom.xml中GraalPy依赖配置
- 修复web-front Playground.vue中Tab选中异常bug
- 添加PyContextPool实现Context池化管理
- 更新PyPlaygroundExecutor和PyParserExecutor使用池化
- 创建PyParserTest完整单元测试
- 创建PyHttpClientTest HTTP客户端测试
- 创建PyCryptoUtilsTest加密工具测试
- 修复所有ShareLinkInfo构造相关错误
2026-01-11 03:19:31 +08:00
q 62cc7449fd feat: add GraalPy Python parser support 2026-01-11 02:40:33 +08:00
248 changed files with 21544 additions and 20785 deletions
+346
View File
@@ -0,0 +1,346 @@
# NetDisk Fast Download - Agent 规则文件
## 项目概述
网盘快速下载项目,支持多种网盘链接解析和下载加速。
## 技术栈
### 后端
- **Java 版本**: JDK 17
- **构建工具**: Maven 3.x
- **核心框架**: Vert.x 4.5.23
- **日志框架**: SLF4J 2.0.5 + Logback 1.5.19
- **工具库**:
- Lombok 1.18.38
- Apache Commons Lang3 3.18.0
- Apache Commons BeanUtils 2.0.0
- Jackson 2.14.2
- Reflections 0.10.2
### 前端
- Vue.js 框架
- Monaco Editor (代码编辑器)
### 测试
- JUnit 4.13.2
- **Maven 测试配置**: 默认跳过测试,使用 `-Dmaven.test.skip=false` 执行测试
## 项目模块结构
```
netdisk-fast-download/
├── core/ # 核心功能模块
├── core-database/ # 数据库模块
├── parser/ # 解析器模块(支持自定义解析器)
├── web-service/ # Web 服务模块
└── web-front/ # 前端模块
```
## 编码规范
### Java 代码规范
1. **使用 Lombok 注解简化代码**
- `@Data`, `@Getter`, `@Setter`, `@Builder` 等
- `@Slf4j` 用于日志
2. **异步编程**
- 使用 Vert.x 的 Future/Promise 模式
- 遵循响应式编程范式
- 避免阻塞操作
3. **日志规范**
- 使用 SLF4J + Logback
- 日志级别:ERROR(错误)、WARN(警告)、INFO(重要信息)、DEBUG(调试信息)
- 日志文件按日期分目录存储在 `logs/` 下
4. **包命名规范**
- 基础包名:`cn.qaiu`
- 子包按模块功能划分
### 测试规范
1. **默认跳过测试**: 打包时使用 `mvn clean package`
2. **执行测试**: 使用 `mvn test -Dmaven.test.skip=false`
3. 测试类放在 `src/test/java` 目录下
### Core 模块封装(禁止重复造轮子)
#### Web 路由封装
**核心类**: `cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory`
使用注解方式定义路由,无需手动创建 Router:
```java
// ✅ 推荐:使用注解定义路由
@RouteHandler("/api") // 类级别路由前缀
@Slf4j
public class MyController {
@RouteMapping(value = "/users", method = RouteMethod.GET)
public Future<List<User>> getUsers() {
// 返回 Future,框架自动处理响应
return userService.findAll();
}
@RouteMapping(value = "/user/:id", method = RouteMethod.GET)
public Future<User> getUserById(String id) {
// 路径参数自动注入
return userService.findById(id);
}
@RouteMapping(value = "/user", method = RouteMethod.POST)
public Future<JsonResult<User>> createUser(HttpServerRequest request, String name, Integer age) {
// 查询参数自动注入
return userService.create(name, age)
.map(JsonResult::success);
}
}
// ❌ 避免:手动创建路由
Router router = Router.router(vertx);
router.get("/api/users").handler(ctx -> {
// 不要这样写
});
```
**支持的注解:**
- `@RouteHandler(value="/path", order=0)` - 标记路由处理类
- `@RouteMapping(value="/path", method=RouteMethod.GET)` - 标记路由方法
- `@SockRouteMapper("/ws")` - WebSocket 路由
**自动参数注入:**
- `HttpServerRequest` - 请求对象
- `HttpServerResponse` - 响应对象
- `RoutingContext` - 路由上下文
- `String param` - 路径参数或查询参数(自动匹配名称)
- 自定义对象 - 自动从请求体反序列化
#### 响应处理工具
**工具类**: `cn.qaiu.vx.core.util.ResponseUtil`
```java
// ✅ 推荐:使用 ResponseUtil
ResponseUtil.redirect(response, "https://example.com");
ResponseUtil.fireJsonObjectResponse(ctx, jsonObject);
ResponseUtil.fireJsonResultResponse(ctx, JsonResult.success(data));
// ❌ 避免:手动设置响应头
response.putHeader("Content-Type", "application/json");
response.end(json);
```
#### 统一响应模型
**模型类**: `cn.qaiu.vx.core.model.JsonResult<T>`
```java
// ✅ 推荐:使用 JsonResult 统一响应格式
public Future<JsonResult<User>> getUser(String id) {
return userService.findById(id)
.map(JsonResult::success) // 成功响应
.otherwise(err -> JsonResult.error(err.getMessage())); // 错误响应
}
// 响应格式:
// {"code": 200, "msg": "success", "success": true, "data": {...}, "timestamp": 123456789}
```
#### 异步服务代理
**工具类**: `cn.qaiu.vx.core.util.AsyncServiceUtil`
```java
// ✅ 推荐:使用服务代理
private final UserService userService = AsyncServiceUtil.getAsyncServiceInstance(UserService.class);
// ❌ 避免:手动管理服务实例和 EventBus
```
### Core-Database 模块封装(禁止重复造轮子)
#### DDL 自动生成
**核心类**: `cn.qaiu.db.ddl.CreateTable`
使用注解定义实体,自动生成建表 SQL:
```java
// ✅ 推荐:使用注解定义实体
@Data
@Table("users") // 表名
public class User {
@Constraint(autoIncrement = true)
private Long id; // 自动识别为主键
@Constraint(notNull = true, uniqueKey = "uk_email")
@Length(varcharSize = 100)
private String email;
@Constraint(notNull = true)
private String name;
@Constraint(defaultValue = "0", defaultValueIsFunction = false)
private Integer status;
@Constraint(defaultValue = "NOW()", defaultValueIsFunction = true)
private Date createdAt;
}
// 自动建表
CreateTable.createTable(pool, JDBCType.MySQL);
// ❌ 避免:手写建表 SQL
pool.query("CREATE TABLE users (...)").execute();
```
**支持的注解:**
- `@Table("tableName")` - 指定表名和主键
- `@Constraint` - 字段约束
- `notNull` - 非空约束
- `uniqueKey` - 唯一键约束
- `defaultValue` - 默认值
- `autoIncrement` - 自增
- `@Length` - 字段长度
- `varcharSize` - VARCHAR 长度
- `decimalSize` - DECIMAL 精度
- `@TableGenIgnore` - 忽略字段(不生成列)
- `@Column(name="column_name")` - 自定义列名
#### 自动数据库创建
**工具类**: `cn.qaiu.db.ddl.CreateDatabase`
```java
// ✅ 推荐:自动创建数据库
JsonObject dbConfig = config.getJsonObject("database");
CreateDatabase.createDatabase(dbConfig);
// ❌ 避免:手动连接和执行 SQL
```
### Parser 模块特殊说明
1. 支持自定义解析器(Java、Python、JavaScript
2. Python 解析器使用 GraalPy 实现
3. 支持 WebSocket 连接到外部 Python 环境
4. 包含安全测试和沙箱机制
## Maven 命令
### 常用命令
```bash
# 编译打包(跳过测试)
mvn clean package
# 安装到本地仓库(跳过测试)
mvn clean install
# 执行测试
mvn test -Dmaven.test.skip=false
# 编译并执行测试
mvn clean package -Dmaven.test.skip=false
# 只编译不打包
mvn clean compile
# 清理
mvn clean
```
### 模块化构建
```bash
# 只构建特定模块
mvn clean package -pl parser -am
# 构建多个模块
mvn clean package -pl core,parser -am
```
## 部署相关
### 目录结构
- `bin/`: 启动脚本和服务安装脚本
- `db/`: 数据库文件
- `logs/`: 日志文件(按日期分目录)
- `webroot/`: Web 静态资源根目录
### 脚本文件
- `run.sh` / `run.bat`: 启动脚本
- `stop.sh`: 停止脚本
- `service-install.sh`: Linux 服务安装
- `nfd-service-install.bat`: Windows 服务安装
## 开发注意事项
1. **字符编码**: 统一使用 UTF-8
2. **Java 版本**: 必须使用 JDK 17 或更高版本
3. **Vert.x 异步**: 避免在 Event Loop 线程中执行阻塞操作
4. **资源文件**:
- 静态资源放在 `webroot/` 目录
- 前端构建产物输出到 `web-front/public/`
5. **日志文件**: 不要提交 `logs/` 目录到版本控制
6. **测试**: 新增功能必须编写单元测试,使用 `-Dmaven.test.skip=false` 验证
## 代码审查要点
1. 是否正确处理异步操作
2. 是否有潜在的资源泄漏(连接、文件句柄等)
3. 异常处理是否完善
4. 日志记录是否合理
5. 是否遵循单一职责原则
6. 是否有适当的注释说明复杂逻辑
## 性能优化建议
1. 使用 Vert.x 的异步特性,避免阻塞
2. 合理使用缓存机制
3. 数据库查询优化
4. 静态资源压缩和缓存策略
5. 使用连接池管理数据库连接
## 安全注意事项
1. **Parser 模块**:
- 自定义解析器需要经过安全验证
- Python/JavaScript 代码执行需要沙箱隔离
- 参考 `parser/doc/SECURITY_TESTING_GUIDE.md`
2. **输入验证**:
- 所有外部输入必须验证和清理
- 防止注入攻击
3. **敏感信息**:
- 不要在日志中输出敏感信息
- 配置文件中的密钥要加密存储
## 文档参考
- Parser 模块文档: `parser/doc/`
- API 使用指南: `API_USAGE.md`
- 自定义解析器指南: `CUSTOM_PARSER_GUIDE.md`
- Python 解析器指南: `PYTHON_PARSER_GUIDE.md`
- JavaScript 解析器指南: `JAVASCRIPT_PARSER_GUIDE.md`
- 安全测试指南: `SECURITY_TESTING_GUIDE.md`
- 前端文档: `web-front/doc/`
- Monaco Editor 集成: `MONACO_EDITOR_NPM.md`
- Playground UI 升级: `PLAYGROUND_UI_UPGRADE.md`
## Git 提交规范
使用语义化提交信息:
- `feat`: 新功能
- `fix`: 修复 Bug
- `docs`: 文档更新
- `style`: 代码格式调整
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具链相关
示例:
```
feat(parser): 添加新的网盘解析器支持
fix(core): 修复下载链接过期问题
docs(readme): 更新安装说明
```
## AI 助手使用建议
1. 在修改代码前,先理解项目的模块结构和依赖关系
2. 生成的代码要符合项目现有的编码风格
3. 涉及异步操作时,优先使用 Vert.x 的 Future/Promise API
4. 修改配置文件时要考虑向后兼容性
5. 新增功能时同步更新相关文档
+495
View File
@@ -0,0 +1,495 @@
# GitHub Copilot Instructions - NetDisk Fast Download
## 项目简介
网盘快速下载项目,支持多种网盘链接解析和下载加速的 Java Web 应用。
## 技术栈要求
### 核心技术
- **Java**: JDK 17(必须)
- **框架**: Vert.x 4.5.23(异步响应式框架)
- **构建**: Maven 3.x
- **日志**: SLF4J 2.0.5 + Logback 1.5.19
- **前端**: Vue.js + Monaco Editor
### 重要依赖
- Lombok 1.18.38 - 简化 Java 代码
- Jackson 2.14.2 - JSON 处理
- Commons Lang3 3.18.0 - 工具类
- Reflections 0.10.2 - 反射工具
## 代码生成规范
### Java 代码风格
#### 1. 使用 Lombok 简化代码
```java
// ✅ 推荐:使用 Lombok 注解
@Data
@Builder
@Slf4j
public class Example {
private String name;
private int value;
}
// ❌ 避免:手写 getter/setter
public class Example {
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
```
#### 2. 异步编程模式(Vert.x
```java
// ✅ 推荐:使用 Vert.x Future
public Future<String> fetchData() {
return vertx.createHttpClient()
.request(HttpMethod.GET, "http://example.com")
.compose(HttpClientRequest::send)
.compose(response -> response.body())
.map(Buffer::toString);
}
// ❌ 避免:阻塞操作
public String fetchData() {
// 不要在 Event Loop 中执行阻塞代码
Thread.sleep(1000); // ❌
return result;
}
```
#### 3. 日志记录
```java
// ✅ 推荐:使用 @Slf4j + 参数化日志
@Slf4j
public class Service {
public void process(String id) {
log.info("Processing item: {}", id);
try {
// ...
} catch (Exception e) {
log.error("Failed to process item: {}", id, e);
}
}
}
// ❌ 避免:字符串拼接
log.info("Processing item: " + id); // 性能差
System.out.println("Debug info"); // 不使用 System.out
```
#### 4. 异常处理
```java
// ✅ 推荐:完整的异常处理
public Future<Result> operation() {
return service.execute()
.recover(err -> {
log.error("Operation failed", err);
return Future.succeededFuture(Result.error(err.getMessage()));
});
}
// ❌ 避免:空的 catch 块或吞掉异常
try {
doSomething();
} catch (Exception e) {
// ❌ 空 catch
}
```
### 包和类命名
- 基础包名:`cn.qaiu`
- 模块包结构:
- `cn.qaiu.core.*` - 核心功能
- `cn.qaiu.parser.*` - 解析器相关
- `cn.qaiu.db.*` - 数据库相关
- `cn.qaiu.service.*` - 业务服务
- `cn.qaiu.web.*` - Web 相关
### 测试代码
```java
// ✅ 推荐:JUnit 4 测试
public class ServiceTest {
@Before
public void setUp() {
// 初始化
}
@Test
public void testMethod() {
// Given
String input = "test";
// When
String result = service.process(input);
// Then
assertEquals("expected", result);
}
@After
public void tearDown() {
// 清理
}
}
```
## 特定模块指导
### Core 模块 - Web 路由封装(必须使用,禁止重复造轮子)
**核心思想:使用注解定义路由,框架自动处理请求和响应**
#### 1. 使用 @RouteHandler 和 @RouteMapping
```java
// ✅ 推荐:使用注解定义路由
@RouteHandler(value = "/api/v1", order = 10)
@Slf4j
public class UserController {
private final UserService userService = AsyncServiceUtil.getAsyncServiceInstance(UserService.class);
// GET /api/v1/users
@RouteMapping(value = "/users", method = RouteMethod.GET)
public Future<JsonResult<List<User>>> getUsers() {
return userService.findAll()
.map(JsonResult::success)
.otherwise(err -> JsonResult.error(err.getMessage()));
}
// GET /api/v1/user/:id (路径参数自动注入)
@RouteMapping(value = "/user/:id", method = RouteMethod.GET)
public Future<User> getUser(String id) {
// 返回值自动序列化为 JSON
return userService.findById(id);
}
// POST /api/v1/user (查询参数自动注入)
@RouteMapping(value = "/user", method = RouteMethod.POST)
public Future<JsonResult<User>> createUser(HttpServerRequest request, String name, Integer age) {
return userService.create(name, age)
.map(JsonResult::success);
}
// 重定向示例
@RouteMapping(value = "/redirect/:id", method = RouteMethod.GET)
public void redirect(HttpServerResponse response, String id) {
String targetUrl = "https://example.com/" + id;
ResponseUtil.redirect(response, targetUrl);
}
}
// ❌ 避免:手动创建 Router 和 Handler
Router router = Router.router(vertx);
router.get("/api/users").handler(ctx -> {
// 不要这样写!使用注解方式
});
```
#### 2. 自动参数注入规则
- **路径参数**`/user/:id``public Future<User> getUser(String id)`
- **查询参数**`?name=xxx&age=18``public Future<User> create(String name, Integer age)`
- **Vert.x 对象**:自动注入 `HttpServerRequest`, `HttpServerResponse`, `RoutingContext`
- **请求体**POST/PUT 的 JSON 自动反序列化为方法参数对象
#### 3. 响应处理
```java
// 方式1:返回 Future,框架自动处理
public Future<User> getUser(String id) {
return userService.findById(id); // 自动序列化为 JSON
}
// 方式2:返回 JsonResult 统一格式
public Future<JsonResult<User>> getUser(String id) {
return userService.findById(id).map(JsonResult::success);
}
// 方式3:手动控制响应(仅在特殊情况使用)
public void customResponse(HttpServerResponse response) {
ResponseUtil.fireJsonObjectResponse(response, jsonObject);
}
```
#### 4. WebSocket 路由
```java
@RouteHandler("/ws")
public class WebSocketHandler {
@SockRouteMapper("/chat")
public void handleChat(SockJSSocket socket) {
socket.handler(buffer -> {
log.info("Received: {}", buffer.toString());
socket.write(buffer); // Echo
});
}
}
```
### Core-Database 模块 - DDL 自动生成(必须使用,禁止重复造轮子)
**核心思想:使用注解定义实体,自动生成建表 SQL**
#### 1. 定义实体类
```java
// ✅ 推荐:使用注解定义实体
@Data
@Table(value = "t_user", keyFields = "id") // 表名和主键
public class User {
@Constraint(autoIncrement = true)
private Long id; // 主键自增
@Constraint(notNull = true, uniqueKey = "uk_email")
@Length(varcharSize = 100)
private String email; // 非空 + 唯一索引 + 长度100
@Constraint(notNull = true)
@Length(varcharSize = 50)
private String name;
@Constraint(defaultValue = "0")
private Integer status; // 默认值 0
@Constraint(defaultValue = "NOW()", defaultValueIsFunction = true)
private Date createdAt; // 默认当前时间
@TableGenIgnore // 忽略此字段,不生成列
private transient String tempField;
}
// 应用启动时自动建表
CreateTable.createTable(pool, JDBCType.MySQL);
// ❌ 避免:手写建表 SQL
String sql = "CREATE TABLE t_user (id BIGINT AUTO_INCREMENT PRIMARY KEY, ...)";
pool.query(sql).execute(); // 不要这样写!
```
#### 2. 支持的注解
**@Table** - 表定义
- `value` - 表名(默认类名转下划线)
- `keyFields` - 主键字段名(默认 "id"
**@Constraint** - 字段约束
- `notNull = true` - 非空约束
- `uniqueKey = "uk_name"` - 唯一索引(相同名称的字段组成联合唯一索引)
- `defaultValue = "value"` - 默认值
- `defaultValueIsFunction = true` - 默认值是函数(如 NOW()
- `autoIncrement = true` - 自增(仅用于主键)
**@Length** - 字段长度
- `varcharSize = 255` - VARCHAR 长度(默认 255
- `decimalSize = {10, 2}` - DECIMAL 精度(默认 {22, 2}
**@Column** - 自定义列名
- `name = "column_name"` - 指定数据库列名
**@TableGenIgnore** - 忽略字段(不生成列)
#### 3. 自动创建数据库
```java
// ✅ 推荐:自动创建数据库
JsonObject dbConfig = new JsonObject()
.put("jdbcUrl", "jdbc:mysql://localhost:3306/mydb")
.put("username", "root")
.put("password", "password");
CreateDatabase.createDatabase(dbConfig);
// ❌ 避免:手动连接和执行 CREATE DATABASE
```
#### 4. 支持的数据库类型
- `JDBCType.MySQL` - MySQL
- `JDBCType.PostgreSQL` - PostgreSQL
- `JDBCType.H2DB` - H2 数据库
### Parser 模块
- 支持自定义解析器(Java/Python/JavaScript
- Python 使用 GraalPy 执行
- 需要考虑安全性和沙箱隔离
- WebSocket 支持外部 Python 环境连接
```java
// Parser 接口实现示例
public class CustomParser implements IParser {
@Override
public Future<ParseResult> parse(String url, Map<String, String> params) {
return Future.future(promise -> {
// 异步解析逻辑
promise.complete(result);
});
}
}
```
## Maven 配置注意事项
### 测试执行
```bash
# 默认打包跳过测试
mvn clean package
# 执行测试
mvn test -Dmaven.test.skip=false
mvn clean package -Dmaven.test.skip=false
```
### 模块化构建
```bash
# 构建特定模块
mvn clean package -pl parser -am
```
## 重要约定
### 1. 异步优先
- 所有 I/O 操作必须异步
- 使用 Vert.x Future/Promise API
- 避免阻塞 Event Loop
### 2. 资源管理
```java
// ✅ 推荐:使用 try-with-resources
try (InputStream is = new FileInputStream(file)) {
// 使用资源
}
// 或者确保在 finally 中关闭
HttpClient client = vertx.createHttpClient();
// 使用后必须关闭
client.close();
```
### 3. 配置外部化
- 配置文件优先使用 JSON 格式
- 敏感信息不要硬编码
- 支持环境变量覆盖
### 4. 错误处理
- 使用 Future 的 recover/otherwise
- 记录详细的错误日志
- 向用户返回友好的错误信息
## 性能考虑
1. **使用连接池**: 数据库连接、HTTP 客户端
2. **缓存策略**: 解析结果、静态资源
3. **批量操作**: 避免 N+1 查询问题
4. **异步非阻塞**: 充分利用 Vert.x 优势
## 安全要求
### Parser 模块安全
- 执行自定义代码必须沙箱隔离
- 限制资源访问(文件、网络)
- 设置执行超时
- 验证输入参数
```java
// ✅ 推荐:带安全检查的执行
public Future<Result> executeUserCode(String code) {
// 验证代码
if (!SecurityValidator.isValid(code)) {
return Future.failedFuture("Invalid code");
}
// 在沙箱中执行
return sandboxExecutor.execute(code, TIMEOUT);
}
```
### 输入验证
```java
// ✅ 推荐:验证所有外部输入
public Future<Result> parse(String url) {
if (StringUtils.isBlank(url) || !UrlValidator.isValid(url)) {
return Future.failedFuture("Invalid URL");
}
// 继续处理
}
```
## 文档和注释
### JavaDoc 注释
```java
/**
* 解析网盘链接获取下载信息
*
* @param url 网盘分享链接
* @param params 额外参数(如密码)
* @return Future<ParseResult> 解析结果
*/
public Future<ParseResult> parse(String url, Map<String, String> params) {
// 实现
}
```
### 复杂逻辑注释
```java
// 处理特殊情况:某些网盘需要二次验证
// 参考文档:docs/parser-flow.md
if (needsSecondaryVerification) {
// 实现二次验证逻辑
}
```
## 常见模式
### 链式异步调用
```java
return fetchMetadata(url)
.compose(meta -> validateMetadata(meta))
.compose(meta -> fetchDownloadUrl(meta))
.compose(downloadUrl -> generateResult(downloadUrl))
.recover(this::handleError);
```
### 事件处理
```java
vertx.eventBus().<JsonObject>consumer("parser.request", msg -> {
JsonObject body = msg.body();
parse(body.getString("url"))
.onSuccess(result -> msg.reply(JsonObject.mapFrom(result)))
.onFailure(err -> msg.fail(500, err.getMessage()));
});
```
## 不应该做的事
1. ❌ 在 Event Loop 线程中执行阻塞操作
2. ❌ 使用 `System.out.println()` 而不是日志框架
3. ❌ 硬编码配置值(端口、路径、密钥等)
4. ❌ 忽略异常或使用空 catch 块
5. ❌ 返回 null,应该使用 Optional 或 Future.failedFuture()
6. ❌ 在生产代码中使用 `e.printStackTrace()`
7. ❌ 直接操作 Thread 而不使用 Vert.x 的 executeBlocking
8. ❌ 提交包含 `logs/` 目录的代码
## 代码审查清单
生成代码时请确保:
- [ ] 使用 Lombok 注解简化代码
- [ ] 异步操作使用 Vert.x Future
- [ ] 添加了 @Slf4j 和适当的日志
- [ ] 异常处理完整
- [ ] 输入参数已验证
- [ ] 资源正确释放
- [ ] 添加了必要的 JavaDoc
- [ ] 遵循项目包命名规范
- [ ] 没有阻塞操作在 Event Loop 中
- [ ] 测试用例覆盖主要场景
## 参考资源
- Vert.x 文档: https://vertx.io/docs/
- 项目 Parser 文档: `parser/doc/`
- 前端文档: `web-front/doc/`
- 安全测试指南: `parser/doc/SECURITY_TESTING_GUIDE.md`
+8 -8
View File
@@ -35,11 +35,11 @@ jobs:
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-m2
- name: 编译项目
run: ./mvnw clean compile
# - name: 运行测试
# run: ./mvnw test
- name: 打包项目
run: ./mvnw package -DskipTests
- name: 安装 GraalPy pip 包
run: |
cd parser
chmod +x setup-graalpy-packages.sh
./setup-graalpy-packages.sh
- name: 编译并打包项目
run: ./mvnw clean package -DskipTests
+58 -217
View File
@@ -1,16 +1,24 @@
name: Java CIMaven 构建 + Docker 镜像 + 原生环境打包)
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
name: Java CI with Maven
# The API requires write permission on the repository to submit dependencies
permissions:
contents: write
packages: write
on:
workflow_dispatch:
push:
tags:
- '*'
- '*' # 只有推送tag时才会触发构建
branches-ignore:
- '*'
- '*' # 排除所有分支的提交
paths-ignore:
- 'bin/**'
- '.github/**'
@@ -24,260 +32,93 @@ on:
- "main"
jobs:
# ================================================================
# 阶段一:构建前端 + Maven 打包(只执行一次,产物共享)
# ================================================================
build:
name: 编译构建
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v3
- name: 设置 Node.js 18
uses: actions/setup-node@v4
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v4
with:
node-version: '18'
- name: 设置 JDK 17
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
cache: maven
- name: 构建前端
- name: Build Frontend
run: cd web-front && yarn install && yarn run build
- name: Maven 编译打包
- name: Install GraalPy pip packages (for Python tags)
if: contains(github.ref, 'py')
run: |
cd parser
chmod +x setup-graalpy-packages.sh
./setup-graalpy-packages.sh
- name: Build with Maven
run: mvn -B package -DskipTests --file pom.xml
- name: 更新依赖图谱
# Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive
- name: Update dependency graph
uses: advanced-security/maven-dependency-submission-action@v3
if: github.event_name != 'pull_request'
continue-on-error: true
with:
ignore-maven-wrapper: true
- name: 分享应用打包目录(供原生包和 Docker 复用)
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
with:
name: app-package
path: web-service/target/package/
# - uses: release-drafter/release-drafter@v5
# env:
# GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: 分享 bin-zip(供 Docker 复用)
if: github.event_name != 'pull_request'
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: app-bin-zip
path: web-service/target/netdisk-fast-download-bin.zip
# ================================================================
# 阶段二-A:Docker 镜像构建(并行)
# ================================================================
docker:
name: Docker 镜像
needs: build
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- name: 检出代码
uses: actions/checkout@v3
- name: 下载 bin-zip 产物
uses: actions/download-artifact@v4
with:
name: app-bin-zip
path: web-service/target/
- name: 登录 GitHub 容器仓库
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 设置 QEMU(多平台构建支持)
uses: docker/setup-qemu-action@v3
- name: 设置 Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 生成 Docker 标签
id: docker_tag
shell: bash
- name: Extract git tag
id: tag
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"
GIT_TAG=$(git tag --points-at HEAD | head -n 1)
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
# 检查是否为 Python 版本标签(以 py 结尾)
if [[ "$GIT_TAG" == *py ]]; then
echo "is_python=true" >> $GITHUB_OUTPUT
else
echo "is_python=false" >> $GITHUB_OUTPUT
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: 构建并推送 Docker 镜像
- name: Build and push Docker image (Standard)
if: github.event_name != 'pull_request' && steps.tag.outputs.is_python == 'false'
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/${{ github.repository }}:${{ steps.docker_tag.outputs.tag }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/qaiu/netdisk-fast-download:${{ steps.tag.outputs.tag }}
ghcr.io/qaiu/netdisk-fast-download:latest
# ================================================================
# 阶段二-B:原生环境打包 Linux + Windows(并行)
# ================================================================
native-package:
name: 原生环境打包 → ${{ matrix.artifact-name }}
needs: build
if: github.event_name != 'pull_request'
strategy:
matrix:
include:
- os: ubuntu-latest
artifact-name: netdisk-fast-download-linux-amd64
- os: windows-latest
artifact-name: netdisk-fast-download-windows-amd64
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: 设置 JDK 17
uses: actions/setup-java@v3
- name: Build and push Docker image (Python)
if: github.event_name != 'pull_request' && steps.tag.outputs.is_python == 'true'
uses: docker/build-push-action@v5
with:
java-version: '17'
distribution: 'temurin'
- name: 下载 Maven 构建产物
uses: actions/download-artifact@v4
with:
name: app-package
path: web-service/target/package
# ============================================================
# jdeps 分析 → 确定所需 JDK 模块
# ============================================================
- name: 分析所需 JDK 模块(jdeps
run: |
MAIN_JAR="web-service/target/package/netdisk-fast-download.jar"
LIB_DIR="web-service/target/package/lib"
CP=""
for jar in "$LIB_DIR"/*.jar; do
CP="$CP${CP:+:}$jar"
done
RAW_MODULES=$(jdeps --print-module-deps --ignore-missing-deps --multi-release 17 \
--class-path "$CP" "$MAIN_JAR" 2>/dev/null | head -n 1 | tr -d '\r\n' || true)
if [ -z "$RAW_MODULES" ] || [[ "$RAW_MODULES" == *"Missing"* ]] || [[ "$RAW_MODULES" == *"Error"* ]]; then
# ⚠️ 回退列表:若项目新增了需要 java.* / jdk.* 模块的依赖,需同步更新此处
RAW_MODULES="java.base,java.logging,java.sql,java.naming,java.management,java.xml,jdk.unsupported,java.net.http,java.instrument,java.security.jgss,java.security.sasl,java.desktop,jdk.crypto.ec"
echo "jdeps 分析失败,使用回退模块列表"
else
# 补上 jdeps 无法检测的反射/SPI依赖
RAW_MODULES="$RAW_MODULES,java.desktop,jdk.crypto.ec"
fi
echo "detected modules: $RAW_MODULES"
printf 'JDK_MODULES=%s\n' "$RAW_MODULES" >> $GITHUB_ENV
# ============================================================
# jlink 生成精简 JRE
# ============================================================
- name: 生成精简 JREjlink
run: |
jlink \
--module-path "$JAVA_HOME/jmods" \
--add-modules "$JDK_MODULES" \
--output "native-package/netdisk-fast-download/jre" \
--strip-debug \
--compress=2 \
--no-header-files \
--no-man-pages
echo "JRE size:"
du -sh native-package/netdisk-fast-download/jre || true
# Windows: 确保 MSVC 运行时 DLL 到位
if [[ "$RUNNER_OS" == "Windows" ]]; then
JRE_BIN="native-package/netdisk-fast-download/jre/bin"
for dll in vcruntime140.dll msvcp140.dll vcruntime140_1.dll; do
if [ ! -f "$JRE_BIN/$dll" ] && [ -f "$JAVA_HOME/bin/$dll" ]; then
echo "jlink 未包含 $dll,从 JDK 补拷"
cp "$JAVA_HOME/bin/$dll" "$JRE_BIN/"
fi
done
echo "=== JRE bin 目录 DLL 清单 ==="
ls -la "$JRE_BIN"/*.dll 2>/dev/null || echo "(无 .dll 文件)"
fi
# ============================================================
# 组装包目录
# ============================================================
- name: 组装包目录
run: |
PKG="native-package/netdisk-fast-download"
SRC="web-service/target/package"
cp "$SRC/netdisk-fast-download.jar" "$PKG/"
cp -r "$SRC/lib" "$PKG/"
cp -r "$SRC/resources" "$PKG/"
cp -r "$SRC/webroot" "$PKG/"
mkdir -p "$PKG/db"
mkdir -p "$PKG/logs"
# ============================================================
# 生成启动脚本
# ============================================================
- name: 生成启动脚本(Linux
run: |
PKG="native-package/netdisk-fast-download"
echo '#!/bin/bash' > "$PKG/run.sh"
echo 'DIR="$(cd "$(dirname "$0")" && pwd)"' >> "$PKG/run.sh"
echo 'cd "$DIR" || exit 1' >> "$PKG/run.sh"
echo 'exec "$DIR/jre/bin/java" -Xmx512M -Dfile.encoding=utf-8 -jar "$DIR/netdisk-fast-download.jar" "$@"' >> "$PKG/run.sh"
chmod +x "$PKG/run.sh"
- name: 生成启动脚本(Windows
run: |
PKG="native-package/netdisk-fast-download"
echo '@echo off' > "$PKG/run.bat"
echo 'chcp 65001 > nul' >> "$PKG/run.bat"
echo 'pushd %~dp0' >> "$PKG/run.bat"
echo '"%~dp0jre\bin\java.exe" -Xmx512M -Dfile.encoding=utf-8 -jar "%~dp0netdisk-fast-download.jar" %*' >> "$PKG/run.bat"
# ============================================================
# 打包为 zip
# ============================================================
- name: 打包 ZIPLinux
if: runner.os == 'Linux'
run: |
cd native-package
zip -r "../${{ matrix.artifact-name }}.zip" netdisk-fast-download/
- name: 打包 ZIPWindows
if: runner.os == 'Windows'
shell: pwsh
run: |
Compress-Archive -Path native-package/netdisk-fast-download -DestinationPath "${{ matrix.artifact-name }}.zip"
# ============================================================
# 上传产物
# ============================================================
- name: 上传原生安装包
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-name }}.zip
- name: 上传到 Release
uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.artifact-name }}.zip
tag_name: ${{ github.ref_name }}
generate_release_notes: true
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
ghcr.io/qaiu/netdisk-fast-download:${{ steps.tag.outputs.tag }}
+3 -12
View File
@@ -31,7 +31,6 @@ target/
sdkTest.log
app.yml
app-local.yml
secret.yml
#some local files
@@ -42,9 +41,7 @@ gradlew.bat
unused.txt
/web-service/src/main/generated/
/db
/netdisk-fast-download/
/webroot/nfd-front/
/netdisk-fast-download/webroot/nfd-front/
package-lock.json
# Maven generated files
@@ -84,12 +81,6 @@ yarn-error.log*
*.ipr
*.iws
# Build directories
**/target/
**/build/
**/classes/
**/out/
**/${project.build.directory}/
**/${project.basedir}/target/
**/${basedir}/target/
.spec-workflow/
# GraalPy pip packages (local installation)
parser/src/main/resources/graalpy-packages/
**/graalpy-packages/
+60 -1
View File
@@ -1,18 +1,77 @@
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "PythonSecurityTestMain",
"request": "launch",
"mainClass": "cn.qaiu.parser.custompy.PythonSecurityTestMain",
"projectName": "parser"
},
{
"type": "java",
"name": "Current File",
"request": "launch",
"mainClass": "${file}"
},
{
"type": "java",
"name": "StringCase",
"request": "launch",
"mainClass": "cn.qaiu.vx.core.util.StringCase",
"projectName": "core"
},
{
"type": "java",
"name": "FCURLParser",
"request": "launch",
"mainClass": "cn.qaiu.parser.FCURLParser",
"projectName": "parser"
},
{
"type": "java",
"name": "QkTool",
"request": "launch",
"mainClass": "cn.qaiu.parser.impl.QkTool",
"projectName": "parser"
},
{
"type": "java",
"name": "WebClientExample",
"request": "launch",
"mainClass": "qaiu.web.test.WebClientExample",
"projectName": "parser"
},
{
"type": "java",
"name": "AppMain",
"request": "launch",
"mainClass": "cn.qaiu.lz.AppMain",
"projectName": "web-service"
},
{
"type": "java",
"name": "TestJs",
"request": "launch",
"mainClass": "cn.qaiu.web.test.TestJs",
"projectName": "web-service"
},
{
"type": "java",
"name": "TestOS",
"request": "launch",
"mainClass": "cn.qaiu.web.test.TestOS",
"projectName": "web-service"
},
{
"type": "java",
"name": "WebProxyExamples",
"request": "launch",
"mainClass": "cn.qaiu.web.test.WebProxyExamples",
"projectName": "web-service"
}
]
}
}
+1 -2
View File
@@ -1,5 +1,4 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "interactive",
"java.debug.settings.onBuildFailureProceed": true
"java.configuration.updateBuildConfiguration": "automatic"
}
+3 -8
View File
@@ -10,13 +10,8 @@ COPY ./web-service/target/netdisk-fast-download-bin.zip .
RUN unzip netdisk-fast-download-bin.zip && \
mv netdisk-fast-download/* ./ && \
rm netdisk-fast-download-bin.zip && \
chmod +x run.sh && \
mkdir -p db logs
chmod +x run.sh
COPY ./docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 6400 6401
EXPOSE 6401
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
ENTRYPOINT ["/docker-entrypoint.sh"]
ENTRYPOINT ["sh", "run.sh"]
+167 -124
View File
@@ -1,49 +1,43 @@
# 一款网盘分享链接云解析快速下载服务
QQ交流群:1017480890
<p align="center">
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/build.yml?branch=main&style=flat"></a>
<img src="https://github.com/user-attachments/assets/87401aae-b0b6-4ffb-bbeb-44756404d26f" alt="项目预览图" />
</p>
<p align="center">
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.27-blue?style=flat"></a>
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.23-blue?style=flat"></a>
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
<a href="https://github.com/QAIU/netdisk-fast-download">
<img src="https://img.shields.io/github/stars/QAIU/netdisk-fast-download?style=flat&logo=github" alt="GitHub Stars">
</a>
<a href="https://atomgit.com/QAIU/netdisk-fast-download"><img src="https://atomgit.com/QAIU/netdisk-fast-download/star/badge.svg" alt="AtomGit"></a>
<p align="center">
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
![alt text](web-front/img/image.png)
# netdisk-fast-download 网盘分享链接云解析服务
QQ群:1017480890
## 介绍
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
> **📌 分支说明:**
> - 本分支 (`feature/graalpy-parser`) 为 **Python 解析器支持版本**,包含 GraalPy 环境
> - 如需标准版本(更小的镜像体积),请访问 [main 分支](https://github.com/qaiu/netdisk-fast-download/tree/main)
> - Docker 镜像:Python 版本使用 py 标签(如 `v0.1.9b19py`),标准版本使用 `latest` 标签
[官方文档](https://nfd-parser.github.io/)
[API接入](https://nfdparser.apifox.cn/)
[公益解析,lz站](https://lz.qaiu.top)
[公益解析,lz0站](https://lz0.qaiu.top)
[专业版](https://189.qaiu.top)
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
## 快速开始
命令行下载分享文件:
```shell
curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
curl -LOJ "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234"
```
或者使用wget:
```shell
wget -O bilibili.mp4 "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/Tk1F2kGQ&pwd=1234"
wget -O bilibili.mp4 "https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234"
```
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4):
或者使用浏览器[直接访问](https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FnQOaNRPW&name=bilibili.mp4&ext=mp4):
```
### 调用演示站下载:
https://lz.qaiu.top/parser?url=https://www.ilanzou.com/s/CDx6xKbT&pwd=1234
https://lz.qaiu.top/parser?url=https://share.feijipan.com/s/nQOaNRPW&pwd=1234
### 调用演示站预览:
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fwww.ilanzou.com%2Fs%2FCDx6xKbT&name=bilibili.mp4&ext=mp4
https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.top%2Fparser%3Furl%3Dhttps%3A%2F%2Fshare.feijipan.com%2Fs%2FnQOaNRPW&name=bilibili.mp4&ext=mp4
```
@@ -51,14 +45,42 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
**JavaScript解析器文档:** [JavaScript解析器开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义解析器扩展指南](parser/doc/CUSTOM_PARSER_GUIDE.md) | [快速开始](parser/doc/CUSTOM_PARSER_QUICKSTART.md)
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
**Python解析器文档:** [Python解析器开发指南](parser/doc/PYTHON_PARSER_GUIDE.md) | [Playground测试报告](parser/doc/PYTHON_PLAYGROUND_TEST_REPORT.md) | [pylsp WebSocket集成](parser/doc/PYLSP_WEBSOCKET_GUIDE.md)
## 演练场(Playground
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效。**
在线编写、测试和发布解析器脚本,支持 JavaScript 和 Python 两种语言。
### 快速开始
- **[演练场使用指南](web-service/doc/PLAYGROUND_GUIDE.md)** - 完整的使用教程和最佳实践
- **[5分钟快速上手](parser/doc/CUSTOM_PARSER_QUICKSTART.md)** - 快速集成指南
### 开发文档
- **JavaScript解析器**: [开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义扩展](parser/doc/CUSTOM_PARSER_GUIDE.md)
- **Python解析器**: [开发指南](parser/doc/PYTHON_PARSER_GUIDE.md) | [Python LSP连接](parser/doc/PYLSP_WEBSOCKET_GUIDE.md)
### 配置和安全
- **[密码保护配置](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)** - 访问控制和安全设置
- **[界面功能说明](web-front/doc/PLAYGROUND_UI_UPGRADE.md)** - IDE功能和快捷键
### 测试报告
- **[Python演练场测试报告](parser/doc/PYTHON_PLAYGROUND_TEST_REPORT.md)** - 功能验证和测试覆盖
### 在线体验
访问演练场页面:`http://your_host/playground`(需要密码或配置公开访问)
## 预览地址
[预览地址1](https://lz.qaiu.top)
[预览地址2](https://lz0.qaiu.top)
[移动/联通/天翼云盘大文件试用版](https://189.qaiu.top)
main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/netdisk-fast-download/tree/main-jdk11)
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
**小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
**注意: 请不要过度依赖lz.qaiu.top预览地址服务,建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制,不推荐做公共解析。**
## 网盘支持情况:
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
> 20230824 123云盘解析大文件(>100MB)失效,需要登录
> 20230722 UC网盘解析失效,需要登录
@@ -66,11 +88,13 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
- [蓝奏云-lz](https://pc.woozooo.com/)
- [蓝奏云优享-iz](https://www.ilanzou.com/)
- [奶牛快传-cow](https://cowtransfer.com/)
- [移动云云空间-ec](https://www.ecpan.cn/web)
- [小飞机网盘-fj](https://www.feijipan.com/)
- [亿方云-fc](https://www.fangcloud.com/)
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
- [123云盘-ye](https://www.123pan.com/)
- ~[115网盘(失效)-p115](https://115.com/)~
- ~[118网盘(已停服)-p118](https://www.118pan.com/)~
- [文叔叔-ws](https://www.wenshushu.cn/)
- [联想乐云-le](https://lecloud.lenovo.com/)
- [QQ邮箱云盘-qqw](https://mail.qq.com/)
@@ -83,25 +107,20 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
- [飞书云盘-fs](https://www.feishu.cn/)
- [WPS云文档-pwps](https://www.kdocs.cn/)
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
- [咪咕音乐-migu](https://music.migu.cn/)
- [一刻相册-baidu_photo](https://photo.baidu.com/)
- Google云盘-pgd
- Onedrive-pod
- Dropbox-pdp
- iCloud-pic
### 专业版提供
- 迅雷网盘-xl
- [夸克云盘-qk](https://pan.quark.cn/)
- [UC云盘-uc](https://fast.uc.cn/)
### 仅专属版提供
- [移动云盘-p139](https://yun.139.com/)
- [联通云盘-pwo](https://pan.wo.cn/)
- [天翼云盘-p189](https://cloud.189.cn/)
## API接口
[api接口文档](https://nfdparser.apifox.cn/)
### 服务端口
- **6400**: API 服务端口(建议使用 Nginx 代理)
@@ -146,89 +165,6 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
- `{网盘标识}` 参考支持的网盘列表
- `your_host` 替换为您的域名或 IP
### 认证参数(v0.2.1+
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
**参数格式**`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
**加密方式**
- 算法:AES/ECB/PKCS5Padding
- 密钥:`nfd_auth_key2026`16字节)
- 流程:JSON → AES加密 → Base64 → URL编码
**JSON 结构**
```json
{
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization/password/custom
"token": "your_cookie_here", // Cookie 或 Token 内容
"username": "", // 用户名(password 类型时使用)
"password": "", // 密码(password 类型时使用)
"ext1": "", // 扩展字段1(custom 类型时使用)
"ext2": "" // 扩展字段2(custom 类型时使用)
}
```
**网盘认证要求**
| 网盘 | 认证要求 | 说明 |
|------|---------|------|
| 夸克网盘(QK) | **必须** | 必须配置 Cookie 才能解析 |
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
**使用示例**
```
GET /parser?url={分享链接}&pwd={密码}&auth={加密后的认证参数}
```
> 💡 提示:Web 界面已内置认证配置功能,可自动处理加密过程,无需手动构造参数。
> [可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
#### 密钥作用说明
- `server.authEncryptKey`
- 作用:用于 `auth` 参数的 AES 加解密
- 要求:16位(AES-128
- `server.donatedAccountFailureTokenSignKey`
- 作用:用于“捐赠账号失败计数 token”的 HMAC 签名/验签
- 目的:防止客户端伪造失败计数请求
- 建议:使用高强度随机字符串,且不要与 `authEncryptKey` 相同
#### `auth` 临时认证参数 与 `auths` 静态配置认证 的区别
本项目存在两种互相独立的认证配置方式,作用范围不同,不要混淆:
| 方式 | 配置位置 | 生效范围 | 适用场景 |
|------|---------|---------|---------|
| `auth` 临时认证参数 | 请求 URL 上的 `auth` 查询参数 | **仅当次请求**,优先级高于 app-dev.yml 中的静态配置 | 调用方按用户临时提供的账号/Cookie/token 解析,不同请求可携带不同认证信息 |
| `auths` 静态配置认证 | `app-dev.yml``auths.<网盘标识>` 节点 | **服务端长期生效**,所有未携带 `auth` 参数的请求都会复用 | 部署方自己长期配置一份账号,供所有请求默认使用 |
以 123网盘(`ye`)为例,`app-dev.yml` 中支持以下几种写法(三选一即可):
```yaml
auths:
ye:
username: 你的123网盘账号
password: 你的123网盘密码
```
```yaml
auths:
ye:
token: 已登录后获取的 Authorization/AccessToken
```
```yaml
auths:
ye:
authorization: 已登录后获取的 Authorization/AccessToken # 与 token 等价,二选一
```
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
### 特殊说明
- 移动云云空间的 `分享key` 取分享链接中的 `data` 参数值
@@ -362,15 +298,15 @@ json返回数据格式示例:
| 网盘名称 | 免登陆下载分享 | 加密分享 | 初始网盘空间 | 单文件大小限制 |
|-------------|---------|----------|-----------|-----------------|
| 蓝奏云 | √ | √ | 不限空间 | 100M |
| 奶牛快传 | √ | X | 10G | 不限大小 |
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
| 小飞机网盘 | √ | √(密码可忽略) | 10G | 不限大小 |
| 360亿方云 | √ | √(密码可忽略) | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G>100M需要登录) |
| 文叔叔 | √ | √ | 10G | 5GB |
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 |
| 飞书云盘 | √ | X | 15G | 不限大小 |
# 打包部署
@@ -396,7 +332,82 @@ mvn package -DskipTests
## Linux服务部署
### Docker 部署Main分支)
### Docker 部署
> **注意:** 本分支(feature/graalpy-parser)包含 Python 解析器支持(GraalPy),镜像体积较大。
> 如果不需要 Python 解析器功能,建议使用 main 分支的标准版本。
#### Python 版本特性
- ✅ 支持 Python 自定义解析器(基于 GraalPy)
- ✅ 内置 Python 语言服务器(pylsp)支持代码补全
- ✅ 支持外部 Python 环境通过 WebSocket 连接
- ⚠️ 镜像体积较大(约增加 200MB)
#### 海外服务器Docker部署(Python版)
> **注意:** 请根据 [Releases](https://github.com/qaiu/netdisk-fast-download/releases) 页面使用最新的 py 标签版本
```shell
# 创建目录
mkdir -p netdisk-fast-download
cd netdisk-fast-download
# 拉取镜像(使用 py 标签,请替换为最新版本号)
docker pull ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
# 复制配置文件(或下载仓库web-service\src\main\resources
docker create --name netdisk-fast-download ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
docker cp netdisk-fast-download:/app/resources ./resources
docker rm netdisk-fast-download
# 启动容器
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
# 反代6401端口
# 升级容器(请手动指定版本号)
# 注意:Python 版本不会更新 latest 标签,需要手动指定版本
docker pull ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
docker stop netdisk-fast-download
docker rm netdisk-fast-download
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.io/qaiu/netdisk-fast-download:v0.1.9b19py
```
#### 国内Docker部署(Python版)
> **注意:** 请根据 [Releases](https://github.com/qaiu/netdisk-fast-download/releases) 页面使用最新的 py 标签版本
```shell
# 创建目录
mkdir -p netdisk-fast-download
cd netdisk-fast-download
# 拉取镜像(使用 py 标签,请替换为最新版本号)
docker pull ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
# 复制配置文件(或下载仓库web-service\src\main\resources
docker create --name netdisk-fast-download ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
docker cp netdisk-fast-download:/app/resources ./resources
docker rm netdisk-fast-download
# 启动容器
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
# 反代6401端口
# 升级容器(请手动指定版本号)
# 注意:Python 版本不会更新 latest 标签,需要手动指定版本
docker pull ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
docker stop netdisk-fast-download
docker rm netdisk-fast-download
docker run -d -it --name netdisk-fast-download -p 6401:6401 --restart unless-stopped -e TZ=Asia/Shanghai -v ./resources:/app/resources -v ./db:/app/db -v ./logs:/app/logs ghcr.nju.edu.cn/qaiu/netdisk-fast-download:v0.1.9b19py
```
---
### Docker 部署(标准版 - Main分支)
如果不需要 Python 解析器功能,建议使用标准版本:
#### 海外服务器Docker部署
```shell
@@ -450,7 +461,7 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
```shell
cd ~
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.1.9b7/netdisk-fast-download-bin.zip
unzip netdisk-fast-download-bin.zip
cd netdisk-fast-download
bash service-install.sh
@@ -523,6 +534,23 @@ auths:
**注意:** 目前仅支持 123(ye)的认证配置。
## 开发计划
### v0.1.8~v0.1.9 ✓
- API添加文件信息(专属版/开源版)
- 目录解析(专属版/开源版)
- 文件预览功能(专属版/开源版)
- 文件夹预览功能(开源版)
- 友好的错误提示和一键反馈功能(开源版)
- 带cookie/token/username/pwd参数解析大文件(专属版)
### v0.2.x
- web后台管理--认证配置/分享链接管理(开源版/专属版)
- 123/小飞机/蓝奏优享等大文件解析(开源版)
- 直链分享(开源版/专属版)
- aria2/idm+/curl/wget链接生成(开源版/专属版)
- IP限流配置(开源版/专属版)
- refere防盗链,API鉴权防盗链(专属版)
- 123/小飞机/蓝奏优享/蓝奏文件夹解析API,天翼云盘/移动云盘文件夹解析API(专属版)
- 用户管理面板--营销推广系统(专属版)
**技术栈:**
Jdk17+Vert.x4
@@ -547,5 +575,20 @@ Core模块集成Vert.x实现类似spring的注解式路由API
</p>
### 关于赞助定制专属版
1. 专属版提供对小飞机,蓝奏优享大文件解析的支持, 提供天翼云盘/移动云盘/联通云盘的解析支持。
2. 可提供托管服务:包含部署服务和云服务器环境。
3. 可提供功能定制开发。
您可能需要提供一定的资金赞助支持定制专属版, 请添加以下任意一个联系方式详谈赞助模式:
<p>qq: 197575894</p>
<p>wechat: imcoding_</p>
<!--
![image](https://github.com/qaiu/netdisk-fast-download/assets/29825328/54276aee-cc3f-4ebd-8973-2e15f6295819)
[手机端支付宝打赏跳转链接](https://qr.alipay.com/fkx01882dnoxxtjenhlxt53)
-->
+2 -1
View File
@@ -1,5 +1,6 @@
#!/bin/bash
# set -x
LAUNCH_JAR="netdisk-fast-download.jar"
exec java -Xmx${JVM_XMX:-512M} ${JVM_OPTS} -jar "$LAUNCH_JAR" "$@"
nohup java -Xmx512M -jar "$LAUNCH_JAR" "$@" >startup.log 2>&1 &
tail -f startup.log
+15 -1
View File
@@ -65,7 +65,21 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.11</version>
<version>42.7.3</version>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
<scope>test</scope>
</dependency>
</dependencies>
@@ -53,7 +53,7 @@ public class CreateDatabase {
stmt.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
LOGGER.info(">>>>>>>>>>> 数据库'{}'创建成功 <<<<<<<<<<<<", dbName);
} catch (SQLException e) {
LOGGER.error("创建数据库失败", e);
e.printStackTrace();
}
}
@@ -24,39 +24,35 @@ import java.util.*;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class CreateTable {
public static final Map<Class<?>, String> javaProperty2SqlColumnMap;
static {
Map<Class<?>, String> map = new HashMap<>();
public static Map<Class<?>, String> javaProperty2SqlColumnMap = new HashMap<>() {{
// Java类型到SQL类型的映射
map.put(Integer.class, "INT");
map.put(Short.class, "SMALLINT");
map.put(Byte.class, "TINYINT");
map.put(Long.class, "BIGINT");
map.put(java.math.BigDecimal.class, "DECIMAL");
map.put(Double.class, "DOUBLE");
map.put(Float.class, "REAL");
map.put(Boolean.class, "BOOLEAN");
map.put(String.class, "VARCHAR");
map.put(Date.class, "TIMESTAMP");
map.put(java.time.LocalDateTime.class, "TIMESTAMP");
map.put(java.sql.Timestamp.class, "TIMESTAMP");
map.put(java.sql.Date.class, "DATE");
map.put(java.sql.Time.class, "TIME");
put(Integer.class, "INT");
put(Short.class, "SMALLINT");
put(Byte.class, "TINYINT");
put(Long.class, "BIGINT");
put(java.math.BigDecimal.class, "DECIMAL");
put(Double.class, "DOUBLE");
put(Float.class, "REAL");
put(Boolean.class, "BOOLEAN");
put(String.class, "VARCHAR");
put(Date.class, "TIMESTAMP");
put(java.time.LocalDateTime.class, "TIMESTAMP");
put(java.sql.Timestamp.class, "TIMESTAMP");
put(java.sql.Date.class, "DATE");
put(java.sql.Time.class, "TIME");
// 基本数据类型
map.put(int.class, "INT");
map.put(short.class, "SMALLINT");
map.put(byte.class, "TINYINT");
map.put(long.class, "BIGINT");
map.put(double.class, "DOUBLE");
map.put(float.class, "REAL");
map.put(boolean.class, "BOOLEAN");
javaProperty2SqlColumnMap = Collections.unmodifiableMap(map);
}
put(int.class, "INT");
put(short.class, "SMALLINT");
put(byte.class, "TINYINT");
put(long.class, "BIGINT");
put(double.class, "DOUBLE");
put(float.class, "REAL");
put(boolean.class, "BOOLEAN");
}};
private static final Logger LOGGER = LoggerFactory.getLogger(CreateTable.class);
public static final String UNIQUE_PREFIX = "idx_";
public static String UNIQUE_PREFIX = "idx_";
private static Case getCase(Class<?> clz) {
return switch (clz.getName()) {
@@ -307,7 +303,7 @@ public class CreateTable {
return promise.future();
}
List<Future<Object>> futures = new ArrayList<>();
List<Future<Object>> createFutures = new ArrayList<>();
for (Class<?> clazz : tableClasses) {
List<String> sqlList = getCreateTableSQL(clazz, type);
@@ -316,23 +312,41 @@ public class CreateTable {
for (String sql : sqlList) {
try {
pool.query(sql).execute().toCompletionStage().toCompletableFuture().join();
futures.add(Future.succeededFuture());
createFutures.add(Future.succeededFuture());
LOGGER.debug("Executed SQL:\n{}", sql);
} catch (Exception e) {
String message = e.getMessage();
if (message != null && message.contains("Duplicate key name")) {
LOGGER.warn("Ignoring duplicate key error: {}", message);
futures.add(Future.succeededFuture());
createFutures.add(Future.succeededFuture());
} else {
LOGGER.error("SQL Error: {}\nSQL: {}", message, sql);
futures.add(Future.failedFuture(e));
createFutures.add(Future.failedFuture(e));
throw new RuntimeException(e); // Stop execution for other exceptions
}
}
}
}
Future.all(futures).onSuccess(r -> promise.complete()).onFailure(promise::fail);
// 创建表完成后,执行表结构迁移检查
Future.all(createFutures)
.compose(v -> {
LOGGER.info("开始检查表结构变更...");
List<Future<Void>> migrationFutures = new ArrayList<>();
for (Class<?> clazz : tableClasses) {
migrationFutures.add(SchemaMigration.migrateTable(pool, clazz, type));
}
return Future.all(migrationFutures).mapEmpty();
})
.onSuccess(v -> {
LOGGER.info("表结构检查和变更完成");
promise.complete();
})
.onFailure(err -> {
LOGGER.error("表结构变更失败", err);
promise.fail(err);
});
return promise.future();
}
@@ -0,0 +1,44 @@
package cn.qaiu.db.ddl;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 标识新增字段,用于数据库表结构迁移
* 只有带此注解的字段才会被 SchemaMigration 检查和添加
*
* <p>使用场景:</p>
* <ul>
* <li>在现有实体类中添加新字段时,使用此注解标记</li>
* <li>应用启动时会自动检测并添加到数据库表中</li>
* <li>添加成功后可以移除此注解,避免重复检查</li>
* </ul>
*
* <p>示例:</p>
* <pre>{@code
* @Data
* @Table("users")
* public class User {
* private Long id;
* private String name;
*
* @NewField // 标记为新增字段
* @Length(varcharSize = 32)
* @Constraint(defaultValue = "active")
* private String status;
* }
* }</pre>
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NewField {
/**
* 字段描述(可选)
*/
String value() default "";
}
@@ -0,0 +1,294 @@
package cn.qaiu.db.ddl;
import cn.qaiu.db.pool.JDBCType;
import io.vertx.codegen.format.Case;
import io.vertx.codegen.format.LowerCamelCase;
import io.vertx.codegen.format.SnakeCase;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.templates.annotations.Column;
import io.vertx.sqlclient.templates.annotations.RowMapped;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.util.*;
/**
* 数据库表结构变更处理器
* 用于在应用启动时自动检测并添加缺失的字段
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class SchemaMigration {
private static final Logger log = LoggerFactory.getLogger(SchemaMigration.class);
/**
* 检查并迁移表结构
* 只处理带有 @NewField 注解的字段,避免检查所有字段导致的重复错误
*
* @param pool 数据库连接池
* @param clazz 实体类
* @param type 数据库类型
* @return Future
*/
public static Future<Void> migrateTable(Pool pool, Class<?> clazz, JDBCType type) {
Promise<Void> promise = Promise.promise();
try {
String tableName = getTableName(clazz);
// 获取带有 @NewField 注解的字段
List<Field> newFields = getNewFields(clazz);
if (newFields.isEmpty()) {
log.debug("表 '{}' 没有标记为 @NewField 的字段,跳过结构检查", tableName);
promise.complete();
return promise.future();
}
log.info("开始检查表 '{}' 的结构变更,新增字段数: {}", tableName, newFields.size());
// 获取表的所有字段
getTableColumns(pool, tableName, type)
.compose(existingColumns -> {
// 只添加带有 @NewField 注解且不存在的字段
return addNewFields(pool, clazz, tableName, newFields, existingColumns, type);
})
.onSuccess(v -> {
log.info("表 '{}' 结构变更完成", tableName);
promise.complete();
})
.onFailure(err -> {
log.error("表 '{}' 结构变更失败", tableName, err);
promise.fail(err);
});
} catch (Exception e) {
log.error("检查表结构失败", e);
promise.fail(e);
}
return promise.future();
}
/**
* 获取带有 @NewField 注解的字段列表
*/
private static List<Field> getNewFields(Class<?> clazz) {
List<Field> newFields = new ArrayList<>();
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(NewField.class) && !isIgnoredField(field)) {
newFields.add(field);
String desc = field.getAnnotation(NewField.class).value();
if (StringUtils.isNotEmpty(desc)) {
log.debug("发现新字段: {} - {}", field.getName(), desc);
} else {
log.debug("发现新字段: {}", field.getName());
}
}
}
return newFields;
}
/**
* 获取表名
*/
private static String getTableName(Class<?> clazz) {
if (clazz.isAnnotationPresent(Table.class)) {
Table annotation = clazz.getAnnotation(Table.class);
if (StringUtils.isNotEmpty(annotation.value())) {
return annotation.value();
}
}
// 默认使用类名转下划线命名
Case caseFormat = SnakeCase.INSTANCE;
if (clazz.isAnnotationPresent(RowMapped.class)) {
RowMapped annotation = clazz.getAnnotation(RowMapped.class);
caseFormat = getCase(annotation.formatter());
}
return LowerCamelCase.INSTANCE.to(caseFormat, clazz.getSimpleName());
}
/**
* 获取表的现有字段
*/
private static Future<Set<String>> getTableColumns(Pool pool, String tableName, JDBCType type) {
Promise<Set<String>> promise = Promise.promise();
String sql = switch (type) {
case MySQL -> String.format(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '%s'",
tableName
);
case H2DB -> String.format(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = SCHEMA() AND TABLE_NAME = '%s'",
tableName.toUpperCase()
);
case PostgreSQL -> String.format(
"SELECT column_name FROM information_schema.columns WHERE table_name = '%s'",
tableName.toLowerCase()
);
};
pool.query(sql).execute()
.onSuccess(rows -> {
Set<String> columns = new HashSet<>();
rows.forEach(row -> {
String columnName = row.getString(0);
if (columnName != null) {
columns.add(columnName.toLowerCase());
}
});
log.debug("表 '{}' 现有字段: {}", tableName, columns);
promise.complete(columns);
})
.onFailure(err -> {
log.warn("获取表 '{}' 字段列表失败,可能表不存在: {}", tableName, err.getMessage());
promise.complete(new HashSet<>()); // 返回空集合,触发创建表逻辑
});
return promise.future();
}
/**
* 添加新字段(只处理带 @NewField 注解的字段)
*/
private static Future<Void> addNewFields(Pool pool, Class<?> clazz, String tableName,
List<Field> newFields, Set<String> existingColumns,
JDBCType type) {
List<Future<Void>> futures = new ArrayList<>();
Case caseFormat = SnakeCase.INSTANCE;
if (clazz.isAnnotationPresent(RowMapped.class)) {
RowMapped annotation = clazz.getAnnotation(RowMapped.class);
caseFormat = getCase(annotation.formatter());
}
String quotationMarks = type == JDBCType.MySQL ? "`" : "\"";
for (Field field : newFields) {
// 获取字段名
String columnName;
if (field.isAnnotationPresent(Column.class)) {
Column annotation = field.getAnnotation(Column.class);
columnName = StringUtils.isNotEmpty(annotation.name())
? annotation.name()
: LowerCamelCase.INSTANCE.to(caseFormat, field.getName());
} else {
columnName = LowerCamelCase.INSTANCE.to(caseFormat, field.getName());
}
// 检查字段是否已存在
if (existingColumns.contains(columnName.toLowerCase())) {
log.warn("字段 '{}' 已存在,请移除 @NewField 注解", columnName);
continue;
}
// 生成 ALTER TABLE 语句
String sql = buildAlterTableSQL(tableName, field, columnName, quotationMarks, type);
log.info("添加字段: {}", sql);
Promise<Void> p = Promise.promise();
pool.query(sql).execute()
.onSuccess(v -> {
log.info("字段 '{}' 添加成功", columnName);
p.complete();
})
.onFailure(err -> {
String errorMsg = err.getMessage();
// 如果字段已存在,忽略错误(可能是并发执行或检测失败)
if (errorMsg != null && (errorMsg.contains("Duplicate column") ||
errorMsg.contains("already exists") ||
errorMsg.contains("duplicate key"))) {
log.warn("字段 '{}' 已存在,跳过添加", columnName);
p.complete();
} else {
log.error("字段 '{}' 添加失败", columnName, err);
p.fail(err);
}
});
futures.add(p.future());
}
return Future.all(futures).mapEmpty();
}
/**
* 构建 ALTER TABLE 添加字段的 SQL
*/
private static String buildAlterTableSQL(String tableName, Field field, String columnName,
String quotationMarks, JDBCType type) {
StringBuilder sb = new StringBuilder();
sb.append("ALTER TABLE ").append(quotationMarks).append(tableName).append(quotationMarks)
.append(" ADD COLUMN ").append(quotationMarks).append(columnName).append(quotationMarks);
// 获取字段类型
String sqlType = CreateTable.javaProperty2SqlColumnMap.get(field.getType());
if (sqlType == null) {
sqlType = "VARCHAR";
}
sb.append(" ").append(sqlType);
// 添加类型长度
int[] decimalSize = {22, 2};
int varcharSize = 255;
if (field.isAnnotationPresent(Length.class)) {
Length length = field.getAnnotation(Length.class);
decimalSize = length.decimalSize();
varcharSize = length.varcharSize();
}
if ("DECIMAL".equals(sqlType)) {
sb.append("(").append(decimalSize[0]).append(",").append(decimalSize[1]).append(")");
} else if ("VARCHAR".equals(sqlType)) {
sb.append("(").append(varcharSize).append(")");
}
// 添加约束
if (field.isAnnotationPresent(Constraint.class)) {
Constraint constraint = field.getAnnotation(Constraint.class);
if (constraint.notNull()) {
sb.append(" NOT NULL");
}
if (StringUtils.isNotEmpty(constraint.defaultValue())) {
String apostrophe = constraint.defaultValueIsFunction() ? "" : "'";
sb.append(" DEFAULT ").append(apostrophe).append(constraint.defaultValue()).append(apostrophe);
}
}
return sb.toString();
}
/**
* 判断是否忽略字段
*/
private static boolean isIgnoredField(Field field) {
int modifiers = field.getModifiers();
return java.lang.reflect.Modifier.isStatic(modifiers)
|| java.lang.reflect.Modifier.isTransient(modifiers)
|| field.isAnnotationPresent(TableGenIgnore.class);
}
/**
* 获取 Case 类型
*/
private static Case getCase(Class<?> clz) {
return switch (clz.getName()) {
case "io.vertx.codegen.format.CamelCase" -> io.vertx.codegen.format.CamelCase.INSTANCE;
case "io.vertx.codegen.format.SnakeCase" -> SnakeCase.INSTANCE;
case "io.vertx.codegen.format.LowerCamelCase" -> LowerCamelCase.INSTANCE;
default -> SnakeCase.INSTANCE;
};
}
}
@@ -17,7 +17,7 @@ import org.slf4j.LoggerFactory;
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JDBCPoolInit implements AutoCloseable {
public class JDBCPoolInit {
private static final Logger LOGGER = LoggerFactory.getLogger(JDBCPoolInit.class);
@@ -101,16 +101,4 @@ public class JDBCPoolInit implements AutoCloseable {
synchronized public JDBCPool getPool() {
return pool;
}
/**
* 关闭连接池,释放数据库资源
*/
@Override
public synchronized void close() {
if (pool != null) {
pool.close();
LOGGER.info("数据库连接池已关闭: URL={}", url);
pool = null;
}
}
}
@@ -0,0 +1,265 @@
package cn.qaiu.db.ddl;
import cn.qaiu.db.pool.JDBCType;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.templates.annotations.Column;
import lombok.Data;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.*;
/**
* SchemaMigration 单元测试
*/
public class SchemaMigrationTest {
private Vertx vertx;
private JDBCPool pool;
@Before
public void setUp() {
vertx = Vertx.vertx();
// 创建 H2 内存数据库连接池
pool = JDBCPool.pool(vertx,
"jdbc:h2:mem:test;DB_CLOSE_DELAY=-1",
"sa",
""
);
}
@After
public void tearDown() {
if (pool != null) {
pool.close();
}
if (vertx != null) {
vertx.close();
}
}
/**
* 测试添加新字段
*/
@Test
public void testAddNewField() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
// 1. 先创建一个基础表
String createTableSQL = """
CREATE TABLE test_user (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL
)
""";
pool.query(createTableSQL).execute()
.compose(v -> {
// 2. 使用 SchemaMigration 添加新字段
return SchemaMigration.migrateTable(pool, TestUserWithNewField.class, JDBCType.H2DB);
})
.compose(v -> {
// 3. 验证新字段是否添加成功
return pool.query("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'TEST_USER' AND COLUMN_NAME = 'EMAIL'")
.execute();
})
.onSuccess(rows -> {
assertEquals("应该找到新添加的 email 字段", 1, rows.size());
latch.countDown();
})
.onFailure(err -> {
fail("测试失败: " + err.getMessage());
latch.countDown();
});
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
}
/**
* 测试不添加已存在的字段
*/
@Test
public void testSkipExistingField() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
// 1. 创建包含 email 字段的表
String createTableSQL = """
CREATE TABLE test_user2 (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(100)
)
""";
pool.query(createTableSQL).execute()
.compose(v -> {
// 2. 尝试再次添加 email 字段(应该跳过)
return SchemaMigration.migrateTable(pool, TestUserWithNewField2.class, JDBCType.H2DB);
})
.onSuccess(v -> {
// 3. 验证表结构正常,没有错误
latch.countDown();
})
.onFailure(err -> {
fail("测试失败: " + err.getMessage());
latch.countDown();
});
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
}
/**
* 测试没有 @NewField 注解时不执行迁移
*/
@Test
public void testNoNewFieldAnnotation() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
// 1. 创建基础表
String createTableSQL = """
CREATE TABLE test_user3 (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL
)
""";
pool.query(createTableSQL).execute()
.compose(v -> {
// 2. 使用没有 @NewField 注解的实体类
return SchemaMigration.migrateTable(pool, TestUserNoAnnotation.class, JDBCType.H2DB);
})
.compose(v -> {
// 3. 验证没有添加 email 字段
return pool.query("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'TEST_USER3' AND COLUMN_NAME = 'EMAIL'")
.execute();
})
.onSuccess(rows -> {
assertEquals("不应该添加没有 @NewField 注解的字段", 0, rows.size());
latch.countDown();
})
.onFailure(err -> {
fail("测试失败: " + err.getMessage());
latch.countDown();
});
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
}
/**
* 测试多个新字段同时添加
*/
@Test
public void testMultipleNewFields() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
// 1. 创建基础表
String createTableSQL = """
CREATE TABLE test_user4 (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL
)
""";
pool.query(createTableSQL).execute()
.compose(v -> {
// 2. 添加多个新字段
return SchemaMigration.migrateTable(pool, TestUserMultipleNewFields.class, JDBCType.H2DB);
})
.compose(v -> {
// 3. 验证所有新字段都添加成功
return pool.query("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'TEST_USER4' AND COLUMN_NAME IN ('EMAIL', 'PHONE', 'ADDRESS')")
.execute();
})
.onSuccess(rows -> {
int count = rows.iterator().next().getInteger(0);
assertEquals("应该添加 3 个新字段", 3, count);
latch.countDown();
})
.onFailure(err -> {
fail("测试失败: " + err.getMessage());
latch.countDown();
});
assertTrue("测试超时", latch.await(10, TimeUnit.SECONDS));
}
// ========== 测试实体类 ==========
@Data
@Table("test_user")
static class TestUserWithNewField {
@Constraint(autoIncrement = true)
private Long id;
@Length(varcharSize = 50)
@Constraint(notNull = true)
private String name;
@NewField("用户邮箱")
@Length(varcharSize = 100)
private String email;
}
@Data
@Table("test_user2")
static class TestUserWithNewField2 {
@Constraint(autoIncrement = true)
private Long id;
@Length(varcharSize = 50)
@Constraint(notNull = true)
private String name;
@NewField("用户邮箱")
@Length(varcharSize = 100)
private String email;
}
@Data
@Table("test_user3")
static class TestUserNoAnnotation {
@Constraint(autoIncrement = true)
private Long id;
@Length(varcharSize = 50)
@Constraint(notNull = true)
private String name;
// 没有 @NewField 注解
@Length(varcharSize = 100)
private String email;
}
@Data
@Table("test_user4")
static class TestUserMultipleNewFields {
@Constraint(autoIncrement = true)
private Long id;
@Length(varcharSize = 50)
@Constraint(notNull = true)
private String name;
@NewField("用户邮箱")
@Length(varcharSize = 100)
private String email;
@NewField("手机号")
@Length(varcharSize = 20)
private String phone;
@NewField("地址")
@Length(varcharSize = 255)
private String address;
}
}
-6
View File
@@ -73,12 +73,6 @@
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
@@ -0,0 +1,73 @@
package cn.qaiu.vx.core.verticle.conf;
import io.vertx.core.json.JsonObject;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.impl.JsonUtil;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/**
* Converter and mapper for {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf}.
* NOTE: This class has been automatically generated from the {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf} original class using Vert.x codegen.
*/
public class HttpProxyConfConverter {
private static final Base64.Decoder BASE64_DECODER = JsonUtil.BASE64_DECODER;
private static final Base64.Encoder BASE64_ENCODER = JsonUtil.BASE64_ENCODER;
static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json, HttpProxyConf obj) {
for (java.util.Map.Entry<String, Object> member : json) {
switch (member.getKey()) {
case "password":
if (member.getValue() instanceof String) {
obj.setPassword((String)member.getValue());
}
break;
case "port":
if (member.getValue() instanceof Number) {
obj.setPort(((Number)member.getValue()).intValue());
}
break;
case "preProxyOptions":
if (member.getValue() instanceof JsonObject) {
obj.setPreProxyOptions(new io.vertx.core.net.ProxyOptions((io.vertx.core.json.JsonObject)member.getValue()));
}
break;
case "timeout":
if (member.getValue() instanceof Number) {
obj.setTimeout(((Number)member.getValue()).intValue());
}
break;
case "username":
if (member.getValue() instanceof String) {
obj.setUsername((String)member.getValue());
}
break;
}
}
}
static void toJson(HttpProxyConf obj, JsonObject json) {
toJson(obj, json.getMap());
}
static void toJson(HttpProxyConf obj, java.util.Map<String, Object> json) {
if (obj.getPassword() != null) {
json.put("password", obj.getPassword());
}
if (obj.getPort() != null) {
json.put("port", obj.getPort());
}
if (obj.getPreProxyOptions() != null) {
json.put("preProxyOptions", obj.getPreProxyOptions().toJson());
}
if (obj.getTimeout() != null) {
json.put("timeout", obj.getTimeout());
}
if (obj.getUsername() != null) {
json.put("username", obj.getUsername());
}
}
}
+20 -154
View File
@@ -3,26 +3,20 @@ package cn.qaiu.vx.core;
import cn.qaiu.vx.core.util.CommonUtil;
import cn.qaiu.vx.core.util.ConfigUtil;
import cn.qaiu.vx.core.util.VertxHolder;
import cn.qaiu.vx.core.verticle.HttpProxyVerticle;
import cn.qaiu.vx.core.verticle.PostExecVerticle;
import cn.qaiu.vx.core.verticle.ReverseProxyVerticle;
import cn.qaiu.vx.core.verticle.RouterVerticle;
import cn.qaiu.vx.core.verticle.ServiceVerticle;
import io.vertx.core.*;
import io.vertx.core.dns.AddressResolverOptions;
import io.vertx.core.impl.launcher.commands.VersionCommand;
import io.vertx.core.json.JsonObject;
import io.vertx.core.shareddata.LocalMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.LockSupport;
import static cn.qaiu.vx.core.util.ConfigConstant.*;
@@ -47,25 +41,11 @@ public final class Deploy {
private Handler<JsonObject> handle;
private Thread mainThread;
private final List<Runnable> preShutdownTasks = new CopyOnWriteArrayList<>();
private final List<Runnable> postShutdownTasks = new CopyOnWriteArrayList<>();
public static Deploy instance() {
return INSTANCE;
}
public void addPreShutdownTask(Runnable task) {
if (task != null) {
preShutdownTasks.add(task);
}
}
public void addPostShutdownTask(Runnable task) {
if (task != null) {
postShutdownTasks.add(task);
}
}
/**
*
* @param args 启动参数
@@ -74,25 +54,15 @@ public final class Deploy {
public void start(String[] args, Handler<JsonObject> handle) {
this.mainThread = Thread.currentThread();
this.handle = handle;
if (args.length > 0 && args[0].startsWith("app-")) {
// 启动参数dev或者prod
path.append("-").append(args[0].replace("app-",""));
}
// 读取yml配置,优先当前目录,其次 resources/ 子目录
String configFile = path + ".yml";
if (!Files.exists(Path.of(configFile)) && Files.exists(Path.of("resources", configFile))) {
path.insert(0, "resources/");
LOGGER.info("从 resources/ 目录加载配置: {}", path + ".yml");
}
// 读取yml配置
ConfigUtil.readYamlConfig(path.toString(), tempVertx)
.onSuccess(this::readConf)
.onFailure(err -> {
LOGGER.error("读取配置文件失败: {}", err.getMessage(), err);
LockSupport.unpark(mainThread);
System.exit(-1);
});
.onFailure(Throwable::printStackTrace);
LockSupport.park();
deployVerticle();
}
@@ -134,7 +104,7 @@ public final class Deploy {
System.out.printf(logoTemplate,
CommonUtil.getAppVersion(),
"4x",
VersionCommand.getVersion(),
conf.getString("copyright"),
year
);
@@ -149,112 +119,44 @@ public final class Deploy {
customConfig = globalConfig.getJsonObject(CUSTOM);
JsonObject vertxConfig = globalConfig.getJsonObject(VERTX);
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);
Integer vertxConfigELPS = vertxConfig.getInteger(EVENT_LOOP_POOL_SIZE);
var vertxOptions = vertxConfigELPS == 0 ?
new VertxOptions() : new VertxOptions(vertxConfig);
// vertxOptions.setAddressResolverOptions(
// new AddressResolverOptions().
// addServer("114.114.114.114").
// addServer("114.114.115.115").
// addServer("8.8.8.8").
// addServer("8.8.4.4"));
vertxOptions.setAddressResolverOptions(
new AddressResolverOptions().
addServer("114.114.114.114").
addServer("114.114.115.115").
addServer("8.8.8.8").
addServer("8.8.4.4"));
LOGGER.info("vertxConfigEventLoopPoolSize: {}, eventLoopPoolSize: {}, workerPoolSize: {}", vertxConfigELPS,
vertxOptions.getEventLoopPoolSize(),
vertxOptions.getWorkerPoolSize());
var vertx = Vertx.vertx(vertxOptions);
VertxHolder.init(vertx);
// 注册 ShutdownHook,确保进程退出时优雅关闭资源
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOGGER.info("JVM shutting down...");
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);
}
}));
//配置保存在共享数据中
var sharedData = vertx.sharedData();
LocalMap<String, Object> localMap = sharedData.getLocalMap(LOCAL);
localMap.put(GLOBAL_CONFIG, globalConfig);
localMap.put(CUSTOM_CONFIG, customConfig);
localMap.put(SERVER, globalConfig.getJsonObject(SERVER));
WorkerExecutor otherHandleExecutor = vertx.createSharedWorkerExecutor("other-handle");
var future0 = otherHandleExecutor.executeBlocking(() -> {
var future0 = vertx.createSharedWorkerExecutor("other-handle")
.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", 1));
var future3 = vertx.deployVerticle(ReverseProxyVerticle.class, getWorkDeploymentOptions("proxy"));
JsonObject jsonObject = ((JsonObject) localMap.get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
if (jsonObject != null) {
genPwd(jsonObject);
var future4 = vertx.deployVerticle(HttpProxyVerticle.class, getWorkDeploymentOptions("proxy", 1));
future4.onSuccess(LOGGER::info);
future4.onFailure(e -> LOGGER.error("Other handle error", e));
Future.all(future1, future2, future3, future4)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
} else {
Future.all(future1, future2, future3)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
}
}).onFailure(e -> {
otherHandleExecutor.close();
LOGGER.error("Other handle error", e);
});
}
private static void genPwd(JsonObject jsonObject) {
if (jsonObject.getBoolean("randUserPwd")) {
var username = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
var password = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
jsonObject.put("username", username);
jsonObject.put("password", password);
}
LOGGER.info("=============server info=================");
LOGGER.info("\nport: {}\nusername: {}\npassword: {}",
jsonObject.getString("port"),
jsonObject.getString("username"),
jsonObject.getString("password"));
LOGGER.info("==============server info================");
}
private static void runShutdownTasks(String stage, List<Runnable> tasks) {
if (tasks.isEmpty()) {
return;
}
LOGGER.info("Running {} shutdown tasks: {}", stage, tasks.size());
for (Runnable task : tasks) {
try {
task.run();
} catch (Exception e) {
LOGGER.warn("Shutdown task failed at stage {}", stage, e);
}
}
Future.all(future1, future2, future3)
.onSuccess(this::deployWorkVerticalSuccess)
.onFailure(this::deployVerticalFailed);
}).onFailure(e -> LOGGER.error("Other handle error", e));
}
/**
@@ -276,42 +178,6 @@ public final class Deploy {
var t1 = ((double) (System.currentTimeMillis() - startTime)) / 1000;
var t2 = ((double) System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime()) / 1000;
LOGGER.info("web服务启动成功 -> 用时: {}s, jvm启动用时: {}s", t1, t2);
// 检查是否处于安装引导模式(数据库未配置)
Object installMode = VertxHolder.getVertxInstance().sharedData()
.getLocalMap(LOCAL).get("installMode");
if (Boolean.TRUE.equals(installMode)) {
LOGGER.info("系统处于安装引导模式,等待用户完成数据库配置后再启动后置初始化...");
return;
}
// 正常模式:部署 PostExecVerticle 执行 AppRun 实现
deployPostExec();
}
/**
* 部署 PostExecVerticle(执行所有 AppRun 实现)
* 安装引导完成后也可手动调用此方法触发后置初始化
*/
public void deployPostExec() {
var vertx = VertxHolder.getVertxInstance();
var postExecFuture = vertx.deployVerticle(PostExecVerticle.class, getWorkDeploymentOptions("postExec", 2));
postExecFuture.onSuccess(id -> {
LOGGER.info("PostExecVerticle 部署成功,AppRun 实现执行完成");
}).onFailure(e -> {
LOGGER.error("PostExecVerticle 部署失败", e);
});
}
/**
* 重新部署 ServiceVerticle,重新注册因 DB 未就绪而失败的服务到 EventBus
* 安装引导完成、DB 初始化后调用
*/
public void redeployServices() {
var vertx = VertxHolder.getVertxInstance();
vertx.deployVerticle(ServiceVerticle.class, getWorkDeploymentOptions("Service"))
.onSuccess(id -> LOGGER.info("ServiceVerticle 重新部署成功,DB 相关服务已注册"))
.onFailure(e -> LOGGER.error("ServiceVerticle 重新部署失败", e));
}
/**
@@ -9,7 +9,6 @@ import java.lang.annotation.*;
public @interface HandleSortFilter {
/**
* 注册顺序,数字越大越先注册<br>
* 前置拦截器会先执行后注册即数字小的, 后置拦截器会先执行先注册的即数字大的<br>
* 值<0时会过滤掉该处理器
*/
int value() default 0;
@@ -1,12 +0,0 @@
package cn.qaiu.vx.core.base;
import io.vertx.core.json.JsonObject;
public interface AppRun {
/**
* 执行方法
* @param config 启动配置文件
*/
void execute(JsonObject config);
}
@@ -1,20 +1,14 @@
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.*;
@@ -28,10 +22,9 @@ public interface BaseHttpApi {
// 需要扫描注册的Router路径
Reflections reflections = ReflectionUtil.getReflections();
Logger LOGGER = LoggerFactory.getLogger(BaseHttpApi.class);
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
if (!isResponseDone(ctx)) {
if (!ctx.response().ended()) {
fireJsonObjectResponse(ctx, jsonObject);
}
handleAfterInterceptor(ctx, jsonObject);
@@ -39,93 +32,31 @@ public interface BaseHttpApi {
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult) {
if (!isResponseDone(ctx)) {
if (!ctx.response().ended()) {
fireJsonResultResponse(ctx, jsonResult);
}
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default void doFireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
if (!isResponseDone(ctx)) {
fireJsonObjectResponse(ctx, jsonObject, statusCode);
}
handleAfterInterceptor(ctx, jsonObject);
}
default <T> void doFireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
if (!isResponseDone(ctx)) {
fireJsonResultResponse(ctx, jsonResult, statusCode);
}
handleAfterInterceptor(ctx, jsonResult.toJsonObject());
}
default Set<AfterInterceptor> getAfterInterceptor() {
return AfterInterceptorHolder.INSTANCES;
}
class AfterInterceptorHolder {
private static final Set<AfterInterceptor> INSTANCES = loadAfterInterceptors();
private static Set<AfterInterceptor> loadAfterInterceptors() {
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
reflections.getSubTypesOf(AfterInterceptor.class);
if (afterInterceptorClassSet == null || afterInterceptorClassSet.isEmpty()) {
return Collections.emptySet();
}
return afterInterceptorClassSet.stream()
.filter(AfterInterceptorHolder::isEnabled)
.sorted(AfterInterceptorHolder::compareOrder)
.map(AfterInterceptorHolder::newInterceptor)
.filter(Objects::nonNull)
.collect(Collectors.collectingAndThen(
Collectors.toCollection(LinkedHashSet::new),
Collections::unmodifiableSet));
}
private static boolean isEnabled(Class<? extends AfterInterceptor> clazz) {
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
return sort == null || sort.value() >= 0;
}
private static int compareOrder(Class<? extends AfterInterceptor> left, Class<? extends AfterInterceptor> right) {
return Integer.compare(order(left), order(right));
}
private static int order(Class<? extends AfterInterceptor> clazz) {
HandleSortFilter sort = clazz.getAnnotation(HandleSortFilter.class);
return sort == null ? 0 : sort.value();
}
private static AfterInterceptor newInterceptor(Class<? extends AfterInterceptor> clazz) {
try {
return ReflectionUtil.newWithNoParam(clazz);
} catch (Exception e) {
LOGGER.warn("AfterInterceptor 初始化失败,已跳过: {}", clazz.getName(), e);
return null;
}
Set<Class<? extends AfterInterceptor>> afterInterceptorClassSet =
reflections.getSubTypesOf(AfterInterceptor.class);
if (afterInterceptorClassSet == null) {
return null;
}
return CommonUtil.sortClassSet(afterInterceptorClassSet);
}
default void handleAfterInterceptor(RoutingContext ctx, JsonObject jsonObject) {
if (ctx.response().closed()) {
return;
}
Set<AfterInterceptor> afterInterceptor = getAfterInterceptor();
afterInterceptor.forEach(ai -> {
try {
ai.handle(ctx, jsonObject);
} catch (Exception e) {
LOGGER.warn("AfterInterceptor 执行失败: {}", ai.getClass().getName(), e);
}
});
if (!isResponseDone(ctx)) {
if (afterInterceptor != null) {
afterInterceptor.forEach(ai -> ai.handle(ctx, jsonObject));
}
if (!ctx.response().ended()) {
fireTextResponse(ctx, "handleAfterInterceptor: response not end");
}
}
default boolean isResponseDone(RoutingContext ctx) {
return ctx.response().ended() || ctx.response().closed();
}
}
@@ -1,23 +0,0 @@
package cn.qaiu.vx.core.base;
import cn.qaiu.vx.core.annotaions.HandleSortFilter;
import io.vertx.core.json.JsonObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 默认的AppRun实现示例
* <br>Create date 2024-01-01 00:00:00
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@HandleSortFilter
public class DefaultAppRun implements AppRun {
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAppRun.class);
@Override
public void execute(JsonObject config) {
LOGGER.info("======> AppRun实现类开始执行,配置数: {}", config.size());
}
}
@@ -23,6 +23,8 @@ import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.*;
import io.vertx.ext.web.handler.sockjs.SockJSHandler;
import io.vertx.ext.web.handler.sockjs.SockJSHandlerOptions;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore;
import javassist.CtClass;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
@@ -34,7 +36,6 @@ 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;
@@ -68,22 +69,120 @@ public class RouterHandlerFactory implements BaseHttpApi {
this.gatewayPrefix = gatewayPrefix;
}
/**
* 在主路由上直接注册 WebSocket 路由
* 必须使用 order(-1000) 确保在所有拦截器之前执行
*/
private void registerWebSocketRoutes(Router mainRouter) {
try {
Set<Class<?>> handlers = reflections.getTypesAnnotatedWith(RouteHandler.class);
for (Class<?> handler : handlers) {
String root = getRootPath(handler);
Method[] methods = handler.getMethods();
for (Method method : methods) {
if (method.isAnnotationPresent(SockRouteMapper.class)) {
SockRouteMapper mapping = method.getAnnotation(SockRouteMapper.class);
String routeUrl = getRouteUrl(mapping.value());
String url = root.concat(routeUrl);
// 在这里创建实例,确保每个 handler 使用同一个实例
final Object instance = ReflectionUtil.newWithNoParam(handler);
final Method finalMethod = method;
LOGGER.info("========================================");
LOGGER.info("注册 WebSocket Handler (主路由,优先级最高):");
LOGGER.info(" 类: {}", handler.getName());
LOGGER.info(" 方法: {}", method.getName());
LOGGER.info(" 实例: {}", instance.getClass().getName());
LOGGER.info(" 完整路径: {}/*", url);
LOGGER.info("========================================");
SockJSHandlerOptions options = new SockJSHandlerOptions()
.setHeartbeatInterval(2000)
.setRegisterWriteHandler(true);
SockJSHandler sockJSHandler = SockJSHandler.create(VertxHolder.getVertxInstance(), options);
// SockJS 路径处理
String sockJsPath = url;
while (sockJsPath.endsWith("/") || sockJsPath.endsWith("*")) {
sockJsPath = sockJsPath.substring(0, sockJsPath.length() - 1);
}
final String finalSockJsPath = sockJsPath;
// ✅ socketHandler() 返回 Router,用于挂载
// 使用 final 变量确保闭包中引用正确
Router sockJsRouter = sockJSHandler.socketHandler(sock -> {
LOGGER.info("[WS] ==========================================");
LOGGER.info("[WS] SockJS socketHandler 回调被调用!");
LOGGER.info("[WS] Socket ID: {}", sock.writeHandlerID());
LOGGER.info("[WS] Remote Address: {}", sock.remoteAddress());
LOGGER.info("[WS] Local Address: {}", sock.localAddress());
LOGGER.info("[WS] 即将调用 method: {}.{}", instance.getClass().getSimpleName(), finalMethod.getName());
LOGGER.info("[WS] ==========================================");
try {
finalMethod.invoke(instance, sock);
LOGGER.info("[WS] Handler 调用成功");
} catch (Throwable e) {
LOGGER.error("[WS] WebSocket handler 调用失败", e);
if (e.getCause() != null) {
LOGGER.error("[WS] 原始异常", e.getCause());
}
}
});
// 添加调试 handler 来检查请求是否到达 SockJS 路径
// 注意:使用 "path*" 格式与 SockJS subRouter 保持一致
mainRouter.route(finalSockJsPath + "*").order(-1001).handler(ctx -> {
LOGGER.info("[WS-DEBUG] 请求到达 SockJS 路径: {}", ctx.request().path());
LOGGER.info("[WS-DEBUG] Method: {}, Upgrade: {}, Connection: {}",
ctx.request().method(),
ctx.request().headers().get("Upgrade"),
ctx.request().headers().get("Connection"));
ctx.next();
});
// 为 SockJS xhr/xhr_send 路径添加 BodyHandler
// 必须在 SockJS 路由之前,但 WebSocket 升级请求不需要
mainRouter.route(finalSockJsPath + "*").order(-1000).handler(BodyHandler.create());
// ✅ 挂载 SockJS 路由 - 注意:subRouter 需要使用 "path*" 格式而不是 "path/*"
mainRouter.route(finalSockJsPath + "*").order(-999).subRouter(sockJsRouter);
LOGGER.info("✅ WebSocket 路由注册完成: {} (order=-1000)", finalSockJsPath);
LOGGER.info(" SockJS 端点: {}/info, {}/websocket, {}/xhr", finalSockJsPath, finalSockJsPath, finalSockJsPath);
}
}
}
} catch (Exception e) {
LOGGER.error("注册 WebSocket 路由失败", e);
}
}
/**
* 开始扫描并注册handler
*/
public Router createRouter() {
// 主路由
Router mainRouter = Router.router(VertxHolder.getVertxInstance());
// ⚠️ 重要:先注册 WebSocket 路由,必须在所有 handler 之前
// SockJSHandler 不能在 subRouter 中,必须直接挂载到主路由
// 注意:WebSocket 路由必须在 BodyHandler 之前注册,否则会干扰 WebSocket 升级
registerWebSocketRoutes(mainRouter);
mainRouter.route().handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(REROUTE_PATH_PREFIX)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
String rePath = realPath.substring(REROUTE_PATH_PREFIX.length());
ctx.reroute(rePath);
return;
}
LOGGER.debug("New request:{}, {}, {}",
LOGGER.debug("The HTTP service request address information ===>path:{}, uri:{}, method:{}",
ctx.request().path(), ctx.request().absoluteURI(), ctx.request().method());
ctx.response().headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
ctx.response().headers().add(DATE, LocalDateTime.now().format(ISO_LOCAL_DATE_TIME));
@@ -97,13 +196,24 @@ public class RouterHandlerFactory implements BaseHttpApi {
mainRouter.route().handler(CorsHandler.create().addRelativeOrigin(".*").allowCredentials(true).allowedMethods(httpMethods));
// 配置文件上传路径
mainRouter.route().handler(BodyHandler.create()
.setUploadsDirectory("uploads")
.setBodyLimit(2L * 1024 * 1024));
// BodyHandler 用于处理 POST 请求体
// SockJS 的 xhr/xhr_send 端点需要 BodyHandler,但 WebSocket 升级请求不需要
// 因此为 SockJS 路径单独配置 BodyHandler(排除 websocket 子路径)
mainRouter.route().handler(BodyHandler.create().setUploadsDirectory("uploads"));
// 拦截器
// 配置Session管理 - 用于演练场登录状态持久化
// 30天过期时间(毫秒)- 排除 WebSocket 路径
SessionStore sessionStore = LocalSessionStore.create(VertxHolder.getVertxInstance());
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionTimeout(30L * 24 * 60 * 60 * 1000) // 30天
.setSessionCookieName("SESSIONID") // Cookie名称
.setCookieHttpOnlyFlag(true) // 防止XSS攻击
.setCookieSecureFlag(false); // 非HTTPS环境设置为false
mainRouter.routeWithRegex("^(?!/v2/ws/).*").handler(sessionHandler);
// 拦截器 - 排除 WebSocket 路径
Set<Handler<RoutingContext>> interceptorSet = getInterceptorSet();
Route route0 = mainRouter.route("/*");
Route route0 = mainRouter.routeWithRegex("^(?!/v2/ws/).*");
interceptorSet.forEach(route0::handler);
try {
@@ -130,9 +240,8 @@ public class RouterHandlerFactory implements BaseHttpApi {
// 错误请求处理
mainRouter.errorHandler(405, ctx -> doFireJsonResultResponse(ctx, JsonResult
.error("Method Not Allowed", 405)));
mainRouter.errorHandler(404, ctx -> {
ctx.response().setStatusCode(404).end("404 not found");
});
mainRouter.errorHandler(404, ctx -> ctx.response().setStatusCode(404).setChunked(true)
.end("Internal server error: 404 not found"));
return mainRouter;
}
@@ -178,38 +287,19 @@ 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 (isResponseDone(ctx)) return;
if (ctx.response().ended()) return;
// 超时处理器状态码503
if (ctx.statusCode() == 503 || ctx.failure() == null) {
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员"), 503);
doFireJsonResultResponse(ctx, JsonResult.error("未知异常, 请联系管理员", 500));
} else {
LOGGER.error("路由处理失败", ctx.failure());
String msg = ctx.failure() != null ? ctx.failure().getMessage() : "未知异常";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
ctx.failure().printStackTrace();
doFireJsonResultResponse(ctx, JsonResult.error(ctx.failure().getMessage(), 500));
}
});
} else if (method.isAnnotationPresent(SockRouteMapper.class)) {
// websocket 基于sockJs
SockRouteMapper mapping = method.getAnnotation(SockRouteMapper.class);
String routeUrl = getRouteUrl(mapping.value());
String url = root.concat(routeUrl);
LOGGER.info("Register New Websocket Handler -> {}", url);
SockJSHandlerOptions options = new SockJSHandlerOptions()
.setHeartbeatInterval(2000)
.setRegisterWriteHandler(true);
SockJSHandler sockJSHandler = SockJSHandler.create(VertxHolder.getVertxInstance(), options);
Router route = sockJSHandler.socketHandler(sock -> {
try {
ReflectionUtil.invokeWithArguments(method, instance, sock);
} catch (Throwable e) {
LOGGER.error("WebSocket处理异常", e);
}
});
if (url.endsWith("*")) {
throw new IllegalArgumentException("Don't include * when mounting a sub router");
}
router.route(url + "*").subRouter(route);
// WebSocket 路由已在 registerWebSocketRoutes() 中提前注册
// 跳过此处,避免重复注册
continue;
}
}
}
@@ -239,7 +329,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
*/
private Set<Handler<RoutingContext>> getInterceptorSet() {
// 配置拦截
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toCollection(LinkedHashSet::new));
return getBeforeInterceptor().stream().map(BeforeInterceptor::doHandle).collect(Collectors.toSet());
}
/**
@@ -308,41 +398,32 @@ public class RouterHandlerFactory implements BaseHttpApi {
final MultiMap queryParams = ctx.queryParams();
// 解析body-json参数
if (HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())) {
// 只处理POST/PUT/PATCH等有body的请求方法,避免GET请求读取body导致"Request has already been read"错误
String httpMethod = ctx.request().method().name();
if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& HttpHeaderValues.APPLICATION_JSON.toString().equals(ctx.parsedHeaders().contentType().value())
&& ctx.body() != null && ctx.body().asJsonObject() != null) {
JsonObject body = ctx.body().asJsonObject();
if (body != null) {
methodParametersTemp.forEach((k, v) -> {
String typeName = v.getRight().getName();
// 直接绑定 JsonObject 类型参数
if (JsonObject.class.getName().equals(typeName)) {
parameterValueList.put(k, body);
}
// 只解析已配置包名前缀的实体类
else if (CommonUtil.matchRegList(entityPackagesReg.getList(), typeName)) {
if (CommonUtil.matchRegList(entityPackagesReg.getList(), v.getRight().getName())) {
try {
Class<?> aClass = Class.forName(typeName);
Class<?> aClass = Class.forName(v.getRight().getName());
JsonObject data = CommonUtil.getSubJsonForEntity(body, aClass);
if (!data.isEmpty()) {
Object entity = data.mapTo(aClass);
parameterValueList.put(k, entity);
}
} catch (ClassNotFoundException e) {
LOGGER.error("实体类绑定异常: {}", typeName, e);
e.printStackTrace();
}
}
});
} else {
// body 可能是 JsonArray
JsonArray bodyArray = ctx.body().asJsonArray();
if (bodyArray != null) {
methodParametersTemp.forEach((k, v) -> {
if (JsonArray.class.getName().equals(v.getRight().getName())) {
parameterValueList.put(k, bodyArray);
}
});
}
}
} else if (ctx.body() != null) {
} else if (("POST".equals(httpMethod) || "PUT".equals(httpMethod) || "PATCH".equals(httpMethod))
&& ctx.body() != null) {
queryParams.addAll(ParamUtil.paramsToMap(ctx.body().asString()));
}
@@ -370,21 +451,7 @@ public class RouterHandlerFactory implements BaseHttpApi {
Object entity = ParamUtil.multiMapToEntity(queryParams, aClass);
parameterValueList.put(k, entity);
} catch (Exception e) {
LOGGER.error("参数绑定异常: {}", v.getRight().getName(), e);
}
} else if (parameterValueList.get(k) == null
&& JsonObject.class.getName().equals(v.getRight().getName())) {
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonObject
if (ctx.body() != null) {
JsonObject jo = ctx.body().asJsonObject();
if (jo != null) parameterValueList.put(k, jo);
}
} else if (parameterValueList.get(k) == null
&& JsonArray.class.getName().equals(v.getRight().getName())) {
// 兜底: content-type 非 application/json 时尝试从 body 解析 JsonArray
if (ctx.body() != null) {
JsonArray ja = ctx.body().asJsonArray();
if (ja != null) parameterValueList.put(k, ja);
e.printStackTrace();
}
}
});
@@ -395,45 +462,40 @@ public class RouterHandlerFactory implements BaseHttpApi {
Object data = ReflectionUtil.invokeWithArguments(method, instance, parameterValueArray);
if (data != null) {
if (data instanceof JsonResult jsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) data, jsonResult.getCode());
} else if (data instanceof JsonObject) {
if (data instanceof JsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) data);
}
if (data instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) data));
} else if (data instanceof Future) { // 处理异步响应
Future<?> responseFuture = (Future<?>) data;
AtomicReference<RoutingContext> ctxRef = new AtomicReference<>(ctx);
ctx.addEndHandler(v -> ctxRef.set(null));
responseFuture.onComplete(ar -> {
RoutingContext responseCtx = ctxRef.getAndSet(null);
if (responseCtx == null || isResponseDone(responseCtx)) {
return;
((Future<?>) data).onSuccess(res -> {
if (res instanceof JsonResult) {
doFireJsonResultResponse(ctx, (JsonResult<?>) 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));
}
if (res instanceof JsonObject) {
doFireJsonObjectResponse(ctx, ((JsonObject) res));
} else if (res != null) {
doFireJsonResultResponse(ctx, JsonResult.data(res));
} else {
Throwable e = ar.cause();
LOGGER.error("请求处理失败", e);
String msg = e != null && e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(responseCtx, JsonResult.error(msg), 500);
handleAfterInterceptor(ctx, null);
}
});
}).onFailure(e -> doFireJsonResultResponse(ctx, JsonResult.error(e.getMessage())));
} else {
doFireJsonResultResponse(ctx, JsonResult.data(data));
}
}
} catch (Throwable e) {
LOGGER.error("请求处理异常", e);
String msg = e.getMessage() != null ? e.getMessage() : "服务器内部错误";
doFireJsonResultResponse(ctx, JsonResult.error(msg), 500);
e.printStackTrace();
String err = e.getMessage();
if (e.getCause() != null) {
if (e.getCause() instanceof InvocationTargetException) {
err = ((InvocationTargetException) e.getCause()).getTargetException().getMessage();
} else {
err = e.getCause().getMessage();
}
}
doFireJsonResultResponse(ctx, JsonResult.error(err));
}
}
@@ -3,12 +3,10 @@ package cn.qaiu.vx.core.interceptor;
import io.vertx.core.Handler;
import io.vertx.ext.web.RoutingContext;
import static cn.qaiu.vx.core.util.ResponseUtil.sendError;
/**
* 前置拦截器接口
* <p>
* 注意:Vert.x是异步非阻塞框架,不能在Event Loop中使用synchronized等阻塞操作!
* 所有操作都应该是非阻塞的,使用Vert.x的上下文数据存储机制保证线程安全。
* </p>
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@@ -16,25 +14,28 @@ public interface BeforeInterceptor extends Handler<RoutingContext> {
String IS_NEXT = "RoutingContextIsNext";
default Handler<RoutingContext> doHandle() {
return ctx -> {
// 【优化】移除synchronized锁,Vert.x的RoutingContext本身就是线程安全的
// 每个请求都有独立的RoutingContext,不需要额外加锁
ctx.put(IS_NEXT, false);
handle(ctx); // 调用具体的处理逻辑
// 确保如果没有调用doNext()并且响应未结束,则返回错误
// if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
// sendError(ctx, 403);
// }
// 加同步锁
synchronized (BeforeInterceptor.class) {
ctx.put(IS_NEXT, false);
BeforeInterceptor.this.handle(ctx);
if (!(Boolean) ctx.get(IS_NEXT) && !ctx.response().ended()) {
sendError(ctx, 403);
}
}
};
}
default void doNext(RoutingContext context) {
// 【优化】移除synchronized锁
// RoutingContext的put和next操作是线程安全的,不需要额外同步
context.put(IS_NEXT, true);
context.next(); // 继续执行下一个处理器
// 设置上下文状态为可以继续执行
// 添加同步锁保障多线程下执行时序
synchronized (BeforeInterceptor.class) {
context.put(IS_NEXT, true);
context.next();
}
}
void handle(RoutingContext context); // 实现具体的拦截处理逻辑
}
void handle(RoutingContext context);
}
@@ -30,7 +30,7 @@ public class JsonResult<T> implements Serializable {
private int code = SUCCESS_CODE;//状态码
private String msg = SUCCESS_MESSAGE;//消息
private String msg = SUCCESS_MESSAGE; //消息
private boolean success = true; //是否成功
@@ -1,7 +1,7 @@
/**
* ModuleGen cn.qaiu.vx.core
*/
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core")
@ModuleGen(name = "vertx-http-proxy", groupPackage = "cn.qaiu.vx.core", useFutures = true)
package cn.qaiu.vx.core;
import io.vertx.codegen.annotations.ModuleGen;
@@ -5,7 +5,7 @@ import io.vertx.serviceproxy.ServiceProxyBuilder;
/**
* @author Xu Haidong
* @date 2018/8/15
* Create at 2018/8/15
*/
public final class AsyncServiceUtil {
@@ -13,12 +13,9 @@ import java.net.Socket;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -31,16 +28,6 @@ public class CommonUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(CommonUtil.class);
/** 正则表达式缓存,避免每次调用重新编译 */
private static final ConcurrentHashMap<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();
/**
* 获取预编译的 Pattern(带缓存)
*/
private static Pattern getCachedPattern(String regex) {
return PATTERN_CACHE.computeIfAbsent(regex, Pattern::compile);
}
/**
* 匹配正则list
*
@@ -51,7 +38,7 @@ public class CommonUtil {
public static boolean matchRegList(List<?> regList, String destStr) {
// 判断是否忽略
for (Object ignores : regList) {
if (getCachedPattern(ignores.toString()).matcher(destStr).matches()) {
if (destStr.matches(ignores.toString())) {
return true;
}
}
@@ -130,7 +117,7 @@ public class CommonUtil {
return set.stream().filter(c1 -> {
HandleSortFilter s1 = c1.getAnnotation(HandleSortFilter.class);
if (s1 != null) {
return s1.value() >= 0;
return s1.value() > 0;
} else {
return true;
}
@@ -151,7 +138,7 @@ public class CommonUtil {
} catch (Exception e) {
throw new RuntimeException(e);
}
}).collect(Collectors.toCollection(LinkedHashSet::new));
}).collect(Collectors.toSet());
}
private static String appVersion;
@@ -159,15 +146,13 @@ public class CommonUtil {
public static String getAppVersion() {
if (null == appVersion) {
Properties properties = new Properties();
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");
}
try {
properties.load(CommonUtil.class.getClassLoader().getResourceAsStream("app.properties"));
if (!properties.isEmpty()) {
appVersion = properties.getProperty("app.version") + "build" + properties.getProperty("build");
}
} catch (IOException e) {
LOGGER.error("读取app.properties失败", e);
e.printStackTrace();
}
}
return appVersion;
@@ -4,15 +4,9 @@ import io.vertx.config.ConfigRetriever;
import io.vertx.config.ConfigRetrieverOptions;
import io.vertx.config.ConfigStoreOptions;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 异步读取配置工具类
* <br>Create date 2021/9/2 1:23
@@ -30,29 +24,7 @@ public class ConfigUtil {
* @return JsonObject的Future
*/
public static Future<JsonObject> readConfig(String format, String path, Vertx vertx) {
// 支持 classpath: 前缀从类路径读取,否则从文件系统读取
if (path != null && path.startsWith("classpath:")) {
String resource = path.substring("classpath:".length());
// 使用 executeBlocking(Callable) 直接返回 Future<JsonObject>
return vertx.executeBlocking(() -> {
InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource);
if (is == null) {
throw new RuntimeException("classpath resource not found: " + resource);
}
try (InputStream in = is) {
byte[] bytes = in.readAllBytes();
String content = new String(bytes, StandardCharsets.UTF_8);
if ("json".equalsIgnoreCase(format)) {
return new JsonObject(content);
} else {
throw new RuntimeException("unsupported classpath format: " + format);
}
}
});
}
Promise<JsonObject> promise = Promise.promise();
// 读取yml配置
ConfigStoreOptions store = new ConfigStoreOptions()
.setType("file")
.setFormat(format)
@@ -61,45 +33,10 @@ public class ConfigUtil {
ConfigRetriever retriever = ConfigRetriever
.create(vertx, new ConfigRetrieverOptions().addStore(store));
// 异步获取配置
// 成功直接完成 promise
retriever.getConfig()
.onSuccess(config -> {
promise.complete(config);
retriever.close();
})
.onFailure(err -> {
retriever.close();
// 读取失败时,尝试从 resources/ 子目录读取(兼容 Docker 卷挂载场景)
String resourcesPath = "resources/" + path;
if (!path.startsWith("resources/") && Files.exists(Path.of(resourcesPath))) {
ConfigStoreOptions fallbackStore = new ConfigStoreOptions()
.setType("file")
.setFormat(format)
.setConfig(new JsonObject().put("path", resourcesPath));
ConfigRetriever fallbackRetriever = ConfigRetriever
.create(vertx, new ConfigRetrieverOptions().addStore(fallbackStore));
fallbackRetriever.getConfig()
.onSuccess(config -> {
promise.complete(config);
fallbackRetriever.close();
})
.onFailure(e2 -> {
promise.fail(new RuntimeException(
"读取配置文件失败: " + path + " (也尝试了 " + resourcesPath + ")", e2));
fallbackRetriever.close();
});
} else {
promise.fail(new RuntimeException(
"读取配置文件失败: " + path, err));
}
});
return promise.future();
return retriever.getConfig();
}
/**
* 异步读取Yaml配置文件
*
@@ -1,44 +0,0 @@
package cn.qaiu.vx.core.util;
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 {
/** 默认同步等待超时时间(秒) */
private static final long DEFAULT_TIMEOUT_SECONDS = 120;
public static <T> T getResult(Future<T> future) {
try {
return future.toCompletionStage().toCompletableFuture()
.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
} catch (TimeoutException e) {
throw new RuntimeException("等待Future超时(" + DEFAULT_TIMEOUT_SECONDS + "秒)", e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
throw new RuntimeException(cause != null ? cause : e);
}
}
public static <T> T getResult(Promise<T> promise) {
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);
}
}
}
@@ -16,7 +16,7 @@ import java.time.format.DateTimeFormatter;
/**
* @author <a href="https://qaiu.top">QAIU</a>
* @date 2023/10/14 9:07
* Create at 2023/10/14 9:07
*/
public class JacksonConfig {
@@ -1,7 +1,7 @@
package cn.qaiu.vx.core.util;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* vertx 上下文外的本地容器 为不在vertx线程的方法传递数据
@@ -10,10 +10,11 @@ import java.util.concurrent.ConcurrentHashMap;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class LocalConstant {
private static final Map<String, Object> LOCAL_CONST = new ConcurrentHashMap<>();
private static final Map<String, Object> LOCAL_CONST = new HashMap<>();
public static Map<String, Object> put(String k, Object v) {
LOCAL_CONST.putIfAbsent(k, v);
if (LOCAL_CONST.containsKey(k)) return LOCAL_CONST;
LOCAL_CONST.put(k, v);
return LOCAL_CONST;
}
@@ -36,20 +36,16 @@ public final class ParamUtil {
public static MultiMap paramsToMap(String paramString) {
MultiMap entries = MultiMap.caseInsensitiveMultiMap();
if (paramString == null || paramString.isEmpty()) return entries;
if (paramString == null) return entries;
String[] params = paramString.split("&");
if (params.length == 0) return entries;
for (String param : params) {
if (param == null || param.isEmpty()) {
continue;
}
String[] kv = param.split("=", 2);
String[] kv = param.split("=");
if (kv.length == 2) {
entries.set(kv[0], kv[1]);
} else if (kv.length == 1) {
} else {
entries.set(kv[0], "");
}
// kv.length == 0 时(空字符串),跳过
}
return entries;
}
@@ -24,10 +24,6 @@ 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;
import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
@@ -40,16 +36,6 @@ import static cn.qaiu.vx.core.util.ConfigConstant.BASE_LOCATIONS;
*/
public final class ReflectionUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(ReflectionUtil.class);
// 缓存Reflections实例,避免重复扫描(每次扫描约35K+值,耗时1-3秒,占用大量内存)
private static final Map<String, Reflections> REFLECTIONS_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
// 预编译的类型匹配正则,避免每次请求重新编译
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)\\[]$");
/**
* 以默认配置的基础包路径获取反射器
@@ -61,48 +47,52 @@ public final class ReflectionUtil {
}
/**
* 获取反射器(带缓存)
* 获取反射器
*
* @param packageAddress Package address String
* @return Reflections object
*/
public static Reflections getReflections(String packageAddress) {
return REFLECTIONS_CACHE.computeIfAbsent(packageAddress, key -> {
List<String> packageAddressList;
if (key.contains(",")) {
packageAddressList = Arrays.asList(key.split(","));
} else if (key.contains(";")) {
packageAddressList = Arrays.asList(key.split(";"));
} else {
packageAddressList = Collections.singletonList(key);
}
return createReflections(packageAddressList);
});
List<String> packageAddressList;
if (packageAddress.contains(",")) {
packageAddressList = Arrays.asList(packageAddress.split(","));
} else if (packageAddress.contains(";")) {
packageAddressList = Arrays.asList(packageAddress.split(";"));
} else {
packageAddressList = Collections.singletonList(packageAddress);
}
return getReflections(packageAddressList);
}
/**
* 获取反射器(带缓存)
* 获取反射器
*
* @param packageAddresses Package address List
* @return Reflections object
*/
public static Reflections getReflections(List<String> packageAddresses) {
String cacheKey = String.join(",", packageAddresses);
return REFLECTIONS_CACHE.computeIfAbsent(cacheKey, key -> createReflections(packageAddresses));
}
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
FilterBuilder filterBuilder = new FilterBuilder();
packageAddresses.forEach(str -> {
Collection<URL> urls = ClasspathHelper.forPackage(str.trim());
configurationBuilder.addUrls(urls);
filterBuilder.includePackage(str.trim());
});
private static Reflections createReflections(List<String> packageAddresses) {
ConfigurationBuilder configurationBuilder = new ConfigurationBuilder()
.addClassLoaders(Thread.currentThread().getContextClassLoader())
.forPackages(packageAddresses.toArray(new String[0]))
.setScanners(
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
// 会报错.默认为true.
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
);
// 采坑记录 2021-05-08
// 发现注解api层 没有继承父类时 这里反射一直有问题(Scanner SubTypesScanner was not configured)
// 因此这里需要手动配置各种Scanner扫描器 -- https://blog.csdn.net/qq_29499107/article/details/106889781
configurationBuilder.setScanners(
Scanners.SubTypes.filterResultsBy(s -> true), //允许getAllTypes获取所有Object的子类, 不设置为false则 getAllTypes
// 会报错.默认为true.
new MethodParameterNamesScanner(), //设置方法参数名称 扫描器,否则调用getConstructorParamNames 会报错
Scanners.MethodsAnnotated, //设置方法注解 扫描器, 否则getConstructorsAnnotatedWith,getMethodsAnnotatedWith 会报错
new MemberUsageScanner(), //设置 member 扫描器,否则 getMethodUsage 会报错
Scanners.TypesAnnotated //设置类注解 扫描器 ,否则 getTypesAnnotatedWith 会报错
);
configurationBuilder.filterInputsBy(filterBuilder);
return new Reflections(configurationBuilder);
}
@@ -140,7 +130,7 @@ public final class ReflectionUtil {
parameterTypes[j - k]));
}
} catch (NotFoundException e) {
LOGGER.error("获取方法参数失败", e);
e.printStackTrace();
}
return paramMap;
}
@@ -195,7 +185,7 @@ public final class ReflectionUtil {
try {
return DateUtils.parseDate(value, fmt);
} catch (ParseException e) {
LOGGER.error("日期解析失败: {}", value, e);
e.printStackTrace();
throw new RuntimeException("无法将格式化日期");
}
default:
@@ -227,7 +217,7 @@ public final class ReflectionUtil {
}
return arr;
} catch (Exception e) {
LOGGER.error("数组类型转换失败: {}", value, e);
e.printStackTrace();
}
return null;
}
@@ -241,7 +231,8 @@ public final class ReflectionUtil {
if (ctClass.isPrimitive() || "java.util.Date".equals(ctClass.getName())) {
return true;
}
return BASIC_TYPE_PATTERN.matcher(ctClass.getName()).matches();
return ctClass.getName().matches("^java\\.lang\\.((Boolean)|(Character)|(Byte)|(Short)|(Integer)|(Long)|" +
"(Float)|(Double)|(String))$");
}
/**
@@ -252,7 +243,7 @@ public final class ReflectionUtil {
public static boolean isBasicTypeArray(CtClass ctClass) {
if (!ctClass.isArray()) {
return false;
} else return BASIC_TYPE_ARRAY_PATTERN.matcher(ctClass.getName()).matches();
} else return (ctClass.getName().matches("^(boolen|char|byte|short|int|long|float|double|String)\\[]$"));
}
/**
@@ -12,46 +12,24 @@ 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) {
try {
redirect(response, url);
promise.tryComplete();
} catch (Throwable t) {
promise.tryFail(t);
}
redirect(response, url);
promise.complete();
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject) {
fireJsonObjectResponse(ctx, jsonObject, 200);
}
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
fireJsonObjectResponse(ctx, jsonObject, 200);
}
public static void fireJsonObjectResponse(RoutingContext ctx, JsonObject jsonObject, int statusCode) {
if (ctx.response().ended() || ctx.response().closed()) {
return;
}
ctx.response().putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(statusCode)
.setStatusCode(200)
.end(jsonObject.encode());
}
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject, int statusCode) {
if (ctx.ended() || ctx.closed()) {
return;
}
public static void fireJsonObjectResponse(HttpServerResponse ctx, JsonObject jsonObject) {
ctx.putHeader(CONTENT_TYPE, "application/json; charset=utf-8")
.setStatusCode(statusCode)
.setStatusCode(200)
.end(jsonObject.encode());
}
@@ -59,25 +37,15 @@ public class ResponseUtil {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
}
public static <T> void fireJsonResultResponse(RoutingContext ctx, JsonResult<T> jsonResult, int statusCode) {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject(), statusCode);
}
public static <T> void fireJsonResultResponse(HttpServerResponse ctx, JsonResult<T> jsonResult) {
fireJsonObjectResponse(ctx, jsonResult.toJsonObject());
}
public static void fireTextResponse(RoutingContext ctx, String text) {
if (ctx.response().ended() || ctx.response().closed()) {
return;
}
ctx.response().putHeader(CONTENT_TYPE, "text/html; charset=utf-8").end(text);
}
public static void sendError(RoutingContext ctx, int statusCode) {
if (ctx.response().ended() || ctx.response().closed()) {
return;
}
ctx.response().setStatusCode(statusCode).end();
}
}
@@ -3,6 +3,7 @@ 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 共享数据
@@ -12,8 +13,10 @@ import io.vertx.core.shareddata.LocalMap;
*/
public class SharedDataUtil {
public static io.vertx.core.shareddata.SharedData shareData() {
return VertxHolder.getVertxInstance().sharedData();
private static final SharedData sharedData = VertxHolder.getVertxInstance().sharedData();
public static SharedData shareData() {
return sharedData;
}
public static LocalMap<String, Object> getLocalMap(String key) {
@@ -21,7 +24,7 @@ public class SharedDataUtil {
}
public static <T> LocalMap<String, T> getLocalMapWithCast(String key) {
return shareData().getLocalMap(key);
return sharedData.getLocalMap(key);
}
public static JsonObject getJsonConfig(String key) {
@@ -1,101 +1,57 @@
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.Vertx;
import io.vertx.core.VertxOptions;
import io.vertx.core.dns.AddressResolverOptions;
import io.vertx.core.http.*;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.NetClient;
import io.vertx.core.net.NetClientOptions;
import io.vertx.core.net.NetSocket;
import io.vertx.core.net.ProxyOptions;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.util.Base64;
import static cn.qaiu.vx.core.util.ConfigConstant.GLOBAL_CONFIG;
import static cn.qaiu.vx.core.util.ConfigConstant.LOCAL;
/**
*
*/
public class HttpProxyVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpProxyVerticle.class);
private HttpClient httpClient;
private NetClient netClient;
private HttpServer httpServer;
private volatile boolean stopping = false;
private JsonObject proxyPreConf;
private JsonObject proxyServerConf;
@Override
public void start(io.vertx.core.Promise<Void> startPromise) {
stopping = false;
proxyServerConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-server");
proxyPreConf = ((JsonObject)vertx.sharedData().getLocalMap(LOCAL).get(GLOBAL_CONFIG)).getJsonObject("proxy-pre");
Integer serverPort = proxyServerConf.getInteger("port");
ProxyOptions proxyOptions = null;
if (proxyPreConf != null && StringUtils.isNotBlank(proxyPreConf.getString("ip"))) {
proxyOptions = new ProxyOptions(proxyPreConf);
}
public void start() {
ProxyOptions proxyOptions = new ProxyOptions().setHost("127.0.0.1").setPort(7890);
// 初始化 HTTP 客户端,用于向目标服务器发送 HTTP 请求
HttpClientOptions httpClientOptions = new HttpClientOptions()
.setMaxPoolSize(64)
.setMaxWaitQueueSize(256)
.setConnectTimeout(15000)
.setIdleTimeout(60)
.setKeepAlive(true);
if (proxyOptions != null) {
httpClientOptions.setProxyOptions(proxyOptions);
}
httpClient = vertx.createHttpClient(httpClientOptions);
HttpClientOptions httpClientOptions = new HttpClientOptions();
httpClient = vertx.createHttpClient(httpClientOptions.setProxyOptions(proxyOptions));
// 创建并启动 HTTP 代理服务器,监听指定端口
HttpServerOptions httpServerOptions = new HttpServerOptions();
if (proxyServerConf.containsKey("username") &&
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
httpServerOptions.setClientAuth(ClientAuth.REQUIRED);
}
httpServer = vertx.createHttpServer(httpServerOptions);
httpServer.requestHandler(this::handleClientRequest);
HttpServer server = vertx.createHttpServer(new HttpServerOptions().setClientAuth(ClientAuth.REQUIRED));
server.requestHandler(this::handleClientRequest);
// 初始化 NetClient,用于在 CONNECT 请求中建立 TCP 连接隧道
NetClientOptions netClientOptions = new NetClientOptions();
if (proxyOptions != null) {
netClientOptions.setProxyOptions(proxyOptions);
}
netClient = vertx.createNetClient(netClientOptions
netClient = vertx.createNetClient(new NetClientOptions()
.setProxyOptions(proxyOptions)
.setConnectTimeout(15000)
.setTrustAll(true));
// 启动 HTTP 代理服务器
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));
});
server.listen(7891, ar -> {
if (ar.succeeded()) {
System.out.println("HTTP Proxy server started on port 7891");
} else {
System.err.println("Failed to start HTTP Proxy server: " + ar.cause());
}
});
}
// 处理 HTTP CONNECT 请求,用于代理 HTTPS 流量
private void handleConnectRequest(HttpServerRequest clientRequest) {
String[] uriParts = clientRequest.uri().split(":");
if (uriParts.length != 2) {
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid URI format");
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid URI format");
return;
}
@@ -105,85 +61,55 @@ public class HttpProxyVerticle extends AbstractVerticle {
try {
targetPort = Integer.parseInt(uriParts[1]);
} catch (NumberFormatException e) {
failClientResponse(clientRequest.response(), 400, "Bad Request: Invalid port");
clientRequest.response().setStatusCode(400).end("Bad Request: Invalid port");
return;
}
clientRequest.pause();
// 通过 NetClient 连接目标服务器并创建隧道
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);
});
netClient.connect(targetPort, targetHost, connectionAttempt -> {
if (connectionAttempt.succeeded()) {
NetSocket targetSocket = connectionAttempt.result();
// 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");
}
// 升级客户端连接到 NetSocket 并实现双向数据流
clientRequest.toNetSocket().onComplete(clientSocketAttempt -> {
if (clientSocketAttempt.succeeded()) {
NetSocket clientSocket = clientSocketAttempt.result();
// 设置双向数据流转发
clientSocket.handler(targetSocket::write);
targetSocket.handler(clientSocket::write);
// 关闭其中一方时关闭另一方
clientSocket.closeHandler(v -> targetSocket.close());
targetSocket.closeHandler(v -> clientSocket.close());
} else {
System.err.println("Failed to upgrade client connection to socket: " + clientSocketAttempt.cause().getMessage());
targetSocket.close();
clientRequest.response().setStatusCode(500).end("Internal Server Error");
}
});
} else {
System.err.println("Failed to connect to target: " + connectionAttempt.cause().getMessage());
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to connect to target");
}
});
}
// 处理客户端的 HTTP 请求
private void handleClientRequest(HttpServerRequest clientRequest) {
if (stopping) {
failClientResponse(clientRequest.response(), 503, "Service Unavailable");
String s = clientRequest.headers().get("Proxy-Authorization");
if (s == null) {
clientRequest.response().setStatusCode(403).end();
return;
}
// 打印来源ip和访问目标URI
LOGGER.debug("source: {}, target: {}", clientRequest.remoteAddress().toString(), clientRequest.uri());
if (proxyServerConf.containsKey("username") &&
StringUtils.isNotBlank(proxyServerConf.getString("username"))) {
String s = clientRequest.headers().get("Proxy-Authorization");
if (s == null) {
failClientResponse(clientRequest.response(), 403, null);
return;
}
String[] split;
try {
split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
} catch (IllegalArgumentException e) {
LOGGER.warn("Proxy-Authorization header is not valid Base64");
failClientResponse(clientRequest.response(), 403, null);
return;
}
if (split.length <= 1) {
LOGGER.warn("Proxy-Authorization header format invalid: missing username:password separator");
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]);
failClientResponse(clientRequest.response(), 403, null);
return;
}
String[] split = new String(Base64.getDecoder().decode(s.replace("Basic ", ""))).split(":");
if (split.length > 1) {
System.out.println(split[0]);
System.out.println(split[1]);
// TODO
}
if (clientRequest.method() == HttpMethod.CONNECT) {
// 处理 CONNECT 请求
handleConnectRequest(clientRequest);
@@ -198,222 +124,61 @@ public class HttpProxyVerticle extends AbstractVerticle {
// 获取目标主机
String hostHeader = clientRequest.getHeader("Host");
if (hostHeader == null) {
failClientResponse(clientRequest.response(), 400, "Host header is missing");
clientRequest.response().setStatusCode(400).end("Host header is missing");
return;
}
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(); // 暂停客户端请求的读取,等上游请求创建完成
String targetHost = hostHeader.split(":")[0];
int targetPort = 80; // 默认为 HTTP 的端口
clientRequest.pause(); // 暂停客户端请求的读取,避免数据丢失
try {
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
.onSuccess(request -> {
// 逐个设置请求头
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
httpClient.request(clientRequest.method(), targetPort, targetHost, clientRequest.uri())
.onSuccess(request -> {
clientRequest.resume(); // 恢复客户端请求的读取
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");
});
// 逐个设置请求头
clientRequest.headers().forEach(header -> request.putHeader(header.getKey(), header.getValue()));
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");
}
// 将客户端请求的 body 转发给目标服务器
clientRequest.bodyHandler(body -> request.send(body, ar -> {
if (ar.succeeded()) {
var response = ar.result();
clientRequest.response().setStatusCode(response.statusCode());
clientRequest.response().headers().setAll(response.headers());
response.body().onSuccess(b-> clientRequest.response().end(b));
} else {
clientRequest.response().setStatusCode(502).end("Bad Gateway: Unable to reach target");
}
}));
})
.onFailure(err -> {
err.printStackTrace();
clientRequest.response().setStatusCode(502).end("Bad Gateway: Request failed");
});
}
private void failClientResponse(HttpServerResponse response, String message) {
failClientResponse(response, 502, message);
}
private void failClientResponse(HttpServerResponse response, int statusCode, String message) {
if (response.ended() || response.closed()) {
return;
}
try {
if (!response.headWritten()) {
response.setStatusCode(statusCode);
if (message == null) {
response.end();
} else {
response.end(message);
}
} else {
response.reset();
}
} catch (Exception e) {
LOGGER.debug("客户端响应已关闭,忽略代理错误响应", e);
}
}
private void failClientRequestAndClose(HttpServerRequest request, int statusCode, String message) {
HttpServerResponse response = request.response();
if (response.ended() || response.closed()) {
closeClientConnection(request);
return;
}
try {
if (!response.headWritten()) {
response.setStatusCode(statusCode);
Future<Void> endFuture = message == null ? response.end() : response.end(message);
endFuture.onComplete(v -> closeClientConnection(request));
} else {
response.reset();
closeClientConnection(request);
}
} catch (Exception e) {
LOGGER.debug("客户端响应已关闭,关闭代理连接", e);
closeClientConnection(request);
}
}
private void closeClientConnection(HttpServerRequest request) {
try {
request.connection().close();
} catch (Exception e) {
LOGGER.debug("关闭客户端代理连接失败", e);
}
}
private void closeTunnelSockets(NetSocket clientSocket, NetSocket targetSocket) {
try {
clientSocket.close();
} catch (Exception e) {
LOGGER.debug("关闭CONNECT客户端socket失败", e);
}
try {
targetSocket.close();
} catch (Exception e) {
LOGGER.debug("关闭CONNECT目标socket失败", e);
}
}
/**
* 从 URL 中提取端口号
*
* @param urlString URL 字符串
* @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();
// 如果 URL 没有指定端口,使用默认端口
if (port == -1) {
if ("https".equalsIgnoreCase(uri.getScheme())) {
port = 443; // HTTPS 默认端口
} else {
port = defaultPort; // HTTP 默认端口
}
}
return port;
} catch (Exception e) {
LOGGER.error("提取端口失败: {}", urlString, e);
// 出现异常时返回 -1,表示提取失败
return -1;
}
}
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(Promise<Void> stopPromise) {
stopping = true;
Future<Void> serverClose = httpServer == null ? Future.succeededFuture() : httpServer.close();
serverClose.onComplete(serverResult -> closeClients().onComplete(clientResult -> {
if (serverResult.failed()) {
stopPromise.fail(serverResult.cause());
} else if (clientResult.failed()) {
stopPromise.fail(clientResult.cause());
} else {
stopPromise.complete();
}
}));
public void stop() {
// 停止 HTTP 客户端以释放资源
if (httpClient != null) {
httpClient.close();
}
}
private Future<Void> closeClients() {
Future<Void> httpClientClose = httpClient == null ? Future.succeededFuture() : httpClient.close();
Future<Void> netClientClose = netClient == null ? Future.succeededFuture() : netClient.close();
return Future.all(httpClientClose, netClientClose).mapEmpty();
}
/**
* TODO add Deploy
* @param args
*/
public static void main(String[] args) {
// 配置 DNS 解析器,使用多个 DNS 服务器来提升解析速度
Vertx vertx = Vertx.vertx(new VertxOptions()
.setAddressResolverOptions(new AddressResolverOptions()
.addServer("114.114.114.114")
.addServer("114.114.115.115")
.addServer("8.8.8.8")
.addServer("8.8.4.4")));
// 部署 Verticle 并启动动态 HTTP 代理服务器
vertx.deployVerticle(new HttpProxyVerticle());
}
}
@@ -1,68 +0,0 @@
package cn.qaiu.vx.core.verticle;
import cn.qaiu.vx.core.base.AppRun;
import cn.qaiu.vx.core.base.DefaultAppRun;
import cn.qaiu.vx.core.util.CommonUtil;
import cn.qaiu.vx.core.util.ReflectionUtil;
import cn.qaiu.vx.core.util.SharedDataUtil;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 后置执行Verticle - 在core启动后立即执行AppRun实现
* <br>Create date 2024-01-01 00:00:00
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class PostExecVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(PostExecVerticle.class);
private static final Set<AppRun> appRunImplementations;
private static final AtomicBoolean lock = new AtomicBoolean(false);
static {
Reflections reflections = ReflectionUtil.getReflections();
Set<Class<? extends AppRun>> subTypesOf = reflections.getSubTypesOf(AppRun.class);
subTypesOf.add(DefaultAppRun.class);
appRunImplementations = CommonUtil.sortClassSet(subTypesOf);
if (appRunImplementations.isEmpty()) {
LOGGER.warn("未找到 AppRun 接口的实现类");
} else {
LOGGER.info("找到 {} 个 AppRun 接口的实现类", appRunImplementations.size());
}
}
@Override
public void start(Promise<Void> startPromise) {
if (!lock.compareAndSet(false, true)) {
return;
}
LOGGER.info("PostExecVerticle 开始执行...");
if (appRunImplementations != null && !appRunImplementations.isEmpty()) {
appRunImplementations.forEach(appRun -> {
try {
LOGGER.info("执行 AppRun 实现: {}", appRun.getClass().getName());
JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
appRun.execute(globalConfig);
LOGGER.info("AppRun 实现 {} 执行完成", appRun.getClass().getName());
} catch (Exception e) {
LOGGER.error("执行 AppRun 实现 {} 时发生错误",appRun.getClass().getName(), e);
}
});
} else {
LOGGER.info("未找到 AppRun 接口的实现类");
}
LOGGER.info("PostExecVerticle 执行完成");
startPromise.complete();
}
}
@@ -3,26 +3,18 @@ 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;
import io.vertx.httpproxy.ProxyContext;
import io.vertx.httpproxy.ProxyInterceptor;
import io.vertx.httpproxy.ProxyResponse;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -30,18 +22,12 @@ 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;
/**
* <p>反向代理服务</p>
* <p>可以根据配置文件自动生成代理服务</p>
* <p>可以配置多个服务, 配置文件见示例</p>
* <p>【优化】支持高并发场景,连接池复用,避免线程阻塞</p>
* <br>Create date 2021/9/2 0:41
*
* @author <a href="https://qaiu.top">QAIU</a>
@@ -50,201 +36,39 @@ public class ReverseProxyVerticle extends AbstractVerticle {
private static final Logger LOGGER = LoggerFactory.getLogger(ReverseProxyVerticle.class);
private static final String PATH_PROXY_CONFIG = SharedDataUtil
.getJsonConfig(ConfigConstant.GLOBAL_CONFIG)
.getString("proxyConf");
private static final Future<JsonObject> CONFIG = ConfigUtil.readYamlConfig(PATH_PROXY_CONFIG);
private static final String DEFAULT_PATH_404 = "webroot/err/page404.html";
private static String serverName = "Vert.x-proxy-server"; //Server name in Http response header
public static String REROUTE_PATH_PREFIX = "/__rrvpspp"; //re_route_vert_proxy_server_path_prefix 硬编码
/**
* 【优化】HttpClient连接池,按host:port缓存复用,避免每个请求都创建新连接
*/
private final Map<String, HttpClientEntry> httpClientPool = new ConcurrentHashMap<>();
private final List<HttpServer> httpServers = new ArrayList<>();
private volatile boolean stopping = false;
/**
* 连接池条目。HttpProxy 会持有这里的 HttpClient 引用,不能在路由仍可用时关闭。
*/
private static class HttpClientEntry {
final HttpClient client;
HttpClientEntry(HttpClient client) {
this.client = client;
}
}
/**
* 【优化】高并发场景下的HttpClient配置
*/
private static final int MAX_POOL_SIZE = 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 = false; // 代理场景关闭管线化,避免慢响应堆积
@Override
public void start(Promise<Void> startPromise) {
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();
});
CONFIG.onSuccess(this::handleProxyConfList);
// createFileListener
startPromise.complete();
}
/**
* 【优化】Verticle停止时清理HttpClient连接池
*/
@Override
public void stop(Promise<Void> stopPromise) {
stopping = true;
LOGGER.info("Stopping ReverseProxyVerticle, closing {} servers and {} HttpClient connections...",
httpServers.size(), httpClientPool.size());
List<Future<Void>> serverCloseFutures = new ArrayList<>();
httpServers.forEach(server -> serverCloseFutures.add(server.close()));
Future<Void> serverCloseFuture = serverCloseFutures.isEmpty()
? Future.succeededFuture()
: Future.all(serverCloseFutures).mapEmpty();
serverCloseFuture.onComplete(serverClose -> {
List<Future<Void>> clientCloseFutures = new ArrayList<>();
closeHttpClients(clientCloseFutures);
Future<Void> clientCloseFuture = clientCloseFutures.isEmpty()
? Future.succeededFuture()
: Future.all(clientCloseFutures).mapEmpty();
clientCloseFuture.onComplete(clientClose -> {
if (serverClose.succeeded()) {
httpServers.clear();
}
if (clientClose.succeeded()) {
httpClientPool.clear();
}
if (serverClose.failed()) {
stopPromise.fail(serverClose.cause());
} else if (clientClose.failed()) {
stopPromise.fail(clientClose.cause());
} else {
stopPromise.complete();
}
});
});
}
private void closeHttpClients(List<Future<Void>> closeFutures) {
httpClientPool.values().forEach(entry -> {
try {
closeFutures.add(entry.client.close());
} catch (Exception e) {
LOGGER.warn("Error closing HttpClient: {}", e.getMessage());
}
});
}
/**
* 【优化】获取或创建HttpClient,实现连接池复用
* @param host 目标主机
* @param port 目标端口
* @return HttpClient实例
*/
private HttpClient getOrCreateHttpClient(String host, int port) {
String key = host + ":" + port;
HttpClientEntry entry = httpClientPool.computeIfAbsent(key, k -> {
LOGGER.info("Creating new HttpClient for {}", key);
HttpClientOptions options = new HttpClientOptions()
.setMaxPoolSize(MAX_POOL_SIZE) // 连接池大小
.setMaxWaitQueueSize(MAX_WAIT_QUEUE_SIZE) // 等待队列大小
.setConnectTimeout(CONNECT_TIMEOUT) // 连接超时
.setIdleTimeout(IDLE_TIMEOUT) // 空闲超时
.setKeepAlive(KEEP_ALIVE) // Keep-Alive
.setKeepAliveTimeout(120) // Keep-Alive超时120秒
.setPipelining(PIPELINING) // HTTP管线化
.setPipeliningLimit(10) // 管线化限制
.setDecompressionSupported(false) // 代理不解压,避免放大内存
.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
return new HttpClientEntry(vertx.createHttpClient(options));
});
return entry.client;
}
/**
* 全局可信上游代理 IP 集合(如 nginx),仅这些 IP 的 X-Forwarded-For 会被信任
*/
private Set<String> globalTrustedProxies = new HashSet<>();
/**
* 获取主配置文件
*
* @param config proxy config
*/
private Future<Void> startProxyServers(JsonObject config) {
private void handleProxyConfList(JsonObject config) {
serverName = config.getString("server-name");
// 解析全局 trusted-proxies
JsonArray trustedArr = config.getJsonArray("trusted-proxies");
if (trustedArr != null) {
trustedArr.forEach(ip -> {
if (ip instanceof String) {
globalTrustedProxies.add(((String) ip).trim());
}
});
}
JsonArray proxyConfList = config.getJsonArray("proxy");
List<Future<Void>> listenFutures = new ArrayList<>();
if (proxyConfList != null) {
proxyConfList.forEach(proxyConf -> {
if (proxyConf instanceof JsonObject) {
listenFutures.add(handleProxyConf((JsonObject) proxyConf));
handleProxyConf((JsonObject) proxyConf);
}
});
}
return listenFutures.isEmpty() ? Future.succeededFuture() : Future.all(listenFutures).mapEmpty();
}
/**
* 解析真实客户端 IP。
* 若直连来源在可信代理列表中,优先取 X-Real-IP,其次取 X-Forwarded-For 第一个值;
* 否则直接使用直连对端地址。
*/
private String resolveClientIp(HttpServerRequest request) {
String peerIp = request.remoteAddress().host();
if (globalTrustedProxies.contains(peerIp)) {
String realIp = request.getHeader("X-Real-IP");
if (StringUtils.isNotBlank(realIp)) {
return realIp.trim();
}
String xff = request.getHeader("X-Forwarded-For");
if (StringUtils.isNotBlank(xff)) {
return xff.split(",")[0].trim();
}
}
return peerIp;
}
/**
* 解析 proxy-set-headers 中的 nginx 风格变量。
* 支持:$remote_addr、$proxy_add_x_forwarded_for、$scheme、$host
* 其他值作为字面量直接使用。
*/
private String resolveHeaderVariable(String tpl, HttpServerRequest req, String clientIp) {
return switch (tpl) {
case "$remote_addr" -> clientIp;
case "$proxy_add_x_forwarded_for" -> {
String existing = req.getHeader("X-Forwarded-For");
yield StringUtils.isNotBlank(existing) ? existing + ", " + clientIp : clientIp;
}
case "$scheme" -> req.isSSL() ? "https" : "http";
case "$host" -> req.getHeader("Host");
default -> tpl;
};
}
/**
@@ -252,7 +76,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
*
* @param proxyConf 代理配置
*/
private Future<Void> handleProxyConf(JsonObject proxyConf) {
private void handleProxyConf(JsonObject proxyConf) {
// page404 path
if (proxyConf.containsKey(
@@ -273,31 +97,18 @@ public class ReverseProxyVerticle extends AbstractVerticle {
proxyConf.put("page404", DEFAULT_PATH_404);
}
final HttpClient httpClient = VertxHolder.getVertxInstance().createHttpClient();
Router proxyRouter = Router.router(vertx);
// Add Server name header
proxyRouter.route().handler(ctx -> {
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
String rePath = realPath.replace(REROUTE_PATH_PREFIX, "");
ctx.reroute(rePath);
return;
}
if (!ctx.response().ended() && !ctx.response().closed()) {
ctx.response().putHeader("Server", serverName);
}
ctx.response().putHeader("Server", serverName);
ctx.next();
});
// http api proxy
if (proxyConf.containsKey("location")) {
handleLocation(proxyConf.getJsonArray("location"), proxyRouter);
handleLocation(proxyConf.getJsonArray("location"), httpClient, proxyRouter);
}
// static server
@@ -306,34 +117,20 @@ public class ReverseProxyVerticle extends AbstractVerticle {
}
// Send page404 page
proxyRouter.errorHandler(404, ctx -> {
sendNotFoundPage(ctx, proxyConf.getString("page404"));
});
proxyRouter.errorHandler(500, this::handleProxyFailure);
proxyRouter.errorHandler(404, ctx -> ctx.response().sendFile(proxyConf.getString("page404")));
HttpServer server = getHttpsServer(proxyConf);
server.requestHandler(proxyRouter);
Integer port = proxyConf.getInteger("listen");
LOGGER.info("proxy server start on {} port", port);
return server.listen(port)
.onSuccess(s -> httpServers.add(s))
.onFailure(e -> LOGGER.error("proxy server start failed on {} port", port, e))
.mapEmpty();
server.listen(port);
}
private HttpServer getHttpsServer(JsonObject proxyConf) {
HttpServerOptions httpServerOptions = new HttpServerOptions()
// 【优化】高并发服务器配置
.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法
.setCompressionSupported(true) // 启用压缩
.setAcceptBacklog(1024) // 限制积压队列,避免小容器内存膨胀
.setIdleTimeout(120) // 空闲超时120秒
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
.setCompressionSupported(true);
if (proxyConf.containsKey("ssl")) {
JsonObject sslConfig = proxyConf.getJsonObject("ssl");
@@ -364,67 +161,6 @@ public class ReverseProxyVerticle extends AbstractVerticle {
return vertx.createHttpServer(httpServerOptions);
}
private void addProxyHandler(Route route, HttpProxy httpProxy) {
Handler<RoutingContext> proxyHandler = ProxyHandler.create(httpProxy);
route.handler(ctx -> {
try {
proxyHandler.handle(ctx);
} catch (Throwable t) {
LOGGER.error("反向代理处理异常", t);
ctx.fail(t);
}
}).failureHandler(this::handleProxyFailure);
}
private void handleProxyFailure(RoutingContext ctx) {
Throwable failure = ctx.failure();
if (failure != null) {
LOGGER.error("反向代理路由失败", failure);
}
int statusCode = ctx.statusCode() > 0 ? ctx.statusCode() : 502;
if (statusCode < 400) {
statusCode = 502;
}
sendProxyError(ctx, statusCode, "Bad Gateway");
}
private void sendNotFoundPage(RoutingContext ctx, String page404) {
HttpServerResponse response = ctx.response();
if (response.ended() || response.closed()) {
return;
}
try {
if (response.headWritten()) {
response.reset();
return;
}
response.sendFile(page404)
.onFailure(e -> {
LOGGER.warn("发送代理 404 页面失败: {}", page404, e);
sendProxyError(ctx, 404, "404 not found");
});
} catch (Exception e) {
LOGGER.warn("发送代理 404 页面异常: {}", page404, e);
sendProxyError(ctx, 404, "404 not found");
}
}
private void sendProxyError(RoutingContext ctx, int statusCode, String message) {
HttpServerResponse response = ctx.response();
if (response.ended() || response.closed()) {
return;
}
try {
if (!response.headWritten()) {
response.setStatusCode(statusCode).end(message);
} else {
response.reset();
}
} catch (Exception e) {
LOGGER.debug("代理响应已关闭,忽略错误响应", e);
}
}
/**
* 处理静态资源配置
*
@@ -448,6 +184,7 @@ public class ReverseProxyVerticle extends AbstractVerticle {
} else {
staticHandler = StaticHandler.create();
}
if (staticConf.containsKey("directory-listing")) {
staticHandler.setDirectoryListing(staticConf.getBoolean("directory-listing"));
} else if (staticConf.containsKey("index")) {
@@ -460,9 +197,10 @@ public class ReverseProxyVerticle extends AbstractVerticle {
* 处理Location配置 代理请求Location(和nginx类似?)
*
* @param locationsConf location配置
* @param httpClient 客户端
* @param proxyRouter 代理路由
*/
private void handleLocation(JsonArray locationsConf, Router proxyRouter) {
private void handleLocation(JsonArray locationsConf, HttpClient httpClient, Router proxyRouter) {
locationsConf.stream().map(e -> (JsonObject) e).forEach(location -> {
// 代理规则
@@ -473,38 +211,14 @@ public class ReverseProxyVerticle extends AbstractVerticle {
String host = url.getHost();
int port = url.getPort();
if (port == -1) {
port = 443;
port = 80;
}
String originPath = url.getPath();
LOGGER.info("path {}, originPath {}, to {}:{}", path, originPath, host, port);
// 【优化】使用连接池获取HttpClient,避免每个location都创建新连接
final HttpClient httpClient = getOrCreateHttpClient(host, port);
// 注意这里不能origin多个代理地址, 一个实例只能代理一个origin
final HttpProxy httpProxy = HttpProxy.reverseProxy(httpClient);
httpProxy.origin(port, host);
// proxy-set-headers 支持(nginx 风格变量替换)
if (location.containsKey("proxy-set-headers")) {
final JsonObject headerConf = location.getJsonObject("proxy-set-headers");
httpProxy.addInterceptor(new ProxyInterceptor() {
@Override
public Future<ProxyResponse> handleProxyRequest(ProxyContext ctx) {
HttpServerRequest incoming = ctx.request().proxiedRequest();
String clientIp = resolveClientIp(incoming);
headerConf.forEach(entry -> {
Object val = entry.getValue();
if (val != null) {
String resolved = resolveHeaderVariable(val.toString(), incoming, clientIp);
if (resolved != null) {
ctx.request().putHeader(entry.getKey(), resolved);
}
}
});
return ProxyInterceptor.super.handleProxyRequest(ctx);
}
});
}
if (StringUtils.isEmpty(path)) {
return;
}
@@ -513,64 +227,24 @@ public class ReverseProxyVerticle extends AbstractVerticle {
if (StringUtils.isEmpty(originPath) || path.equals(originPath)) {
Route route = path.startsWith("~") ? proxyRouter.routeWithRegex(path.substring(1))
: proxyRouter.route(path);
addProxyHandler(route, httpProxy);
route.handler(ProxyHandler.create(httpProxy));
} else {
// 配置 /api/, / => 请求 /api/test 代理后 /test
// 配置 /api/, /xxx => 请求 /api/test 代理后 /xxx/test
final String path0 = path;
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
final String path0 = path;
final String originPath0 = REROUTE_PATH_PREFIX + originPath;
addProxyHandler(proxyRouter.route(originPath0 + "*"), httpProxy);
proxyRouter.route(path0 + "*").handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(path0)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replaceAll("^" + path0, originPath0);
ctx.reroute(rePath);
} else {
ctx.next();
}
});
// 计算唯一后缀,避免多个 location 冲突
// String uniqueKey = (host + ":" + port + "|" + path).replaceAll("[^a-zA-Z0-9:_|/]", "");
// String uniqueSuffix = Integer.toHexString(uniqueKey.hashCode());
//
//// 规格化 originPath
// //String originPath = url.getPath(); // 原值
// if (StringUtils.isBlank(originPath)) originPath = "/";
//
//// 处理 index.html 的情况:用于首页兜底,其它子路径仍按目录穿透
// String indexFile;
// if (originPath.endsWith(".html")) {
// indexFile = originPath; // 例如 /index.html
// originPath = "/"; // 目录穿透基准改为根
// } else {
// indexFile = null;
// }
//
//// 唯一内部挂载前缀
// final String originMount = REROUTE_PATH_PREFIX + uniqueSuffix + originPath;
//
//// 1) 目标挂载:所有被重写的请求最终到这里走 ProxyHandler
// proxyRouter.route(originMount + "*").handler(ProxyHandler.create(httpProxy));
//
//// 2) 从外部前缀 -> 内部挂载 的重写
// final String path0 = path;
// proxyRouter.route(path0 + "*").handler(ctx -> {
// String uri = ctx.request().uri();
// if (!uri.startsWith(path0)) { ctx.next(); return; }
//
// // 首页兜底:访问 /n2 或 /n2/ 时,重写到 index.html(如果配置了)
// if (indexFile != null && (uri.equals(path0) || uri.equals(path0.substring(0, path0.length()-1)))) {
// String rePath = originMount.endsWith("/") ? (originMount + indexFile.substring(1)) : (originMount + indexFile);
// ctx.reroute(rePath);
// return;
// }
//
// // 一般穿透:/n2/xxx -> originMount + xxx
// String rePath = uri.replaceFirst("^" + path0, originMount);
// ctx.reroute(rePath);
// });
proxyRouter.route(originPath0 + "*").handler(ProxyHandler.create(httpProxy));
proxyRouter.route(path0 + "*").handler(ctx -> {
String realPath = ctx.request().uri();
if (realPath.startsWith(path0)) {
// vertx web proxy暂不支持rewrite, 所以这里进行手动替换, 请求地址中的请求path前缀替换为originPath
String rePath = realPath.replaceAll("^" + path0, originPath0);
ctx.reroute(rePath);
} else {
ctx.next();
}
});
}
} catch (MalformedURLException e) {
@@ -22,22 +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 Router router = new RouterHandlerFactory(
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
private static final JsonObject globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
private HttpServer server;
private Router router;
private int port;
private JsonObject globalConfig;
static {
LOGGER.info(JacksonConfig.class.getSimpleName() + " >> ");
JacksonConfig.nothing();
LOGGER.info("To start listening to port {} ......", port);
}
@Override
public void start(Promise<Void> startPromise) {
port = SharedDataUtil.getValueForServerConfig("port");
globalConfig = SharedDataUtil.getJsonConfig("globalConfig");
LOGGER.info("To start listening to port {} ......", port);
// 端口是否占用
if (CommonUtil.isPortUsing(port)) {
throw new RuntimeException("Start fail: the '" + port + "' port is already in use...");
@@ -49,23 +49,9 @@ public class RouterVerticle extends AbstractVerticle {
options = new HttpServerOptions();
}
options.setPort(port);
// 【优化】高并发服务器配置
options.setTcpKeepAlive(true) // TCP Keep-Alive
.setTcpNoDelay(true) // 禁用Nagle算法,降低延迟
.setCompressionSupported(true) // 启用压缩
.setAcceptBacklog(50000) // 增加积压队列到50000,防止高并发时连接被拒绝
.setIdleTimeout(120) // 空闲超时120秒
.setTcpFastOpen(true) // 启用TCP Fast Open
.setTcpQuickAck(true) // 启用TCP Quick ACK
.setReuseAddress(true) // 允许地址重用
.setReusePort(true); // 允许端口重用
router = new RouterHandlerFactory(
SharedDataUtil.getJsonStringForServerConfig("contextPath")).createRouter();
server = vertx.createHttpServer(options);
server.requestHandler(router).listen()
server.requestHandler(router).webSocketHandler(s->{}).listen()
.onSuccess(s -> startPromise.complete())
.onFailure(e -> startPromise.fail(e.getCause()));
}
@@ -5,15 +5,11 @@ import cn.qaiu.vx.core.base.BaseAsyncService;
import cn.qaiu.vx.core.util.ReflectionUtil;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
import io.vertx.core.eventbus.MessageConsumer;
import io.vertx.core.json.JsonObject;
import io.vertx.serviceproxy.ServiceBinder;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
@@ -28,48 +24,26 @@ public class ServiceVerticle extends AbstractVerticle {
Logger LOGGER = LoggerFactory.getLogger(ServiceVerticle.class);
private static final AtomicInteger ID = new AtomicInteger(1);
private static final Set<Class<?>> handlers;
private final List<MessageConsumer<JsonObject>> consumers = new ArrayList<>();
static {
Reflections reflections = ReflectionUtil.getReflections();
handlers = reflections.getTypesAnnotatedWith(Service.class);
}
@Override
public void start(Promise<Void> startPromise) {
ServiceBinder binder = new ServiceBinder(vertx);
if (null != handlers && handlers.size() > 0) {
// handlers转为拼接类列表,xxx,yyy,zzz
StringBuilder serviceNames = new StringBuilder();
handlers.forEach(asyncService -> {
try {
serviceNames.append(asyncService.getName()).append("|");
BaseAsyncService asInstance = (BaseAsyncService) ReflectionUtil.newWithNoParam(asyncService);
String address = asInstance.getAddress();
MessageConsumer<JsonObject> consumer = binder.setAddress(address)
.register(asInstance.getAsyncInterfaceClass(), asInstance);
consumers.add(consumer);
binder.setAddress(asInstance.getAddress()).register(asInstance.getAsyncInterfaceClass(), asInstance);
} catch (Exception e) {
LOGGER.error("Failed to register service: {}", asyncService.getName(), e);
LOGGER.error(e.getMessage());
}
});
LOGGER.info("registered async services -> id: {}, name: {}", ID.getAndIncrement(), serviceNames.toString());
LOGGER.info("registered async services -> id: {}", ID.getAndIncrement());
}
startPromise.complete();
}
@Override
public void stop(Promise<Void> stopPromise) {
int count = consumers.size();
consumers.forEach(consumer -> {
try {
consumer.unregister();
} catch (Exception e) {
LOGGER.warn("Failed to unregister service consumer at address: {}", consumer.address(), e);
}
});
consumers.clear();
LOGGER.info("ServiceVerticle stopped, unregistered {} services", count);
stopPromise.complete();
}
}
@@ -8,14 +8,14 @@ import io.vertx.core.net.ProxyOptions;
import java.util.UUID;
@DataObject
//@JsonGen(publicConverter = false)
@JsonGen(publicConverter = false)
public class HttpProxyConf {
public static final String DEFAULT_USERNAME = UUID.randomUUID().toString();
public static final String DEFAULT_PASSWORD = UUID.randomUUID().toString();
public static final Integer DEFAULT_PORT = 6432;
public static final Integer DEFAULT_PORT = 6402;
public static final Integer DEFAULT_TIMEOUT = 15000;
@@ -32,7 +32,7 @@ public class HttpProxyConf {
public HttpProxyConf() {
this.username = DEFAULT_USERNAME;
this.password = DEFAULT_PASSWORD;
this.port = DEFAULT_PORT;
this.timeout = DEFAULT_PORT;
this.timeout = DEFAULT_TIMEOUT;
this.preProxyOptions = new ProxyOptions();
}
@@ -1,134 +0,0 @@
package cn.qaiu.vx.core.test;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.junit.Assert;
import org.junit.Test;
/**
* 单元测试:验证 RouterHandlerFactory 关于 JsonObject/JsonArray 参数绑定的核心分支逻辑是否正确
* (不启动整个 Vert.x 服务器,直接用 Vert.x JsonObject/JsonArray API 模拟验证关键逻辑)
*/
public class JsonBodyBindingLogicTest {
// === 模拟 handlerMethod 中的 JSON body 绑定逻辑 ===
/**
* 模拟:content-type = application/jsonbody 是 JsonObject
* 期望:JsonObject 类型参数被正确绑定
*/
@Test
public void testJsonObjectBinding() {
String bodyStr = "{\"name\":\"test\",\"value\":123}";
// 模拟 ctx.body().asJsonObject()
JsonObject body = parseAsJsonObject(bodyStr);
Assert.assertNotNull("body 应能解析为 JsonObject", body);
// 模拟绑定逻辑中的类型判断
String targetType = JsonObject.class.getName();
boolean matched = JsonObject.class.getName().equals(targetType);
Assert.assertTrue("JsonObject 类型应命中绑定分支", matched);
// 模拟结果
Object bound = body; // parameterValueList.put(k, body)
Assert.assertNotNull("JsonObject 参数应被绑定(非null", bound);
Assert.assertEquals("name字段应为test", "test", ((JsonObject) bound).getString("name"));
Assert.assertEquals("value字段应为123", 123, (int) ((JsonObject) bound).getInteger("value"));
System.out.println("[PASS] testJsonObjectBinding: JsonObject 绑定成功 -> " + bound);
}
/**
* 模拟:content-type = application/jsonbody 是 JsonArray
* 期望:JsonArray 类型参数被正确绑定
*/
@Test
public void testJsonArrayBinding() {
String bodyStr = "[1,2,3]";
// body 解析为 JsonObject 应返回 null
JsonObject bodyAsObj = parseAsJsonObject(bodyStr);
Assert.assertNull("JsonArray body 解析为 JsonObject 应为 null", bodyAsObj);
// 进入 else 分支,解析为 JsonArray
JsonArray bodyArr = parseAsJsonArray(bodyStr);
Assert.assertNotNull("body 应能解析为 JsonArray", bodyArr);
String targetType = JsonArray.class.getName();
boolean matched = JsonArray.class.getName().equals(targetType);
Assert.assertTrue("JsonArray 类型应命中绑定分支", matched);
Object bound = bodyArr;
Assert.assertNotNull("JsonArray 参数应被绑定(非null", bound);
Assert.assertEquals("数组大小应为3", 3, ((JsonArray) bound).size());
System.out.println("[PASS] testJsonArrayBinding: JsonArray 绑定成功, size=" + ((JsonArray) bound).size());
}
/**
* 验证旧代码的 bug:条件 ctx.body().asJsonObject() != null 会把 JsonArray body 排除在外
* 新代码只判断 content-type,在 body==null 时才进 else 分支处理 JsonArray
*/
@Test
public void testOldConditionBug() {
String jsonArrayBody = "[1,2,3]";
// 旧代码条件:content-type==json && asJsonObject()!=null
// 对于 JsonArray bodyasJsonObject() 返回 null,整个 if 跳过
JsonObject wrongParsed = parseAsJsonObject(jsonArrayBody);
boolean oldConditionPassed = wrongParsed != null; // 旧代码的第二个条件
Assert.assertFalse("旧代码 bug: JsonArray body 会导致 asJsonObject()==null,整个分支跳过", oldConditionPassed);
// 新代码:先进 ifbody==null 再走 else 解析 JsonArray
boolean newConditionFirst = true; // content-type 匹配
JsonObject newBody = parseAsJsonObject(jsonArrayBody);
boolean newBodyIsNull = newBody == null; // null -> 进 else
Assert.assertTrue("新代码: body 解析为 null 时应走 else 分支解析 JsonArray", newBodyIsNull);
JsonArray newArr = parseAsJsonArray(jsonArrayBody);
Assert.assertNotNull("新代码: else 分支正确解析出 JsonArray", newArr);
System.out.println("[PASS] testOldConditionBug: 修复验证通过,新代码正确处理 JsonArray body");
}
/**
* 验证:JsonObject 参数旧代码没有绑定分支(只处理实体类)
*/
@Test
public void testOldMissingJsonObjectBranch() {
String bodyStr = "{\"key\":\"value\"}";
JsonObject body = parseAsJsonObject(bodyStr);
// 旧代码只调用 matchRegList(entityPackagesReg, typeName)
// 对于 io.vertx.core.json.JsonObject,该方法返回 false,不会被绑定
String typeName = JsonObject.class.getName(); // "io.vertx.core.json.JsonObject"
// entityPackagesReg 一般是 "cn.qaiu.*" 这类,不会匹配 io.vertx
boolean oldWouldBind = typeName.startsWith("cn.qaiu"); // 模拟旧代码逻辑
Assert.assertFalse("旧代码 bug: JsonObject 参数不会被绑定", oldWouldBind);
// 新代码:增加了 JsonObject 类型判断
boolean newWouldBind = JsonObject.class.getName().equals(typeName);
Assert.assertTrue("新代码: JsonObject 参数应能被绑定", newWouldBind);
System.out.println("[PASS] testOldMissingJsonObjectBranch: 修复验证通过");
}
// ===== 辅助方法:模拟 Vert.x RequestBody 的 asJsonObject/asJsonArray 行为 =====
private JsonObject parseAsJsonObject(String str) {
try {
return new JsonObject(str);
} catch (Exception e) {
return null;
}
}
private JsonArray parseAsJsonArray(String str) {
try {
return new JsonArray(str);
} catch (Exception e) {
return null;
}
}
}
@@ -1,125 +0,0 @@
package cn.qaiu.vx.core.test;
import cn.qaiu.vx.core.util.VertxHolder;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* 集成测试: 验证 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定逻辑是否正确
*
* 运行方式: mvn test-compile -pl core && java -cp "core/target/test-classes:core/target/classes:..." \
* cn.qaiu.vx.core.test.RouterHandlerBindingTest
*
* 或直接在 IDE 中运行 main 方法。
*/
public class RouterHandlerBindingTest {
static final int TEST_PORT = 18989;
public static void main(String[] args) throws Exception {
System.out.println("=== RouterHandler JsonObject/JsonArray 绑定测试 ===\n");
// 1. 先初始化 Vert.x 与 VertxHolder ——必须在加载 RouterHandlerFactory 之前
Vertx vertx = Vertx.vertx();
VertxHolder.init(vertx);
// 2. 向 SharedData 注入最小化配置
// baseLocations 指向测试包,使 Reflections 只扫描 TestJsonHandler
vertx.sharedData().getLocalMap("local").put("customConfig", new JsonObject()
.put("baseLocations", "cn.qaiu.vx.core.test")
.put("routeTimeOut", 30000)
.put("entityPackagesReg", new JsonArray()));
// ReverseProxyVerticle.<clinit> 需要 globalConfig.proxyConf(非空字符串即可)
vertx.sharedData().getLocalMap("local").put("globalConfig", new JsonObject()
.put("proxyConf", "proxy.yml"));
// 3. 创建 Router(此时才触发 BaseHttpApi.reflections 静态字段初始化)
// 用反射延迟加载,确保上面的 SharedData 已就绪
cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory factory =
new cn.qaiu.vx.core.handlerfactory.RouterHandlerFactory("api");
io.vertx.ext.web.Router router = factory.createRouter();
// 4. 启动 HTTP 服务器
CountDownLatch latch = new CountDownLatch(1);
vertx.createHttpServer()
.requestHandler(router)
.listen(TEST_PORT, res -> {
if (res.succeeded()) {
System.out.println("✔ 测试服务器启动成功 port=" + TEST_PORT);
} else {
System.err.println("✘ 服务器启动失败: " + res.cause().getMessage());
}
latch.countDown();
});
if (!latch.await(5, TimeUnit.SECONDS)) {
System.err.println("服务器启动超时");
vertx.close();
System.exit(1);
}
Thread.sleep(100); // 等 Vert.x 就绪
// 5. 执行测试
boolean allPassed = true;
allPassed &= testJsonObject();
allPassed &= testJsonArray();
// 6. 关闭
CountDownLatch closeLatch = new CountDownLatch(1);
vertx.close(v -> closeLatch.countDown());
closeLatch.await(3, TimeUnit.SECONDS);
System.out.println("\n" + (allPassed ? "✅ 全部测试通过!" : "❌ 存在测试失败!"));
System.exit(allPassed ? 0 : 1);
}
// ---------- 子测试 ----------
private static boolean testJsonObject() throws Exception {
String bodyStr = "{\"name\":\"test\",\"value\":123}";
String respBody = post("/api/test/json-object", bodyStr);
System.out.println("[JsonObject] 响应: " + respBody);
JsonObject result = new JsonObject(respBody);
JsonObject data = result.getJsonObject("data");
boolean bound = data != null && Boolean.TRUE.equals(data.getBoolean("bound"));
System.out.println("[JsonObject] " + (bound
? "PASS ✅ body 正确绑定为 JsonObject"
: "FAIL ❌ body 未绑定 (null)"));
return bound;
}
private static boolean testJsonArray() throws Exception {
String bodyStr = "[1,2,3]";
String respBody = post("/api/test/json-array", bodyStr);
System.out.println("[JsonArray] 响应: " + respBody);
JsonObject result = new JsonObject(respBody);
JsonObject data = result.getJsonObject("data");
boolean bound = data != null
&& Boolean.TRUE.equals(data.getBoolean("bound"))
&& Integer.valueOf(3).equals(data.getInteger("size"));
System.out.println("[JsonArray] " + (bound
? "PASS ✅ body 正确绑定为 JsonArray, size=3"
: "FAIL ❌ body 未绑定 或 size 不对"));
return bound;
}
private static String post(String path, String body) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + TEST_PORT + path))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
}
@@ -1,36 +0,0 @@
package cn.qaiu.vx.core.test;
import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.MIMEType;
import cn.qaiu.vx.core.enums.RouteMethod;
import cn.qaiu.vx.core.model.JsonResult;
import io.vertx.core.Future;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
/**
* 用于测试 RouterHandlerFactory 对 JsonObject/JsonArray 参数绑定的测试 Handler
*/
@RouteHandler("test")
public class TestJsonHandler {
/** POST /api/test/json-object Body: {"name":"test","value":123} */
@RouteMapping(value = "/json-object", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
public Future<JsonResult> testJsonObject(JsonObject body) {
// 只返回是否绑定成功及已知字段值,不嵌套原始 body 避免 toJsonObject() 循环
boolean bound = body != null;
String nameVal = bound ? body.getString("name", "") : "";
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
.put("bound", bound)
.put("name", nameVal)));
}
/** POST /api/test/json-array Body: [1,2,3] */
@RouteMapping(value = "/json-array", method = RouteMethod.POST, requestMIMEType = MIMEType.APPLICATION_JSON)
public Future<JsonResult> testJsonArray(JsonArray body) {
return Future.succeededFuture(JsonResult.data(new io.vertx.core.json.JsonObject()
.put("bound", body != null)
.put("size", body != null ? body.size() : -1)));
}
}
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
set -e
# Fix permissions on volume-mounted directories (runs as root)
chown -R appuser:appgroup /app/db /app/logs /app/resources 2>/dev/null || true
# Run Java directly - entrypoint is PID 1, exec makes Java PID 1
# Docker SIGTERM goes directly to Java, triggering ShutdownHook
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
-5
View File
@@ -1,5 +0,0 @@
{
"dependencies": {
"mvn": "^3.5.0"
}
}
View File
+5 -4
View File
@@ -4,26 +4,26 @@ NFD 解析器模块:聚合各类网盘/分享页解析,统一输出文件列
- 语言:Java 17
- 构建:Maven
- 模块版本:10.2.5
- 模块版本:10.1.17
## 依赖(Maven Central
```xml
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.2.5</version>
<version>10.1.17</version>
</dependency>
```
- Gradle Groovy DSL
```groovy
dependencies {
implementation 'cn.qaiu:parser:10.2.5'
implementation 'cn.qaiu:parser:10.1.17'
}
```
- Gradle Kotlin DSL
```kotlin
dependencies {
implementation("cn.qaiu:parser:10.2.5")
implementation("cn.qaiu:parser:10.1.17")
}
```
@@ -96,6 +96,7 @@ String url = tool.parseSync();
## 文档
- parser/doc/README.md:解析约定、示例、IDEA `.http` 调试
- **parser/doc/JAVASCRIPT_PARSER_GUIDE.mdJavaScript解析器开发完整指南** - 使用JavaScript编写自定义解析器
- **parser/doc/PYTHON_PARSER_GUIDE.mdPython解析器开发完整指南** - 使用Python(GraalPy)编写自定义解析器
- **parser/doc/CUSTOM_PARSER_GUIDE.md:自定义解析器扩展完整指南** - Java自定义解析器扩展
- **parser/doc/CUSTOM_PARSER_QUICKSTART.md:自定义解析器快速开始** - 快速上手指南
-26
View File
@@ -20,32 +20,6 @@
|------|------|------|------|
| url | string | ✅ 是 | 分享链接(需URL编码) |
| pwd | string | ❌ 否 | 分享密码 |
| auth | string | ❌ 否 | 认证参数(AES加密后的JSON,用于需要登录的网盘) |
### 认证参数说明(v0.2.1+
部分网盘(如夸克QK、UC网盘)需要登录后的 Cookie 才能解析。`auth` 参数用于传递认证信息:
**加密方式**
- 算法:AES/ECB/PKCS5Padding
- 密钥:`nfd_auth_key2026`16字节)
- 流程:JSON → AES加密 → Base64 → URL编码
**JSON 结构**
```json
{
"authType": "cookie", // 认证类型: cookie/accesstoken/authorization
"token": "your_cookie_here" // Cookie 或 Token 内容
}
```
**网盘认证要求**
| 网盘 | 认证要求 |
|------|---------|
| 夸克网盘(QK) | **必须** |
| UC网盘(UC) | **必须** |
| 小飞机网盘(FJ) | 大文件需要 |
| 蓝奏优享(IZ) | 大文件需要 |
### 请求示例
+5 -4
View File
@@ -1,13 +1,14 @@
# 自定义解析器扩展指南
> 最后更新:2025-10-17
> 最后更新:2026-01-11
## 概述
本模块支持用户自定义解析器扩展。用户在依赖本项目的 Maven 坐标后,可以实现自己的网盘解析器并注册到系统中使用。
> **提示**:除了Java自定义解析器,本项目还支持使用JavaScript编写解析器,无需编译即可使用
> 查看 [JavaScript解析器开发指南](JAVASCRIPT_PARSER_GUIDE.md) 了解更多。
> **提示**:除了Java自定义解析器,本项目还支持使用脚本语言编写解析器,无需编译即可使用
> - [JavaScript解析器开发指南](JAVASCRIPT_PARSER_GUIDE.md) - 使用JavaScript编写解析器
> - [Python解析器开发指南](PYTHON_PARSER_GUIDE.md) - 使用Python编写解析器(基于GraalPy
## 核心组件
@@ -28,7 +29,7 @@
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.2.5</version>
<version>10.1.17</version>
</dependency>
```
+1 -1
View File
@@ -11,7 +11,7 @@
<dependency>
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>10.2.5</version>
<version>10.1.17</version>
</dependency>
```
+24 -3
View File
@@ -4,6 +4,19 @@
本指南介绍如何使用JavaScript编写自定义网盘解析器,支持通过JavaScript代码实现网盘解析逻辑,无需编写Java代码。
### 技术规格
- **JavaScript 引擎**: Nashorn (JDK 8-14 内置)
- **ECMAScript 版本**: ES5.1 (ECMA-262 5.1 Edition)
- **语法支持**: ES5 标准语法,不支持 ES6+ 特性(如箭头函数、async/await、模板字符串等)
- **运行模式**: 同步执行,所有操作都是阻塞式的
### 参考文档
- **ECMAScript 5.1 规范**: https://262.ecma-international.org/5.1/
- **MDN JavaScript 文档**: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript
- **Nashorn 用户指南**: https://docs.oracle.com/javase/8/docs/technotes/guides/scripting/nashorn/
## 目录
- [快速开始](#快速开始)
@@ -240,13 +253,13 @@ var encoded = JsHttpClient.urlEncode("hello world"); // "hello%20world"
var decoded = JsHttpClient.urlDecode("hello%20world"); // "hello world"
// 发送简单表单数据
var formResponse = http.sendForm("https://api.example.com/login", {
var formResponse = http.sendForm({
username: "user",
password: "pass"
});
// 发送JSON数据
var jsonResponse = http.sendJson("https://api.example.com/submit", {
var jsonResponse = http.sendJson({
name: "test",
value: 123
});
@@ -637,7 +650,7 @@ A: 使用 `shareLinkInfo.getSharePassword()` 方法。
### Q: 如何处理需要登录的网盘?
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm(url, data)` 发送登录表单。
A: 使用 `http.putHeader()` 设置认证头,或使用 `http.sendForm()` 发送登录表单。
### Q: 如何解析复杂的HTML
@@ -711,9 +724,17 @@ var response = http.get("https://api.example.com/data");
## 相关文档
### 项目文档
- [自定义解析器扩展指南](CUSTOM_PARSER_GUIDE.md) - Java自定义解析器扩展
- [自定义解析器快速开始](CUSTOM_PARSER_QUICKSTART.md) - 快速上手指南
- [解析器开发文档](README.md) - 解析器开发约定和规范
- [Python解析器开发指南](PYTHON_PARSER_GUIDE.md) - Python 版本解析器指南
### 外部资源
- **ECMAScript 5.1 规范**: https://262.ecma-international.org/5.1/
- **MDN JavaScript 参考**: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference
- **MDN JavaScript 指南**: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide
- **Nashorn 文档**: https://docs.oracle.com/javase/8/docs/technotes/guides/scripting/nashorn/
## 更新日志
+215
View File
@@ -0,0 +1,215 @@
# Python Playground pylsp WebSocket 集成指南
## 概述
本文档说明了如何将 jedi 的 pylsp (python-lsp-server) 通过 WebSocket 集成到 Python Playground 中,实现实时代码检查、自动完成和悬停提示等功能。
## 架构
```
┌─────────────────────────────────────────────────────────────┐
│ 前端 (Vue + Monaco) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ PylspClient.js ││
│ │ - 通过 WebSocket 发送 LSP JSON-RPC 消息 ││
│ │ - 接收诊断信息并转换为 Monaco markers ││
│ └─────────────────────────────────────────────────────────┘│
└──────────────────────────┬──────────────────────────────────┘
│ WebSocket (SockJS)
┌─────────────────────────────────────────────────────────────┐
│ 后端 (Vert.x + SockJS) │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ PylspWebSocketHandler.java ││
│ │ - @SockRouteMapper("/pylsp/") ││
│ │ - 管理 pylsp 子进程 ││
│ │ - 转发 LSP 消息 ││
│ └─────────────────────────────────────────────────────────┘│
└──────────────────────────┬──────────────────────────────────┘
│ stdio (LSP协议)
┌─────────────────────────────────────────────────────────────┐
│ pylsp (python-lsp-server) │
│ - jedi: 代码补全、定义跳转 │
│ - pyflakes: 语法错误检查 │
│ - pycodestyle: PEP8 风格检查 │
│ - mccabe: 复杂度检查 │
└─────────────────────────────────────────────────────────────┘
```
## 文件清单
### 后端 (Java)
1. **PylspWebSocketHandler.java**
- 路径: `web-service/src/main/java/cn/qaiu/lz/web/controller/PylspWebSocketHandler.java`
- 功能: WebSocket 端点,桥接前端与 pylsp 子进程
- 端点: `/ws/pylsp/*`
### 前端 (JavaScript/Vue)
1. **pylspClient.js**
- 路径: `web-front/src/utils/pylspClient.js`
- 功能: LSP WebSocket 客户端,封装 LSP 协议
### 测试
1. **RequestsIntegrationTest.java**
- 路径: `web-service/src/test/java/cn/qaiu/lz/web/playground/RequestsIntegrationTest.java`
- 功能: requests 库集成测试
2. **test_playground_api.py**
- 路径: `web-service/src/test/python/test_playground_api.py`
- 功能: API 接口的 pytest 测试脚本
## 使用方法
### 1. 安装 pylsp
```bash
pip install python-lsp-server[all]
```
或者只安装核心功能:
```bash
pip install python-lsp-server jedi
```
### 2. 前端集成示例
```javascript
import PylspClient from '@/utils/pylspClient';
// 创建客户端
const pylsp = new PylspClient({
onDiagnostics: (uri, markers) => {
// 设置 Monaco Editor markers
monaco.editor.setModelMarkers(model, 'pylsp', markers);
},
onConnected: () => {
console.log('pylsp 已连接');
},
onError: (error) => {
console.error('pylsp 错误:', error);
}
});
// 连接
await pylsp.connect();
// 打开文档
pylsp.openDocument(pythonCode);
// 更新文档(当代码改变时)
pylsp.updateDocument(newCode);
// 获取补全
const completions = await pylsp.getCompletions(line, column);
// 获取悬停信息
const hover = await pylsp.getHover(line, column);
// 断开连接
pylsp.disconnect();
```
### 3. 与 Monaco Editor 集成
```javascript
// 监听代码变化
editor.onDidChangeModelContent((e) => {
const content = editor.getValue();
pylsp.updateDocument(content);
});
// 注册补全提供者
monaco.languages.registerCompletionItemProvider('python', {
provideCompletionItems: async (model, position) => {
const items = await pylsp.getCompletions(
position.lineNumber - 1,
position.column - 1
);
return { suggestions: items.map(convertToMonacoItem) };
}
});
```
## 已知限制
### GraalPy requests 库限制
由于 GraalPy 的 `unicodedata/LLVM` 限制,`requests` 库在后续创建的 Context 中无法正常导入(会抛出 `PolyglotException: null`)。
**错误链**
```
requests → encodings.idna → stringprep → from unicodedata import ucd_3_2_0
```
**解决方案**
1. 在代码顶层导入 requests(不要在函数内部导入)
2. 使用标准库的 `urllib.request` 作为替代
3. 首次执行时预热 requests 导入
### 测试注意事项
1. PyPlaygroundFullTest 中的测试2和测试5被标记为跳过(已知限制)
2. 测试13(前端模板代码)使用不依赖 requests 的版本
3. requests 功能在实际运行时通过首个 Context 可以正常使用
## 测试命令
### 运行 Java 单元测试
```bash
# PyPlaygroundFullTest (13 个测试)
cd parser && mvn exec:java \
-Dexec.mainClass="cn.qaiu.parser.custompy.PyPlaygroundFullTest" \
-Dexec.classpathScope=test -q
# RequestsIntegrationTest
cd web-service && mvn exec:java \
-Dexec.mainClass="cn.qaiu.lz.web.playground.RequestsIntegrationTest" \
-Dexec.classpathScope=test -q
```
### 运行 Python API 测试
```bash
# 需要后端服务运行
cd web-service/src/test/python
pip install pytest requests
pytest test_playground_api.py -v
```
## 配置
### 后端配置
`PylspWebSocketHandler.java` 中可以配置:
- pylsp 启动命令
- 心跳间隔
- 进程超时
### 前端配置
`pylspClient.js` 中可以配置:
- WebSocket URL
- 重连次数
- 重连延迟
- 请求超时
## 安全考虑
1. pylsp 进程在沙箱环境中运行
2. 每个 WebSocket 连接对应一个独立的 pylsp 进程
3. 连接关闭时自动清理进程
4. Playground 访问需要认证(如果配置了密码)
## 未来改进
1. 支持多文件项目分析
2. 添加 pyright 类型检查
3. 支持代码格式化(black/autopep8
4. 添加重构功能
5. 支持虚拟环境选择
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
# Python Playground 测试报告
## 测试概述
本文档总结了 Python Playground 功能的单元测试和接口测试结果。
## 测试文件
| 文件 | 位置 | 说明 |
|------|------|------|
| `PyPlaygroundFullTest.java` | parser/src/test/java/cn/qaiu/parser/custompy/ | 完整单元测试套件(13个测试) |
| `PyCodeSecurityCheckerTest.java` | parser/src/test/java/cn/qaiu/parser/custompy/ | 安全检查器测试(17个测试) |
| `PlaygroundApiTest.java` | parser/src/test/java/cn/qaiu/parser/custompy/ | API接口测试(需要后端运行) |
## 单元测试结果
### PyPlaygroundFullTest - 13/13 通过 ✅
| 测试 | 说明 | 结果 |
|------|------|------|
| 测试1 | 基础 Python 执行(1+2, 字符串操作) | ✅ 通过 |
| 测试2 | requests 库导入 | ⚠️ 跳过(已知限制,功能由测试13验证) |
| 测试3 | 标准库导入(json, re, base64, hashlib | ✅ 通过 |
| 测试4 | 简单 parse 函数 | ✅ 通过 |
| 测试5 | 带 requests 的 parse 函数 | ⚠️ 跳过(已知限制,功能由测试13验证) |
| 测试6 | 带 share_link_info 的 parse 函数 | ✅ 通过 |
| 测试7 | PyPlaygroundExecutor 完整流程 | ✅ 通过 |
| 测试8 | 安全检查 - 拦截 subprocess | ✅ 通过 |
| 测试9 | 安全检查 - 拦截 socket | ✅ 通过 |
| 测试10 | 安全检查 - 拦截 os.system | ✅ 通过 |
| 测试11 | 安全检查 - 拦截 exec/eval | ✅ 通过 |
| 测试12 | 安全检查 - 允许安全代码 | ✅ 通过 |
| 测试13 | 前端模板代码执行(含 requests) | ✅ 通过 |
### PyCodeSecurityCheckerTest - 17/17 通过 ✅
所有安全检查器测试通过,验证了以下功能:
- 危险模块拦截:subprocess, socket, ctypes, multiprocessing
- 危险 os 方法拦截:system, popen, execv, fork, spawn, kill
- 危险内置函数拦截:exec, eval, compile, __import__
- 危险文件操作拦截:open with write mode
- 安全代码正确放行
## 已知限制
### GraalPy unicodedata/LLVM 限制
由于 GraalPy 的限制,`requests` 库只能在**第一个**创建的 Context 中成功导入。后续创建的 Context 导入 `requests` 会触发以下错误:
```
SystemError: GraalPy option 'NativeModules' is set to false, but the 'llvm' language,
which is required for this feature, is not available.
```
**原因**`requests` 依赖的 `encodings.idna` 模块会导入 `unicodedata`,而该模块需要 LLVM 支持。
**影响**
- 在单元测试中,多个测试用例无法同时测试 `requests` 导入
- 在实际运行中,只要使用 Context 池并确保 `requests` 在代码顶层导入,功能正常
**解决方案**
- 确保 `import requests` 放在 Python 代码的顶层,而不是函数内部
- 前端模板已正确配置,实际使用不受影响
## 运行测试
### 运行单元测试
```bash
cd parser
mvn test-compile -q && mvn exec:java \
-Dexec.mainClass="cn.qaiu.parser.custompy.PyPlaygroundFullTest" \
-Dexec.classpathScope=test -q
```
### 运行安全检查器测试
```bash
cd parser
mvn test-compile -q && mvn exec:java \
-Dexec.mainClass="cn.qaiu.parser.custompy.PyCodeSecurityCheckerTest" \
-Dexec.classpathScope=test -q
```
### 运行 API 接口测试
**注意**:需要先启动后端服务
```bash
# 启动后端服务
cd web-service && mvn exec:java -Dexec.mainClass=cn.qaiu.lz.AppMain
# 在另一个终端运行测试
cd parser
mvn test-compile -q && mvn exec:java \
-Dexec.mainClass="cn.qaiu.parser.custompy.PlaygroundApiTest" \
-Dexec.classpathScope=test -q
```
## API 接口测试内容
`PlaygroundApiTest` 测试以下接口:
1. **GET /v2/playground/status** - 获取演练场状态
2. **POST /v2/playground/test (JavaScript)** - JavaScript 代码执行
3. **POST /v2/playground/test (Python)** - Python 代码执行
4. **POST /v2/playground/test (安全检查)** - 验证危险代码被拦截
5. **POST /v2/playground/test (参数验证)** - 验证缺少参数时的错误处理
## 测试覆盖的核心组件
| 组件 | 说明 | 测试覆盖 |
|------|------|----------|
| `PyContextPool` | GraalPy Context 池管理 | ✅ 间接覆盖 |
| `PyPlaygroundExecutor` | Python 代码执行器 | ✅ 直接测试 |
| `PyCodeSecurityChecker` | 代码安全检查器 | ✅ 17个测试 |
| `PyPlaygroundLogger` | 日志记录器 | ✅ 间接覆盖 |
| `PyShareLinkInfoWrapper` | ShareLinkInfo 包装器 | ✅ 直接测试 |
| `PyHttpClient` | HTTP 客户端封装 | ⚠️ 部分覆盖 |
| `PyCryptoUtils` | 加密工具类 | ❌ 未直接测试 |
## 前端模板代码验证
测试13验证了前端 Python 模板代码的完整执行流程:
```python
import requests
import re
import json
def parse(share_link_info, http, logger):
share_url = share_link_info.get_share_url()
logger.info(f"开始解析: {share_url}")
# ... 解析逻辑
return "https://download.example.com/test.zip"
```
验证内容:
-`requests` 库导入
-`share_link_info.get_share_url()` 调用
-`logger.info()` 日志记录
- ✅ f-string 格式化
- ✅ 函数返回值处理
## 结论
Python Playground 功能已通过全面测试,核心功能正常工作。唯一的限制是 GraalPy 的 unicodedata/LLVM 问题,但在实际使用中不影响功能。建议在正式部署前进行完整的集成测试。
+1 -1
View File
@@ -68,7 +68,7 @@ List<FileInfo> files = tool.parseFileListSync();
```
要点:
- 必须先 WebClientVertxInit.init(Vertx)未初始化时会直接报错,避免解析器偷偷创建第二个 Vert.x 实例
- 必须先 WebClientVertxInit.init(Vertx)若未显式初始化,内部将懒加载 Vertx.vertx(),建议显式注入以统一生命周期
- 支持三种同步方法:
- `parseSync()`: 解析单个文件下载链接
- `parseFileListSync()`: 解析文件列表
-341
View File
@@ -1,341 +0,0 @@
# 认证参数传递指南 (Auth Parameter Guide)
## 概述
本文档描述了网盘解析接口中携带认证参数的方法。通过 `auth` 参数,可以在解析请求时传递临时认证信息(如 Cookie、Token、用户名密码等),使解析器能够访问需要登录或授权的网盘资源。
## 网盘认证要求
| 网盘 | 类型代码 | 认证要求 | 说明 |
|------|---------|---------|------|
| 夸克网盘 | QK | **必须** | 必须配置 Cookie 才能解析和下载 |
| UC网盘 | UC | **必须** | 必须配置 Cookie 才能解析和下载 |
| 小飞机网盘 | FJ | 可选 | 大文件(>100MB)需要配置认证信息 |
| 蓝奏优享 | IZ | 可选 | 大文件需要配置认证信息 |
| 其他网盘 | - | 不需要 | 无需认证即可解析 |
> 💡 **如何获取 Cookie**: 在浏览器中登录对应网盘,打开开发者工具(F12),切换到 Network 标签,刷新页面,在请求头中找到 Cookie 字段并复制完整内容。
## 认证参数格式
### 编码流程
```
JSON对象 → AES加密 → Base64编码 → URL编码
```
### 解码流程
```
URL解码 → Base64解码 → AES解密 → JSON对象
```
### 加密配置
- **加密算法**: AES/ECB/PKCS5Padding
- **密钥长度**: 16位(128位)
- **默认密钥**: `nfd_auth_key2026`(可在 `app-dev.yml` 中通过 `server.authEncryptKey` 配置)
### 密钥作用说明(重要)
当前系统中涉及两类不同用途的密钥:
1. `server.authEncryptKey`
- 用途:加解密 `auth` 参数(前端/调用方传入的认证信息)
- 影响范围:`/parser``/json/parser``/v2/linkInfo` 等接口中的 `auth` 参数
- 注意:这是 **AES 对称加密密钥**,要求 16 位
2. `server.donatedAccountFailureTokenSignKey`
- 用途:签名和验签“捐赠账号失败计数 token”(用于防伪造、失败计数)
- 影响范围:捐赠账号失败计数与自动失效逻辑
- 注意:这是 **HMAC 签名密钥**,与 `authEncryptKey` 已解耦,建议使用高强度随机字符串
> 建议:生产环境务必同时自定义这两个密钥,且不要设置为相同值。
## JSON 模型定义
### AuthParam 对象
```json
{
"authType": "string", // 认证类型(必填)
"username": "string", // 用户名
"password": "string", // 密码
"token": "string", // Token/AccessToken/Cookie值
"cookie": "string", // Cookie 字符串
"auth": "string", // Authorization 头内容
"ext1": "string", // 扩展字段1(格式: key:value
"ext2": "string", // 扩展字段2(格式: key:value
"ext3": "string", // 扩展字段3(格式: key:value
"ext4": "string", // 扩展字段4(格式: key:value
"ext5": "string" // 扩展字段5(格式: key:value
}
```
### 认证类型 (authType)
| authType | 说明 | 主要字段 |
|----------|------|---------|
| `accesstoken` | 使用 AccessToken 认证 | `token` |
| `cookie` | 使用 Cookie 认证 | `token` (存放 cookie 值) |
| `authorization` | 使用 Authorization 头认证 | `token` |
| `password` / `username_password` | 用户名密码认证 | `username`, `password` |
| `custom` | 自定义认证(使用扩展字段) | `token`, `ext1`-`ext5` |
### 示例 JSON
#### 1. Token 认证
```json
{
"authType": "accesstoken",
"token": "your_access_token_here"
}
```
#### 2. Cookie 认证
```json
{
"authType": "cookie",
"token": "session_id=abc123; user_token=xyz789"
}
```
#### 3. 用户名密码认证
```json
{
"authType": "password",
"username": "your_username",
"password": "your_password"
}
```
#### 4. 自定义认证
```json
{
"authType": "custom",
"token": "main_token",
"ext1": "refresh_token:your_refresh_token",
"ext2": "device_id:device123"
}
```
## 接口调用示例
### 基础接口
#### 1. 解析并重定向 (GET /parser)
```
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**参数说明:**
- `url`: 网盘分享链接(必填)
- `pwd`: 提取码(可选)
- `auth`: 加密后的认证参数(可选)
**响应:** 302 重定向到直链
#### 2. 解析返回 JSON (GET /json/parser)
```
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应示例:**
```json
{
"shareKey": "lz:xxxx",
"directLink": "https://...",
"cacheHit": false,
"expires": "2026-02-05 12:00:00",
"expiration": 1738728000000
}
```
#### 3. 获取链接信息 (GET /v2/linkInfo)
```
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应:** 返回下载链接、API 链接、预览链接等信息
## 各语言加密示例
### Java
```java
import cn.qaiu.lz.common.util.AuthParamCodec;
import cn.qaiu.lz.web.model.AuthParam;
// 方式1: 使用 AuthParam 对象
AuthParam authParam = AuthParam.builder()
.authType("accesstoken")
.token("your_token_here")
.build();
String encrypted = AuthParamCodec.encode(authParam);
// 方式2: 快速编码
String encrypted = AuthParamCodec.quickEncode("accesstoken", "your_token_here");
// 方式3: 用户名密码
String encrypted = AuthParamCodec.quickEncodePassword("username", "password");
// 解码
AuthParam decoded = AuthParamCodec.decode(encrypted);
```
### JavaScript (浏览器/Node.js)
```javascript
// 使用 CryptoJS 库
const CryptoJS = require('crypto-js');
const AUTH_KEY = 'nfd_auth_key2026';
// 加密
function encodeAuthParam(authObj) {
const jsonStr = JSON.stringify(authObj);
const encrypted = CryptoJS.AES.encrypt(jsonStr, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
const base64 = encrypted.toString();
return encodeURIComponent(base64);
}
// 解密
function decodeAuthParam(encryptedAuth) {
const base64 = decodeURIComponent(encryptedAuth);
const decrypted = CryptoJS.AES.decrypt(base64, CryptoJS.enc.Utf8.parse(AUTH_KEY), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return JSON.parse(decrypted.toString(CryptoJS.enc.Utf8));
}
// 使用示例
const auth = encodeAuthParam({
authType: 'accesstoken',
token: 'your_token_here'
});
const url = `http://127.0.0.1:6400/parser?url=${shareUrl}&auth=${auth}`;
```
### Python
```python
import json
import base64
from urllib.parse import quote, unquote
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
AUTH_KEY = b'nfd_auth_key2026'
def encode_auth_param(auth_obj):
"""加密认证参数"""
json_str = json.dumps(auth_obj, ensure_ascii=False)
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
padded = pad(json_str.encode('utf-8'), AES.block_size)
encrypted = cipher.encrypt(padded)
base64_str = base64.b64encode(encrypted).decode('utf-8')
return quote(base64_str)
def decode_auth_param(encrypted_auth):
"""解密认证参数"""
base64_str = unquote(encrypted_auth)
encrypted = base64.b64decode(base64_str)
cipher = AES.new(AUTH_KEY, AES.MODE_ECB)
decrypted = unpad(cipher.decrypt(encrypted), AES.block_size)
return json.loads(decrypted.decode('utf-8'))
# 使用示例
auth = encode_auth_param({
'authType': 'accesstoken',
'token': 'your_token_here'
})
url = f'http://127.0.0.1:6400/parser?url={share_url}&auth={auth}'
```
### cURL 命令行
```bash
# 假设已加密的 auth 参数为 ENCRYPTED_AUTH
curl -L "http://127.0.0.1:6400/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
# 获取 JSON 响应
curl "http://127.0.0.1:6400/json/parser?url=https://www.lanzoux.com/xxxx&auth=ENCRYPTED_AUTH"
```
## 解析器使用认证信息
解析器可以从 `shareLinkInfo.otherParam.get("auths")` 获取 MultiMap 格式的认证信息:
```java
// 在解析器中获取认证信息
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (auths != null) {
String authType = auths.get("authType");
String token = auths.get("token");
String username = auths.get("username");
String password = auths.get("password");
// 根据 authType 使用相应的认证方式
switch (authType) {
case "accesstoken":
// 使用 token 认证
break;
case "password":
// 使用用户名密码登录
break;
// ...
}
}
```
## 注意事项
1. **安全性**:
- 不要在日志中打印完整的认证参数
- 认证参数通过 HTTPS 传输更安全
- 密钥应妥善保管,建议在生产环境中更换默认密钥
2. **缓存策略**:
- 带有临时认证参数的请求目前不会被缓存
- 每次请求都会重新解析
3. **兼容性**:
- `auth` 参数与原有的 `pwd` 参数可以同时使用
- 不提供 `auth` 参数时,使用后台配置的认证信息
4. **扩展字段**:
- `ext1`-`ext5` 使用 `key:value` 格式
- 适用于需要传递多个自定义参数的场景
## 配置说明
`app-dev.yml` 中配置密钥:
```yaml
server:
# auth参数加密密钥(16位AES密钥)
authEncryptKey: 'your_custom_key16'
# 捐赠账号失败计数token签名密钥(HMAC)
# 建议使用较长随机字符串,并与 authEncryptKey 不同
donatedAccountFailureTokenSignKey: 'your_random_hmac_sign_key'
```
### 密钥管理建议
- 不要在公开仓库提交生产密钥
- 建议通过环境变量或私有配置注入
- 调整 `authEncryptKey` 会影响 `auth` 参数兼容性
- 调整 `donatedAccountFailureTokenSignKey` 会使已签发的失败计数 token 失效(短期可接受)
## 更新日志
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
@@ -1,118 +0,0 @@
# 认证参数传递指南 (简化版)
## JSON 对象模型
### AuthParam 对象
```json
{
"authType": "string", // 认证类型(必填)
"username": "string", // 用户名
"password": "string", // 密码
"token": "string", // Token/AccessToken/Cookie值
"cookie": "string", // Cookie 字符串
"auth": "string", // Authorization 头内容
"ext1": "string", // 扩展字段1(格式: key:value
"ext2": "string", // 扩展字段2(格式: key:value
"ext3": "string", // 扩展字段3(格式: key:value
"ext4": "string", // 扩展字段4(格式: key:value
"ext5": "string" // 扩展字段5(格式: key:value
}
```
## 认证类型
| authType | 说明 | 主要字段 |
|----------|------|---------|
| `accesstoken` | AccessToken 认证 | `token` |
| `cookie` | Cookie 认证 | `token` |
| `authorization` | Authorization 头认证 | `token` |
| `password` | 用户名密码认证 | `username`, `password` |
| `custom` | 自定义认证 | `token`, `ext1`-`ext5` |
## 示例
### Token 认证
```json
{
"authType": "accesstoken",
"token": "your_access_token_here"
}
```
### Cookie 认证
```json
{
"authType": "cookie",
"token": "session_id=abc123; user_token=xyz789"
}
```
### 用户名密码
```json
{
"authType": "password",
"username": "your_username",
"password": "your_password"
}
```
### 自定义认证
```json
{
"authType": "custom",
"token": "main_token",
"ext1": "refresh_token:your_refresh_token",
"ext2": "device_id:device123"
}
```
## 使用说明
1. **编码流程**: JSON对象 → AES加密 → Base64编码 → URL编码
2. **加密配置**: AES/ECB/PKCS5Padding, 密钥: `nfd_auth_key2026` (16位)
3. **接口调用**: `GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}`
## 接口调用示例
### 基础接口
#### 1. 解析并重定向 (GET /parser)
```
GET /parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**参数说明:**
- `url`: 网盘分享链接(必填)
- `pwd`: 提取码(可选)
- `auth`: 加密后的认证参数(可选)
**响应:** 302 重定向到直链
#### 2. 解析返回 JSON (GET /json/parser)
```
GET /json/parser?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应示例:**
```json
{
"shareKey": "lz:xxxx",
"directLink": "https://...",
"cacheHit": false,
"expires": "2026-02-05 12:00:00",
"expiration": 1738728000000
}
```
#### 3. 获取链接信息 (GET /v2/linkInfo)
```
GET /v2/linkInfo?url={分享链接}&pwd={提取码}&auth={加密认证参数}
```
**响应:** 返回下载链接、API 链接、预览链接等信息
+1 -1
View File
@@ -17,7 +17,7 @@
this.temporaryExecutor = WebClientVertxInit.get().createSharedWorkerExecutor(
"playground-temp-" + System.currentTimeMillis(),
1, // 每个请求只需要1个线程
10000000000L // 设置非常长的超时,避免触发Vert.x阻塞线程告警
10000000000L // 设置非常长的超时,避免被vertx强制中断
);
// 执行完成或超时后关闭
+1 -1
View File
@@ -106,7 +106,7 @@ executionFuture.toCompletionStage()
### 长期方案(需大量工作)
1. **迁移到GraalVM JavaScript引擎**
- 支持CPU时间限制
- 相比Nashorn更容易实现受控取消
- 可以强制中断
- 更好的性能
- 但需要额外依赖
+52 -36
View File
@@ -12,7 +12,7 @@
<groupId>cn.qaiu</groupId>
<artifactId>parser</artifactId>
<version>${parserVersion}</version>
<version>10.2.3</version>
<packaging>jar</packaging>
<name>cn.qaiu:parser</name>
@@ -35,9 +35,9 @@
</developers>
<scm>
<connection>scm:git:https://github.com/${github.owner}/${github.repo}.git</connection>
<developerConnection>scm:git:ssh://[email protected]:${github.owner}/${github.repo}.git</developerConnection>
<url>https://github.com/${github.owner}/${github.repo}</url>
<connection>scm:git:https://github.com/qaiu/netdisk-fast-download.git</connection>
<developerConnection>scm:git:ssh://[email protected]:qaiu/netdisk-fast-download.git</developerConnection>
<url>https://github.com/qaiu/netdisk-fast-download</url>
</scm>
<distributionManagement>
@@ -52,20 +52,23 @@
</distributionManagement>
<properties>
<revision>0.1.8</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Versions -->
<vertx.version>4.5.27</vertx.version>
<vertx.version>4.5.23</vertx.version>
<org.reflections.version>0.10.2</org.reflections.version>
<lombok.version>1.18.38</lombok.version>
<slf4j.version>2.0.16</slf4j.version>
<slf4j.version>2.0.5</slf4j.version>
<commons-lang3.version>3.18.0</commons-lang3.version>
<jackson.version>2.18.6</jackson.version>
<logback.version>1.5.32</logback.version>
<jackson.version>2.14.2</jackson.version>
<logback.version>1.5.19</logback.version>
<junit.version>4.13.2</junit.version>
<!-- GraalPy -->
<graalpy.version>24.1.1</graalpy.version>
</properties>
<dependencies>
@@ -104,6 +107,32 @@
<scope>compile</scope>
</dependency>
<!-- GraalPy Python Runtime -->
<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>polyglot</artifactId>
<version>${graalpy.version}</version>
</dependency>
<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>python</artifactId>
<version>${graalpy.version}</version>
<type>pom</type>
</dependency>
<!-- GraalPy Python 包资源支持 -->
<dependency>
<groupId>org.graalvm.python</groupId>
<artifactId>python-embedding</artifactId>
<version>${graalpy.version}</version>
</dependency>
<!-- GraalPy LLVM 支持 - 允许多 Context 使用原生模块 (如 unicodedata) -->
<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>llvm-community</artifactId>
<version>${graalpy.version}</version>
<type>pom</type>
</dependency>
<!-- Compression (Brotli) -->
<dependency>
<groupId>org.brotli</groupId>
@@ -123,37 +152,24 @@
<build>
<plugins>
<!-- 从 git remote origin 自动识别 GitHub 仓库地址 -->
<!-- GraalPy Maven Plugin - 仅创建 Python Home,不使用 pip 安装 -->
<!-- pip 包手动安装到 src/main/resources/graalpy-packages/,可打包进 jar -->
<!-- 安装方法: ./setup-graalpy-packages.sh -->
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>4.1.1</version>
<dependencies>
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy</artifactId>
<version>4.0.24</version>
</dependency>
</dependencies>
<groupId>org.graalvm.python</groupId>
<artifactId>graalpy-maven-plugin</artifactId>
<version>${graalpy.version}</version>
<configuration>
<!-- 不声明 packages,避免代理问题 -->
<!-- pip 包从 resources/graalpy-packages 加载 -->
</configuration>
<executions>
<execution>
<phase>initialize</phase>
<goals><goal>execute</goal></goals>
<configuration>
<scripts>
<script>
def url = 'git remote get-url origin'.execute().text.trim()
def m = (url =~ 'github\\.com[:/]([^/]+)/([^/.]+?)(?:\\.git)?$')
if (m.find()) {
project.properties.setProperty('github.owner', m.group(1))
project.properties.setProperty('github.repo', m.group(2))
} else {
project.properties.setProperty('github.owner', 'qaiu')
project.properties.setProperty('github.repo', 'netdisk-fast-download')
}
</script>
</scripts>
</configuration>
<id>prepare-python-resources</id>
<phase>generate-resources</phase>
<goals>
<goal>process-graalpy-resources</goal>
</goals>
</execution>
</executions>
</plugin>
+127
View File
@@ -0,0 +1,127 @@
#!/bin/bash
# GraalPy pip 包安装脚本
# 将 pip 包安装到 src/main/resources/graalpy-packages/,可打包进 jar
# 不受 mvn clean 影响
#
# requests 是纯 Python 包,可以用系统 pip 安装
# GraalPy 运行时可以正常加载这些包
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PARSER_DIR="$SCRIPT_DIR"
PACKAGES_DIR="$PARSER_DIR/src/main/resources/graalpy-packages"
echo "=== GraalPy pip 包安装脚本 ==="
echo ""
echo "目标目录: $PACKAGES_DIR"
echo ""
# 确保目标目录存在
mkdir -p "$PACKAGES_DIR"
# 定义要安装的包列表
# 1. requests 及其依赖 - HTTP 客户端
# 2. python-lsp-server 及其依赖 - Python LSP 服务器(用于代码智能提示)
PACKAGES=(
# requests 依赖
"requests"
"urllib3"
"charset_normalizer"
"idna"
"certifi"
# python-lsp-server (pylsp) 核心
"python-lsp-server"
"jedi"
"python-lsp-jsonrpc"
"pluggy"
# pylsp 可选功能
"pyflakes" # 代码检查
"pycodestyle" # PEP8 风格检查
"autopep8" # 自动格式化
"rope" # 重构支持
"yapf" # 代码格式化
)
echo "将安装以下包到 $PACKAGES_DIR :"
printf '%s\n' "${PACKAGES[@]}"
echo ""
# 使用系统 pip 安装包(纯 Python 包)
echo "开始安装..."
# 尝试不同的 pip 命令
if command -v pip3 &> /dev/null; then
PIP_CMD="pip3"
elif command -v pip &> /dev/null; then
PIP_CMD="pip"
elif command -v python3 &> /dev/null; then
PIP_CMD="python3 -m pip"
elif command -v python &> /dev/null; then
PIP_CMD="python -m pip"
else
echo "✗ 未找到 pip,请先安装 Python 和 pip"
exit 1
fi
echo "使用 pip 命令: $PIP_CMD"
echo ""
# 安装所有包
$PIP_CMD install --target="$PACKAGES_DIR" --upgrade "${PACKAGES[@]}" 2>&1
# 验证安装
echo ""
echo "验证安装..."
FAILED=0
if [ -d "$PACKAGES_DIR/requests" ]; then
echo "✓ requests 安装成功"
else
echo "✗ requests 安装失败"
FAILED=1
fi
if [ -d "$PACKAGES_DIR/pylsp" ] || [ -d "$PACKAGES_DIR/python_lsp_server" ]; then
echo "✓ python-lsp-server 安装成功"
else
echo "✗ python-lsp-server 安装失败"
FAILED=1
fi
if [ -d "$PACKAGES_DIR/jedi" ]; then
echo "✓ jedi 安装成功"
else
echo "✗ jedi 安装失败"
FAILED=1
fi
if [ -d "$PACKAGES_DIR/jedi" ]; then
echo "✓ jedi 安装成功"
else
echo "✗ jedi 安装失败"
FAILED=1
fi
if [ $FAILED -eq 1 ]; then
echo ""
echo "✗ 部分包安装失败,请检查错误信息"
exit 1
fi
# 列出已安装的包
echo ""
echo "已安装的主要包:"
ls -1 "$PACKAGES_DIR" | grep -E "^(requests|jedi|pylsp|python_lsp)" | sort | uniq
echo ""
echo "=== 安装完成 ==="
echo ""
echo "pip 包已安装到: $PACKAGES_DIR"
echo "此目录会被打包进 jar,不受 mvn clean 影响"
echo ""
echo "包含以下功能:"
echo " - requests: HTTP 客户端,用于网络请求"
echo " - python-lsp-server: Python 语言服务器,提供代码智能提示"
echo " - jedi: Python 自动完成和静态分析库"
@@ -7,15 +7,12 @@ import org.slf4j.LoggerFactory;
import cn.qaiu.parser.custom.CustomParserRegistry;
public class WebClientVertxInit {
private volatile Vertx vertx = null;
private Vertx vertx = null;
private static final WebClientVertxInit INSTANCE = new WebClientVertxInit();
private static final Logger log = LoggerFactory.getLogger(WebClientVertxInit.class);
public static synchronized void init(Vertx vx) {
if (vx == null) {
throw new IllegalArgumentException("Vertx instance must not be null");
}
public static void init(Vertx vx) {
INSTANCE.vertx = vx;
// 自动加载JavaScript解析器脚本
@@ -26,10 +23,18 @@ public class WebClientVertxInit {
}
}
public static synchronized Vertx get() {
public static Vertx get() {
if (INSTANCE.vertx == null) {
throw new IllegalStateException("Vertx实例未初始化,请先调用 WebClientVertxInit.init(vertx)");
log.info("getVertx: Vertx实例不存在, 创建Vertx实例.");
INSTANCE.vertx = Vertx.vertx();
// 如果Vertx实例是新创建的,也尝试加载JavaScript脚本
try {
CustomParserRegistry.autoLoadJsScripts();
} catch (Exception e) {
log.warn("自动加载JavaScript解析器脚本失败", e);
}
}
return INSTANCE.vertx;
}
}
}
@@ -86,10 +86,7 @@ public class ShareLinkInfo {
// 将type和shareKey组合成一个字符串作为缓存key
String key = type + ":" + shareKey;
if (type.equals("p115")) {
Object ua = otherParam != null ? otherParam.get("UA") : null;
if (ua != null) {
key += ("_" + ua.toString().hashCode());
}
key += ("_" + otherParam.get("UA").toString().hashCode());
}
return key;
}
@@ -1,6 +1,5 @@
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;
@@ -8,26 +7,10 @@ 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 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;
});
public interface IPanTool {
/**
* 解析文件
@@ -35,72 +18,8 @@ public interface IPanTool extends AutoCloseable {
*/
Future<String> parse();
static <T> Future<T> closeAfter(IPanTool tool, Supplier<Future<T>> action) {
Promise<T> promise = Promise.promise();
AtomicBoolean cleanupDone = new AtomicBoolean(false);
ScheduledFuture<?> cleanupTask = null;
try {
Future<T> future = action.get();
if (future == null) {
closeQuietly(tool);
return Future.failedFuture("解析器返回空 Future");
}
cleanupTask = CLOSE_AFTER_SCHEDULER.schedule(() -> {
if (cleanupDone.compareAndSet(false, true)) {
closeQuietly(tool);
failOnVertxContext(promise, "解析超时(" + SYNC_TIMEOUT_SECONDS + "秒)");
}
}, SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
ScheduledFuture<?> scheduledCleanupTask = cleanupTask;
future.onComplete(ar -> {
scheduledCleanupTask.cancel(false);
if (!cleanupDone.compareAndSet(false, true)) {
return;
}
closeQuietly(tool);
if (ar.succeeded()) {
promise.tryComplete(ar.result());
} else {
promise.tryFail(ar.cause());
}
});
return promise.future();
} catch (Throwable t) {
if (cleanupTask != null) {
cleanupTask.cancel(false);
}
closeQuietly(tool);
return Future.failedFuture(t);
}
}
private static <T> void failOnVertxContext(Promise<T> promise, String message) {
try {
WebClientVertxInit.get().runOnContext(ignored -> promise.tryFail(message));
} catch (Exception ignored) {
promise.tryFail(message);
}
}
static void closeQuietly(IPanTool tool) {
if (tool == null) {
return;
}
try {
tool.close();
} catch (Exception ignored) {
// ignore cleanup failures
}
}
static void shutdownCloseAfterScheduler() {
CLOSE_AFTER_SCHEDULER.shutdownNow();
}
default String parseSync() {
return timedJoin(parse());
return parse().toCompletionStage().toCompletableFuture().join();
}
/**
@@ -114,7 +33,7 @@ public interface IPanTool extends AutoCloseable {
}
default List<FileInfo> parseFileListSync() {
return timedJoin(parseFileList());
return parseFileList().toCompletionStage().toCompletableFuture().join();
}
/**
@@ -128,7 +47,7 @@ public interface IPanTool extends AutoCloseable {
}
default String parseByIdSync() {
return timedJoin(parseById());
return parseById().toCompletionStage().toCompletableFuture().join();
}
/**
@@ -207,7 +126,7 @@ public interface IPanTool extends AutoCloseable {
* @return Map<ClientLinkType, String> 客户端下载链接集合
*/
default Map<ClientLinkType, String> parseWithClientLinksSync() {
return timedJoin(parseWithClientLinks());
return parseWithClientLinks().toCompletionStage().toCompletableFuture().join();
}
/**
@@ -218,26 +137,4 @@ public interface IPanTool extends AutoCloseable {
default ShareLinkInfo getShareLinkInfo() {
return null;
}
@Override
default void close() {
// default no-op
}
/**
* 带超时的同步等待工具方法,替代无超时的 join()
*/
private static <T> T timedJoin(Future<T> future) {
try {
return future.toCompletionStage().toCompletableFuture()
.get(SYNC_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
} catch (TimeoutException e) {
throw new RuntimeException("同步等待超时(" + SYNC_TIMEOUT_SECONDS + "秒)", e);
} catch (java.util.concurrent.ExecutionException e) {
throw new RuntimeException(e.getCause() != null ? e.getCause() : e);
}
}
}
+39 -199
View File
@@ -34,67 +34,30 @@ import java.util.zip.GZIPInputStream;
* <p>{网盘标识}Tool, 网盘标识不超过5个字符, 可以取网盘名称首字母缩写或拼音首字母, <br>
* 音乐类型的解析以M开头, 例如网易云音乐Mne</p>
*/
public abstract class PanBase implements IPanTool, Closeable {
public abstract class PanBase implements IPanTool {
protected Logger log = LoggerFactory.getLogger(this.getClass());
protected Promise<String> promise = Promise.promise();
private static final int MAX_COMPRESSED_RESPONSE_BYTES = 8 * 1024 * 1024;
private static final int MAX_DECOMPRESSED_RESPONSE_CHARS = 16 * 1024 * 1024;
private static final int MAX_ERROR_BODY_CHARS = 4096;
/**
* 共享的 WebClient 配置设置超时避免连接无限期占用
* Http client
*/
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();
protected WebClient client = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions());
/**
* 共享的 WebClient 实例线程安全避免每请求创建导致资源泄漏
*/
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 = sharedClient();
/**
* Http client session (会话管理, 带cookie请求, 每实例独立)
* Http client session (会话管理, 带cookie请求)
*/
protected WebClientSession clientSession = WebClientSession.create(client);
/**
* Http client 不自动跳转
*/
protected WebClient clientNoRedirects = sharedClientNoRedirects();
/**
* Http client disable UserAgent
*/
protected WebClient clientDisableUA = sharedClientDisableUA();
protected WebClient clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setFollowRedirects(false));
protected ShareLinkInfo shareLinkInfo;
/**
* 标记是否为代理模式代理模式创建的 WebClient 需要手动关闭
*/
private boolean isProxyMode = false;
/**
* 代理模式下创建的独立 WebClient 实例需要在 close 时释放
*/
private WebClient proxyClient = null;
private WebClient proxyClientNoRedirects = null;
/**
* 子类重写此构造方法不需要添加额外逻辑
* :
@@ -109,7 +72,6 @@ public abstract class PanBase implements IPanTool, Closeable {
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()))
@@ -121,86 +83,22 @@ public abstract class PanBase implements IPanTool, Closeable {
if (StringUtils.isNotEmpty(proxy.getString("password"))) {
proxyOptions.setPassword(proxy.getString("password"));
}
// 代理模式下创建独立的 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)
this.client = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions()
.setUserAgentEnabled(false)
.setProxyOptions(proxyOptions));
this.client = proxyClient;
this.clientSession = WebClientSession.create(client);
this.clientNoRedirects = proxyClientNoRedirects;
this.clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions().setFollowRedirects(false)
.setUserAgentEnabled(false)
.setProxyOptions(proxyOptions));
}
}
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();
@@ -225,19 +123,16 @@ public abstract class PanBase implements IPanTool, Closeable {
return;
}
String s = String.format(errorMsg.replaceAll("\\{}", "%s"), args);
// 只记录异常消息和类型不调用 fillInStackTrace 避免产生巨大栈信息
log.error("解析异常: {} - {}: {}", s, t.getClass().getSimpleName(), t.getMessage());
// 只传递异常消息不传递完整异常对象减少内存占用
String failMsg = baseMsg() + ": 解析异常: " + s + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage();
promise.fail(failMsg);
log.error("解析异常: " + s, t.fillInStackTrace());
promise.fail(baseMsg() + ": 解析异常: " + s + " -> " + t);
} catch (Exception e) {
log.error("ErrorMsg format fail. The parameter has been discarded", e);
log.error("解析异常: {} - {}: {}", errorMsg, t.getClass().getSimpleName(), t.getMessage());
log.error("解析异常: " + errorMsg, t.fillInStackTrace());
if (promise.future().isComplete()) {
log.warn("ErrorMsg format. Promise 已经完成, 无法再次失败: {}", errorMsg);
return;
}
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t.getClass().getSimpleName() + ": " + t.getMessage());
promise.fail(baseMsg() + ": 解析异常: " + errorMsg + " -> " + t);
}
}
@@ -277,7 +172,7 @@ public abstract class PanBase implements IPanTool, Closeable {
* @return Handler
*/
protected Handler<Throwable> handleFail(String errorMsg) {
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.getClass().getSimpleName() + ": " + t.getMessage());
return t -> fail(baseMsg() + " - 请求异常 {}: -> {}", errorMsg, t.fillInStackTrace());
}
protected Handler<Throwable> handleFail() {
@@ -296,22 +191,28 @@ public abstract class PanBase implements IPanTool, Closeable {
String contentEncoding = res.getHeader("Content-Encoding");
try {
if ("gzip".equalsIgnoreCase(contentEncoding)) {
// 如果是gzip压缩的响应体解压只解压一次缓存结果
String decompressed = decompressGzip((Buffer) res.body());
return new JsonObject(decompressed);
// 如果是gzip压缩的响应体解压
return new JsonObject(decompressGzip((Buffer) res.body()));
} else {
return res.bodyAsJsonObject();
}
} catch (Exception e) {
if ("gzip".equalsIgnoreCase(contentEncoding)) {
// gzip解压失败记录错误
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
// 如果是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);
}
} else {
String bodyPreview = responseBodyPreview(res);
log.error("解析失败: json格式异常: {}", bodyPreview);
fail("解析失败: json格式异常: {}", bodyPreview);
log.error("解析失败: json格式异常: {}", res.bodyAsString());
fail("解析失败: json格式异常: {}", res.bodyAsString());
//throw new RuntimeException("解析失败: json格式异常");
}
return JsonObject.of();
}
@@ -374,15 +275,11 @@ public abstract class PanBase implements IPanTool, Closeable {
if (iterator.hasNext()) {
PanDomainTemplate next = iterator.next();
log.debug("规则不匹配, 处理解析器转发: {} -> {}", shareLinkInfo.getPanName(), next.getDisplayName());
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());
}
ParserCreate.fromType(next.name())
.fromAnyShareUrl(shareLinkInfo.getShareUrl())
.createTool()
.parse()
.onComplete(promise);
} else {
fail("error: 没有下一个解析处理器");
}
@@ -398,12 +295,6 @@ public abstract class PanBase implements IPanTool, Closeable {
* @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);
@@ -412,39 +303,12 @@ public abstract class PanBase implements IPanTool, Closeable {
char[] buffer = new char[4096];
int n;
while ((n = isr.read(buffer)) != -1) {
writeLimited(writer, buffer, n);
writer.write(buffer, 0, n);
}
return writer.toString();
}
}
private void writeLimited(StringWriter writer, char[] buffer, int len) throws IOException {
if (writer.getBuffer().length() + len > MAX_DECOMPRESSED_RESPONSE_CHARS) {
throw new IOException("gzip解压后响应体过大");
}
writer.write(buffer, 0, len);
}
private String responseBodyPreview(HttpResponse<?> res) {
if (res == null || res.body() == null) {
return "";
}
try {
if (res.body() instanceof Buffer body) {
int length = Math.min(body.length(), MAX_ERROR_BODY_CHARS);
String preview = new String(body.getBytes(0, length), StandardCharsets.UTF_8);
return body.length() > length ? preview + "...(truncated " + body.length() + " bytes)" : preview;
}
String text = res.bodyAsString();
if (text == null || text.length() <= MAX_ERROR_BODY_CHARS) {
return text;
}
return text.substring(0, MAX_ERROR_BODY_CHARS) + "...(truncated " + text.length() + " chars)";
} catch (Exception e) {
return "<body preview failed: " + e.getMessage() + ">";
}
}
protected String getDomainName(){
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
}
@@ -453,28 +317,4 @@ public abstract class PanBase implements IPanTool, Closeable {
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());
}
}
}
}
@@ -68,44 +68,43 @@ public enum PanDomainTemplate {
t-is.cn
*/
LZ("蓝奏云",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(?:" +
"(?:lanzoul|" +
"lanzouh|" +
"lanosso|" +
"lanpv|" +
"bakstotre|" +
"lanzouo|" +
"lanzov|" +
"lanpw|" +
"ulanzou|" +
"lanzouf|" +
"lanzn|" +
"lanzouj|" +
"lanzouk|" +
"lanzouq|" +
"lanzouv|" +
"lanzoue|" +
"lanzouw|" +
"lanzoub|" +
"lanzouu|" +
"lanwp|" +
"lanzouy|" +
"lanzoup|" +
"woozooo|" +
"lanzv|" +
"dmpdmp|" +
"lanrar|" +
"webgetstore|" +
"lanzb|" +
"lanzoux|" +
"lanzout|" +
"lanzouc|" +
"lanzoui|" +
"lanzoug|" +
"lanzoum)\\.com" +
"|t-is\\.cn" +
")/(?<KEY>.+)"),
"https://w1.lanzn.com/{shareKey}",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(" +
"lanzoul|" +
"lanzouh|" +
"lanosso|" +
"lanpv|" +
"bakstotre|" +
"lanzouo|" +
"lanzov|" +
"lanpw|" +
"ulanzou|" +
"lanzouf|" +
"lanzn|" +
"lanzouj|" +
"lanzouk|" +
"lanzouq|" +
"lanzouv|" +
"lanzoue|" +
"lanzouw|" +
"lanzoub|" +
"lanzouu|" +
"lanwp|" +
"lanzouy|" +
"lanzoup|" +
"woozooo|" +
"lanzv|" +
"dmpdmp|" +
"lanrar|" +
"webgetstore|" +
"lanzb|" +
"lanzoux|" +
"lanzout|" +
"lanzouc|" +
"lanzoui|" +
"lanzoug|" +
"lanzoum" +
")\\.com/(.+/)?(?<KEY>.+)"),
"https://lanzoux.com/{shareKey}",
LzTool.class),
// https://www.feijix.com/s/
@@ -115,15 +114,15 @@ public enum PanDomainTemplate {
"https://www.feijix.com/s/{shareKey}",
FjTool.class),
// https://lecloud.lenovo.com/share/ https://lecloud.lenovo.com/mshare/
LE("联想乐云",
compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
// https://lecloud.lenovo.com/share/
LE("联想乐云",
compile("https://lecloud?\\.lenovo\\.com/share/(?<KEY>.+)"),
"https://lecloud.lenovo.com/share/{shareKey}",
LeTool.class),
// https://v2.fangcloud.com/s/
FC("亿方云",
compile("https://v2\\.fangcloud\\.(com|cn)/(s|share|sharing)/(?<KEY>.+)"),
compile("https://v2\\.fangcloud\\.(com|cn)/(s|sharing)/(?<KEY>.+)"),
"https://v2.fangcloud.com/s/{shareKey}",
"https://www.fangcloud.com/",
FcTool.class),
@@ -144,41 +143,9 @@ public enum PanDomainTemplate {
compile("https://qfile\\.qq\\.com/q/(?<KEY>.+)"),
"https://qfile.qq.com/q/{shareKey}",
QQscTool.class),
// https://f.ws59.cn/f/ 或者 https://www.wenshushu.cn/f/ 等多个镜像域名
/*
f.wsNN.cn ( f.ws59.cn, f.ws28.cn )
www.wenshushu.cn
新增域名:
www.wenxiaozhan.net
www.wenxiaozhan.cn
www.wss.show
www.ws28.cn
www.wss.email
www.wss1.cn
www.ws59.cn
www.wss.cc
www.wss.pet
www.wss.ink
www.wenxiaozhan.com
www.wenshushu.com
www.wss.zone
*/
// https://f.ws59.cn/f/或者https://www.wenshushu.cn/f/
WS("文叔叔",
compile("https://(f\\.ws(\\d{2})\\.cn|" +
"www\\.wenxiaozhan\\.net|" +
"www\\.wenxiaozhan\\.cn|" +
"www\\.wss\\.show|" +
"www\\.ws28\\.cn|" +
"www\\.wss\\.email|" +
"www\\.wss1\\.cn|" +
"www\\.ws59\\.cn|" +
"www\\.wss\\.cc|" +
"www\\.wss\\.pet|" +
"www\\.wss\\.ink|" +
"www\\.wenxiaozhan\\.com|" +
"www\\.wenshushu\\.com|" +
"www\\.wss\\.zone|" +
"www\\.wenshushu\\.cn)/f/(?<KEY>.+)"),
compile("https://(f\\.ws(\\d{2})\\.cn|www\\.wenshushu\\.cn)/f/(?<KEY>.+)"),
"https://www.wenshushu.cn/f/{shareKey}",
WsTool.class),
// https://www.123pan.com/s/
@@ -208,7 +175,7 @@ public enum PanDomainTemplate {
123795.com
*/
YE("123网盘",
compile("https://(?:[a-zA-Z\\d-]+\\.)*(" +
compile("https://www\\.(" +
"123254\\.com|" +
"123957\\.com|" +
"123295\\.com|" +
@@ -232,7 +199,7 @@ public enum PanDomainTemplate {
"123635\\.com|" +
"123242\\.com|" +
"123795\\.com" +
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[a-zA-Z0-9_-]+)(?:\\.html)?(?:\\?.*)?"),
")/s/(?<KEY>.+)(.html)?"),
"https://www.123pan.com/s/{shareKey}",
Ye2Tool.class),
// https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1
@@ -243,20 +210,19 @@ public enum PanDomainTemplate {
EcTool.class),
// https://cowtransfer.com/s/
COW("奶牛快传",
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
"https://cowtransfer.com/s/{shareKey}",
CowTool.class),
CT("城通网盘",
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
"https://ctfile.com/file/{shareKey}",
CtTool.class),
// https://url94.ctfile.com/d/64115194-164803691-48508c?p=7609&d=164803691&fk=decb36
CTD("城通网盘-目录",
compile("https?://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/d/" +
"(?<KEY>[0-9a-zA-Z_-]+)/?(?:\\?(?:(?:[^#&]*&)*p=(?<PWD>\\w+)(?:&[^#]*)?|[^#]*))?"),
"https://ctfile.com/d/{shareKey}",
compile("https://(?:[a-zA-Z\\d-]+\\.)?(ctfile|545c|u062|ghpym|474b)\\.com/f(ile)?/" +
"(?<KEY>[0-9a-zA-Z_-]+)(\\?p=(?<PWD>\\w+))?"),
"https://474b.com/file/{shareKey}",
CtTool.class),
// https://xxx.118pan.com/bxxx
P118("118网盘",
compile("https://(?:[a-zA-Z\\d-]+\\.)?118pan\\.com/b(?<KEY>.+)"),
"https://qaiu.118pan.com/b{shareKey}",
P118Tool.class),
// https://www.vyuyun.com/s/QMa6ie?password=I4KG7H
// https://www.vyuyun.com/s/QMa6ie/file?password=I4KG7H
PVYY("微雨云存储",
@@ -272,7 +238,7 @@ public enum PanDomainTemplate {
PodTool.class),
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
PGD("GoogleDrive",
compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
compile("https://drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
PgdTool.class),
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
@@ -282,11 +248,11 @@ public enum PanDomainTemplate {
PicTool.class),
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
PDB("dropbox",
compile("https://www\\.dropbox\\.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
compile("https://www.dropbox.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
PdbTool.class),
P115("115网盘",
compile("https://(115|anxia)\\.com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
compile("https://(115|anxia).com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
"https://115.com/s/{shareKey}?password={pwd}",
P115Tool.class),
// 链接https://www.yunpan.com/surl_yD7wz4VgU9v提取码fc70
@@ -297,7 +263,7 @@ public enum PanDomainTemplate {
// https://pan-yz.cldisk.com/external/m/file/953658049102462976
Pcx("超星云盘(需要referer头)",
compile("https://pan-yz\\.(chaoxing\\.com|cldisk\\.com)/external/m/file/(?<KEY>\\w+)(\\?.*)?"),
compile("https://pan-yz\\.cldisk\\.com/external/m/file/(?<KEY>\\w+)"),
"https://pan-yz.cldisk.com/external/m/file/{shareKey}",
PcxTool.class),
// WPS分享格式https://www.kdocs.cn/l/ck0azivLlDi3 API格式https://www.kdocs.cn/api/office/file/{shareKey}/download
@@ -306,27 +272,6 @@ public enum PanDomainTemplate {
compile("https://(?:[a-zA-Z\\d-]+\\.)?kdocs\\.cn/l/(?<KEY>.+)"),
"https://www.kdocs.cn/l/{shareKey}",
PwpsTool.class),
// https://fast.uc.cn/s/33197dd53ace4
// https://drive.uc.cn/s/e623b6da278e4?public=1#/list/share
UC("UC网盘",
compile("https://(fast|drive)\\.uc\\.cn/s/(?<KEY>\\w+)(\\?public=\\d+)?([&#].*)?"),
"https://drive.uc.cn/s/{shareKey}",
UcTool.class),
// https://pan.quark.cn/s/6a325cdaec58
QK("夸克网盘",
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\w+)([&#].*)?"),
"https://pan.quark.cn/s/{shareKey}",
QkTool.class),
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
FS("飞书云盘",
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
"https://feishu.cn/file/{shareKey}",
"https://www.feishu.cn/",
FsTool.class),
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
// http://163cn.tv/xxx
MNES("网易云音乐分享",
@@ -335,7 +280,7 @@ public enum PanDomainTemplate {
MnesTool.class),
// https://music.163.com/#/song?id=xxx
MNE("网易云音乐歌曲详情",
compile("https://(y\\.)?music\\.163\\.com/(?:#/|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
compile("https://(y.)?music\\.163\\.com/(#|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
"https://music.163.com/#/song?id={shareKey}",
MnesTool.MneTool.class),
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
@@ -356,7 +301,7 @@ public enum PanDomainTemplate {
MkgsTool.class),
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
MKGS2("酷狗音乐分享2",
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+)\\.html.*"),
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+).html.*"),
"https://www.kugou.com/share/{shareKey}.html",
MkgsTool.Mkgs2Tool.class),
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
@@ -4,13 +4,12 @@ import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.custom.CustomParserConfig;
import cn.qaiu.parser.custom.CustomParserRegistry;
import cn.qaiu.parser.customjs.JsParserExecutor;
import cn.qaiu.parser.custompy.PyParserExecutor;
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;
@@ -25,9 +24,6 @@ import static cn.qaiu.parser.PanDomainTemplate.PWD;
* Create at 2024/9/15 14:10
*/
public class ParserCreate {
private static final Set<PanDomainTemplate> GENERIC_BUILT_IN_PARSERS =
EnumSet.of(PanDomainTemplate.CE, PanDomainTemplate.KD, PanDomainTemplate.OTHER);
private final PanDomainTemplate panDomainTemplate;
private final ShareLinkInfo shareLinkInfo;
@@ -86,16 +82,16 @@ public class ParserCreate {
if (shareKey != null) {
shareLinkInfo.setShareKey(shareKey);
}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
} catch (Exception ignored) {}
// 提取密码
try {
String pwd = matcher.group("PWD");
if (StringUtils.isNotEmpty(pwd)) {
shareLinkInfo.setSharePassword(pwd);
}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
} catch (Exception ignored) {}
// 设置标准URL
if (customParserConfig.getStandardUrlTemplate() != null) {
String standardUrl = customParserConfig.getStandardUrlTemplate()
@@ -137,12 +133,12 @@ public class ParserCreate {
if (StringUtils.isNotEmpty(pwd)) {
shareLinkInfo.setSharePassword(pwd);
}
standardUrl = standardUrl.replace("{pwd}", StringUtils.defaultString(pwd));
} catch (IllegalStateException | IllegalArgumentException ignored) {}
standardUrl = standardUrl.replace("{pwd}", pwd);
} catch (Exception ignored) {}
shareLinkInfo.setShareUrl(shareUrl);
shareLinkInfo.setShareKey(shareKey);
if (!isGenericBuiltInParser(panDomainTemplate)) {
if (!(panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal())) {
shareLinkInfo.setStandardUrl(standardUrl);
}
return this;
@@ -160,6 +156,9 @@ public class ParserCreate {
// 检查是否为JavaScript解析器
if (customParserConfig.isJsParser()) {
return new JsParserExecutor(shareLinkInfo, customParserConfig);
} else if (customParserConfig.isPyParser()) {
// Python解析器
return new PyParserExecutor(shareLinkInfo, customParserConfig);
} else {
// Java实现的解析器
try {
@@ -202,7 +201,7 @@ public class ParserCreate {
}
// 内置解析器处理
if (isGenericBuiltInParser(panDomainTemplate)) {
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
// 处理Cloudreve(ce): pan.huang1111.cn_s_wDz5TK _ -> /
String[] s = shareKey.split("_");
String standardUrl = "https://" + String.join("/", s);
@@ -252,19 +251,9 @@ 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);
@@ -281,15 +270,15 @@ public class ParserCreate {
if (shareKey != null) {
shareLinkInfo.setShareKey(shareKey);
}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
} catch (Exception ignored) {}
try {
String password = matcher.group("PWD");
if (password != null) {
shareLinkInfo.setSharePassword(password);
}
} catch (IllegalStateException | IllegalArgumentException ignored) {}
} catch (Exception ignored) {}
// 设置标准URL如果有模板
if (customConfig.getStandardUrlTemplate() != null) {
String standardUrl = customConfig.getStandardUrlTemplate()
@@ -310,35 +299,22 @@ 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 (isGenericBuiltInParser(panDomainTemplate)) {
if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
shareLinkInfo.setStandardUrl(shareUrl);
}
ParserCreate parserCreate = new ParserCreate(panDomainTemplate, shareLinkInfo);
return parserCreate.normalizeShareLink();
}
}
return null;
throw new IllegalArgumentException("Unsupported share URL");
}
// 根据type获取枚举实例优先查找自定义解析器
@@ -381,7 +357,7 @@ public class ParserCreate {
// 自定义解析器处理
if (isCustomParser) {
path = this.shareLinkInfo.getType() + "/" + this.shareLinkInfo.getShareKey();
} else if (isGenericBuiltInParser(panDomainTemplate)) {
} else if (panDomainTemplate.ordinal() >= PanDomainTemplate.CE.ordinal()) {
// 处理Cloudreve(ce): pan.huang1111.cn_s_wDz5TK _ -> /
path = this.shareLinkInfo.getType() + "/"
+ this.shareLinkInfo.getShareUrl()
@@ -409,11 +385,7 @@ 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
@@ -1,53 +0,0 @@
package cn.qaiu.parser;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Parser token cache keyed by parser type and account identity.
*/
public final class TokenCache {
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
private TokenCache() {
}
public static String key(String type, String accountId) {
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
}
public static void putToken(String key, String token) {
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
return;
}
TOKENS.put(key, token);
}
public static String getToken(String key) {
if (StringUtils.isBlank(key)) {
return null;
}
if (isExpired(key)) {
TOKENS.remove(key);
EXPIRES.remove(key);
return null;
}
return TOKENS.get(key);
}
public static void putExpire(String key, long expireTimeMillis) {
if (StringUtils.isBlank(key)) {
return;
}
EXPIRES.put(key, expireTimeMillis);
}
public static boolean isExpired(String key) {
Long expireTimeMillis = EXPIRES.get(key);
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
}
}
@@ -11,13 +11,6 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* 客户端下载链接生成器工厂类
* <p>
* 支持的客户端类型
* <ul>
* <li>CURL - cURL 命令支持 Cookie</li>
* <li>ARIA2 - Aria2 命令支持 Cookie</li>
* <li>THUNDER - 迅雷协议不支持 Cookie</li>
* </ul>
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
@@ -32,10 +25,16 @@ public class ClientLinkGeneratorFactory {
// 静态初始化块注册默认的生成器
static {
try {
// 注册默认生成器 - 只保留3种按需求
register(new CurlLinkGenerator()); // cURL 命令支持 Cookie
register(new Aria2LinkGenerator()); // Aria2 命令支持 Cookie
register(new ThunderLinkGenerator()); // 迅雷协议不支持 Cookie
// 注册默认生成器 - 按指定顺序注册
register(new Aria2LinkGenerator());
register(new MotrixLinkGenerator());
register(new BitCometLinkGenerator());
register(new ThunderLinkGenerator());
register(new WgetLinkGenerator());
register(new CurlLinkGenerator());
register(new IdmLinkGenerator());
register(new FdmLinkGenerator());
register(new PowerShellLinkGenerator());
log.info("客户端链接生成器工厂初始化完成,已注册 {} 个生成器", generators.size());
} catch (Exception e) {
@@ -2,32 +2,27 @@ package cn.qaiu.parser.clientlink;
/**
* 客户端下载工具类型枚举
* <p>
* 支持的客户端类型
* <ul>
* <li>CURL - cURL 命令行工具支持 Cookie</li>
* <li>ARIA2 - 多线程下载器支持 Cookie</li>
* <li>THUNDER - 迅雷下载器不支持 Cookie使用迅雷协议</li>
* </ul>
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public enum ClientLinkType {
CURL("curl", "cURL 命令", true, "命令行下载工具,支持Cookie"),
ARIA2("aria2", "Aria2", true, "多线程下载器,支持Cookie"),
THUNDER("thunder", "迅雷", false, "迅雷下载器,不支持Cookie");
ARIA2("aria2", "Aria2"),
MOTRIX("motrix", "Motrix"),
BITCOMET("bitcomet", "比特彗星"),
THUNDER("thunder", "迅雷"),
WGET("wget", "wget 命令"),
CURL("curl", "cURL 命令"),
IDM("idm", "IDM"),
FDM("fdm", "Free Download Manager"),
POWERSHELL("powershell", "PowerShell");
private final String code;
private final String displayName;
private final boolean supportsCookie;
private final String description;
ClientLinkType(String code, String displayName, boolean supportsCookie, String description) {
ClientLinkType(String code, String displayName) {
this.code = code;
this.displayName = displayName;
this.supportsCookie = supportsCookie;
this.description = description;
}
public String getCode() {
@@ -38,14 +33,6 @@ public enum ClientLinkType {
return displayName;
}
public boolean isSupportsCookie() {
return supportsCookie;
}
public String getDescription() {
return description;
}
@Override
public String toString() {
return displayName;
@@ -7,13 +7,6 @@ import java.util.Map;
/**
* 客户端下载链接生成工具类
* 提供便捷的静态方法来生成各种客户端下载链接
* <p>
* 支持的客户端类型
* <ul>
* <li>CURL - cURL 命令支持 Cookie</li>
* <li>ARIA2 - Aria2 命令支持 Cookie</li>
* <li>THUNDER - 迅雷协议不支持 Cookie</li>
* </ul>
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
@@ -42,7 +35,7 @@ public class ClientLinkUtils {
}
/**
* 生成 curl 命令支持 Cookie
* 生成 curl 命令
*
* @param info ShareLinkInfo 对象
* @return curl 命令字符串
@@ -52,7 +45,17 @@ public class ClientLinkUtils {
}
/**
* 生成 aria2 命令支持 Cookie
* 生成 wget 命令
*
* @param info ShareLinkInfo 对象
* @return wget 命令字符串
*/
public static String generateWgetCommand(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.WGET);
}
/**
* 生成 aria2 命令
*
* @param info ShareLinkInfo 对象
* @return aria2 命令字符串
@@ -62,7 +65,7 @@ public class ClientLinkUtils {
}
/**
* 生成迅雷链接不支持 Cookie
* 生成迅雷链接
*
* @param info ShareLinkInfo 对象
* @return 迅雷协议链接
@@ -71,6 +74,56 @@ public class ClientLinkUtils {
return generateClientLink(info, ClientLinkType.THUNDER);
}
/**
* 生成 IDM 链接
*
* @param info ShareLinkInfo 对象
* @return IDM 协议链接
*/
public static String generateIdmLink(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.IDM);
}
/**
* 生成比特彗星链接
*
* @param info ShareLinkInfo 对象
* @return 比特彗星协议链接
*/
public static String generateBitCometLink(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.BITCOMET);
}
/**
* 生成 Motrix 导入格式
*
* @param info ShareLinkInfo 对象
* @return Motrix JSON 格式字符串
*/
public static String generateMotrixFormat(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.MOTRIX);
}
/**
* 生成 FDM 导入格式
*
* @param info ShareLinkInfo 对象
* @return FDM 格式字符串
*/
public static String generateFdmFormat(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.FDM);
}
/**
* 生成 PowerShell 命令
*
* @param info ShareLinkInfo 对象
* @return PowerShell 命令字符串
*/
public static String generatePowerShellCommand(ShareLinkInfo info) {
return generateClientLink(info, ClientLinkType.POWERSHELL);
}
/**
* 检查 ShareLinkInfo 是否包含有效的下载元数据
*
@@ -41,8 +41,6 @@ public class Aria2LinkGenerator implements ClientLinkGenerator {
parts.add("--continue"); // 支持断点续传
parts.add("--max-tries=3"); // 最大重试次数
parts.add("--retry-wait=5"); // 重试等待时间
parts.add("-s 8"); // 分成8片段下载
parts.add("-x 8"); // 每个服务器使用8个连接
// 添加URL
parts.add("\"" + meta.getUrl() + "\"");
@@ -0,0 +1,69 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* 比特彗星协议链接生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class BitCometLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
try {
// 比特彗星支持 HTTP 下载格式类似 IDM
String encodedUrl = Base64.getEncoder().encodeToString(
meta.getUrl().getBytes(StandardCharsets.UTF_8)
);
StringBuilder link = new StringBuilder("bitcomet:///?url=").append(encodedUrl);
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
StringBuilder headerStr = new StringBuilder();
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (headerStr.length() > 0) {
headerStr.append("\\r\\n");
}
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
}
String encodedHeaders = Base64.getEncoder().encodeToString(
headerStr.toString().getBytes(StandardCharsets.UTF_8)
);
link.append("&header=").append(encodedHeaders);
}
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
String encodedFileName = Base64.getEncoder().encodeToString(
meta.getFileName().getBytes(StandardCharsets.UTF_8)
);
link.append("&filename=").append(encodedFileName);
}
return link.toString();
} catch (Exception e) {
// 如果编码失败返回简单的URL
return "bitcomet:///?url=" + meta.getUrl();
}
}
@Override
public ClientLinkType getType() {
return ClientLinkType.BITCOMET;
}
}
@@ -0,0 +1,56 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.util.Map;
/**
* Free Download Manager 导入格式生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class FdmLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
// FDM 支持简单的文本格式导入
StringBuilder result = new StringBuilder();
result.append("URL=").append(meta.getUrl()).append("\n");
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
result.append("Filename=").append(meta.getFileName()).append("\n");
}
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
result.append("Headers=");
boolean first = true;
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (!first) {
result.append("; ");
}
result.append(entry.getKey()).append(": ").append(entry.getValue());
first = false;
}
result.append("\n");
}
result.append("Referer=").append(meta.getReferer() != null ? meta.getReferer() : "").append("\n");
result.append("User-Agent=").append(meta.getUserAgent() != null ? meta.getUserAgent() : "").append("\n");
return result.toString();
}
@Override
public ClientLinkType getType() {
return ClientLinkType.FDM;
}
}
@@ -0,0 +1,69 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* IDM 协议链接生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class IdmLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
try {
// 对URL进行Base64编码
String encodedUrl = Base64.getEncoder().encodeToString(
meta.getUrl().getBytes(StandardCharsets.UTF_8)
);
StringBuilder link = new StringBuilder("idm:///?url=").append(encodedUrl);
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
StringBuilder headerStr = new StringBuilder();
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (headerStr.length() > 0) {
headerStr.append("\\r\\n");
}
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
}
String encodedHeaders = Base64.getEncoder().encodeToString(
headerStr.toString().getBytes(StandardCharsets.UTF_8)
);
link.append("&header=").append(encodedHeaders);
}
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
String encodedFileName = Base64.getEncoder().encodeToString(
meta.getFileName().getBytes(StandardCharsets.UTF_8)
);
link.append("&filename=").append(encodedFileName);
}
return link.toString();
} catch (Exception e) {
// 如果编码失败返回简单的URL
return "idm:///?url=" + meta.getUrl();
}
}
@Override
public ClientLinkType getType() {
return ClientLinkType.IDM;
}
}
@@ -0,0 +1,53 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import io.vertx.core.json.JsonObject;
import java.util.Map;
/**
* Motrix 导入格式生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class MotrixLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
// 使用 Vert.x JsonObject 构建 JSON
JsonObject taskJson = new JsonObject();
taskJson.put("url", meta.getUrl());
// 添加文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
taskJson.put("filename", meta.getFileName());
}
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
JsonObject headersJson = new JsonObject();
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
headersJson.put(entry.getKey(), entry.getValue());
}
taskJson.put("headers", headersJson);
}
// 设置输出文件名
String outputFile = meta.getFileName() != null ? meta.getFileName() : "";
taskJson.put("out", outputFile);
return taskJson.encodePrettily();
}
@Override
public ClientLinkType getType() {
return ClientLinkType.MOTRIX;
}
}
@@ -0,0 +1,98 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* PowerShell 命令生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class PowerShellLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
List<String> lines = new ArrayList<>();
// 创建 WebRequestSession
lines.add("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession");
// 设置 User-Agent如果存在
String userAgent = meta.getUserAgent();
if (userAgent == null && meta.getHeaders() != null) {
userAgent = meta.getHeaders().get("User-Agent");
}
if (userAgent != null && !userAgent.trim().isEmpty()) {
lines.add("$session.UserAgent = \"" + escapePowerShellString(userAgent) + "\"");
}
// 构建 Invoke-WebRequest 命令
List<String> invokeParams = new ArrayList<>();
invokeParams.add("Invoke-WebRequest");
invokeParams.add("-UseBasicParsing");
invokeParams.add("-Uri \"" + escapePowerShellString(meta.getUrl()) + "\"");
// 添加 WebSession
invokeParams.add("-WebSession $session");
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
List<String> headerLines = new ArrayList<>();
headerLines.add("-Headers @{");
boolean first = true;
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
if (!first) {
headerLines.add("");
}
headerLines.add(" \"" + escapePowerShellString(entry.getKey()) + "\"=\"" +
escapePowerShellString(entry.getValue()) + "\"");
first = false;
}
headerLines.add("}");
// 将头部参数添加到主命令中
invokeParams.add(String.join("`\n", headerLines));
}
// 设置输出文件如果指定了文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
invokeParams.add("-OutFile \"" + escapePowerShellString(meta.getFileName()) + "\"");
}
// 将所有参数连接起来
String invokeCommand = String.join(" `\n", invokeParams);
lines.add(invokeCommand);
return String.join("\n", lines);
}
/**
* 转义 PowerShell 字符串中的特殊字符
*/
private String escapePowerShellString(String str) {
if (str == null) {
return "";
}
return str.replace("`", "``")
.replace("\"", "`\"")
.replace("$", "`$");
}
@Override
public ClientLinkType getType() {
return ClientLinkType.POWERSHELL;
}
}
@@ -0,0 +1,51 @@
package cn.qaiu.parser.clientlink.impl;
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* wget 命令生成器
*
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/01/21
*/
public class WgetLinkGenerator implements ClientLinkGenerator {
@Override
public String generate(DownloadLinkMeta meta) {
if (!supports(meta)) {
return null;
}
List<String> parts = new ArrayList<>();
parts.add("wget");
// 添加请求头
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
parts.add("--header=\"" + entry.getKey() + ": " + entry.getValue() + "\"");
}
}
// 设置输出文件名
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
parts.add("-O");
parts.add("\"" + meta.getFileName() + "\"");
}
// 添加URL
parts.add("\"" + meta.getUrl() + "\"");
return String.join(" \\\n ", parts);
}
@Override
public ClientLinkType getType() {
return ClientLinkType.WGET;
}
}
@@ -53,11 +53,26 @@ public class CustomParserConfig {
*/
private final String jsCode;
/**
* Python代码用于Python解析器
*/
private final String pyCode;
/**
* 是否为JavaScript解析器
*/
private final boolean isJsParser;
/**
* 是否为Python解析器
*/
private final boolean isPyParser;
/**
* 脚本语言类型javascript, python
*/
private final String language;
/**
* 元数据信息从脚本注释中解析
*/
@@ -71,7 +86,10 @@ public class CustomParserConfig {
this.panDomain = builder.panDomain;
this.matchPattern = builder.matchPattern;
this.jsCode = builder.jsCode;
this.pyCode = builder.pyCode;
this.isJsParser = builder.isJsParser;
this.isPyParser = builder.isPyParser;
this.language = builder.language;
this.metadata = builder.metadata;
}
@@ -103,10 +121,22 @@ public class CustomParserConfig {
return jsCode;
}
public String getPyCode() {
return pyCode;
}
public boolean isJsParser() {
return isJsParser;
}
public boolean isPyParser() {
return isPyParser;
}
public String getLanguage() {
return language;
}
public Map<String, String> getMetadata() {
return metadata;
}
@@ -134,7 +164,10 @@ public class CustomParserConfig {
private String panDomain;
private Pattern matchPattern;
private String jsCode;
private String pyCode;
private boolean isJsParser;
private boolean isPyParser;
private String language;
private Map<String, String> metadata;
/**
@@ -211,12 +244,45 @@ public class CustomParserConfig {
return this;
}
/**
* 设置Python代码用于Python解析器
* @param pyCode Python代码
*/
public Builder pyCode(String pyCode) {
this.pyCode = pyCode;
return this;
}
/**
* 设置是否为JavaScript解析器
* @param isJsParser 是否为JavaScript解析器
*/
public Builder isJsParser(boolean isJsParser) {
this.isJsParser = isJsParser;
if (isJsParser) {
this.language = "javascript";
}
return this;
}
/**
* 设置是否为Python解析器
* @param isPyParser 是否为Python解析器
*/
public Builder isPyParser(boolean isPyParser) {
this.isPyParser = isPyParser;
if (isPyParser) {
this.language = "python";
}
return this;
}
/**
* 设置脚本语言类型
* @param language 语言类型javascript, python
*/
public Builder language(String language) {
this.language = language;
return this;
}
@@ -246,6 +312,11 @@ public class CustomParserConfig {
if (jsCode == null || jsCode.trim().isEmpty()) {
throw new IllegalArgumentException("JavaScript解析器的jsCode不能为空");
}
} else if (isPyParser) {
// 如果是Python解析器验证pyCode
if (pyCode == null || pyCode.trim().isEmpty()) {
throw new IllegalArgumentException("Python解析器的pyCode不能为空");
}
} else {
// 如果是Java解析器验证toolClass
if (toolClass == null) {
@@ -288,7 +359,10 @@ public class CustomParserConfig {
", panDomain='" + panDomain + '\'' +
", matchPattern=" + (matchPattern != null ? matchPattern.pattern() : "null") +
", jsCode=" + (jsCode != null ? "[JavaScript代码]" : "null") +
", pyCode=" + (pyCode != null ? "[Python代码]" : "null") +
", isJsParser=" + isJsParser +
", isPyParser=" + isPyParser +
", language='" + language + '\'' +
", metadata=" + metadata +
'}';
}
@@ -6,6 +6,8 @@ import org.slf4j.LoggerFactory;
import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.customjs.JsScriptLoader;
import cn.qaiu.parser.customjs.JsScriptMetadataParser;
import cn.qaiu.parser.custompy.PyScriptLoader;
import cn.qaiu.parser.custompy.PyScriptMetadataParser;
import java.util.List;
import java.util.Map;
@@ -21,7 +23,6 @@ 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);
/**
* 存储自定义解析器配置的Mapkey为类型标识value为配置对象
@@ -34,7 +35,7 @@ public class CustomParserRegistry {
* @param config 解析器配置
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
*/
public static synchronized void register(CustomParserConfig config) {
public static void register(CustomParserConfig config) {
if (config == null) {
throw new IllegalArgumentException("config不能为空");
}
@@ -60,11 +61,6 @@ 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);
@@ -88,6 +84,24 @@ public class CustomParserRegistry {
register(config);
}
/**
* 注册Python解析器
*
* @param config Python解析器配置
* @throws IllegalArgumentException 如果type已存在或与内置解析器冲突
*/
public static void registerPy(CustomParserConfig config) {
if (config == null) {
throw new IllegalArgumentException("config不能为空");
}
if (!config.isPyParser()) {
throw new IllegalArgumentException("config必须是Python解析器配置");
}
register(config);
}
/**
* 从JavaScript代码字符串注册解析器
*
@@ -145,6 +159,63 @@ public class CustomParserRegistry {
}
}
/**
* 从Python代码字符串注册解析器
*
* @param pyCode Python代码
* @throws IllegalArgumentException 如果解析失败
*/
public static void registerPyFromCode(String pyCode) {
if (pyCode == null || pyCode.trim().isEmpty()) {
throw new IllegalArgumentException("Python代码不能为空");
}
try {
CustomParserConfig config = PyScriptMetadataParser.parseScript(pyCode);
registerPy(config);
} catch (Exception e) {
throw new IllegalArgumentException("解析Python代码失败: " + e.getMessage(), e);
}
}
/**
* 从文件注册Python解析器
*
* @param filePath 文件路径
* @throws IllegalArgumentException 如果文件不存在或解析失败
*/
public static void registerPyFromFile(String filePath) {
if (filePath == null || filePath.trim().isEmpty()) {
throw new IllegalArgumentException("文件路径不能为空");
}
try {
CustomParserConfig config = PyScriptLoader.loadFromFile(filePath);
registerPy(config);
} catch (Exception e) {
throw new IllegalArgumentException("从文件加载Python解析器失败: " + e.getMessage(), e);
}
}
/**
* 从资源文件注册Python解析器
*
* @param resourcePath 资源路径
* @throws IllegalArgumentException 如果资源不存在或解析失败
*/
public static void registerPyFromResource(String resourcePath) {
if (resourcePath == null || resourcePath.trim().isEmpty()) {
throw new IllegalArgumentException("资源路径不能为空");
}
try {
CustomParserConfig config = PyScriptLoader.loadFromResource(resourcePath);
registerPy(config);
} catch (Exception e) {
throw new IllegalArgumentException("从资源加载Python解析器失败: " + e.getMessage(), e);
}
}
/**
* 自动加载所有JavaScript脚本
*/
@@ -171,13 +242,47 @@ public class CustomParserRegistry {
}
}
/**
* 自动加载所有Python脚本
*/
public static void autoLoadPyScripts() {
try {
List<CustomParserConfig> configs = PyScriptLoader.loadAllScripts();
int successCount = 0;
int failCount = 0;
for (CustomParserConfig config : configs) {
try {
registerPy(config);
successCount++;
} catch (Exception e) {
log.error("加载Python脚本失败: {}", config.getType(), e);
failCount++;
}
}
log.info("自动加载Python脚本完成: 成功 {} 个,失败 {} 个", successCount, failCount);
} catch (Exception e) {
log.error("自动加载Python脚本时发生异常", e);
}
}
/**
* 自动加载所有脚本JavaScript和Python
*/
public static void autoLoadAllScripts() {
autoLoadJsScripts();
autoLoadPyScripts();
}
/**
* 注销自定义解析器
*
* @param type 解析器类型标识
* @return 是否注销成功
*/
public static synchronized boolean unregister(String type) {
public static boolean unregister(String type) {
if (type == null || type.trim().isEmpty()) {
return false;
}
@@ -219,7 +324,7 @@ public class CustomParserRegistry {
/**
* 清空所有自定义解析器
*/
public static synchronized void clear() {
public static void clear() {
CUSTOM_PARSERS.clear();
}
@@ -2,21 +2,19 @@ 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;
@@ -29,13 +27,8 @@ 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;
/**
@@ -46,60 +39,11 @@ import java.util.regex.Pattern;
* Create at 2025/10/17
*/
public class JsHttpClient {
private static final Logger log = LoggerFactory.getLogger(JsHttpClient.class);
private static final int MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024;
private static final int MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024;
private static final int MAX_HEADER_COUNT = 64;
private static final int MAX_HEADER_VALUE_LENGTH = 4096;
private static final int MAX_TIMEOUT_SECONDS = 120;
private static final int MAX_REDIRECTS = 5;
private static final String DEFAULT_ACCEPT_ENCODING = "gzip, deflate, br";
private static final Object SHARED_CLIENT_LOCK = new Object();
// 共享 HttpClient 实例非代理模式懒加载避免类初始化阶段抢跑 Vert.x
private static volatile HttpClient sharedClient;
private static volatile boolean sharedClientShutdown = false;
/**
* 关闭共享 HttpClient应用关闭时调用
*/
public static void shutdownSharedClient() {
synchronized (SHARED_CLIENT_LOCK) {
sharedClientShutdown = true;
if (sharedClient != null) {
sharedClient.close();
sharedClient = null;
}
}
}
private static HttpClient sharedClient() {
synchronized (SHARED_CLIENT_LOCK) {
ensureSharedClientAvailable();
if (sharedClient == null) {
sharedClient = WebClientVertxInit.get().createHttpClient(
new HttpClientOptions()
.setConnectTimeout(10000)
.setIdleTimeout(30)
.setIdleTimeoutUnit(TimeUnit.SECONDS)
.setMaxPoolSize(64));
}
return sharedClient;
}
}
private static void ensureSharedClientAvailable() {
if (sharedClientShutdown) {
throw new IllegalStateException("共享 JavaScript HttpClient 已关闭");
}
}
private final HttpClient client;
private final boolean ownClient; // 标记是否为自建 client需要 close
private final AtomicBoolean closed = new AtomicBoolean(false);
private final Object requestLock = new Object();
private final Set<HttpClientRequest> activeRequests = ConcurrentHashMap.newKeySet();
private final WebClient client;
private final WebClientSession clientSession;
private MultiMap headers;
private int timeoutSeconds = 30; // 默认超时时间30秒
@@ -117,12 +61,11 @@ public class JsHttpClient {
};
public JsHttpClient() {
ensureSharedClientAvailable();
this.client = sharedClient();
this.ownClient = false;
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());;
this.clientSession = WebClientSession.create(client);
this.headers = MultiMap.caseInsensitiveMultiMap();
// 设置默认的Accept-Encoding头以支持压缩响应
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
// 设置默认的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头
@@ -134,35 +77,31 @@ public class JsHttpClient {
* @param proxyConfig 代理配置JsonObject包含typehostportusernamepassword
*/
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 = WebClientVertxInit.get().createHttpClient(
new HttpClientOptions()
.setConnectTimeout(10000)
.setIdleTimeout(30)
.setIdleTimeoutUnit(TimeUnit.SECONDS)
.setMaxPoolSize(16)
this.client = WebClient.create(WebClientVertxInit.get(),
new WebClientOptions()
.setUserAgentEnabled(false)
.setProxyOptions(proxyOptions));
this.ownClient = true;
this.clientSession = WebClientSession.create(client);
} else {
this.client = sharedClient();
this.ownClient = false;
this.client = WebClient.create(WebClientVertxInit.get());
this.clientSession = WebClientSession.create(client);
}
this.headers = MultiMap.caseInsensitiveMultiMap();
// 设置默认的Accept-Encoding头以支持压缩响应
this.headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
// 设置默认的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头
@@ -244,7 +183,13 @@ public class JsHttpClient {
*/
public JsHttpResponse get(String url) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.GET, url, null, false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.send();
});
}
/**
@@ -253,25 +198,16 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse getWithRedirect(String url) {
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;
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
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("重定向处理失败");
// 设置跟随重定向
request.followRedirects(true);
return request.send();
});
}
/**
@@ -281,7 +217,15 @@ public class JsHttpClient {
*/
public JsHttpResponse getNoRedirect(String url) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.GET, url, null, false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
// 设置不跟随重定向
request.followRedirects(false);
return request.send();
});
}
/**
@@ -292,7 +236,26 @@ public class JsHttpClient {
*/
public JsHttpResponse post(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.POST, url, bodyFromData(data), false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
}
/**
@@ -303,7 +266,26 @@ public class JsHttpClient {
*/
public JsHttpResponse put(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.PUT, url, bodyFromData(data), false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.putAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
}
/**
@@ -312,8 +294,13 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse delete(String url) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.DELETE, url, null, false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.deleteAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.send();
});
}
/**
@@ -323,8 +310,26 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse patch(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.PATCH, url, bodyFromData(data), false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.patchAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
}
/**
@@ -335,12 +340,6 @@ 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;
@@ -354,7 +353,9 @@ public class JsHttpClient {
public JsHttpClient putHeaders(Map<String, String> headersMap) {
if (headersMap != null) {
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
putHeader(entry.getKey(), entry.getValue());
if (entry.getKey() != null && entry.getValue() != null) {
headers.set(entry.getKey(), entry.getValue());
}
}
}
return this;
@@ -379,7 +380,7 @@ public class JsHttpClient {
public JsHttpClient clearHeaders() {
headers.clear();
// 重新设置默认头
headers.set("Accept-Encoding", DEFAULT_ACCEPT_ENCODING);
headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
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;
@@ -404,7 +405,7 @@ public class JsHttpClient {
*/
public JsHttpClient setTimeout(int seconds) {
if (seconds > 0) {
this.timeoutSeconds = Math.min(seconds, MAX_TIMEOUT_SECONDS);
this.timeoutSeconds = seconds;
}
return this;
}
@@ -449,12 +450,19 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse sendForm(Map<String, String> data) {
throw new IllegalArgumentException("sendForm(data) 缺少请求URL,请使用 post(url, data)");
}
public JsHttpResponse sendForm(String url, Map<String, String> data) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.POST, url, formBody(data), false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs("");
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
MultiMap formData = MultiMap.caseInsensitiveMultiMap();
if (data != null) {
formData.addAll(data);
}
return request.sendForm(formData);
});
}
/**
@@ -466,8 +474,34 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse sendMultipartForm(String url, Map<String, Object> data) {
validateUrlSecurity(url);
return executeRequest(HttpMethod.POST, url, multipartBody(data), false);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
MultipartForm form = MultipartForm.create();
if (data != null) {
for (Map.Entry<String, Object> entry : data.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (value instanceof String) {
form.attribute(key, (String) value);
} else if (value instanceof byte[]) {
form.binaryFileUpload(key, key, Buffer.buffer((byte[]) value), "application/octet-stream");
} else if (value instanceof Buffer) {
form.binaryFileUpload(key, key, (Buffer) value, "application/octet-stream");
} else if (value != null) {
// 其他类型转换为字符串
form.attribute(key, value.toString());
}
}
}
return request.sendMultipartForm(form);
});
}
/**
@@ -476,103 +510,44 @@ public class JsHttpClient {
* @return HTTP响应
*/
public JsHttpResponse sendJson(Object 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);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs("");
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.sendJson(data);
});
}
/**
* 执行HTTP请求同步
*/
private JsHttpResponse executeRequest(HttpMethod method, String url, RequestBody requestBody, boolean followRedirects) {
if (closed.get()) {
throw new IllegalStateException("HTTP客户端已关闭");
}
AtomicReference<HttpClientRequest> requestRef = new AtomicReference<>();
AtomicBoolean abandoned = new AtomicBoolean(false);
private JsHttpResponse executeRequest(RequestExecutor executor) {
try {
Promise<JsHttpResponse> promise = Promise.promise();
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;
Promise<HttpResponse<Buffer>> promise = Promise.promise();
Future<HttpResponse<Buffer>> future = executor.execute();
future.onComplete(result -> {
if (result.succeeded()) {
promise.complete(result.result());
} else {
promise.fail(result.cause());
}
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()
}).onFailure(Throwable::printStackTrace);
// 等待响应完成使用配置的超时时间
HttpResponse<Buffer> response = 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();
@@ -584,196 +559,13 @@ public class JsHttpClient {
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
}
}
private static boolean isRedirectStatus(int statusCode) {
return statusCode == 301 || statusCode == 302 || statusCode == 303
|| statusCode == 307 || statusCode == 308;
}
private String resolveRedirectUrl(String currentUrl, String location) {
try {
URI redirectUri = new URI(currentUrl).resolve(location.trim());
String scheme = redirectUri.getScheme();
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new SecurityException("🔒 安全拦截: 重定向协议不被允许");
}
String redirectUrl = redirectUri.toString();
validateUrlSecurity(redirectUrl);
return redirectUrl;
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("解析重定向地址失败: " + e.getMessage(), e);
}
}
private void collectResponse(HttpClientRequest request, HttpClientResponse response, Promise<JsHttpResponse> promise) {
Buffer body = Buffer.buffer();
AtomicBoolean done = new AtomicBoolean(false);
String contentLengthHeader = response.getHeader(HttpHeaders.CONTENT_LENGTH.toString());
if (StringUtils.isNumeric(contentLengthHeader)) {
long contentLength = Long.parseLong(contentLengthHeader);
if (contentLength > MAX_RESPONSE_BODY_BYTES) {
done.set(true);
request.reset();
finishRequest(request);
promise.tryFail("响应体过大: " + contentLength + " bytes");
return;
}
}
response.exceptionHandler(e -> {
if (done.compareAndSet(false, true)) {
finishRequest(request);
promise.tryFail(e);
}
});
response.handler(chunk -> {
if (done.get()) {
return;
}
if (body.length() + chunk.length() > MAX_RESPONSE_BODY_BYTES) {
if (done.compareAndSet(false, true)) {
request.reset();
finishRequest(request);
promise.tryFail("响应体过大: " + (body.length() + chunk.length()) + " bytes");
}
return;
}
body.appendBuffer(chunk);
});
response.endHandler(v -> {
if (done.compareAndSet(false, true)) {
finishRequest(request);
promise.tryComplete(new JsHttpResponse(
response.statusCode(),
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
body,
response.statusMessage(),
null
));
}
});
response.resume();
}
private void finishRequest(HttpClientRequest request) {
if (request != null) {
activeRequests.remove(request);
}
}
private void abortRequest(AtomicReference<HttpClientRequest> requestRef) {
HttpClientRequest request = requestRef.get();
if (request != null) {
try {
request.reset();
} finally {
finishRequest(request);
}
}
}
private RequestBody bodyFromData(Object data) {
if (data == null) {
return null;
}
if (data instanceof String str) {
return plainTextBody(str);
}
if (data instanceof Buffer buffer) {
return limitedBody(buffer, null);
}
if (data instanceof byte[] bytes) {
return limitedBody(Buffer.buffer(bytes), null);
}
if (data instanceof Map<?, ?> map) {
Map<String, String> formMap = new HashMap<>();
map.forEach((key, value) -> {
if (key != null && value != null) {
formMap.put(String.valueOf(key), String.valueOf(value));
}
});
return formBody(formMap);
}
return jsonBody(data);
}
private RequestBody plainTextBody(String data) {
return limitedBody(Buffer.buffer(data, StandardCharsets.UTF_8.name()), null);
}
private RequestBody jsonBody(Object data) {
Buffer body = data == null ? Buffer.buffer() : Buffer.buffer(Json.encode(data), StandardCharsets.UTF_8.name());
return limitedBody(body, "application/json; charset=utf-8");
}
private RequestBody formBody(Map<String, String> data) {
StringBuilder encoded = new StringBuilder();
if (data != null) {
for (Map.Entry<String, String> entry : data.entrySet()) {
if (encoded.length() > 0) {
encoded.append('&');
}
encoded.append(urlEncode(entry.getKey()));
encoded.append('=');
encoded.append(urlEncode(entry.getValue()));
}
}
return limitedBody(Buffer.buffer(encoded.toString(), StandardCharsets.UTF_8.name()),
"application/x-www-form-urlencoded; charset=utf-8");
}
private RequestBody multipartBody(Map<String, Object> data) {
String boundary = "----NetdiskJsHttpClientBoundary" + UUID.randomUUID().toString().replace("-", "");
Buffer body = Buffer.buffer();
if (data != null) {
for (Map.Entry<String, Object> entry : data.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (key == null || value == null) {
continue;
}
appendAscii(body, "--" + boundary + "\r\n");
if (value instanceof byte[] bytes) {
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
body.appendBytes(bytes);
appendAscii(body, "\r\n");
} else if (value instanceof Buffer buffer) {
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key)
+ "\"; filename=\"" + escapeMultipart(key) + "\"\r\n");
appendAscii(body, "Content-Type: application/octet-stream\r\n\r\n");
body.appendBuffer(buffer);
appendAscii(body, "\r\n");
} else {
appendAscii(body, "Content-Disposition: form-data; name=\"" + escapeMultipart(key) + "\"\r\n\r\n");
body.appendString(String.valueOf(value), StandardCharsets.UTF_8.name());
appendAscii(body, "\r\n");
}
ensureRequestBodyLimit(body);
}
}
appendAscii(body, "--" + boundary + "--\r\n");
return limitedBody(body, "multipart/form-data; boundary=" + boundary);
}
private static void appendAscii(Buffer body, String value) {
body.appendString(value, StandardCharsets.US_ASCII.name());
}
private static String escapeMultipart(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static RequestBody limitedBody(Buffer body, String contentType) {
ensureRequestBodyLimit(body);
return new RequestBody(body, contentType);
}
private record RequestBody(Buffer body, String contentType) {
/**
* 请求执行器接口
*/
@FunctionalInterface
private interface RequestExecutor {
Future<HttpResponse<Buffer>> execute();
}
/**
@@ -781,29 +573,10 @@ public class JsHttpClient {
*/
public static class JsHttpResponse {
private final int statusCode;
private final MultiMap headers;
private final Buffer body;
private final String statusMessage;
private final HttpResponse<Buffer> originalResponse;
private final HttpResponse<Buffer> response;
public JsHttpResponse(HttpResponse<Buffer> response) {
this(
response.statusCode(),
MultiMap.caseInsensitiveMultiMap().setAll(response.headers()),
response.body(),
response.statusMessage(),
response
);
}
public JsHttpResponse(int statusCode, MultiMap headers, Buffer body, String statusMessage,
HttpResponse<Buffer> originalResponse) {
this.statusCode = statusCode;
this.headers = headers == null ? MultiMap.caseInsensitiveMultiMap() : headers;
this.body = body == null ? Buffer.buffer() : body;
this.statusMessage = statusMessage;
this.originalResponse = originalResponse;
this.response = response;
}
/**
@@ -811,7 +584,7 @@ public class JsHttpClient {
* @return 响应体字符串
*/
public String body() {
return HttpResponseHelper.asText(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
return HttpResponseHelper.asText(response);
}
/**
@@ -820,7 +593,7 @@ public class JsHttpClient {
*/
public Object json() {
try {
JsonObject jsonObject = HttpResponseHelper.asJson(body, header(HttpHeaders.CONTENT_ENCODING.toString()));
JsonObject jsonObject = HttpResponseHelper.asJson(response);
if (jsonObject == null || jsonObject.isEmpty()) {
return null;
}
@@ -838,7 +611,7 @@ public class JsHttpClient {
* @return 状态码
*/
public int statusCode() {
return statusCode;
return response.statusCode();
}
/**
@@ -847,7 +620,7 @@ public class JsHttpClient {
* @return 头值
*/
public String header(String name) {
return headers.get(name);
return response.getHeader(name);
}
/**
@@ -855,9 +628,10 @@ public class JsHttpClient {
* @return 响应头Map
*/
public Map<String, String> headers() {
MultiMap responseHeaders = response.headers();
Map<String, String> result = new HashMap<>();
for (String name : headers.names()) {
result.put(name, headers.get(name));
for (String name : responseHeaders.names()) {
result.put(name, responseHeaders.get(name));
}
return result;
}
@@ -875,14 +649,8 @@ public class JsHttpClient {
* 获取原始响应对象
* @return HttpResponse对象
*/
@Deprecated
public HttpResponse<Buffer> getOriginalResponse() {
if (originalResponse == null) {
throw new UnsupportedOperationException(
"流式HTTP客户端不再保留原始Vert.x HttpResponse,请使用statusCode/header/headers/body/bodyBytes方法"
);
}
return originalResponse;
return response;
}
/**
@@ -890,8 +658,11 @@ public class JsHttpClient {
* @return 响应体字节数组
*/
public byte[] bodyBytes() {
ensureResponseBodyLimit(body);
return body.getBytes();
Buffer buffer = response.body();
if (buffer == null) {
return new byte[0];
}
return buffer.getBytes();
}
/**
@@ -899,46 +670,11 @@ public class JsHttpClient {
* @return 响应体大小字节
*/
public long bodySize() {
return body.length();
}
public String statusMessage() {
return statusMessage;
}
}
/**
* 关闭 HttpClient 释放连接池资源
* 仅关闭自建的 client代理模式共享实例不关闭
*/
public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
synchronized (requestLock) {
for (HttpClientRequest request : activeRequests) {
try {
request.reset();
} catch (Exception e) {
log.debug("重置 JavaScript HTTP 请求失败: {}", e.getMessage());
}
Buffer buffer = response.body();
if (buffer == null) {
return 0;
}
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");
return buffer.length();
}
}
}
@@ -6,7 +6,6 @@ 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;
@@ -21,13 +20,6 @@ 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;
/**
@@ -37,46 +29,21 @@ import java.util.stream.Collectors;
* @author <a href="https://qaiu.top">QAIU</a>
* Create at 2025/10/17
*/
public class JsParserExecutor implements IPanTool, AutoCloseable {
public class JsParserExecutor implements IPanTool {
private static final Logger log = LoggerFactory.getLogger(JsParserExecutor.class);
private static volatile WorkerExecutor EXECUTOR;
private static final Object EXECUTOR_LOCK = new Object();
private static volatile boolean executorShutdown = false;
/** 安全网调度器:当 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 static final WorkerExecutor EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
private static String FETCH_RUNTIME_JS = null;
private final CustomParserConfig config;
private final ShareLinkInfo shareLinkInfo;
private volatile ScriptEngine engine;
private final Object engineLock = new Object();
private final ScriptEngine engine;
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;
@@ -92,6 +59,7 @@ 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();
}
/**
@@ -143,7 +111,6 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
if (engine == null) {
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
}
this.engine = engine;
// 注入Java对象到JavaScript环境
engine.put("http", httpClient);
@@ -178,207 +145,28 @@ 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();
}
}
private void cleanupEngine() {
// 清除 ScriptEngine 持有的所有引用和内部状态帮助 GC 回收
if (engine != 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 和清理调度器应在应用关闭时调用
*/
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() {
synchronized (EXECUTOR_LOCK) {
if (executorShutdown) {
throw new IllegalStateException("JavaScript解析器 WorkerExecutor 已关闭");
}
if (EXECUTOR == null) {
EXECUTOR = WebClientVertxInit.get().createSharedWorkerExecutor("parser-executor", 32);
}
return EXECUTOR;
}
}
private <T> Future<T> executeBlockingWithPermit(String operation, Callable<T> blockingCode) {
if (!EXECUTION_PERMITS.tryAcquire()) {
String message = "JavaScript " + operation + " 执行并发已满,请稍后重试";
jsLogger.error(message);
close();
return Future.failedFuture(message);
}
try {
return getExecutor().executeBlocking(() -> {
boolean executionStarted = false;
try {
beginExecution();
executionStarted = true;
return blockingCode.call();
} finally {
if (executionStarted) {
finishExecution();
}
EXECUTION_PERMITS.release();
}
});
} catch (Throwable e) {
EXECUTION_PERMITS.release();
close();
return Future.failedFuture(e);
}
}
private <T> Future<T> withTimeout(Future<T> executionFuture, String operation) {
Promise<T> promise = Promise.promise();
try {
safetyCleanupFuture = CLEANUP_SCHEDULER.schedule(() -> {
if (promise.tryFail("JavaScript " + operation + " 执行超时(" + EXECUTION_TIMEOUT_SECONDS + "秒)")) {
jsLogger.error("{} 执行超时,已停止外部HTTP资源;ScriptEngine将在执行线程退出后清理", operation);
close();
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("安全网调度失败: {}", e.getMessage());
}
executionFuture.onComplete(ar -> {
cancelSafetyCleanup();
if (ar.succeeded()) {
promise.tryComplete(ar.result());
} else {
promise.tryFail(ar.cause());
}
close();
});
return promise.future();
}
@Override
public Future<String> parse() {
jsLogger.info("开始执行JavaScript解析器: {}", config.getType());
// 使用executeBlocking在工作线程上执行避免阻塞EventLoop线程
Future<String> executionFuture = executeBlockingWithPermit("parse", () -> {
ScriptEngine engine = engine();
return EXECUTOR.executeBlocking(() -> {
// 直接调用全局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) {
String resultText = limitResultString((String) result, "parse");
jsLogger.info("解析成功,结果长度: {}", resultText.length());
return resultText;
jsLogger.info("解析成功: {}", result);
return (String) result;
} else {
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
jsLogger.error("parse方法返回值类型错误,期望String,实际: {}",
result != null ? result.getClass().getSimpleName() : "null");
throw new RuntimeException("parse方法返回值类型错误");
}
@@ -386,34 +174,32 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
throw new RuntimeException("parse函数类型错误");
}
});
return withTimeout(executionFuture, "parse");
}
@Override
public Future<List<FileInfo>> parseFileList() {
jsLogger.info("开始执行JavaScript文件列表解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行避免阻塞EventLoop线程
Future<List<FileInfo>> executionFuture = executeBlockingWithPermit("parseFileList", () -> {
ScriptEngine engine = engine();
return EXECUTOR.executeBlocking(() -> {
// 直接调用全局parseFileList函数
Object parseFileListFunction = engine.get("parseFileList");
if (parseFileListFunction == null) {
throw new RuntimeException("JavaScript代码中未找到parseFileList函数");
}
// 调用parseFileList方法
if (parseFileListFunction instanceof ScriptObjectMirror parseFileListMirror) {
Object result = parseFileListMirror.call(null, shareLinkInfoWrapper, httpClient, jsLogger);
if (result instanceof ScriptObjectMirror resultMirror) {
List<FileInfo> fileList = convertToFileInfoList(resultMirror);
jsLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
return fileList;
} else {
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
jsLogger.error("parseFileList方法返回值类型错误,期望数组,实际: {}",
result != null ? result.getClass().getSimpleName() : "null");
throw new RuntimeException("parseFileList方法返回值类型错误");
}
@@ -421,33 +207,30 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
throw new RuntimeException("parseFileList函数类型错误");
}
});
return withTimeout(executionFuture, "parseFileList");
}
@Override
public Future<String> parseById() {
jsLogger.info("开始执行JavaScript按ID解析: {}", config.getType());
// 使用executeBlocking在工作线程上执行避免阻塞EventLoop线程
Future<String> executionFuture = executeBlockingWithPermit("parseById", () -> {
ScriptEngine engine = engine();
return EXECUTOR.executeBlocking(() -> {
// 直接调用全局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) {
String resultText = limitResultString((String) result, "parseById");
jsLogger.info("按ID解析成功,结果长度: {}", resultText.length());
return resultText;
jsLogger.info("按ID解析成功: {}", result);
return (String) result;
} else {
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
jsLogger.error("parseById方法返回值类型错误,期望String,实际: {}",
result != null ? result.getClass().getSimpleName() : "null");
throw new RuntimeException("parseById方法返回值类型错误");
}
@@ -455,7 +238,6 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
throw new RuntimeException("parseById函数类型错误");
}
});
return withTimeout(executionFuture, "parseById");
}
/**
@@ -465,9 +247,6 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
List<FileInfo> fileList = new ArrayList<>();
if (resultMirror.isArray()) {
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
}
for (int i = 0; i < resultMirror.size(); i++) {
Object item = resultMirror.get(String.valueOf(i));
if (item instanceof ScriptObjectMirror) {
@@ -491,13 +270,13 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
// 设置基本字段
if (itemMirror.hasMember("fileName")) {
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
}
if (itemMirror.hasMember("fileId")) {
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
}
if (itemMirror.hasMember("fileType")) {
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
}
if (itemMirror.hasMember("size")) {
Object size = itemMirror.getMember("size");
@@ -506,16 +285,16 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
}
}
if (itemMirror.hasMember("sizeStr")) {
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
}
if (itemMirror.hasMember("createTime")) {
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
}
if (itemMirror.hasMember("updateTime")) {
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
}
if (itemMirror.hasMember("createBy")) {
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
}
if (itemMirror.hasMember("downloadCount")) {
Object downloadCount = itemMirror.getMember("downloadCount");
@@ -524,16 +303,16 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
}
}
if (itemMirror.hasMember("fileIcon")) {
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
}
if (itemMirror.hasMember("panType")) {
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
fileInfo.setPanType(itemMirror.getMember("panType").toString());
}
if (itemMirror.hasMember("parserUrl")) {
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
}
if (itemMirror.hasMember("previewUrl")) {
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
}
return fileInfo;
@@ -543,22 +322,4 @@ public class JsParserExecutor implements IPanTool, AutoCloseable {
return null;
}
}
private static String limitResultString(String value, String operation) {
if (value.length() > MAX_RESULT_STRING_LENGTH) {
throw new RuntimeException(operation + " 返回结果过大: " + value.length() + " 字符");
}
return value;
}
private static String limitField(Object value) {
if (value == null) {
return null;
}
String text = value.toString();
if (text.length() > MAX_FILE_FIELD_LENGTH) {
throw new RuntimeException("文件字段过长: " + text.length() + " 字符");
}
return text;
}
}
@@ -21,37 +21,20 @@ import java.util.concurrent.*;
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JsPlaygroundExecutor implements AutoCloseable {
public class JsPlaygroundExecutor {
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;
// 使用有界线程池防止线程无限增长导致内存溢出
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("演练场线程池已满,请稍后重试");
}
);
// 使用独立的线程池不受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 ScheduledExecutorService TIMEOUT_SCHEDULER = Executors.newScheduledThreadPool(2, r -> {
@@ -60,29 +43,14 @@ public class JsPlaygroundExecutor implements AutoCloseable {
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 volatile ScriptEngine engine;
private final Object engineLock = new Object();
private final ScriptEngine engine;
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;
/**
* 创建演练场执行器
@@ -104,6 +72,7 @@ public class JsPlaygroundExecutor implements AutoCloseable {
this.playgroundLogger = new JsPlaygroundLogger();
this.shareLinkInfoWrapper = new JsShareLinkInfoWrapper(shareLinkInfo);
this.fetchBridge = new JsFetchBridge(httpClient);
this.engine = initEngine();
}
/**
@@ -120,7 +89,6 @@ public class JsPlaygroundExecutor implements AutoCloseable {
if (engine == null) {
throw new RuntimeException("无法创建JavaScript引擎,请确保Nashorn可用");
}
this.engine = engine;
// 注入Java对象到JavaScript环境
engine.put("http", httpClient);
@@ -165,14 +133,9 @@ public class JsPlaygroundExecutor implements AutoCloseable {
*/
public Future<String> executeParseAsync() {
Promise<String> promise = Promise.promise();
final CompletableFuture<String> executionFuture;
try {
// 使用独立的ExecutorService执行避免Vert.x的BlockedThreadChecker输出警告
executionFuture = CompletableFuture.supplyAsync(() -> {
beginExecution();
try {
ScriptEngine engine = engine();
// 使用独立的ExecutorService执行避免Vert.x的BlockedThreadChecker输出警告
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
playgroundLogger.infoJava("开始执行parse方法");
try {
Object parseFunction = engine.get("parse");
@@ -188,9 +151,8 @@ public class JsPlaygroundExecutor implements AutoCloseable {
log.debug("[JsPlaygroundExecutor] parse函数执行完成,当前日志数量: {}", playgroundLogger.size());
if (result instanceof String) {
String resultText = limitResultString((String) result, "parse");
playgroundLogger.infoJava("解析成功,返回结果长度: " + resultText.length());
return resultText;
playgroundLogger.infoJava("解析成功,返回结果: " + result);
return (String) result;
} else {
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
(result != null ? result.getClass().getSimpleName() : "null");
@@ -205,27 +167,25 @@ public class JsPlaygroundExecutor implements AutoCloseable {
playgroundLogger.errorJava("执行parse方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
} finally {
finishExecution();
}
}, 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");
// 创建超时任务强制取消执行
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true); // 强制中断执行线程
playgroundLogger.errorJava("执行超时,已强制中断");
log.warn("JavaScript执行超时,已强制取消");
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
// 处理执行结果
executionFuture.whenComplete((result, error) -> {
// 取消超时任务
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
playgroundLogger.errorJava(timeoutMsg);
log.error(timeoutMsg);
promise.fail(new RuntimeException(timeoutMsg));
@@ -237,10 +197,10 @@ public class JsPlaygroundExecutor implements AutoCloseable {
promise.complete(result);
}
});
return promise.future();
}
/**
* 执行parseFileList方法异步带超时控制
* 使用独立线程池不受Vert.x BlockedThreadChecker监控
@@ -249,14 +209,9 @@ public class JsPlaygroundExecutor implements AutoCloseable {
*/
public Future<List<FileInfo>> executeParseFileListAsync() {
Promise<List<FileInfo>> promise = Promise.promise();
final CompletableFuture<List<FileInfo>> executionFuture;
try {
// 使用独立的ExecutorService执行避免Vert.x的BlockedThreadChecker输出警告
executionFuture = CompletableFuture.supplyAsync(() -> {
beginExecution();
try {
ScriptEngine engine = engine();
// 使用独立的ExecutorService执行避免Vert.x的BlockedThreadChecker输出警告
CompletableFuture<List<FileInfo>> executionFuture = CompletableFuture.supplyAsync(() -> {
playgroundLogger.infoJava("开始执行parseFileList方法");
try {
Object parseFileListFunction = engine.get("parseFileList");
@@ -287,27 +242,25 @@ public class JsPlaygroundExecutor implements AutoCloseable {
playgroundLogger.errorJava("执行parseFileList方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
} finally {
finishExecution();
}
}, 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");
// 创建超时任务强制取消执行
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true); // 强制中断执行线程
playgroundLogger.errorJava("执行超时,已强制中断");
log.warn("JavaScript执行超时,已强制取消");
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
// 处理执行结果
executionFuture.whenComplete((result, error) -> {
// 取消超时任务
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
playgroundLogger.errorJava(timeoutMsg);
log.error(timeoutMsg);
promise.fail(new RuntimeException(timeoutMsg));
@@ -319,10 +272,10 @@ public class JsPlaygroundExecutor implements AutoCloseable {
promise.complete(result);
}
});
return promise.future();
}
/**
* 执行parseById方法异步带超时控制
* 使用独立线程池不受Vert.x BlockedThreadChecker监控
@@ -331,14 +284,9 @@ public class JsPlaygroundExecutor implements AutoCloseable {
*/
public Future<String> executeParseByIdAsync() {
Promise<String> promise = Promise.promise();
final CompletableFuture<String> executionFuture;
try {
// 使用独立的ExecutorService执行避免Vert.x的BlockedThreadChecker输出警告
executionFuture = CompletableFuture.supplyAsync(() -> {
beginExecution();
try {
ScriptEngine engine = engine();
// 使用独立的ExecutorService执行避免Vert.x的BlockedThreadChecker输出警告
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
playgroundLogger.infoJava("开始执行parseById方法");
try {
Object parseByIdFunction = engine.get("parseById");
@@ -352,9 +300,8 @@ public class JsPlaygroundExecutor implements AutoCloseable {
Object result = parseByIdMirror.call(null, shareLinkInfoWrapper, httpClient, playgroundLogger);
if (result instanceof String) {
String resultText = limitResultString((String) result, "parseById");
playgroundLogger.infoJava("按ID解析成功,返回结果长度: " + resultText.length());
return resultText;
playgroundLogger.infoJava("按ID解析成功: " + result);
return (String) result;
} else {
String errorMsg = "parseById方法返回值类型错误,期望String,实际: " +
(result != null ? result.getClass().getSimpleName() : "null");
@@ -369,27 +316,25 @@ public class JsPlaygroundExecutor implements AutoCloseable {
playgroundLogger.errorJava("执行parseById方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
} finally {
finishExecution();
}
}, 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");
// 创建超时任务强制取消执行
ScheduledFuture<?> timeoutTask = TIMEOUT_SCHEDULER.schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true); // 强制中断执行线程
playgroundLogger.errorJava("执行超时,已强制中断");
log.warn("JavaScript执行超时,已强制取消");
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
// 处理执行结果
executionFuture.whenComplete((result, error) -> {
// 取消超时任务
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已返回超时并停止外部HTTP资源;ScriptEngine将在执行线程退出后清理";
String timeoutMsg = "JavaScript执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
playgroundLogger.errorJava(timeoutMsg);
log.error(timeoutMsg);
promise.fail(new RuntimeException(timeoutMsg));
@@ -401,7 +346,7 @@ public class JsPlaygroundExecutor implements AutoCloseable {
promise.complete(result);
}
});
return promise.future();
}
@@ -410,7 +355,7 @@ public class JsPlaygroundExecutor implements AutoCloseable {
*/
public List<JsPlaygroundLogger.LogEntry> getLogs() {
List<JsPlaygroundLogger.LogEntry> logs = playgroundLogger.getLogs();
log.debug("获取日志,数量: {}", logs.size());
System.out.println("[JsPlaygroundExecutor] 获取日志,数量: " + logs.size());
return logs;
}
@@ -428,9 +373,6 @@ public class JsPlaygroundExecutor implements AutoCloseable {
List<FileInfo> fileList = new ArrayList<>();
if (resultMirror.isArray()) {
if (resultMirror.size() > MAX_FILE_LIST_SIZE) {
throw new RuntimeException("文件列表数量超过限制: " + resultMirror.size());
}
for (int i = 0; i < resultMirror.size(); i++) {
Object item = resultMirror.get(String.valueOf(i));
if (item instanceof ScriptObjectMirror) {
@@ -454,13 +396,13 @@ public class JsPlaygroundExecutor implements AutoCloseable {
// 设置基本字段
if (itemMirror.hasMember("fileName")) {
fileInfo.setFileName(limitField(itemMirror.getMember("fileName")));
fileInfo.setFileName(itemMirror.getMember("fileName").toString());
}
if (itemMirror.hasMember("fileId")) {
fileInfo.setFileId(limitField(itemMirror.getMember("fileId")));
fileInfo.setFileId(itemMirror.getMember("fileId").toString());
}
if (itemMirror.hasMember("fileType")) {
fileInfo.setFileType(limitField(itemMirror.getMember("fileType")));
fileInfo.setFileType(itemMirror.getMember("fileType").toString());
}
if (itemMirror.hasMember("size")) {
Object size = itemMirror.getMember("size");
@@ -469,16 +411,16 @@ public class JsPlaygroundExecutor implements AutoCloseable {
}
}
if (itemMirror.hasMember("sizeStr")) {
fileInfo.setSizeStr(limitField(itemMirror.getMember("sizeStr")));
fileInfo.setSizeStr(itemMirror.getMember("sizeStr").toString());
}
if (itemMirror.hasMember("createTime")) {
fileInfo.setCreateTime(limitField(itemMirror.getMember("createTime")));
fileInfo.setCreateTime(itemMirror.getMember("createTime").toString());
}
if (itemMirror.hasMember("updateTime")) {
fileInfo.setUpdateTime(limitField(itemMirror.getMember("updateTime")));
fileInfo.setUpdateTime(itemMirror.getMember("updateTime").toString());
}
if (itemMirror.hasMember("createBy")) {
fileInfo.setCreateBy(limitField(itemMirror.getMember("createBy")));
fileInfo.setCreateBy(itemMirror.getMember("createBy").toString());
}
if (itemMirror.hasMember("downloadCount")) {
Object downloadCount = itemMirror.getMember("downloadCount");
@@ -487,154 +429,24 @@ public class JsPlaygroundExecutor implements AutoCloseable {
}
}
if (itemMirror.hasMember("fileIcon")) {
fileInfo.setFileIcon(limitField(itemMirror.getMember("fileIcon")));
fileInfo.setFileIcon(itemMirror.getMember("fileIcon").toString());
}
if (itemMirror.hasMember("panType")) {
fileInfo.setPanType(limitField(itemMirror.getMember("panType")));
fileInfo.setPanType(itemMirror.getMember("panType").toString());
}
if (itemMirror.hasMember("parserUrl")) {
fileInfo.setParserUrl(limitField(itemMirror.getMember("parserUrl")));
fileInfo.setParserUrl(itemMirror.getMember("parserUrl").toString());
}
if (itemMirror.hasMember("previewUrl")) {
fileInfo.setPreviewUrl(limitField(itemMirror.getMember("previewUrl")));
fileInfo.setPreviewUrl(itemMirror.getMember("previewUrl").toString());
}
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());
}
}
}
}
@@ -4,9 +4,6 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 演练场日志收集器
* 收集JavaScript执行过程中的日志信息
@@ -15,12 +12,8 @@ import org.slf4j.LoggerFactory;
* @author <a href="https://qaiu.top">QAIU</a>
*/
public class JsPlaygroundLogger {
private static final Logger log = LoggerFactory.getLogger(JsPlaygroundLogger.class);
// 使用线程安全的列表
private static final int MAX_LOG_SIZE = 1000;
private static final int MAX_LOG_MESSAGE_LENGTH = 4096;
private final List<LogEntry> logs = Collections.synchronizedList(new ArrayList<>());
/**
@@ -63,25 +56,9 @@ public class JsPlaygroundLogger {
if (obj == null) {
return "null";
}
String msg = obj.toString();
if (msg.length() <= MAX_LOG_MESSAGE_LENGTH) {
return msg;
}
return msg.substring(0, MAX_LOG_MESSAGE_LENGTH) + "...[truncated]";
return obj.toString();
}
/**
* 添加日志条目超过最大容量时移除最早的条目
*/
private void addLog(LogEntry entry) {
synchronized (logs) {
if (logs.size() >= MAX_LOG_SIZE) {
logs.remove(0);
}
logs.add(entry);
}
}
/**
* 记录日志内部方法
* @param level 日志级别
@@ -90,8 +67,8 @@ public class JsPlaygroundLogger {
*/
private void log(String level, Object message, String source) {
String msg = toString(message);
addLog(new LogEntry(level, msg, source));
log.debug("[{}PlaygroundLogger] {}: {}", source, level, msg);
logs.add(new LogEntry(level, msg, source));
System.out.println("[" + source + "PlaygroundLogger] " + level + ": " + msg);
}
/**
@@ -132,10 +109,10 @@ public class JsPlaygroundLogger {
public void error(Object message, Throwable throwable) {
String msg = toString(message);
if (throwable != null) {
msg = toString(msg + ": " + throwable.getMessage());
msg = msg + ": " + throwable.getMessage();
}
addLog(new LogEntry("ERROR", msg, "JS"));
log.debug("[JSPlaygroundLogger] ERROR: {}", msg);
logs.add(new LogEntry("ERROR", msg, "JS"));
System.out.println("[JSPlaygroundLogger] ERROR: " + msg);
}
// ===== 以下是供Java层调用的内部方法 =====
@@ -172,12 +149,12 @@ public class JsPlaygroundLogger {
* 错误日志带异常供Java层调用
*/
public void errorJava(String message, Throwable throwable) {
String msg = toString(message);
String msg = message;
if (throwable != null) {
msg = toString(msg + ": " + throwable.getMessage());
msg = msg + ": " + throwable.getMessage();
}
addLog(new LogEntry("ERROR", msg, "JAVA"));
log.debug("[JAVAPlaygroundLogger] ERROR: {}", msg);
logs.add(new LogEntry("ERROR", msg, "JAVA"));
System.out.println("[JAVAPlaygroundLogger] ERROR: " + msg);
}
/**
@@ -202,17 +179,4 @@ public class JsPlaygroundLogger {
public void clear() {
logs.clear();
}
public void trimToLast(int maxEntries) {
if (maxEntries < 0) {
throw new IllegalArgumentException("maxEntries不能小于0");
}
synchronized (logs) {
int removeCount = logs.size() - maxEntries;
if (removeCount <= 0) {
return;
}
logs.subList(0, removeCount).clear();
}
}
}
@@ -31,8 +31,6 @@ 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";
@@ -83,16 +81,14 @@ public class JsScriptLoader {
try {
InputStream inputStream = JsScriptLoader.class.getClassLoader()
.getResourceAsStream(resourceFile);
if (inputStream != null) {
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);
}
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);
}
} catch (Exception e) {
log.warn("加载资源脚本失败: {}", resourceFile, e);
@@ -143,20 +139,21 @@ public class JsScriptLoader {
try {
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
try (JarFile jarFile = new JarFile(jarPath)) {
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.startsWith(RESOURCE_PATH + "/") &&
entryName.endsWith(".js") &&
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
resourceFiles.add(entryName);
}
JarFile jarFile = new JarFile(jarPath);
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.startsWith(RESOURCE_PATH + "/") &&
entryName.endsWith(".js") &&
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
resourceFiles.add(entryName);
}
}
jarFile.close();
} catch (Exception e) {
log.debug("解析JAR包资源文件失败", e);
}
@@ -211,10 +208,8 @@ 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);
@@ -268,7 +263,6 @@ public class JsScriptLoader {
throw new IllegalArgumentException("文件不存在: " + filePath);
}
ensureScriptSize(path);
String jsCode = Files.readString(path, StandardCharsets.UTF_8);
return JsScriptMetadataParser.parseScript(jsCode);
@@ -286,16 +280,14 @@ public class JsScriptLoader {
try {
InputStream inputStream = JsScriptLoader.class.getClassLoader()
.getResourceAsStream(resourcePath);
if (inputStream == null) {
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
}
try (inputStream) {
String jsCode = readResourceScript(inputStream, resourcePath);
return JsScriptMetadataParser.parseScript(jsCode);
}
String jsCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
return JsScriptMetadataParser.parseScript(jsCode);
} catch (IOException e) {
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
}
@@ -355,19 +347,4 @@ 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);
}
}
@@ -0,0 +1,202 @@
package cn.qaiu.parser.custompy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Python 代码安全检查器
* 在执行前对代码进行静态分析检测危险操作
*/
public class PyCodeSecurityChecker {
private static final Logger log = LoggerFactory.getLogger(PyCodeSecurityChecker.class);
/**
* 危险的导入模块
*/
private static final Set<String> DANGEROUS_IMPORTS = Set.of(
"subprocess", // 子进程执行
"socket", // 原始网络套接字
"ctypes", // C 语言接口
"_ctypes", // C 语言接口
"multiprocessing", // 多进程
"threading", // 多线程可选禁止
"asyncio", // 异步IO可选禁止
"pty", // 伪终端
"fcntl", // 文件控制
"resource", // 资源限制
"syslog", // 系统日志
"signal" // 信号处理
);
/**
* 危险的 os 模块方法
*/
private static final Set<String> DANGEROUS_OS_METHODS = Set.of(
"system", // 执行系统命令
"popen", // 打开进程管道
"spawn", // 生成进程
"spawnl", "spawnle", "spawnlp", "spawnlpe",
"spawnv", "spawnve", "spawnvp", "spawnvpe",
"exec", "execl", "execle", "execlp", "execlpe",
"execv", "execve", "execvp", "execvpe",
"fork", "forkpty",
"kill", "killpg",
"remove", "unlink",
"rmdir", "removedirs",
"mkdir", "makedirs",
"rename", "renames", "replace",
"chmod", "chown", "lchown",
"chroot",
"mknod", "mkfifo",
"link", "symlink"
);
/**
* 危险的内置函数
*/
private static final Set<String> DANGEROUS_BUILTINS = Set.of(
"exec", // 执行代码
"eval", // 评估表达式
"compile", // 编译代码
"__import__" // 动态导入
);
/**
* 检查代码安全性
* @param code Python 代码
* @return 安全检查结果
*/
public static SecurityCheckResult check(String code) {
if (code == null || code.trim().isEmpty()) {
return SecurityCheckResult.fail("代码为空");
}
List<String> violations = new ArrayList<>();
// 1. 检查危险导入
for (String module : DANGEROUS_IMPORTS) {
if (containsImport(code, module)) {
violations.add("禁止导入危险模块: " + module);
}
}
// 2. 检查危险的 os 方法调用
for (String method : DANGEROUS_OS_METHODS) {
if (containsOsMethodCall(code, method)) {
violations.add("禁止使用危险的 os 方法: os." + method + "()");
}
}
// 3. 检查危险的内置函数
for (String builtin : DANGEROUS_BUILTINS) {
if (containsBuiltinCall(code, builtin)) {
violations.add("禁止使用危险的内置函数: " + builtin + "()");
}
}
// 4. 检查危险的文件操作模式
if (containsDangerousFileOperation(code)) {
violations.add("禁止使用危险的文件写入操作");
}
if (violations.isEmpty()) {
return SecurityCheckResult.pass();
} else {
return SecurityCheckResult.fail(String.join("; ", violations));
}
}
/**
* 检查是否包含指定模块的导入
*/
private static boolean containsImport(String code, String module) {
// 匹配: import module / from module import xxx
String pattern1 = "(?m)^\\s*import\\s+" + Pattern.quote(module) + "\\b";
String pattern2 = "(?m)^\\s*from\\s+" + Pattern.quote(module) + "\\s+import";
return Pattern.compile(pattern1).matcher(code).find() ||
Pattern.compile(pattern2).matcher(code).find();
}
/**
* 检查是否包含指定的 os 方法调用
*/
private static boolean containsOsMethodCall(String code, String method) {
// 匹配: os.method(
String pattern = "\\bos\\s*\\.\\s*" + Pattern.quote(method) + "\\s*\\(";
return Pattern.compile(pattern).matcher(code).find();
}
/**
* 检查是否包含指定的内置函数调用
*/
private static boolean containsBuiltinCall(String code, String builtin) {
// 匹配: builtin( 但排除方法调用 xxx.builtin(
String pattern = "(?<!\\.)\\b" + Pattern.quote(builtin) + "\\s*\\(";
return Pattern.compile(pattern).matcher(code).find();
}
/**
* 检查是否包含危险的文件操作
*/
private static boolean containsDangerousFileOperation(String code) {
// 检查 open() 的写入模式
Pattern openPattern = Pattern.compile("\\bopen\\s*\\([^)]*['\"][wax+]['\"]");
if (openPattern.matcher(code).find()) {
return true;
}
// 检查直接的文件写入
Pattern writePattern = Pattern.compile("\\.write\\s*\\(|\\.writelines\\s*\\(");
if (writePattern.matcher(code).find()) {
// 需要进一步判断是否是文件写入而不是 response 写入等
// 这里简单处理如果有 write 调用但没有 requests/http 相关的上下文则禁止
if (!code.contains("requests") && !code.contains("http")) {
return true;
}
}
return false;
}
/**
* 安全检查结果
*/
public static class SecurityCheckResult {
private final boolean passed;
private final String message;
private SecurityCheckResult(boolean passed, String message) {
this.passed = passed;
this.message = message;
}
public static SecurityCheckResult pass() {
return new SecurityCheckResult(true, null);
}
public static SecurityCheckResult fail(String message) {
return new SecurityCheckResult(false, message);
}
public boolean isPassed() {
return passed;
}
public String getMessage() {
return message;
}
@Override
public String toString() {
return passed ? "PASSED" : "FAILED: " + message;
}
}
}
@@ -0,0 +1,817 @@
package cn.qaiu.parser.custompy;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.HostAccess;
import org.graalvm.polyglot.Value;
import org.graalvm.polyglot.io.IOAccess;
import org.graalvm.python.embedding.utils.GraalPyResources;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.List;
import java.util.ArrayList;
/**
* GraalPy Context 池化管理器
* 提供共享的 Engine 实例和 Context 池化支持
* 支持真正的 pip requests
*
* <p>特性
* <ul>
* <li>共享单个 Engine 实例减少内存占用和启动时间</li>
* <li>Context 对象池避免重复创建和销毁的开销</li>
* <li>支持真正的 pip 通过 GraalPy Resources</li>
* <li>支持安全的沙箱配置</li>
* <li>线程安全的池化管理</li>
* <li>支持优雅关闭和资源清理</li>
* <li>路径缓存避免重复检测文件系统</li>
* <li>预热机制在后台预导入常用模块</li>
* </ul>
*
* @author QAIU
*/
public class PyContextPool {
private static final Logger log = LoggerFactory.getLogger(PyContextPool.class);
// 池化配置 - 增加初始池大小和延长生命周期
private static final int INITIAL_POOL_SIZE = 4;
private static final int MAX_POOL_SIZE = 10;
private static final long CONTEXT_TIMEOUT_MS = 30000; // 30秒获取超时
private static final long CONTEXT_MAX_AGE_MS = 900000; // 15分钟最大使用时间
// 路径缓存 - 避免重复检测文件系统
private static volatile List<String> cachedValidPaths = null;
private static final Object PATH_CACHE_LOCK = new Object();
// 单例实例
private static volatile PyContextPool instance;
private static final Object LOCK = new Object();
// 共享的GraalPy引擎
private final Engine sharedEngine;
// Context
private final BlockingQueue<PooledContext> contextPool;
// 已创建的Context数量
private final AtomicInteger createdCount = new AtomicInteger(0);
// 是否已关闭
private final AtomicBoolean closed = new AtomicBoolean(false);
// 定期清理过期Context的调度器
private final ScheduledExecutorService cleanupScheduler;
// Python执行专用线程池
private final ExecutorService pythonExecutor;
// 超时调度器
private final ScheduledExecutorService timeoutScheduler;
/**
* 池化的Context包装器
*/
public static class PooledContext implements AutoCloseable {
private final Context context;
private final long createdTime;
private final PyContextPool pool;
private volatile boolean inUse = false;
private volatile long lastUsedTime;
private PooledContext(Context context, PyContextPool pool) {
this.context = context;
this.pool = pool;
this.createdTime = System.currentTimeMillis();
this.lastUsedTime = createdTime;
}
/**
* 获取底层Context
*/
public Context getContext() {
return context;
}
/**
* 检查是否过期
*/
public boolean isExpired() {
return System.currentTimeMillis() - createdTime > CONTEXT_MAX_AGE_MS;
}
/**
* 归还到池中或关闭
*/
@Override
public void close() {
pool.release(this);
}
/**
* 强制关闭Context
*/
void forceClose() {
try {
context.close(true);
} catch (Exception e) {
log.warn("关闭Context失败: {}", e.getMessage());
}
}
/**
* 重置Context状态清除绑定等
*/
boolean reset() {
try {
// 由于GraalPy的Context不能很好地重置状态
// 简单场景下我们选择创建新的Context
// 但对于短生命周期的执行可以尝试继续使用
lastUsedTime = System.currentTimeMillis();
return !isExpired();
} catch (Exception e) {
log.warn("重置Context失败: {}", e.getMessage());
return false;
}
}
}
/**
* 私有构造函数
*/
private PyContextPool() {
log.info("初始化GraalPy Context池...");
// 创建共享Engine - 使用标准Polyglot API
Engine engine = null;
try {
engine = Engine.newBuilder()
.option("engine.WarnInterpreterOnly", "false")
.build();
// 验证Python语言是否可用
if (!engine.getLanguages().containsKey("python")) {
throw new IllegalStateException("Python语言不可用,请检查GraalPy依赖配置");
}
log.info("Engine创建成功,可用语言: {}", engine.getLanguages().keySet());
} catch (Exception e) {
log.error("创建Engine失败: {}", e.getMessage());
checkGraalPyAvailability();
throw new RuntimeException("无法初始化GraalPy Engine,请确保GraalPy依赖正确配置", e);
}
this.sharedEngine = engine;
// 创建Context池
this.contextPool = new LinkedBlockingQueue<>(MAX_POOL_SIZE);
// 创建Python执行专用线程池
this.pythonExecutor = Executors.newCachedThreadPool(r -> {
Thread thread = new Thread(r);
thread.setName("py-context-pool-worker-" + System.currentTimeMillis());
thread.setDaemon(true);
return thread;
});
// 创建超时调度器
this.timeoutScheduler = Executors.newScheduledThreadPool(2, r -> {
Thread thread = new Thread(r);
thread.setName("py-context-timeout-" + System.currentTimeMillis());
thread.setDaemon(true);
return thread;
});
// 创建清理调度器
this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread thread = new Thread(r);
thread.setName("py-context-cleanup");
thread.setDaemon(true);
return thread;
});
// 预热初始化一些Context
warmup();
// 定期清理过期的Context
cleanupScheduler.scheduleWithFixedDelay(this::cleanup, 60, 60, TimeUnit.SECONDS);
log.info("GraalPy Context池初始化完成,初始大小: {}", INITIAL_POOL_SIZE);
}
/**
* 获取单例实例
*/
public static PyContextPool getInstance() {
if (instance == null) {
synchronized (LOCK) {
if (instance == null) {
instance = new PyContextPool();
}
}
}
return instance;
}
/**
* 获取共享Engine
*/
public Engine getSharedEngine() {
return sharedEngine;
}
/**
* 获取Python执行线程池
*/
public ExecutorService getPythonExecutor() {
return pythonExecutor;
}
/**
* 获取超时调度器
*/
public ScheduledExecutorService getTimeoutScheduler() {
return timeoutScheduler;
}
/**
* 预热Context池
* 在后台线程中预创建 Context 并预导入常用模块
*/
private void warmup() {
log.info("开始预热 Context 池,目标数量: {}", INITIAL_POOL_SIZE);
// 使用线程池并行预热
for (int i = 0; i < INITIAL_POOL_SIZE; i++) {
final int index = i;
pythonExecutor.submit(() -> {
try {
long start = System.currentTimeMillis();
PooledContext pc = createPooledContext();
// 预导入 requests 模块主要耗时点
try {
warmupContext(pc.getContext());
} catch (Exception e) {
log.debug("预热 Context {} 导入模块失败(非首个Context的NativeModules限制): {}",
index, e.getMessage());
}
if (!contextPool.offer(pc)) {
pc.forceClose();
} else {
long elapsed = System.currentTimeMillis() - start;
log.info("预热 Context {} 完成,耗时: {}ms", index, elapsed);
}
} catch (Exception e) {
log.warn("预热 Context {} 失败: {}", index, e.getMessage());
}
});
}
}
/**
* 预热单个 Context - 预导入常用模块
*/
private void warmupContext(Context context) {
String warmupScript = """
# 预导入常用模块
import json
import re
import base64
import hashlib
import urllib.parse
# 尝试导入 requests可能因 NativeModules 限制失败
try:
import requests
except (ImportError, SystemError):
pass
""";
context.eval("python", warmupScript);
}
/**
* 创建新的池化Context
* 使用 GraalPyResources 支持 pip
*/
private PooledContext createPooledContext() {
if (closed.get()) {
throw new IllegalStateException("Context池已关闭");
}
Context context;
try {
// 检查 VFS 资源是否存在
var vfsResource = getClass().getClassLoader().getResource("org.graalvm.python.vfs/venv");
log.info("GraalPy VFS资源检查: venv={}", vfsResource != null ? "存在" : "不存在");
// 使用 GraalPyResources 创建支持 pip 包的 Context
// 注意不传入共享 Engine GraalPyResources 管理自己的 Engine
log.info("正在创建 GraalPyResources Context...");
context = GraalPyResources.contextBuilder()
.allowHostAccess(HostAccess.newBuilder(HostAccess.EXPLICIT)
.allowArrayAccess(true)
.allowListAccess(true)
.allowMapAccess(true)
.allowIterableAccess(true)
.allowIteratorAccess(true)
.build())
.allowExperimentalOptions(true)
.allowCreateThread(true)
// 允许 IO 以支持 pip 包加载和网络请求
.allowIO(IOAccess.ALL)
.allowNativeAccess(true)
.option("engine.WarnInterpreterOnly", "false")
.build();
log.info("GraalPyResources Context 创建成功");
// 配置 Python 路径
setupPythonPath(context);
} catch (Exception e) {
log.error("使用GraalPyResources创建Context失败: {}", e.getMessage(), e);
throw new RuntimeException("无法创建支持pip包的Python Context: " + e.getMessage(), e);
}
createdCount.incrementAndGet();
log.debug("创建新的GraalPy Context,当前总数: {}", createdCount.get());
return new PooledContext(context, this);
}
/**
* 从池中获取Context
*
* @return 池化的Context用完后需要调用close()归还
* @throws InterruptedException 如果等待被中断
* @throws TimeoutException 如果超时未获取到
*/
public PooledContext acquire() throws InterruptedException, TimeoutException {
if (closed.get()) {
throw new IllegalStateException("Context池已关闭");
}
// 尝试从池中获取
PooledContext pc = contextPool.poll();
if (pc != null) {
if (!pc.isExpired() && pc.reset()) {
pc.inUse = true;
log.debug("从池中获取Context,池剩余: {}", contextPool.size());
return pc;
} else {
// Context已过期关闭它
pc.forceClose();
createdCount.decrementAndGet();
}
}
// 池中没有可用的检查是否可以创建新的
if (createdCount.get() < MAX_POOL_SIZE) {
try {
pc = createPooledContext();
pc.inUse = true;
return pc;
} catch (Exception e) {
log.error("创建新Context失败: {}", e.getMessage());
throw new RuntimeException("无法创建GraalPy Context", e);
}
}
// 已达最大数量等待归还
pc = contextPool.poll(CONTEXT_TIMEOUT_MS, TimeUnit.MILLISECONDS);
if (pc == null) {
throw new TimeoutException("获取GraalPy Context超时");
}
if (!pc.isExpired() && pc.reset()) {
pc.inUse = true;
return pc;
} else {
pc.forceClose();
createdCount.decrementAndGet();
// 递归重试
return acquire();
}
}
/**
* 创建一个新的非池化Context用于需要独立生命周期的场景
* 调用者负责管理其生命周期
* 支持真正的 pip requests, zlib
*
* 注意GraalPyResources 需要独立的 Engine不能与共享 Engine 一起使用
*/
public Context createFreshContext() {
try {
// 检查 VFS 资源是否存在
var vfsResource = getClass().getClassLoader().getResource("org.graalvm.python.vfs/venv");
var homeResource = getClass().getClassLoader().getResource("org.graalvm.python.vfs/home");
log.info("GraalPy VFS资源检查: venv={}, home={}",
vfsResource != null ? "存在" : "不存在",
homeResource != null ? "存在" : "不存在");
// 使用 GraalPyResources 创建支持 pip 包的 Context
// 注意不传入共享 Engine GraalPyResources 管理自己的 Engine
log.info("正在创建 GraalPyResources FreshContext...");
Context ctx = GraalPyResources.contextBuilder()
.allowHostAccess(HostAccess.newBuilder(HostAccess.EXPLICIT)
.allowArrayAccess(true)
.allowListAccess(true)
.allowMapAccess(true)
.allowIterableAccess(true)
.allowIteratorAccess(true)
.build())
.allowExperimentalOptions(true)
.allowCreateThread(true)
// 允许 IO 以支持 pip 包加载和网络请求
.allowIO(IOAccess.ALL)
.allowNativeAccess(true)
.option("engine.WarnInterpreterOnly", "false")
.build();
log.info("GraalPyResources FreshContext 创建成功");
// 手动配置 Python 路径以加载 VFS 中的 pip
setupPythonPath(ctx);
return ctx;
} catch (Exception e) {
log.error("使用GraalPyResources创建Context失败: {}", e.getMessage(), e);
throw new RuntimeException("无法创建支持pip包的Python Context: " + e.getMessage(), e);
}
}
/**
* 配置 Python 路径确保能够加载 pip
* 使用路径缓存机制避免重复检测文件系统
*
* pip 包安装在 src/main/resources/graalpy-packages/ 会打包进 jar
* 运行时从 classpath 或文件系统加载
*
* 注意GraalPy NativeModules 限制 - 只有进程中的第一个 Context 可以使用原生模块
* 后续 Context 会回退到 LLVM 模式这可能导致某些依赖原生模块的库无法正常工作
*
* 安装方法运行 parser/setup-graalpy-packages.sh
*/
private void setupPythonPath(Context context) {
try {
log.debug("配置 Python 环境...");
// 使用缓存的有效路径
List<String> validPaths = getValidPythonPaths();
if (validPaths.isEmpty()) {
log.warn("未找到有效的 Python 包路径");
return;
}
// 构建添加路径的脚本 - 使用已验证的路径跳过文件系统检测
StringBuilder pathsJson = new StringBuilder("[");
boolean first = true;
for (String path : validPaths) {
if (!first) pathsJson.append(", ");
first = false;
pathsJson.append("'").append(path.replace("\\", "/").replace("'", "\\'")).append("'");
}
pathsJson.append("]");
// 简化的路径添加脚本 - 不再调用 os.path.isdir直接添加已验证的路径
String addPathScript = String.format("""
import sys
_paths_to_add = %s
_added_paths = []
for path in _paths_to_add:
if path not in sys.path:
sys.path.insert(0, path)
_added_paths.append(path)
_added_paths_str = ', '.join(_added_paths) if _added_paths else ''
""", pathsJson);
context.eval("python", addPathScript);
Value bindings = context.getBindings("python");
String addedPaths = bindings.getMember("_added_paths_str").asString();
if (!addedPaths.isEmpty()) {
log.debug("添加的 Python 路径: {}", addedPaths);
}
// 验证 requests 是否可用简化版不阻塞
// 注意在多 Context 环境中可能因 NativeModules 限制而失败
String verifyScript = """
import sys
_requests_available = False
_requests_version = ''
_error_msg = ''
_native_module_error = False
try:
import requests
_requests_available = True
_requests_version = requests.__version__
except SystemError as e:
# NativeModules 冲突 - GraalPy 限制
_error_msg = str(e)
if 'NativeModules' in _error_msg or 'llvm' in _error_msg:
_native_module_error = True
except ImportError as e:
_error_msg = str(e)
_sys_path_length = len(sys.path)
""";
context.eval("python", verifyScript);
boolean requestsAvailable = bindings.getMember("_requests_available").asBoolean();
boolean nativeModuleError = bindings.getMember("_native_module_error").asBoolean();
int pathLength = bindings.getMember("_sys_path_length").asInt();
if (requestsAvailable) {
String version = bindings.getMember("_requests_version").asString();
log.info("Python 环境配置完成: requests {} 可用, sys.path长度: {}", version, pathLength);
} else if (nativeModuleError) {
// GraalPy NativeModules 限制 - 这是已知限制不是配置错误
log.debug("Python 环境配置: requests 因 NativeModules 限制不可用 (非首个 Context). " +
"这是 GraalPy 的已知限制,标准库仍可正常使用。");
} else {
String error = bindings.getMember("_error_msg").asString();
log.warn("Python 环境配置: requests 不可用 ({}), sys.path长度: {}. " +
"请运行: ./setup-graalpy-packages.sh", error, pathLength);
}
} catch (Exception e) {
String msg = e.getMessage();
// 检查是否是 NativeModules 相关的错误
if (msg != null && (msg.contains("NativeModules") || msg.contains("llvm"))) {
log.debug("Python 环境配置: 因 NativeModules 限制跳过 requests 验证 (非首个 Context)");
} else {
log.warn("Python 环境配置失败,继续使用默认配置: {}", msg);
}
// 不抛出异常允许 Context 继续使用
}
}
/**
* 设置安全的 OS 模块限制
* 只允许安全的读取操作禁止危险的文件系统操作
*
* 注意此方法应在所有必要的库导入完成后调用
* 因为替换 os 模块会影响依赖它的库 requests
*/
private void setupSecureOsModule(Context context) {
// 此方法当前禁用因为会影响 requests 库的正常工作
// 安全限制将在代码执行层面实现而不是替换系统模块
log.debug("OS 模块安全策略:通过代码审查实现,不替换系统模块");
}
/**
* 获取有效的 Python 包路径带缓存
* 首次调用时检测文件系统后续直接返回缓存
*/
private List<String> getValidPythonPaths() {
if (cachedValidPaths != null) {
return cachedValidPaths;
}
synchronized (PATH_CACHE_LOCK) {
if (cachedValidPaths != null) {
return cachedValidPaths;
}
log.debug("首次检测 Python 包路径...");
long start = System.currentTimeMillis();
List<String> validPaths = new ArrayList<>();
String userDir = System.getProperty("user.dir");
// 尝试从 classpath 获取 graalpy-packages 路径
String classpathPackages = null;
try {
var resource = getClass().getClassLoader().getResource("graalpy-packages");
if (resource != null) {
classpathPackages = resource.getPath();
// 处理 jar 内路径
if (classpathPackages.contains("!")) {
classpathPackages = null; // jar 内无法直接作为文件系统路径
}
}
} catch (Exception e) {
log.debug("无法从 classpath 获取 graalpy-packages: {}", e.getMessage());
}
// 可能的 pip 包路径列表
String[] possiblePaths = {
classpathPackages,
userDir + "/resources/graalpy-packages",
userDir + "/src/main/resources/graalpy-packages",
userDir + "/parser/src/main/resources/graalpy-packages",
userDir + "/target/classes/graalpy-packages",
userDir + "/parser/target/classes/graalpy-packages",
userDir + "/graalpy-venv/lib/python3.11/site-packages",
userDir + "/parser/graalpy-venv/lib/python3.11/site-packages",
};
// 检测有效路径
for (String path : possiblePaths) {
if (path != null) {
java.io.File dir = new java.io.File(path);
if (dir.isDirectory()) {
validPaths.add(path);
}
}
}
long elapsed = System.currentTimeMillis() - start;
log.info("Python 包路径检测完成,耗时: {}ms,有效路径数: {}", elapsed, validPaths.size());
if (!validPaths.isEmpty()) {
log.debug("有效路径: {}", validPaths);
}
cachedValidPaths = validPaths;
return validPaths;
}
}
/**
* 安全策略说明
*
* 由于 requests 等第三方库内部会使用 os 模块的功能
* 直接替换 os 模块会导致这些库无法正常工作
*
* 因此安全控制通过以下方式实现
* 1. 代码静态检查在执行前扫描危险的 os.system 等调用
* 2. PyPlaygroundExecutor 中对用户代码进行预处理
* 3. 使用 GraalPy 的沙箱机制限制文件系统访问
*
* 禁止的操作
* - os.system(), os.popen() - 系统命令执行
* - os.remove(), os.unlink(), os.rmdir() - 文件删除
* - os.mkdir(), os.makedirs() - 目录创建
* - subprocess.* - 子进程操作
*
* 允许的操作
* - requests.* - HTTP 请求
* - os.path.* - 路径操作只读
* - os.getcwd() - 获取当前目录
* - json, re, base64, hashlib 等标准库
*/
/**
* 归还Context到池中
*/
private void release(PooledContext pc) {
if (pc == null) return;
pc.inUse = false;
if (closed.get() || pc.isExpired()) {
// 池已关闭或Context已过期直接销毁
pc.forceClose();
createdCount.decrementAndGet();
log.debug("Context已过期或池已关闭,销毁Context");
} else if (!contextPool.offer(pc)) {
// 池已满销毁Context
pc.forceClose();
createdCount.decrementAndGet();
log.debug("池已满,销毁多余Context");
} else {
log.debug("归还Context到池,池当前大小: {}", contextPool.size());
}
}
/**
* 清理过期的Context
*/
private void cleanup() {
if (closed.get()) return;
int removed = 0;
PooledContext pc;
while ((pc = contextPool.poll()) != null) {
if (pc.isExpired() || closed.get()) {
pc.forceClose();
createdCount.decrementAndGet();
removed++;
} else {
// 还没过期放回池中
if (!contextPool.offer(pc)) {
pc.forceClose();
createdCount.decrementAndGet();
removed++;
}
break;
}
}
if (removed > 0) {
log.info("清理了 {} 个过期的Context,当前池大小: {}", removed, contextPool.size());
}
}
/**
* 获取池状态信息
*/
public String getStatus() {
return String.format("PyContextPool[total=%d, available=%d, maxSize=%d]",
createdCount.get(), contextPool.size(), MAX_POOL_SIZE);
}
/**
* 获取池中可用的Context数量
*/
public int getAvailableCount() {
return contextPool.size();
}
/**
* 获取已创建的Context总数
*/
public int getCreatedCount() {
return createdCount.get();
}
/**
* 检查GraalPy是否可用
*/
private void checkGraalPyAvailability() {
log.error("===== GraalPy 可用性检查 =====");
// 检查类路径
try {
Class.forName("org.graalvm.polyglot.Engine");
log.info("✓ org.graalvm.polyglot.Engine 类存在");
} catch (ClassNotFoundException e) {
log.error("✗ org.graalvm.polyglot.Engine 类不存在");
}
try {
Class.forName("org.graalvm.python.embedding.GraalPyResources");
log.info("✓ org.graalvm.python.embedding.GraalPyResources 类存在");
} catch (ClassNotFoundException e) {
log.warn(" python-embedding 类不存在(可选依赖)");
}
// 尝试列出可用语言
try {
log.info("尝试使用标准 Polyglot API 创建 Context...");
try (Engine engine = Engine.create()) {
log.info(" 可用语言: {}", engine.getLanguages().keySet());
if (engine.getLanguages().containsKey("python")) {
log.info("✓ Python 语言可用");
} else {
log.error("✗ Python 语言不可用");
}
}
} catch (Exception e) {
log.error("✗ 创建 Engine 失败: {}", e.getMessage());
}
log.error("================================");
log.error("请检查以下依赖是否正确配置:");
log.error(" 1. org.graalvm.polyglot:polyglot");
log.error(" 2. org.graalvm.polyglot:python (type=pom)");
log.error("================================");
}
/**
* 关闭Context池
*/
public void shutdown() {
if (closed.compareAndSet(false, true)) {
log.info("关闭GraalPy Context池...");
// 停止清理调度器
cleanupScheduler.shutdownNow();
timeoutScheduler.shutdownNow();
pythonExecutor.shutdownNow();
// 关闭所有池中的Context
PooledContext pc;
while ((pc = contextPool.poll()) != null) {
pc.forceClose();
}
// 关闭共享Engine
try {
sharedEngine.close(true);
} catch (Exception e) {
log.warn("关闭共享Engine失败: {}", e.getMessage());
}
log.info("GraalPy Context池已关闭");
}
}
/**
* 检查池是否已关闭
*/
public boolean isClosed() {
return closed.get();
}
}
@@ -0,0 +1,381 @@
package cn.qaiu.parser.custompy;
import org.graalvm.polyglot.HostAccess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
/**
* Python加密工具类
* 为Python脚本提供常用的加密解密功能
*
* @author QAIU
*/
public class PyCryptoUtils {
private static final Logger log = LoggerFactory.getLogger(PyCryptoUtils.class);
// ==================== MD5 ====================
/**
* MD5加密返回32位小写
* @param data 待加密数据
* @return MD5值32位小写
*/
@HostAccess.Export
public String md5(String data) {
if (data == null) {
return null;
}
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(digest);
} catch (Exception e) {
log.error("MD5加密失败", e);
throw new RuntimeException("MD5加密失败: " + e.getMessage(), e);
}
}
/**
* MD5加密返回16位小写取中间16位
* @param data 待加密数据
* @return MD5值16位小写
*/
@HostAccess.Export
public String md5_16(String data) {
String md5 = md5(data);
return md5 != null ? md5.substring(8, 24) : null;
}
// ==================== SHA ====================
/**
* SHA-1加密
* @param data 待加密数据
* @return SHA-1值小写
*/
@HostAccess.Export
public String sha1(String data) {
return sha(data, "SHA-1");
}
/**
* SHA-256加密
* @param data 待加密数据
* @return SHA-256值小写
*/
@HostAccess.Export
public String sha256(String data) {
return sha(data, "SHA-256");
}
/**
* SHA-512加密
* @param data 待加密数据
* @return SHA-512值小写
*/
@HostAccess.Export
public String sha512(String data) {
return sha(data, "SHA-512");
}
private String sha(String data, String algorithm) {
if (data == null) {
return null;
}
try {
MessageDigest md = MessageDigest.getInstance(algorithm);
byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(digest);
} catch (Exception e) {
log.error(algorithm + "加密失败", e);
throw new RuntimeException(algorithm + "加密失败: " + e.getMessage(), e);
}
}
// ==================== Base64 ====================
/**
* Base64编码
* @param data 待编码数据
* @return Base64字符串
*/
@HostAccess.Export
public String base64_encode(String data) {
if (data == null) {
return null;
}
return Base64.getEncoder().encodeToString(data.getBytes(StandardCharsets.UTF_8));
}
/**
* Base64编码字节数组
* @param data 待编码字节数组
* @return Base64字符串
*/
@HostAccess.Export
public String base64_encode_bytes(byte[] data) {
if (data == null) {
return null;
}
return Base64.getEncoder().encodeToString(data);
}
/**
* Base64解码
* @param data Base64字符串
* @return 解码后的字符串
*/
@HostAccess.Export
public String base64_decode(String data) {
if (data == null) {
return null;
}
try {
byte[] decoded = Base64.getDecoder().decode(data);
return new String(decoded, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("Base64解码失败", e);
throw new RuntimeException("Base64解码失败: " + e.getMessage(), e);
}
}
/**
* Base64解码返回字节数组
* @param data Base64字符串
* @return 解码后的字节数组
*/
@HostAccess.Export
public byte[] base64_decode_bytes(String data) {
if (data == null) {
return null;
}
try {
return Base64.getDecoder().decode(data);
} catch (Exception e) {
log.error("Base64解码失败", e);
throw new RuntimeException("Base64解码失败: " + e.getMessage(), e);
}
}
/**
* URL安全的Base64编码
* @param data 待编码数据
* @return URL安全的Base64字符串
*/
@HostAccess.Export
public String base64_url_encode(String data) {
if (data == null) {
return null;
}
return Base64.getUrlEncoder().encodeToString(data.getBytes(StandardCharsets.UTF_8));
}
/**
* URL安全的Base64解码
* @param data URL安全的Base64字符串
* @return 解码后的字符串
*/
@HostAccess.Export
public String base64_url_decode(String data) {
if (data == null) {
return null;
}
try {
byte[] decoded = Base64.getUrlDecoder().decode(data);
return new String(decoded, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("Base64 URL解码失败", e);
throw new RuntimeException("Base64 URL解码失败: " + e.getMessage(), e);
}
}
// ==================== AES ====================
/**
* AES加密ECB模式PKCS5Padding
* @param data 待加密数据
* @param key 密钥16/24/32字节
* @return Base64编码的密文
*/
@HostAccess.Export
public String aes_encrypt_ecb(String data, String key) {
if (data == null || key == null) {
return null;
}
try {
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
log.error("AES ECB加密失败", e);
throw new RuntimeException("AES ECB加密失败: " + e.getMessage(), e);
}
}
/**
* AES解密ECB模式PKCS5Padding
* @param data Base64编码的密文
* @param key 密钥16/24/32字节
* @return 明文
*/
@HostAccess.Export
public String aes_decrypt_ecb(String data, String key) {
if (data == null || key == null) {
return null;
}
try {
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(data));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("AES ECB解密失败", e);
throw new RuntimeException("AES ECB解密失败: " + e.getMessage(), e);
}
}
/**
* AES加密CBC模式PKCS5Padding
* @param data 待加密数据
* @param key 密钥16/24/32字节
* @param iv 初始向量16字节
* @return Base64编码的密文
*/
@HostAccess.Export
public String aes_encrypt_cbc(String data, String key, String iv) {
if (data == null || key == null || iv == null) {
return null;
}
try {
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
IvParameterSpec ivSpec = new IvParameterSpec(padIv(iv));
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
log.error("AES CBC加密失败", e);
throw new RuntimeException("AES CBC加密失败: " + e.getMessage(), e);
}
}
/**
* AES解密CBC模式PKCS5Padding
* @param data Base64编码的密文
* @param key 密钥16/24/32字节
* @param iv 初始向量16字节
* @return 明文
*/
@HostAccess.Export
public String aes_decrypt_cbc(String data, String key, String iv) {
if (data == null || key == null || iv == null) {
return null;
}
try {
SecretKeySpec secretKey = new SecretKeySpec(padKey(key), "AES");
IvParameterSpec ivSpec = new IvParameterSpec(padIv(iv));
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(data));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("AES CBC解密失败", e);
throw new RuntimeException("AES CBC解密失败: " + e.getMessage(), e);
}
}
// ==================== Hex ====================
/**
* 字节数组转十六进制字符串
* @param bytes 字节数组
* @return 十六进制字符串小写
*/
@HostAccess.Export
public String bytes_to_hex(byte[] bytes) {
return bytesToHex(bytes);
}
/**
* 十六进制字符串转字节数组
* @param hex 十六进制字符串
* @return 字节数组
*/
@HostAccess.Export
public byte[] hex_to_bytes(String hex) {
if (hex == null || hex.length() % 2 != 0) {
return null;
}
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
// ==================== 工具方法 ====================
private static String bytesToHex(byte[] bytes) {
if (bytes == null) {
return null;
}
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
/**
* 将密钥填充到16/24/32字节
*/
private byte[] padKey(String key) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
int len = keyBytes.length;
// 根据密钥长度决定填充到16/24/32字节
int targetLen;
if (len <= 16) {
targetLen = 16;
} else if (len <= 24) {
targetLen = 24;
} else {
targetLen = 32;
}
if (len == targetLen) {
return keyBytes;
}
byte[] paddedKey = new byte[targetLen];
System.arraycopy(keyBytes, 0, paddedKey, 0, Math.min(len, targetLen));
return paddedKey;
}
/**
* 将IV填充到16字节
*/
private byte[] padIv(String iv) {
byte[] ivBytes = iv.getBytes(StandardCharsets.UTF_8);
if (ivBytes.length == 16) {
return ivBytes;
}
byte[] paddedIv = new byte[16];
System.arraycopy(ivBytes, 0, paddedIv, 0, Math.min(ivBytes.length, 16));
return paddedIv;
}
}
@@ -0,0 +1,649 @@
package cn.qaiu.parser.custompy;
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.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.graalvm.polyglot.HostAccess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.InetAddress;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
/**
* Python HTTP客户端封装
* 为Python脚本提供类似requests库的HTTP请求功能
* 基于Vert.x WebClient实现提供同步API风格
*
* @author QAIU
*/
public class PyHttpClient {
private static final Logger log = LoggerFactory.getLogger(PyHttpClient.class);
private final WebClient client;
private final WebClientSession clientSession;
private MultiMap headers;
private int timeoutSeconds = 30; // 默认超时时间30秒
// SSRF防护内网IP正则表达式
private static final Pattern PRIVATE_IP_PATTERN = Pattern.compile(
"^(127\\..*|10\\..*|172\\.(1[6-9]|2[0-9]|3[01])\\..*|192\\.168\\..*|169\\.254\\..*|::1|[fF][cCdD].*)"
);
// SSRF防护危险域名黑名单
private static final String[] DANGEROUS_HOSTS = {
"localhost",
"169.254.169.254", // AWS/阿里云等云服务元数据API
"metadata.google.internal", // GCP元数据
"100.100.100.200" // 阿里云元数据
};
public PyHttpClient() {
this.client = WebClient.create(WebClientVertxInit.get(), new WebClientOptions());
this.clientSession = WebClientSession.create(client);
this.headers = MultiMap.caseInsensitiveMultiMap();
initDefaultHeaders();
}
/**
* 带代理配置的构造函数
* @param proxyConfig 代理配置JsonObject包含typehostportusernamepassword
*/
public PyHttpClient(JsonObject proxyConfig) {
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)
.setProxyOptions(proxyOptions));
this.clientSession = WebClientSession.create(client);
} else {
this.client = WebClient.create(WebClientVertxInit.get());
this.clientSession = WebClientSession.create(client);
}
this.headers = MultiMap.caseInsensitiveMultiMap();
initDefaultHeaders();
}
private void initDefaultHeaders() {
// 设置默认的Accept-Encoding头以支持压缩响应
this.headers.set("Accept-Encoding", "gzip, deflate, br, zstd");
// 设置默认的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头
this.headers.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6");
}
/**
* 验证URL安全性SSRF防护- 仅拦截明显的内网攻击
* @param url 待验证的URL
* @throws SecurityException 如果URL不安全
*/
private void validateUrlSecurity(String url) {
try {
URI uri = new URI(url);
String host = uri.getHost();
if (host == null) {
log.debug("URL没有host信息: {}", url);
return;
}
String lowerHost = host.toLowerCase();
// 1. 检查明确的危险域名云服务元数据API等
for (String dangerous : DANGEROUS_HOSTS) {
if (lowerHost.equals(dangerous)) {
log.warn("🔒 安全拦截: 尝试访问云服务元数据API - {}", host);
throw new SecurityException("🔒 安全拦截: 禁止访问云服务元数据API");
}
}
// 2. 如果host是IP地址格式检查是否为内网IP
if (isIpAddress(lowerHost)) {
if (PRIVATE_IP_PATTERN.matcher(lowerHost).find()) {
log.warn("🔒 安全拦截: 尝试访问内网IP - {}", host);
throw new SecurityException("🔒 安全拦截: 禁止访问内网IP地址");
}
}
// 3. 对于域名尝试解析IP但不因解析失败而拦截
if (!isIpAddress(lowerHost)) {
try {
InetAddress addr = InetAddress.getByName(host);
String ip = addr.getHostAddress();
if (PRIVATE_IP_PATTERN.matcher(ip).find()) {
log.warn("🔒 安全拦截: 域名解析到内网IP - {} -> {}", host, ip);
throw new SecurityException("🔒 安全拦截: 该域名指向内网地址");
}
} catch (UnknownHostException e) {
log.debug("DNS解析失败,允许继续: {}", host);
}
}
log.debug("URL安全检查通过: {}", url);
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
log.debug("URL验证异常,允许继续: {}", url, e);
}
}
/**
* 判断字符串是否为IP地址格式
*/
private boolean isIpAddress(String host) {
return host.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$") || host.contains(":");
}
/**
* 发起GET请求
* @param url 请求URL
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse get(String url) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.send();
});
}
/**
* 发起GET请求并跟随重定向
* @param url 请求URL
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse get_with_redirect(String url) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
request.followRedirects(true);
return request.send();
});
}
/**
* 发起GET请求但不跟随重定向用于获取Location头
* @param url 请求URL
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse get_no_redirect(String url) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.getAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
request.followRedirects(false);
return request.send();
});
}
/**
* 发起POST请求
* @param url 请求URL
* @param data 请求数据支持StringMap
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse post(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
}
/**
* 发起POST请求JSON数据
* @param url 请求URL
* @param jsonData JSON字符串或Map
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse post_json(String url, Object jsonData) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.postAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
headers.set("Content-Type", "application/json");
if (jsonData instanceof String) {
return request.sendBuffer(Buffer.buffer((String) jsonData));
} else {
return request.sendJson(jsonData);
}
});
}
/**
* 发起PUT请求
* @param url 请求URL
* @param data 请求数据
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse put(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.putAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
}
/**
* 发起DELETE请求
* @param url 请求URL
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse delete(String url) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.deleteAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
return request.send();
});
}
/**
* 发起PATCH请求
* @param url 请求URL
* @param data 请求数据
* @return HTTP响应
*/
@HostAccess.Export
public PyHttpResponse patch(String url, Object data) {
validateUrlSecurity(url);
return executeRequest(() -> {
HttpRequest<Buffer> request = client.patchAbs(url);
if (!headers.isEmpty()) {
request.putHeaders(headers);
}
if (data != null) {
if (data instanceof String) {
return request.sendBuffer(Buffer.buffer((String) data));
} else if (data instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, String> mapData = (Map<String, String>) data;
return request.sendForm(MultiMap.caseInsensitiveMultiMap().addAll(mapData));
} else {
return request.sendJson(data);
}
} else {
return request.send();
}
});
}
/**
* 设置请求头
* @param name 头名称
* @param value 头值
* @return 当前客户端实例支持链式调用
*/
@HostAccess.Export
public PyHttpClient put_header(String name, String value) {
if (name != null && value != null) {
headers.set(name, value);
}
return this;
}
/**
* 批量设置请求头
* @param headersMap 请求头Map
* @return 当前客户端实例支持链式调用
*/
@HostAccess.Export
public PyHttpClient put_headers(Map<String, String> headersMap) {
if (headersMap != null) {
for (Map.Entry<String, String> entry : headersMap.entrySet()) {
if (entry.getKey() != null && entry.getValue() != null) {
headers.set(entry.getKey(), entry.getValue());
}
}
}
return this;
}
/**
* 删除指定请求头
* @param name 头名称
* @return 当前客户端实例支持链式调用
*/
@HostAccess.Export
public PyHttpClient remove_header(String name) {
if (name != null) {
headers.remove(name);
}
return this;
}
/**
* 清空所有请求头保留默认头
* @return 当前客户端实例支持链式调用
*/
@HostAccess.Export
public PyHttpClient clear_headers() {
headers.clear();
initDefaultHeaders();
return this;
}
/**
* 获取所有请求头
* @return 请求头Map
*/
@HostAccess.Export
public Map<String, String> get_headers() {
Map<String, String> result = new HashMap<>();
for (String name : headers.names()) {
result.put(name, headers.get(name));
}
return result;
}
/**
* 设置请求超时时间
* @param seconds 超时时间
* @return 当前客户端实例支持链式调用
*/
@HostAccess.Export
public PyHttpClient set_timeout(int seconds) {
if (seconds > 0) {
this.timeoutSeconds = seconds;
}
return this;
}
/**
* URL编码
* @param str 要编码的字符串
* @return 编码后的字符串
*/
@HostAccess.Export
public static String url_encode(String str) {
if (str == null) {
return null;
}
try {
return URLEncoder.encode(str, StandardCharsets.UTF_8.name());
} catch (Exception e) {
log.error("URL编码失败", e);
return str;
}
}
/**
* URL解码
* @param str 要解码的字符串
* @return 解码后的字符串
*/
@HostAccess.Export
public static String url_decode(String str) {
if (str == null) {
return null;
}
try {
return URLDecoder.decode(str, StandardCharsets.UTF_8.name());
} catch (Exception e) {
log.error("URL解码失败", e);
return str;
}
}
/**
* 执行HTTP请求同步
*/
private PyHttpResponse executeRequest(RequestExecutor executor) {
try {
Promise<HttpResponse<Buffer>> promise = Promise.promise();
Future<HttpResponse<Buffer>> future = executor.execute();
future.onComplete(result -> {
if (result.succeeded()) {
promise.complete(result.result());
} else {
promise.fail(result.cause());
}
}).onFailure(Throwable::printStackTrace);
// 等待响应完成使用配置的超时时间
HttpResponse<Buffer> response = promise.future().toCompletionStage()
.toCompletableFuture()
.get(timeoutSeconds, TimeUnit.SECONDS);
return new PyHttpResponse(response);
} catch (TimeoutException e) {
String errorMsg = "HTTP请求超时(" + timeoutSeconds + "秒)";
log.error(errorMsg, e);
throw new RuntimeException(errorMsg, e);
} catch (Exception e) {
String errorMsg = e.getMessage();
if (errorMsg == null || errorMsg.trim().isEmpty()) {
errorMsg = e.getClass().getSimpleName();
if (e.getCause() != null && e.getCause().getMessage() != null) {
errorMsg += ": " + e.getCause().getMessage();
}
}
log.error("HTTP请求执行失败: " + errorMsg, e);
throw new RuntimeException("HTTP请求执行失败: " + errorMsg, e);
}
}
/**
* 请求执行器接口
*/
@FunctionalInterface
private interface RequestExecutor {
Future<HttpResponse<Buffer>> execute();
}
/**
* Python HTTP响应封装
*/
public static class PyHttpResponse {
private final HttpResponse<Buffer> response;
public PyHttpResponse(HttpResponse<Buffer> response) {
this.response = response;
}
/**
* 获取响应体字符串
* @return 响应体字符串
*/
@HostAccess.Export
public String text() {
return HttpResponseHelper.asText(response);
}
/**
* 获取响应体字符串- 别名
*/
@HostAccess.Export
public String body() {
return text();
}
/**
* 解析JSON响应
* @return JSON对象的Map表示
*/
@HostAccess.Export
public Object json() {
try {
JsonObject jsonObject = HttpResponseHelper.asJson(response);
if (jsonObject == null || jsonObject.isEmpty()) {
return null;
}
return jsonObject.getMap();
} catch (Exception e) {
log.error("解析JSON响应失败", e);
throw new RuntimeException("解析JSON响应失败: " + e.getMessage(), e);
}
}
/**
* 获取HTTP状态码
* @return 状态码
*/
@HostAccess.Export
public int status_code() {
return response.statusCode();
}
/**
* 获取响应头
* @param name 头名称
* @return 头值
*/
@HostAccess.Export
public String header(String name) {
return response.getHeader(name);
}
/**
* 获取所有响应头
* @return 响应头Map
*/
@HostAccess.Export
public Map<String, String> headers() {
MultiMap responseHeaders = response.headers();
Map<String, String> result = new HashMap<>();
for (String name : responseHeaders.names()) {
result.put(name, responseHeaders.get(name));
}
return result;
}
/**
* 检查请求是否成功
* @return true表示成功2xx状态码
*/
@HostAccess.Export
public boolean ok() {
int status = status_code();
return status >= 200 && status < 300;
}
/**
* 获取响应体字节数组
* @return 响应体字节数组
*/
@HostAccess.Export
public byte[] content() {
Buffer buffer = response.body();
if (buffer == null) {
return new byte[0];
}
return buffer.getBytes();
}
/**
* 获取响应体大小
* @return 响应体大小字节
*/
@HostAccess.Export
public long content_length() {
Buffer buffer = response.body();
if (buffer == null) {
return 0;
}
return buffer.length();
}
/**
* 获取原始响应对象
*/
public HttpResponse<Buffer> getOriginalResponse() {
return response;
}
}
}
@@ -0,0 +1,139 @@
package cn.qaiu.parser.custompy;
import org.graalvm.polyglot.HostAccess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Python日志封装
* 为Python脚本提供日志功能
*
* @author QAIU
*/
public class PyLogger {
private final Logger logger;
private final String prefix;
public PyLogger(String name) {
this.logger = LoggerFactory.getLogger(name);
this.prefix = "[" + name + "] ";
}
public PyLogger(Class<?> clazz) {
this.logger = LoggerFactory.getLogger(clazz);
this.prefix = "[" + clazz.getSimpleName() + "] ";
}
/**
* 调试日志
* @param message 日志消息
*/
@HostAccess.Export
public void debug(String message) {
logger.debug(prefix + message);
}
/**
* 调试日志带参数
* @param message 日志消息模板
* @param args 参数
*/
@HostAccess.Export
public void debug(String message, Object... args) {
logger.debug(prefix + message, args);
}
/**
* 信息日志
* @param message 日志消息
*/
@HostAccess.Export
public void info(String message) {
logger.info(prefix + message);
}
/**
* 信息日志带参数
* @param message 日志消息模板
* @param args 参数
*/
@HostAccess.Export
public void info(String message, Object... args) {
logger.info(prefix + message, args);
}
/**
* 警告日志
* @param message 日志消息
*/
@HostAccess.Export
public void warn(String message) {
logger.warn(prefix + message);
}
/**
* 警告日志带参数
* @param message 日志消息模板
* @param args 参数
*/
@HostAccess.Export
public void warn(String message, Object... args) {
logger.warn(prefix + message, args);
}
/**
* 错误日志
* @param message 日志消息
*/
@HostAccess.Export
public void error(String message) {
logger.error(prefix + message);
}
/**
* 错误日志带参数
* @param message 日志消息模板
* @param args 参数
*/
@HostAccess.Export
public void error(String message, Object... args) {
logger.error(prefix + message, args);
}
/**
* 错误日志带异常
* @param message 日志消息
* @param throwable 异常对象
*/
@HostAccess.Export
public void error(String message, Throwable throwable) {
logger.error(prefix + message, throwable);
}
/**
* 检查是否启用调试级别日志
* @return true表示启用false表示不启用
*/
@HostAccess.Export
public boolean isDebugEnabled() {
return logger.isDebugEnabled();
}
/**
* 检查是否启用信息级别日志
* @return true表示启用false表示不启用
*/
@HostAccess.Export
public boolean isInfoEnabled() {
return logger.isInfoEnabled();
}
/**
* 获取原始Logger对象
* @return Logger对象
*/
public Logger getOriginalLogger() {
return logger;
}
}
@@ -0,0 +1,272 @@
package cn.qaiu.parser.custompy;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.entity.FileInfo;
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.WorkerExecutor;
import io.vertx.core.json.JsonObject;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
/**
* Python解析器执行器
* 使用GraalPy执行Python解析器脚本
* 实现IPanTool接口执行Python解析器逻辑
* 使用 PyContextPool 进行 Engine 池化管理
*
* @author QAIU
*/
public class PyParserExecutor implements IPanTool {
private static final Logger log = LoggerFactory.getLogger(PyParserExecutor.class);
private static final WorkerExecutor EXECUTOR = WebClientVertxInit.get()
.createSharedWorkerExecutor("py-parser-executor", 32);
// Context池实例
private static final PyContextPool CONTEXT_POOL = PyContextPool.getInstance();
private final CustomParserConfig config;
private final ShareLinkInfo shareLinkInfo;
private final PyHttpClient httpClient;
private final PyLogger pyLogger;
private final PyShareLinkInfoWrapper shareLinkInfoWrapper;
private final PyCryptoUtils cryptoUtils;
public PyParserExecutor(ShareLinkInfo shareLinkInfo, CustomParserConfig config) {
this.config = config;
this.shareLinkInfo = shareLinkInfo;
// 检查是否有代理配置
JsonObject proxyConfig = null;
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
proxyConfig = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
}
this.httpClient = new PyHttpClient(proxyConfig);
this.pyLogger = new PyLogger("PyParser-" + config.getType());
this.shareLinkInfoWrapper = new PyShareLinkInfoWrapper(shareLinkInfo);
this.cryptoUtils = new PyCryptoUtils();
}
/**
* 获取ShareLinkInfo对象
* @return ShareLinkInfo对象
*/
@Override
public ShareLinkInfo getShareLinkInfo() {
return shareLinkInfo;
}
@Override
public Future<String> parse() {
pyLogger.info("开始执行Python解析器: {}", config.getType());
return EXECUTOR.executeBlocking(() -> {
// 使用池化的 Context自动归还
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
Context context = pc.getContext();
// 注入Java对象到Python环境
Value bindings = context.getBindings("python");
bindings.putMember("http", httpClient);
bindings.putMember("logger", pyLogger);
bindings.putMember("share_link_info", shareLinkInfoWrapper);
bindings.putMember("crypto", cryptoUtils);
// 执行Python代码已支持真正的 pip 包如 requests, zlib
context.eval("python", config.getPyCode());
// 调用parse函数
Value parseFunc = bindings.getMember("parse");
if (parseFunc == null || !parseFunc.canExecute()) {
throw new RuntimeException("Python代码中未找到parse函数");
}
Value result = parseFunc.execute(shareLinkInfoWrapper, httpClient, pyLogger);
if (result.isString()) {
String downloadUrl = result.asString();
pyLogger.info("解析成功: {}", downloadUrl);
return downloadUrl;
} else {
pyLogger.error("parse方法返回值类型错误,期望String,实际: {}",
result.getMetaObject().toString());
throw new RuntimeException("parse方法返回值类型错误");
}
} catch (Exception e) {
pyLogger.error("Python解析器执行失败: {}", e.getMessage());
throw new RuntimeException("Python解析器执行失败: " + e.getMessage(), e);
}
});
}
@Override
public Future<List<FileInfo>> parseFileList() {
pyLogger.info("开始执行Python文件列表解析: {}", config.getType());
return EXECUTOR.executeBlocking(() -> {
// 使用池化的 Context自动归还
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
Context context = pc.getContext();
// 注入Java对象到Python环境
Value bindings = context.getBindings("python");
bindings.putMember("http", httpClient);
bindings.putMember("logger", pyLogger);
bindings.putMember("share_link_info", shareLinkInfoWrapper);
bindings.putMember("crypto", cryptoUtils);
// 执行Python代码已支持真正的 pip
context.eval("python", config.getPyCode());
// 调用parseFileList函数
Value parseFileListFunc = bindings.getMember("parse_file_list");
if (parseFileListFunc == null || !parseFileListFunc.canExecute()) {
throw new RuntimeException("Python代码中未找到parse_file_list函数");
}
Value result = parseFileListFunc.execute(shareLinkInfoWrapper, httpClient, pyLogger);
List<FileInfo> fileList = convertToFileInfoList(result);
pyLogger.info("文件列表解析成功,共 {} 个文件", fileList.size());
return fileList;
} catch (Exception e) {
pyLogger.error("Python文件列表解析失败: {}", e.getMessage());
throw new RuntimeException("Python文件列表解析失败: " + e.getMessage(), e);
}
});
}
@Override
public Future<String> parseById() {
pyLogger.info("开始执行Python按ID解析: {}", config.getType());
return EXECUTOR.executeBlocking(() -> {
// 使用池化的 Context自动归还
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
Context context = pc.getContext();
// 注入Java对象到Python环境
Value bindings = context.getBindings("python");
bindings.putMember("http", httpClient);
bindings.putMember("logger", pyLogger);
bindings.putMember("share_link_info", shareLinkInfoWrapper);
bindings.putMember("crypto", cryptoUtils);
// 执行Python代码已支持真正的 pip
context.eval("python", config.getPyCode());
// 调用parseById函数
Value parseByIdFunc = bindings.getMember("parse_by_id");
if (parseByIdFunc == null || !parseByIdFunc.canExecute()) {
throw new RuntimeException("Python代码中未找到parse_by_id函数");
}
Value result = parseByIdFunc.execute(shareLinkInfoWrapper, httpClient, pyLogger);
if (result.isString()) {
String downloadUrl = result.asString();
pyLogger.info("按ID解析成功: {}", downloadUrl);
return downloadUrl;
} else {
pyLogger.error("parse_by_id方法返回值类型错误,期望String,实际: {}",
result.getMetaObject().toString());
throw new RuntimeException("parse_by_id方法返回值类型错误");
}
} catch (Exception e) {
pyLogger.error("Python按ID解析失败: {}", e.getMessage());
throw new RuntimeException("Python按ID解析失败: " + e.getMessage(), e);
}
});
}
/**
* 将Python列表转换为FileInfo列表
*/
private List<FileInfo> convertToFileInfoList(Value result) {
List<FileInfo> fileList = new ArrayList<>();
if (result.hasArrayElements()) {
long size = result.getArraySize();
for (long i = 0; i < size; i++) {
Value item = result.getArrayElement(i);
FileInfo fileInfo = convertToFileInfo(item);
if (fileInfo != null) {
fileList.add(fileInfo);
}
}
}
return fileList;
}
/**
* 将Python字典转换为FileInfo
*/
private FileInfo convertToFileInfo(Value item) {
try {
FileInfo fileInfo = new FileInfo();
if (item.hasMember("file_name") || item.hasMember("fileName")) {
Value val = item.hasMember("file_name") ? item.getMember("file_name") : item.getMember("fileName");
if (val != null && !val.isNull()) {
fileInfo.setFileName(val.asString());
}
}
if (item.hasMember("file_id") || item.hasMember("fileId")) {
Value val = item.hasMember("file_id") ? item.getMember("file_id") : item.getMember("fileId");
if (val != null && !val.isNull()) {
fileInfo.setFileId(val.asString());
}
}
if (item.hasMember("file_type") || item.hasMember("fileType")) {
Value val = item.hasMember("file_type") ? item.getMember("file_type") : item.getMember("fileType");
if (val != null && !val.isNull()) {
fileInfo.setFileType(val.asString());
}
}
if (item.hasMember("size")) {
Value val = item.getMember("size");
if (val != null && !val.isNull() && val.isNumber()) {
fileInfo.setSize(val.asLong());
}
}
if (item.hasMember("size_str") || item.hasMember("sizeStr")) {
Value val = item.hasMember("size_str") ? item.getMember("size_str") : item.getMember("sizeStr");
if (val != null && !val.isNull()) {
fileInfo.setSizeStr(val.asString());
}
}
if (item.hasMember("create_time") || item.hasMember("createTime")) {
Value val = item.hasMember("create_time") ? item.getMember("create_time") : item.getMember("createTime");
if (val != null && !val.isNull()) {
fileInfo.setCreateTime(val.asString());
}
}
if (item.hasMember("pan_type") || item.hasMember("panType")) {
Value val = item.hasMember("pan_type") ? item.getMember("pan_type") : item.getMember("panType");
if (val != null && !val.isNull()) {
fileInfo.setPanType(val.asString());
}
}
if (item.hasMember("parser_url") || item.hasMember("parserUrl")) {
Value val = item.hasMember("parser_url") ? item.getMember("parser_url") : item.getMember("parserUrl");
if (val != null && !val.isNull()) {
fileInfo.setParserUrl(val.asString());
}
}
return fileInfo;
} catch (Exception e) {
pyLogger.error("转换FileInfo对象失败", e);
return null;
}
}
}
@@ -0,0 +1,375 @@
package cn.qaiu.parser.custompy;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
/**
* Python演练场执行器
* 用于临时执行Python代码不注册到解析器注册表
* 使用独立线程池避免Vert.x BlockedThreadChecker警告
* 使用 PyContextPool 进行 Engine Context 池化管理
*
* @author QAIU
*/
public class PyPlaygroundExecutor {
private static final Logger log = LoggerFactory.getLogger(PyPlaygroundExecutor.class);
// Python执行超时时间
private static final long EXECUTION_TIMEOUT_SECONDS = 30;
// Context池实例
private static final PyContextPool CONTEXT_POOL = PyContextPool.getInstance();
private final ShareLinkInfo shareLinkInfo;
private final String pyCode;
private final PyHttpClient httpClient;
private final PyPlaygroundLogger playgroundLogger;
private final PyShareLinkInfoWrapper shareLinkInfoWrapper;
private final PyCryptoUtils cryptoUtils;
/**
* 创建演练场执行器
*
* @param shareLinkInfo 分享链接信息
* @param pyCode Python代码
*/
public PyPlaygroundExecutor(ShareLinkInfo shareLinkInfo, String pyCode) {
this.shareLinkInfo = shareLinkInfo;
this.pyCode = pyCode;
// 检查是否有代理配置
JsonObject proxyConfig = null;
if (shareLinkInfo.getOtherParam().containsKey("proxy")) {
proxyConfig = (JsonObject) shareLinkInfo.getOtherParam().get("proxy");
}
this.httpClient = new PyHttpClient(proxyConfig);
this.playgroundLogger = new PyPlaygroundLogger();
this.shareLinkInfoWrapper = new PyShareLinkInfoWrapper(shareLinkInfo);
this.cryptoUtils = new PyCryptoUtils();
}
/**
* 执行parse方法异步带超时控制
*/
public Future<String> executeParseAsync() {
Promise<String> promise = Promise.promise();
// 在执行前进行安全检查
PyCodeSecurityChecker.SecurityCheckResult securityResult = PyCodeSecurityChecker.check(pyCode);
if (!securityResult.isPassed()) {
playgroundLogger.errorJava("安全检查失败: " + securityResult.getMessage());
promise.fail(new SecurityException("代码安全检查失败: " + securityResult.getMessage()));
return promise.future();
}
playgroundLogger.debugJava("安全检查通过");
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
playgroundLogger.infoJava("开始执行parse方法");
// 使用池化的 Context自动归还
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
Context context = pc.getContext();
// 注入Java对象到Python环境
Value bindings = context.getBindings("python");
bindings.putMember("http", httpClient);
bindings.putMember("logger", playgroundLogger);
bindings.putMember("share_link_info", shareLinkInfoWrapper);
bindings.putMember("crypto", cryptoUtils);
// 执行Python代码已支持真正的 pip 包如 requests, zlib
playgroundLogger.debugJava("执行Python代码");
context.eval("python", pyCode);
// 调用parse函数
Value parseFunc = bindings.getMember("parse");
if (parseFunc == null || !parseFunc.canExecute()) {
playgroundLogger.errorJava("Python代码中未找到parse函数");
throw new RuntimeException("Python代码中未找到parse函数");
}
playgroundLogger.debugJava("调用parse函数");
Value result = parseFunc.execute(shareLinkInfoWrapper, httpClient, playgroundLogger);
if (result.isString()) {
String downloadUrl = result.asString();
playgroundLogger.infoJava("解析成功,返回结果: " + downloadUrl);
return downloadUrl;
} else {
String errorMsg = "parse方法返回值类型错误,期望String,实际: " +
(result.isNull() ? "null" : result.getMetaObject().toString());
playgroundLogger.errorJava(errorMsg);
throw new RuntimeException(errorMsg);
}
} catch (Exception e) {
String errorMsg = e.getMessage();
if (errorMsg == null || errorMsg.isEmpty()) {
errorMsg = e.getClass().getName();
if (e.getCause() != null) {
errorMsg += ": " + (e.getCause().getMessage() != null ?
e.getCause().getMessage() : e.getCause().getClass().getName());
}
}
playgroundLogger.errorJava("执行parse方法失败: " + errorMsg, e);
throw new RuntimeException(errorMsg, e);
}
}, CONTEXT_POOL.getPythonExecutor());
// 创建超时任务
ScheduledFuture<?> timeoutTask = CONTEXT_POOL.getTimeoutScheduler().schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true);
playgroundLogger.errorJava("执行超时,已强制中断");
log.warn("Python执行超时,已强制取消");
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
// 处理执行结果
executionFuture.whenComplete((result, error) -> {
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "Python执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
playgroundLogger.errorJava(timeoutMsg);
log.error(timeoutMsg);
promise.fail(new RuntimeException(timeoutMsg));
} else {
Throwable cause = error.getCause();
promise.fail(cause != null ? cause : error);
}
} else {
promise.complete(result);
}
});
return promise.future();
}
/**
* 执行parseFileList方法异步带超时控制
*/
public Future<List<FileInfo>> executeParseFileListAsync() {
Promise<List<FileInfo>> promise = Promise.promise();
CompletableFuture<List<FileInfo>> executionFuture = CompletableFuture.supplyAsync(() -> {
playgroundLogger.infoJava("开始执行parse_file_list方法");
// 使用池化的 Context自动归还
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
Context context = pc.getContext();
Value bindings = context.getBindings("python");
bindings.putMember("http", httpClient);
bindings.putMember("logger", playgroundLogger);
bindings.putMember("share_link_info", shareLinkInfoWrapper);
bindings.putMember("crypto", cryptoUtils);
// 执行Python代码已支持真正的 pip
context.eval("python", pyCode);
Value parseFileListFunc = bindings.getMember("parse_file_list");
if (parseFileListFunc == null || !parseFileListFunc.canExecute()) {
playgroundLogger.errorJava("Python代码中未找到parse_file_list函数");
throw new RuntimeException("Python代码中未找到parse_file_list函数");
}
playgroundLogger.debugJava("调用parse_file_list函数");
Value result = parseFileListFunc.execute(shareLinkInfoWrapper, httpClient, playgroundLogger);
List<FileInfo> fileList = convertToFileInfoList(result);
playgroundLogger.infoJava("文件列表解析成功,共 " + fileList.size() + " 个文件");
return fileList;
} catch (Exception e) {
playgroundLogger.errorJava("执行parse_file_list方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
}, CONTEXT_POOL.getPythonExecutor());
ScheduledFuture<?> timeoutTask = CONTEXT_POOL.getTimeoutScheduler().schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true);
playgroundLogger.errorJava("执行超时,已强制中断");
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
executionFuture.whenComplete((result, error) -> {
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "Python执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
promise.fail(new RuntimeException(timeoutMsg));
} else {
Throwable cause = error.getCause();
promise.fail(cause != null ? cause : error);
}
} else {
promise.complete(result);
}
});
return promise.future();
}
/**
* 执行parseById方法异步带超时控制
*/
public Future<String> executeParseByIdAsync() {
Promise<String> promise = Promise.promise();
CompletableFuture<String> executionFuture = CompletableFuture.supplyAsync(() -> {
playgroundLogger.infoJava("开始执行parse_by_id方法");
// 使用池化的 Context自动归还
try (PyContextPool.PooledContext pc = CONTEXT_POOL.acquire()) {
Context context = pc.getContext();
Value bindings = context.getBindings("python");
bindings.putMember("http", httpClient);
bindings.putMember("logger", playgroundLogger);
bindings.putMember("share_link_info", shareLinkInfoWrapper);
bindings.putMember("crypto", cryptoUtils);
// 执行Python代码已支持真正的 pip
context.eval("python", pyCode);
Value parseByIdFunc = bindings.getMember("parse_by_id");
if (parseByIdFunc == null || !parseByIdFunc.canExecute()) {
playgroundLogger.errorJava("Python代码中未找到parse_by_id函数");
throw new RuntimeException("Python代码中未找到parse_by_id函数");
}
playgroundLogger.debugJava("调用parse_by_id函数");
Value result = parseByIdFunc.execute(shareLinkInfoWrapper, httpClient, playgroundLogger);
if (result.isString()) {
String downloadUrl = result.asString();
playgroundLogger.infoJava("按ID解析成功,返回结果: " + downloadUrl);
return downloadUrl;
} else {
String errorMsg = "parse_by_id方法返回值类型错误";
playgroundLogger.errorJava(errorMsg);
throw new RuntimeException(errorMsg);
}
} catch (Exception e) {
playgroundLogger.errorJava("执行parse_by_id方法失败: " + e.getMessage(), e);
throw new RuntimeException(e);
}
}, CONTEXT_POOL.getPythonExecutor());
ScheduledFuture<?> timeoutTask = CONTEXT_POOL.getTimeoutScheduler().schedule(() -> {
if (!executionFuture.isDone()) {
executionFuture.cancel(true);
playgroundLogger.errorJava("执行超时,已强制中断");
}
}, EXECUTION_TIMEOUT_SECONDS, TimeUnit.SECONDS);
executionFuture.whenComplete((result, error) -> {
timeoutTask.cancel(false);
if (error != null) {
if (error instanceof CancellationException) {
String timeoutMsg = "Python执行超时(超过" + EXECUTION_TIMEOUT_SECONDS + "秒),已强制中断";
promise.fail(new RuntimeException(timeoutMsg));
} else {
Throwable cause = error.getCause();
promise.fail(cause != null ? cause : error);
}
} else {
promise.complete(result);
}
});
return promise.future();
}
/**
* 获取日志列表
*/
public List<PyPlaygroundLogger.LogEntry> getLogs() {
return playgroundLogger.getLogs();
}
/**
* 将Python列表转换为FileInfo列表
*/
private List<FileInfo> convertToFileInfoList(Value result) {
List<FileInfo> fileList = new ArrayList<>();
if (result.hasArrayElements()) {
long size = result.getArraySize();
for (long i = 0; i < size; i++) {
Value item = result.getArrayElement(i);
FileInfo fileInfo = convertToFileInfo(item);
if (fileInfo != null) {
fileList.add(fileInfo);
}
}
}
return fileList;
}
/**
* 将Python字典转换为FileInfo
*/
private FileInfo convertToFileInfo(Value item) {
try {
FileInfo fileInfo = new FileInfo();
if (item.hasMember("file_name") || item.hasMember("fileName")) {
Value val = item.hasMember("file_name") ? item.getMember("file_name") : item.getMember("fileName");
if (val != null && !val.isNull()) {
fileInfo.setFileName(val.asString());
}
}
if (item.hasMember("file_id") || item.hasMember("fileId")) {
Value val = item.hasMember("file_id") ? item.getMember("file_id") : item.getMember("fileId");
if (val != null && !val.isNull()) {
fileInfo.setFileId(val.asString());
}
}
if (item.hasMember("file_type") || item.hasMember("fileType")) {
Value val = item.hasMember("file_type") ? item.getMember("file_type") : item.getMember("fileType");
if (val != null && !val.isNull()) {
fileInfo.setFileType(val.asString());
}
}
if (item.hasMember("size")) {
Value val = item.getMember("size");
if (val != null && !val.isNull() && val.isNumber()) {
fileInfo.setSize(val.asLong());
}
}
if (item.hasMember("pan_type") || item.hasMember("panType")) {
Value val = item.hasMember("pan_type") ? item.getMember("pan_type") : item.getMember("panType");
if (val != null && !val.isNull()) {
fileInfo.setPanType(val.asString());
}
}
if (item.hasMember("parser_url") || item.hasMember("parserUrl")) {
Value val = item.hasMember("parser_url") ? item.getMember("parser_url") : item.getMember("parserUrl");
if (val != null && !val.isNull()) {
fileInfo.setParserUrl(val.asString());
}
}
return fileInfo;
} catch (Exception e) {
playgroundLogger.errorJava("转换FileInfo对象失败: " + e.getMessage());
return null;
}
}
}
@@ -0,0 +1,193 @@
package cn.qaiu.parser.custompy;
import org.graalvm.polyglot.HostAccess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
/**
* Python演练场日志封装
* 收集日志信息用于前端显示
*
* @author QAIU
*/
public class PyPlaygroundLogger extends PyLogger {
private static final Logger log = LoggerFactory.getLogger(PyPlaygroundLogger.class);
private final List<LogEntry> logs = new ArrayList<>();
public PyPlaygroundLogger() {
super("PyPlayground");
}
@Override
@HostAccess.Export
public void debug(String message) {
super.debug(message);
addLog("DEBUG", message);
}
@Override
@HostAccess.Export
public void debug(String message, Object... args) {
super.debug(message, args);
addLog("DEBUG", formatMessage(message, args));
}
@Override
@HostAccess.Export
public void info(String message) {
super.info(message);
addLog("INFO", message);
}
@Override
@HostAccess.Export
public void info(String message, Object... args) {
super.info(message, args);
addLog("INFO", formatMessage(message, args));
}
@Override
@HostAccess.Export
public void warn(String message) {
super.warn(message);
addLog("WARN", message);
}
@Override
@HostAccess.Export
public void warn(String message, Object... args) {
super.warn(message, args);
addLog("WARN", formatMessage(message, args));
}
@Override
@HostAccess.Export
public void error(String message) {
super.error(message);
addLog("ERROR", message);
}
@Override
@HostAccess.Export
public void error(String message, Object... args) {
super.error(message, args);
addLog("ERROR", formatMessage(message, args));
}
@Override
@HostAccess.Export
public void error(String message, Throwable throwable) {
super.error(message, throwable);
addLog("ERROR", message + " - " + throwable.getMessage());
}
/**
* 添加Java内部日志不在Python脚本中调用
*/
public void infoJava(String message) {
log.info("[PyPlayground] " + message);
addLog("INFO", "[Java] " + message, "java");
}
public void debugJava(String message) {
log.debug("[PyPlayground] " + message);
addLog("DEBUG", "[Java] " + message, "java");
}
public void errorJava(String message) {
log.error("[PyPlayground] " + message);
addLog("ERROR", "[Java] " + message, "java");
}
public void errorJava(String message, Throwable throwable) {
log.error("[PyPlayground] " + message, throwable);
addLog("ERROR", "[Java] " + message + " - " + throwable.getMessage(), "java");
}
private void addLog(String level, String message) {
addLog(level, message, "python");
}
private void addLog(String level, String message, String source) {
logs.add(new LogEntry(level, message, System.currentTimeMillis(), source));
}
private String formatMessage(String message, Object... args) {
if (args == null || args.length == 0) {
return message;
}
// 简单的占位符替换
String result = message;
for (Object arg : args) {
int index = result.indexOf("{}");
if (index >= 0) {
result = result.substring(0, index) + (arg != null ? arg.toString() : "null") + result.substring(index + 2);
}
}
return result;
}
/**
* 获取所有日志
*/
public List<LogEntry> getLogs() {
return new ArrayList<>(logs);
}
/**
* 清空日志
*/
public void clearLogs() {
logs.clear();
}
/**
* 获取日志数量
*/
public int size() {
return logs.size();
}
/**
* 日志条目
*/
public static class LogEntry {
private final String level;
private final String message;
private final long timestamp;
private final String source;
public LogEntry(String level, String message, long timestamp) {
this(level, message, timestamp, "python");
}
public LogEntry(String level, String message, long timestamp, String source) {
this.level = level;
this.message = message;
this.timestamp = timestamp;
this.source = source;
}
public String getLevel() {
return level;
}
public String getMessage() {
return message;
}
public long getTimestamp() {
return timestamp;
}
public String getSource() {
return source;
}
}
}
@@ -0,0 +1,334 @@
package cn.qaiu.parser.custompy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import cn.qaiu.parser.custom.CustomParserConfig;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Stream;
/**
* Python脚本加载器
* 自动加载资源目录和外部目录的Python脚本文件
*
* @author QAIU
*/
public class PyScriptLoader {
private static final Logger log = LoggerFactory.getLogger(PyScriptLoader.class);
private static final String RESOURCE_PATH = "custom-parsers/py";
private static final String EXTERNAL_PATH = "./custom-parsers/py";
// 系统属性配置的外部目录路径
private static final String EXTERNAL_PATH_PROPERTY = "parser.custom-parsers.py.path";
/**
* 加载所有Python脚本
* @return 解析器配置列表
*/
public static List<CustomParserConfig> loadAllScripts() {
List<CustomParserConfig> configs = new ArrayList<>();
// 1. 加载资源目录下的Python文件
try {
List<CustomParserConfig> resourceConfigs = loadFromResources();
configs.addAll(resourceConfigs);
log.info("从资源目录加载了 {} 个Python解析器", resourceConfigs.size());
} catch (Exception e) {
log.warn("从资源目录加载Python脚本失败", e);
}
// 2. 加载外部目录下的Python文件
try {
List<CustomParserConfig> externalConfigs = loadFromExternal();
configs.addAll(externalConfigs);
log.info("从外部目录加载了 {} 个Python解析器", externalConfigs.size());
} catch (Exception e) {
log.warn("从外部目录加载Python脚本失败", e);
}
log.info("总共加载了 {} 个Python解析器", configs.size());
return configs;
}
/**
* 从资源目录加载Python脚本
*/
private static List<CustomParserConfig> loadFromResources() {
List<CustomParserConfig> configs = new ArrayList<>();
try {
List<String> resourceFiles = getResourceFileList();
resourceFiles.sort(String::compareTo);
for (String resourceFile : resourceFiles) {
try {
InputStream inputStream = PyScriptLoader.class.getClassLoader()
.getResourceAsStream(resourceFile);
if (inputStream != null) {
String pyCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
CustomParserConfig config = PyScriptMetadataParser.parseScript(pyCode);
configs.add(config);
String fileName = resourceFile.substring(resourceFile.lastIndexOf('/') + 1);
log.debug("从资源目录加载Python脚本: {}", fileName);
}
} catch (Exception e) {
log.warn("加载资源脚本失败: {}", resourceFile, e);
}
}
} catch (Exception e) {
log.error("从资源目录加载脚本时发生异常", e);
}
return configs;
}
/**
* 获取资源目录中的Python文件列表
*/
private static List<String> getResourceFileList() {
List<String> resourceFiles = new ArrayList<>();
try {
java.net.URL resourceUrl = PyScriptLoader.class.getClassLoader()
.getResource(RESOURCE_PATH);
if (resourceUrl != null) {
String protocol = resourceUrl.getProtocol();
if ("jar".equals(protocol)) {
resourceFiles = getJarResourceFiles(resourceUrl);
} else if ("file".equals(protocol)) {
resourceFiles = getFileSystemResourceFiles(resourceUrl);
}
}
} catch (Exception e) {
log.debug("获取资源文件列表失败", e);
}
return resourceFiles;
}
/**
* 获取JAR包内的Python资源文件列表
*/
private static List<String> getJarResourceFiles(java.net.URL jarUrl) {
List<String> resourceFiles = new ArrayList<>();
try {
String jarPath = jarUrl.getPath().substring(5, jarUrl.getPath().indexOf("!"));
JarFile jarFile = new JarFile(jarPath);
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();
if (entryName.startsWith(RESOURCE_PATH + "/") &&
entryName.endsWith(".py") &&
!isExcludedFile(entryName.substring(entryName.lastIndexOf('/') + 1))) {
resourceFiles.add(entryName);
}
}
jarFile.close();
} catch (Exception e) {
log.debug("解析JAR包资源文件失败", e);
}
return resourceFiles;
}
/**
* 获取文件系统中的Python资源文件列表
*/
private static List<String> getFileSystemResourceFiles(java.net.URL fileUrl) {
List<String> resourceFiles = new ArrayList<>();
try {
java.io.File resourceDir = new java.io.File(fileUrl.getPath());
if (resourceDir.exists() && resourceDir.isDirectory()) {
java.io.File[] files = resourceDir.listFiles();
if (files != null) {
for (java.io.File file : files) {
if (file.isFile() && file.getName().endsWith(".py") &&
!isExcludedFile(file.getName())) {
resourceFiles.add(RESOURCE_PATH + "/" + file.getName());
}
}
}
}
} catch (Exception e) {
log.debug("解析文件系统资源文件失败", e);
}
return resourceFiles;
}
/**
* 从外部目录加载Python脚本
*/
private static List<CustomParserConfig> loadFromExternal() {
List<CustomParserConfig> configs = new ArrayList<>();
try {
String externalPath = getExternalPath();
Path externalDir = Paths.get(externalPath);
if (!Files.exists(externalDir) || !Files.isDirectory(externalDir)) {
log.debug("外部目录 {} 不存在或不是目录", externalPath);
return configs;
}
try (Stream<Path> paths = Files.walk(externalDir)) {
paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".py"))
.filter(path -> !isExcludedFile(path.getFileName().toString()))
.forEach(path -> {
try {
String pyCode = Files.readString(path, StandardCharsets.UTF_8);
CustomParserConfig config = PyScriptMetadataParser.parseScript(pyCode);
configs.add(config);
log.debug("从外部目录加载Python脚本: {}", path.getFileName());
} catch (Exception e) {
log.warn("加载外部脚本失败: {}", path.getFileName(), e);
}
});
}
} catch (Exception e) {
log.error("从外部目录加载脚本时发生异常", e);
}
return configs;
}
/**
* 获取外部目录路径
*/
private static String getExternalPath() {
// 1. 检查系统属性
String systemProperty = System.getProperty(EXTERNAL_PATH_PROPERTY);
if (systemProperty != null && !systemProperty.trim().isEmpty()) {
log.debug("使用系统属性配置的Python外部目录: {}", systemProperty);
return systemProperty;
}
// 2. 检查环境变量
String envVariable = System.getenv("PARSER_CUSTOM_PARSERS_PY_PATH");
if (envVariable != null && !envVariable.trim().isEmpty()) {
log.debug("使用环境变量配置的Python外部目录: {}", envVariable);
return envVariable;
}
// 3. 使用默认路径
log.debug("使用默认Python外部目录: {}", EXTERNAL_PATH);
return EXTERNAL_PATH;
}
/**
* 从指定文件加载Python脚本
* @param filePath 文件路径
* @return 解析器配置
*/
public static CustomParserConfig loadFromFile(String filePath) {
try {
Path path = Paths.get(filePath);
if (!Files.exists(path)) {
throw new IllegalArgumentException("文件不存在: " + filePath);
}
String pyCode = Files.readString(path, StandardCharsets.UTF_8);
return PyScriptMetadataParser.parseScript(pyCode);
} catch (IOException e) {
throw new RuntimeException("读取文件失败: " + filePath, e);
}
}
/**
* 从指定资源路径加载Python脚本
* @param resourcePath 资源路径
* @return 解析器配置
*/
public static CustomParserConfig loadFromResource(String resourcePath) {
try {
InputStream inputStream = PyScriptLoader.class.getClassLoader()
.getResourceAsStream(resourcePath);
if (inputStream == null) {
throw new IllegalArgumentException("资源文件不存在: " + resourcePath);
}
String pyCode = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
return PyScriptMetadataParser.parseScript(pyCode);
} catch (IOException e) {
throw new RuntimeException("读取资源文件失败: " + resourcePath, e);
}
}
/**
* 检查外部目录是否存在
*/
public static boolean isExternalDirectoryExists() {
Path externalDir = Paths.get(EXTERNAL_PATH);
return Files.exists(externalDir) && Files.isDirectory(externalDir);
}
/**
* 创建外部目录
*/
public static boolean createExternalDirectory() {
try {
Path externalDir = Paths.get(EXTERNAL_PATH);
Files.createDirectories(externalDir);
log.info("创建Python外部目录成功: {}", EXTERNAL_PATH);
return true;
} catch (IOException e) {
log.error("创建Python外部目录失败: {}", EXTERNAL_PATH, e);
return false;
}
}
/**
* 获取外部目录路径
*/
public static String getExternalDirectoryPath() {
return EXTERNAL_PATH;
}
/**
* 获取资源目录路径
*/
public static String getResourceDirectoryPath() {
return RESOURCE_PATH;
}
/**
* 检查文件是否应该被排除
*/
private static boolean isExcludedFile(String fileName) {
return fileName.equals("types.pyi") ||
fileName.equals("__init__.py") ||
fileName.equals("README.md") ||
fileName.contains("_test.") ||
fileName.contains("_spec.") ||
fileName.startsWith("test_");
}
}
@@ -0,0 +1,188 @@
package cn.qaiu.parser.custompy;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import cn.qaiu.parser.custom.CustomParserConfig;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Python脚本元数据解析器
* 解析类油猴格式的元数据注释Python风格
*
* @author QAIU
*/
public class PyScriptMetadataParser {
private static final Logger log = LoggerFactory.getLogger(PyScriptMetadataParser.class);
// 元数据块匹配正则Python注释风格
// 支持 # ==UserScript== 格式
private static final Pattern METADATA_BLOCK_PATTERN = Pattern.compile(
"#\\s*==UserScript==\\s*(.*?)\\s*#\\s*==/UserScript==",
Pattern.DOTALL
);
// 元数据行匹配正则
private static final Pattern METADATA_LINE_PATTERN = Pattern.compile(
"#\\s*@(\\w+)\\s+(.*)"
);
/**
* 解析Python脚本提取元数据并构建CustomParserConfig
*
* @param pyCode Python代码
* @return CustomParserConfig配置对象
* @throws IllegalArgumentException 如果解析失败或缺少必填字段
*/
public static CustomParserConfig parseScript(String pyCode) {
if (StringUtils.isBlank(pyCode)) {
throw new IllegalArgumentException("Python代码不能为空");
}
// 1. 提取元数据块
Map<String, String> metadata = extractMetadata(pyCode);
// 2. 验证必填字段
validateRequiredFields(metadata);
// 3. 构建CustomParserConfig
return buildConfig(metadata, pyCode);
}
/**
* 提取元数据
*/
private static Map<String, String> extractMetadata(String pyCode) {
Map<String, String> metadata = new HashMap<>();
Matcher blockMatcher = METADATA_BLOCK_PATTERN.matcher(pyCode);
if (!blockMatcher.find()) {
throw new IllegalArgumentException("未找到元数据块,请确保包含 # ==UserScript== ... # ==/UserScript== 格式的注释");
}
String metadataBlock = blockMatcher.group(1);
Matcher lineMatcher = METADATA_LINE_PATTERN.matcher(metadataBlock);
while (lineMatcher.find()) {
String key = lineMatcher.group(1).toLowerCase();
String value = lineMatcher.group(2).trim();
metadata.put(key, value);
}
log.debug("解析到Python脚本元数据: {}", metadata);
return metadata;
}
/**
* 验证必填字段
*/
private static void validateRequiredFields(Map<String, String> metadata) {
if (!metadata.containsKey("name")) {
throw new IllegalArgumentException("缺少必填字段 @name");
}
if (!metadata.containsKey("type")) {
throw new IllegalArgumentException("缺少必填字段 @type");
}
if (!metadata.containsKey("displayname")) {
throw new IllegalArgumentException("缺少必填字段 @displayName");
}
if (!metadata.containsKey("match")) {
throw new IllegalArgumentException("缺少必填字段 @match");
}
// 验证match字段包含KEY命名捕获组
String matchPattern = metadata.get("match");
if (!matchPattern.contains("(?P<KEY>") && !matchPattern.contains("(?<KEY>")) {
throw new IllegalArgumentException("@match 正则表达式必须包含命名捕获组 KEYPython格式: (?P<KEY>...) 或 Java格式: (?<KEY>...)");
}
}
/**
* 构建CustomParserConfig
*/
private static CustomParserConfig buildConfig(Map<String, String> metadata, String pyCode) {
CustomParserConfig.Builder builder = CustomParserConfig.builder()
.type(metadata.get("type"))
.displayName(metadata.get("displayname"))
.isPyParser(true)
.pyCode(pyCode)
.language("python")
.metadata(metadata);
// 设置匹配正则将Python风格的(?P<KEY>...)转换为Java风格的(?<KEY>...)
String matchPattern = metadata.get("match");
if (StringUtils.isNotBlank(matchPattern)) {
// 将Python命名捕获组转换为Java格式
matchPattern = matchPattern.replace("(?P<", "(?<");
builder.matchPattern(matchPattern);
}
return builder.build();
}
/**
* 检查Python代码是否包含有效的元数据块
*
* @param pyCode Python代码
* @return true表示包含有效元数据false表示不包含
*/
public static boolean hasValidMetadata(String pyCode) {
if (StringUtils.isBlank(pyCode)) {
return false;
}
try {
Map<String, String> metadata = extractMetadata(pyCode);
return metadata.containsKey("name") &&
metadata.containsKey("type") &&
metadata.containsKey("displayname") &&
metadata.containsKey("match");
} catch (Exception e) {
return false;
}
}
/**
* 获取脚本类型不验证必填字段
*
* @param pyCode Python代码
* @return 脚本类型如果无法提取则返回null
*/
public static String getScriptType(String pyCode) {
if (StringUtils.isBlank(pyCode)) {
return null;
}
try {
Map<String, String> metadata = extractMetadata(pyCode);
return metadata.get("type");
} catch (Exception e) {
return null;
}
}
/**
* 获取脚本显示名称不验证必填字段
*
* @param pyCode Python代码
* @return 显示名称如果无法提取则返回null
*/
public static String getScriptDisplayName(String pyCode) {
if (StringUtils.isBlank(pyCode)) {
return null;
}
try {
Map<String, String> metadata = extractMetadata(pyCode);
return metadata.get("displayname");
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,262 @@
package cn.qaiu.parser.custompy;
import cn.qaiu.entity.ShareLinkInfo;
import org.graalvm.polyglot.HostAccess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
/**
* ShareLinkInfo的Python包装器
* 为Python脚本提供ShareLinkInfo对象的访问接口
*
* @author QAIU
*/
public class PyShareLinkInfoWrapper {
private static final Logger log = LoggerFactory.getLogger(PyShareLinkInfoWrapper.class);
private final ShareLinkInfo shareLinkInfo;
public PyShareLinkInfoWrapper(ShareLinkInfo shareLinkInfo) {
this.shareLinkInfo = shareLinkInfo;
}
/**
* 获取分享URL
* @return 分享URL
*/
@HostAccess.Export
public String getShareUrl() {
return shareLinkInfo.getShareUrl();
}
/**
* Python风格方法名 - 获取分享URL
*/
@HostAccess.Export
public String get_share_url() {
return getShareUrl();
}
/**
* 获取分享Key
* @return 分享Key
*/
@HostAccess.Export
public String getShareKey() {
return shareLinkInfo.getShareKey();
}
/**
* Python风格方法名 - 获取分享Key
*/
@HostAccess.Export
public String get_share_key() {
return getShareKey();
}
/**
* 获取分享密码
* @return 分享密码
*/
@HostAccess.Export
public String getSharePassword() {
return shareLinkInfo.getSharePassword();
}
/**
* Python风格方法名 - 获取分享密码
*/
@HostAccess.Export
public String get_share_password() {
return getSharePassword();
}
/**
* 获取网盘类型
* @return 网盘类型
*/
@HostAccess.Export
public String getType() {
return shareLinkInfo.getType();
}
/**
* Python风格方法名 - 获取网盘类型
*/
@HostAccess.Export
public String get_type() {
return getType();
}
/**
* 获取网盘名称
* @return 网盘名称
*/
@HostAccess.Export
public String getPanName() {
return shareLinkInfo.getPanName();
}
/**
* Python风格方法名 - 获取网盘名称
*/
@HostAccess.Export
public String get_pan_name() {
return getPanName();
}
/**
* 获取其他参数
* @param key 参数键
* @return 参数值
*/
@HostAccess.Export
public Object getOtherParam(String key) {
if (key == null) {
return null;
}
return shareLinkInfo.getOtherParam().get(key);
}
/**
* Python风格方法名 - 获取其他参数
*/
@HostAccess.Export
public Object get_other_param(String key) {
return getOtherParam(key);
}
/**
* 获取所有其他参数
* @return 参数Map
*/
@HostAccess.Export
public Map<String, Object> getAllOtherParams() {
return shareLinkInfo.getOtherParam();
}
/**
* Python风格方法名 - 获取所有其他参数
*/
@HostAccess.Export
public Map<String, Object> get_all_other_params() {
return getAllOtherParams();
}
/**
* 检查是否包含指定参数
* @param key 参数键
* @return true表示包含false表示不包含
*/
@HostAccess.Export
public boolean hasOtherParam(String key) {
if (key == null) {
return false;
}
return shareLinkInfo.getOtherParam().containsKey(key);
}
/**
* Python风格方法名 - 检查是否包含指定参数
*/
@HostAccess.Export
public boolean has_other_param(String key) {
return hasOtherParam(key);
}
/**
* 获取其他参数的字符串值
* @param key 参数键
* @return 参数值字符串形式
*/
@HostAccess.Export
public String getOtherParamAsString(String key) {
Object value = getOtherParam(key);
return value != null ? value.toString() : null;
}
/**
* Python风格方法名 - 获取其他参数的字符串值
*/
@HostAccess.Export
public String get_other_param_as_string(String key) {
return getOtherParamAsString(key);
}
/**
* 获取其他参数的整数值
* @param key 参数键
* @return 参数值整数形式
*/
@HostAccess.Export
public Integer getOtherParamAsInteger(String key) {
Object value = getOtherParam(key);
if (value instanceof Integer) {
return (Integer) value;
} else if (value instanceof Number) {
return ((Number) value).intValue();
} else if (value instanceof String) {
try {
return Integer.parseInt((String) value);
} catch (NumberFormatException e) {
log.warn("无法将参数 {} 转换为整数: {}", key, value);
return null;
}
}
return null;
}
/**
* Python风格方法名 - 获取其他参数的整数值
*/
@HostAccess.Export
public Integer get_other_param_as_integer(String key) {
return getOtherParamAsInteger(key);
}
/**
* 获取其他参数的布尔值
* @param key 参数键
* @return 参数值布尔形式
*/
@HostAccess.Export
public Boolean getOtherParamAsBoolean(String key) {
Object value = getOtherParam(key);
if (value instanceof Boolean) {
return (Boolean) value;
} else if (value instanceof String) {
return Boolean.parseBoolean((String) value);
}
return null;
}
/**
* Python风格方法名 - 获取其他参数的布尔值
*/
@HostAccess.Export
public Boolean get_other_param_as_boolean(String key) {
return getOtherParamAsBoolean(key);
}
/**
* 获取原始的ShareLinkInfo对象
* @return ShareLinkInfo对象
*/
public ShareLinkInfo getOriginalShareLinkInfo() {
return shareLinkInfo;
}
@Override
public String toString() {
return "PyShareLinkInfoWrapper{" +
"shareUrl='" + getShareUrl() + '\'' +
", shareKey='" + getShareKey() + '\'' +
", sharePassword='" + getSharePassword() + '\'' +
", type='" + getType() + '\'' +
", panName='" + getPanName() + '\'' +
'}';
}
}

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