js演练场

This commit is contained in:
q
2025-11-29 03:41:51 +08:00
parent 1dfdff7024
commit e74d5ea97e
25 changed files with 6379 additions and 112 deletions

View File

@@ -0,0 +1,436 @@
package cn.qaiu.lz.web.controller;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.lz.web.model.PlaygroundTestResp;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.parser.customjs.JsPlaygroundExecutor;
import cn.qaiu.parser.customjs.JsPlaygroundLogger;
import cn.qaiu.parser.customjs.JsScriptMetadataParser;
import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.RouteMethod;
import cn.qaiu.vx.core.model.JsonResult;
import cn.qaiu.vx.core.util.AsyncServiceUtil;
import cn.qaiu.vx.core.util.ResponseUtil;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 演练场API控制器
* 提供JavaScript解析脚本的测试接口
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@RouteHandler(value = "/v2/playground", order = 10)
@Slf4j
public class PlaygroundApi {
private static final int MAX_PARSER_COUNT = 100;
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
/**
* 测试执行JavaScript代码
*
* @param ctx 路由上下文
* @return 测试结果
*/
@RouteMapping(value = "/test", method = RouteMethod.POST)
public Future<JsonObject> test(RoutingContext ctx) {
Promise<JsonObject> promise = Promise.promise();
try {
JsonObject body = ctx.body().asJsonObject();
String jsCode = body.getString("jsCode");
String shareUrl = body.getString("shareUrl");
String pwd = body.getString("pwd");
String method = body.getString("method", "parse");
// 参数验证
if (StringUtils.isBlank(jsCode)) {
promise.complete(JsonObject.mapFrom(PlaygroundTestResp.builder()
.success(false)
.error("JavaScript代码不能为空")
.build()));
return promise.future();
}
if (StringUtils.isBlank(shareUrl)) {
promise.complete(JsonObject.mapFrom(PlaygroundTestResp.builder()
.success(false)
.error("分享链接不能为空")
.build()));
return promise.future();
}
// 验证方法类型
if (!"parse".equals(method) && !"parseFileList".equals(method) && !"parseById".equals(method)) {
promise.complete(JsonObject.mapFrom(PlaygroundTestResp.builder()
.success(false)
.error("方法类型无效,必须是 parse、parseFileList 或 parseById")
.build()));
return promise.future();
}
long startTime = System.currentTimeMillis();
try {
// 创建ShareLinkInfo
ParserCreate parserCreate = ParserCreate.fromShareUrl(shareUrl);
if (StringUtils.isNotBlank(pwd)) {
parserCreate.setShareLinkInfoPwd(pwd);
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
// 创建演练场执行器
JsPlaygroundExecutor executor = new JsPlaygroundExecutor(shareLinkInfo, jsCode);
// 根据方法类型选择执行,并异步处理结果
Future<Object> executionFuture;
switch (method) {
case "parse":
executionFuture = executor.executeParseAsync().map(r -> (Object) r);
break;
case "parseFileList":
executionFuture = executor.executeParseFileListAsync().map(r -> (Object) r);
break;
case "parseById":
executionFuture = executor.executeParseByIdAsync().map(r -> (Object) r);
break;
default:
promise.fail(new IllegalArgumentException("未知的方法类型: " + method));
return promise.future();
}
// 异步处理执行结果
executionFuture.onSuccess(result -> {
log.debug("执行成功,结果类型: {}, 结果值: {}",
result != null ? result.getClass().getSimpleName() : "null",
result);
// 获取日志
List<JsPlaygroundLogger.LogEntry> logEntries = executor.getLogs();
log.debug("获取到 {} 条日志记录", logEntries.size());
List<PlaygroundTestResp.LogEntry> respLogs = logEntries.stream()
.map(entry -> PlaygroundTestResp.LogEntry.builder()
.level(entry.getLevel())
.message(entry.getMessage())
.timestamp(entry.getTimestamp())
.source(entry.getSource()) // 使用日志条目的来源标识
.build())
.collect(Collectors.toList());
long executionTime = System.currentTimeMillis() - startTime;
// 构建响应
PlaygroundTestResp response = PlaygroundTestResp.builder()
.success(true)
.result(result)
.logs(respLogs)
.executionTime(executionTime)
.build();
JsonObject jsonResponse = JsonObject.mapFrom(response);
log.debug("测试成功响应: {}", jsonResponse.encodePrettily());
promise.complete(jsonResponse);
}).onFailure(e -> {
long executionTime = System.currentTimeMillis() - startTime;
String errorMessage = e.getMessage();
String stackTrace = getStackTrace(e);
log.error("演练场执行失败", e);
// 尝试获取已有的日志
List<JsPlaygroundLogger.LogEntry> logEntries = executor.getLogs();
List<PlaygroundTestResp.LogEntry> respLogs = logEntries.stream()
.map(entry -> PlaygroundTestResp.LogEntry.builder()
.level(entry.getLevel())
.message(entry.getMessage())
.timestamp(entry.getTimestamp())
.source(entry.getSource()) // 使用日志条目的来源标识
.build())
.collect(Collectors.toList());
PlaygroundTestResp response = PlaygroundTestResp.builder()
.success(false)
.error(errorMessage)
.stackTrace(stackTrace)
.executionTime(executionTime)
.logs(respLogs)
.build();
promise.complete(JsonObject.mapFrom(response));
});
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
String errorMessage = e.getMessage();
String stackTrace = getStackTrace(e);
log.error("演练场初始化失败", e);
PlaygroundTestResp response = PlaygroundTestResp.builder()
.success(false)
.error(errorMessage)
.stackTrace(stackTrace)
.executionTime(executionTime)
.logs(new ArrayList<>())
.build();
promise.complete(JsonObject.mapFrom(response));
}
} catch (Exception e) {
log.error("解析请求参数失败", e);
promise.complete(JsonObject.mapFrom(PlaygroundTestResp.builder()
.success(false)
.error("解析请求参数失败: " + e.getMessage())
.stackTrace(getStackTrace(e))
.build()));
}
return promise.future();
}
/**
* 获取types.js文件内容
*
* @param response HTTP响应
*/
@RouteMapping(value = "/types.js", method = RouteMethod.GET)
public void getTypesJs(HttpServerResponse response) {
try (InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("custom-parsers/types.js")) {
if (inputStream == null) {
ResponseUtil.fireJsonResultResponse(response, JsonResult.error("types.js文件不存在"));
return;
}
String content = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))
.lines()
.collect(Collectors.joining("\n"));
response.putHeader("Content-Type", "text/javascript; charset=utf-8")
.end(content);
} catch (Exception e) {
log.error("读取types.js失败", e);
ResponseUtil.fireJsonResultResponse(response, JsonResult.error("读取types.js失败: " + e.getMessage()));
}
}
/**
* 获取解析器列表
*/
@RouteMapping(value = "/parsers", method = RouteMethod.GET)
public Future<JsonObject> getParserList() {
return dbService.getPlaygroundParserList();
}
/**
* 保存解析器
*/
@RouteMapping(value = "/parsers", method = RouteMethod.POST)
public Future<JsonObject> saveParser(RoutingContext ctx) {
Promise<JsonObject> promise = Promise.promise();
try {
JsonObject body = ctx.body().asJsonObject();
String jsCode = body.getString("jsCode");
if (StringUtils.isBlank(jsCode)) {
promise.complete(JsonResult.error("JavaScript代码不能为空").toJsonObject());
return promise.future();
}
// 解析元数据
try {
var config = JsScriptMetadataParser.parseScript(jsCode);
String type = config.getType();
String displayName = config.getDisplayName();
String name = config.getMetadata().get("name");
String description = config.getMetadata().get("description");
String author = config.getMetadata().get("author");
String version = config.getMetadata().get("version");
String matchPattern = config.getMatchPattern() != null ? config.getMatchPattern().pattern() : null;
// 检查数量限制
dbService.getPlaygroundParserCount().onSuccess(count -> {
if (count >= MAX_PARSER_COUNT) {
promise.complete(JsonResult.error("解析器数量已达到上限(" + MAX_PARSER_COUNT + "个),请先删除不需要的解析器").toJsonObject());
return;
}
// 检查type是否已存在
dbService.getPlaygroundParserList().onSuccess(listResult -> {
var list = listResult.getJsonArray("data");
boolean exists = false;
if (list != null) {
for (int i = 0; i < list.size(); i++) {
var item = list.getJsonObject(i);
if (type.equals(item.getString("type"))) {
exists = true;
break;
}
}
}
if (exists) {
promise.complete(JsonResult.error("解析器类型 " + type + " 已存在,请使用其他类型标识").toJsonObject());
return;
}
// 保存到数据库
JsonObject parser = new JsonObject();
parser.put("name", name);
parser.put("type", type);
parser.put("displayName", displayName);
parser.put("description", description);
parser.put("author", author);
parser.put("version", version);
parser.put("matchPattern", matchPattern);
parser.put("jsCode", jsCode);
parser.put("ip", getClientIp(ctx.request()));
parser.put("enabled", true);
dbService.savePlaygroundParser(parser).onSuccess(result -> {
promise.complete(result);
}).onFailure(e -> {
log.error("保存解析器失败", e);
promise.complete(JsonResult.error("保存失败: " + e.getMessage()).toJsonObject());
});
}).onFailure(e -> {
log.error("获取解析器列表失败", e);
promise.complete(JsonResult.error("检查解析器失败: " + e.getMessage()).toJsonObject());
});
}).onFailure(e -> {
log.error("获取解析器数量失败", e);
promise.complete(JsonResult.error("检查解析器数量失败: " + e.getMessage()).toJsonObject());
});
} catch (Exception e) {
log.error("解析脚本元数据失败", e);
promise.complete(JsonResult.error("解析脚本元数据失败: " + e.getMessage()).toJsonObject());
}
} catch (Exception e) {
log.error("解析请求参数失败", e);
promise.complete(JsonResult.error("解析请求参数失败: " + e.getMessage()).toJsonObject());
}
return promise.future();
}
/**
* 更新解析器
*/
@RouteMapping(value = "/parsers/:id", method = RouteMethod.PUT)
public Future<JsonObject> updateParser(RoutingContext ctx, Long id) {
Promise<JsonObject> promise = Promise.promise();
try {
JsonObject body = ctx.body().asJsonObject();
String jsCode = body.getString("jsCode");
if (StringUtils.isBlank(jsCode)) {
promise.complete(JsonResult.error("JavaScript代码不能为空").toJsonObject());
return promise.future();
}
// 解析元数据
try {
var config = JsScriptMetadataParser.parseScript(jsCode);
String displayName = config.getDisplayName();
String name = config.getMetadata().get("name");
String description = config.getMetadata().get("description");
String author = config.getMetadata().get("author");
String version = config.getMetadata().get("version");
String matchPattern = config.getMatchPattern() != null ? config.getMatchPattern().pattern() : null;
JsonObject parser = new JsonObject();
parser.put("name", name);
parser.put("displayName", displayName);
parser.put("description", description);
parser.put("author", author);
parser.put("version", version);
parser.put("matchPattern", matchPattern);
parser.put("jsCode", jsCode);
parser.put("enabled", body.getBoolean("enabled", true));
dbService.updatePlaygroundParser(id, parser).onSuccess(result -> {
promise.complete(result);
}).onFailure(e -> {
log.error("更新解析器失败", e);
promise.complete(JsonResult.error("更新失败: " + e.getMessage()).toJsonObject());
});
} catch (Exception e) {
log.error("解析脚本元数据失败", e);
promise.complete(JsonResult.error("解析脚本元数据失败: " + e.getMessage()).toJsonObject());
}
} catch (Exception e) {
log.error("解析请求参数失败", e);
promise.complete(JsonResult.error("解析请求参数失败: " + e.getMessage()).toJsonObject());
}
return promise.future();
}
/**
* 删除解析器
*/
@RouteMapping(value = "/parsers/:id", method = RouteMethod.DELETE)
public Future<JsonObject> deleteParser(Long id) {
return dbService.deletePlaygroundParser(id);
}
/**
* 根据ID获取解析器
*/
@RouteMapping(value = "/parsers/:id", method = RouteMethod.GET)
public Future<JsonObject> getParserById(Long id) {
return dbService.getPlaygroundParserById(id);
}
/**
* 获取客户端IP
*/
private String getClientIp(HttpServerRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.remoteAddress().host();
}
return ip;
}
/**
* 获取异常堆栈信息
*/
private String getStackTrace(Throwable throwable) {
if (throwable == null) {
return "";
}
java.io.StringWriter sw = new java.io.StringWriter();
java.io.PrintWriter pw = new java.io.PrintWriter(sw);
throwable.printStackTrace(pw);
return sw.toString();
}
}

View File

@@ -0,0 +1,62 @@
package cn.qaiu.lz.web.model;
import cn.qaiu.db.ddl.Constraint;
import cn.qaiu.db.ddl.Length;
import cn.qaiu.db.ddl.Table;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
/**
* 演练场解析器实体
* 用于保存用户创建的临时JS解析器
*/
@Data
@Table("playground_parser")
public class PlaygroundParser {
private static final long serialVersionUID = 1L;
@Constraint(autoIncrement = true, notNull = true)
private Long id;
@Length(varcharSize = 64)
@Constraint(notNull = true)
private String name; // 解析器名称
@Length(varcharSize = 64)
@Constraint(notNull = true, uniqueKey = "uk_type")
private String type; // 解析器类型标识(唯一)
@Length(varcharSize = 128)
private String displayName; // 显示名称
@Length(varcharSize = 512)
private String description; // 描述
@Length(varcharSize = 64)
private String author; // 作者
@Length(varcharSize = 32)
private String version; // 版本号
@Length(varcharSize = 512)
private String matchPattern; // URL匹配正则
@Length(varcharSize = 65535)
@Constraint(notNull = true)
private String jsCode; // JavaScript代码
@Length(varcharSize = 64)
private String ip; // 创建者IP
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime = new Date(); // 创建时间
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateTime; // 更新时间
private Boolean enabled = true; // 是否启用
}

View File

@@ -0,0 +1,76 @@
package cn.qaiu.lz.web.model;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 演练场测试响应模型
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@Data
@Builder
public class PlaygroundTestResp {
/**
* 是否执行成功
*/
private boolean success;
/**
* 执行结果(根据方法类型返回不同格式)
* - parse: String (下载链接)
* - parseFileList: List<FileInfo>
* - parseById: String (下载链接)
*/
private Object result;
/**
* 执行日志列表
*/
private List<LogEntry> logs;
/**
* 错误信息
*/
private String error;
/**
* 错误堆栈
*/
private String stackTrace;
/**
* 执行时间(毫秒)
*/
private long executionTime;
/**
* 日志条目
*/
@Data
@Builder
public static class LogEntry {
/**
* 日志级别DEBUG, INFO, WARN, ERROR
*/
private String level;
/**
* 日志消息
*/
private String message;
/**
* 日志时间戳
*/
private long timestamp;
/**
* 日志来源JSJavaScript日志或 JAVAJava日志
*/
private String source;
}
}

View File

@@ -20,4 +20,34 @@ public interface DbService extends BaseAsyncService {
Future<StatisticsInfo> getStatisticsInfo();
/**
* 获取演练场解析器列表
*/
Future<JsonObject> getPlaygroundParserList();
/**
* 保存演练场解析器
*/
Future<JsonObject> savePlaygroundParser(JsonObject parser);
/**
* 更新演练场解析器
*/
Future<JsonObject> updatePlaygroundParser(Long id, JsonObject parser);
/**
* 删除演练场解析器
*/
Future<JsonObject> deletePlaygroundParser(Long id);
/**
* 获取演练场解析器数量
*/
Future<Integer> getPlaygroundParserCount();
/**
* 根据ID获取演练场解析器
*/
Future<JsonObject> getPlaygroundParserById(Long id);
}

View File

@@ -10,10 +10,14 @@ import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import io.vertx.jdbcclient.JDBCPool;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import io.vertx.sqlclient.templates.SqlTemplate;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
/**
* lz-web
@@ -66,4 +70,199 @@ public class DbServiceImpl implements DbService {
});
return promise.future();
}
@Override
public Future<JsonObject> getPlaygroundParserList() {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = "SELECT * FROM playground_parser ORDER BY create_time DESC";
client.query(sql).execute().onSuccess(rows -> {
List<JsonObject> list = new ArrayList<>();
for (Row row : rows) {
JsonObject parser = new JsonObject();
parser.put("id", row.getLong("id"));
parser.put("name", row.getString("name"));
parser.put("type", row.getString("type"));
parser.put("displayName", row.getString("display_name"));
parser.put("description", row.getString("description"));
parser.put("author", row.getString("author"));
parser.put("version", row.getString("version"));
parser.put("matchPattern", row.getString("match_pattern"));
parser.put("jsCode", row.getString("js_code"));
parser.put("ip", row.getString("ip"));
// 将LocalDateTime转换为字符串格式避免序列化为数组
var createTime = row.getLocalDateTime("create_time");
if (createTime != null) {
parser.put("createTime", createTime.toString().replace("T", " "));
}
var updateTime = row.getLocalDateTime("update_time");
if (updateTime != null) {
parser.put("updateTime", updateTime.toString().replace("T", " "));
}
parser.put("enabled", row.getBoolean("enabled"));
list.add(parser);
}
promise.complete(JsonResult.data(list).toJsonObject());
}).onFailure(e -> {
log.error("getPlaygroundParserList failed", e);
promise.fail(e);
});
return promise.future();
}
@Override
public Future<JsonObject> savePlaygroundParser(JsonObject parser) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = """
INSERT INTO playground_parser
(name, type, display_name, description, author, version, match_pattern, js_code, ip, create_time, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?)
""";
client.preparedQuery(sql)
.execute(Tuple.of(
parser.getString("name"),
parser.getString("type"),
parser.getString("displayName"),
parser.getString("description"),
parser.getString("author"),
parser.getString("version"),
parser.getString("matchPattern"),
parser.getString("jsCode"),
parser.getString("ip"),
parser.getBoolean("enabled", true)
))
.onSuccess(res -> {
promise.complete(JsonResult.success("保存成功").toJsonObject());
})
.onFailure(e -> {
log.error("savePlaygroundParser failed", e);
promise.fail(e);
});
return promise.future();
}
@Override
public Future<JsonObject> updatePlaygroundParser(Long id, JsonObject parser) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = """
UPDATE playground_parser
SET name = ?, display_name = ?, description = ?, author = ?,
version = ?, match_pattern = ?, js_code = ?, update_time = NOW(), enabled = ?
WHERE id = ?
""";
client.preparedQuery(sql)
.execute(Tuple.of(
parser.getString("name"),
parser.getString("displayName"),
parser.getString("description"),
parser.getString("author"),
parser.getString("version"),
parser.getString("matchPattern"),
parser.getString("jsCode"),
parser.getBoolean("enabled", true),
id
))
.onSuccess(res -> {
promise.complete(JsonResult.success("更新成功").toJsonObject());
})
.onFailure(e -> {
log.error("updatePlaygroundParser failed", e);
promise.fail(e);
});
return promise.future();
}
@Override
public Future<JsonObject> deletePlaygroundParser(Long id) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = "DELETE FROM playground_parser WHERE id = ?";
client.preparedQuery(sql)
.execute(Tuple.of(id))
.onSuccess(res -> {
promise.complete(JsonResult.success("删除成功").toJsonObject());
})
.onFailure(e -> {
log.error("deletePlaygroundParser failed", e);
promise.fail(e);
});
return promise.future();
}
@Override
public Future<Integer> getPlaygroundParserCount() {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<Integer> promise = Promise.promise();
String sql = "SELECT COUNT(*) as count FROM playground_parser";
client.query(sql).execute().onSuccess(rows -> {
Integer count = rows.iterator().next().getInteger("count");
promise.complete(count);
}).onFailure(e -> {
log.error("getPlaygroundParserCount failed", e);
promise.fail(e);
});
return promise.future();
}
@Override
public Future<JsonObject> getPlaygroundParserById(Long id) {
JDBCPool client = JDBCPoolInit.instance().getPool();
Promise<JsonObject> promise = Promise.promise();
String sql = "SELECT * FROM playground_parser WHERE id = ?";
client.preparedQuery(sql)
.execute(Tuple.of(id))
.onSuccess(rows -> {
if (rows.size() > 0) {
Row row = rows.iterator().next();
JsonObject parser = new JsonObject();
parser.put("id", row.getLong("id"));
parser.put("name", row.getString("name"));
parser.put("type", row.getString("type"));
parser.put("displayName", row.getString("display_name"));
parser.put("description", row.getString("description"));
parser.put("author", row.getString("author"));
parser.put("version", row.getString("version"));
parser.put("matchPattern", row.getString("match_pattern"));
parser.put("jsCode", row.getString("js_code"));
parser.put("ip", row.getString("ip"));
// 将LocalDateTime转换为字符串格式避免序列化为数组
var createTime = row.getLocalDateTime("create_time");
if (createTime != null) {
parser.put("createTime", createTime.toString().replace("T", " "));
}
var updateTime = row.getLocalDateTime("update_time");
if (updateTime != null) {
parser.put("updateTime", updateTime.toString().replace("T", " "));
}
parser.put("enabled", row.getBoolean("enabled"));
promise.complete(JsonResult.data(parser).toJsonObject());
} else {
promise.fail("解析器不存在");
}
})
.onFailure(e -> {
log.error("getPlaygroundParserById failed", e);
promise.fail(e);
});
return promise.future();
}
}