From 872477485070f19e03241c8ea84971c7dd6a3aa0 Mon Sep 17 00:00:00 2001 From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:39:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B5=E9=9D=A2=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=9C=B0=E5=9D=80=E6=B7=BB=E5=8A=A0=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=8C=89=E9=92=AE=EF=BC=8C=E8=A7=A3=E6=9E=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=87=AA=E5=8A=A8=E6=8F=90=E4=BA=A4=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 POST /api/v1/admin/site-pages/verify 端点 - 接收 siteId/url/pageKey,用对应 Groovy 脚本访问URL并解析 - 解析失败(matched=false)时自动提交 error-log 到 PAR - 支持 HTTP_ERROR/EMPTY_RESPONSE/PARSE_FAILED/SCRIPT_ERROR 多种错误类型 - 前端 SitePages.vue 每条页面入口 URL 后添加验证按钮 - 成功显示绿色 ✓,失败显示红色 ✗,并展示解析结果消息 - 显示响应耗时 - api/index.js 新增 adapter.verifyPage 方法 --- .../api/controller/SitePagesController.java | 142 ++++++++++++++++++ web/src/api/index.js | 1 + web/src/views/SitePages.vue | 41 ++++- 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/par-api/src/main/java/com/par/api/controller/SitePagesController.java b/par-api/src/main/java/com/par/api/controller/SitePagesController.java index e661cdd..26a8bc0 100644 --- a/par-api/src/main/java/com/par/api/controller/SitePagesController.java +++ b/par-api/src/main/java/com/par/api/controller/SitePagesController.java @@ -1,12 +1,18 @@ package com.par.api.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.par.core.adapter.GroovyScriptEngine; +import com.par.core.adapter.HttpUtils; import com.par.core.dto.ApiResponse; +import com.par.core.mapper.SiteConfigMapper; +import com.par.core.service.ErrorLogService; import com.par.core.service.SitePagesService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -18,6 +24,10 @@ public class SitePagesController { private final SitePagesService sitePagesService; private final com.par.core.mapper.SiteMapper siteMapper; private final com.par.core.storage.CachedStorageService storageService; + private final GroovyScriptEngine groovyEngine; + private final SiteConfigMapper siteConfigMapper; + private final ErrorLogService errorLogService; + private static final ObjectMapper objectMapper = new ObjectMapper(); @GetMapping("/api/v1/sites/{siteId}/pages") public ApiResponse> getPages(@PathVariable String siteId) { @@ -66,6 +76,138 @@ public class SitePagesController { return ApiResponse.success(); } + /** + * 验证页面入口:用脚本访问指定URL并解析,解析失败时自动提交错误日志 + */ + @PostMapping("/api/v1/admin/site-pages/verify") + public ApiResponse> verifyPageEntry(@RequestBody Map payload, + HttpServletRequest request) { + checkAdmin(request); + String siteId = (String) payload.get("siteId"); + String url = (String) payload.get("url"); + String pageKey = (String) payload.get("pageKey"); + if (siteId == null || siteId.isBlank()) return ApiResponse.error(400, "siteId is required"); + if (url == null || url.isBlank()) return ApiResponse.error(400, "url is required"); + + Map result = new LinkedHashMap<>(); + result.put("siteId", siteId); + result.put("url", url); + result.put("pageKey", pageKey != null ? pageKey : ""); + + // 1. 获取站点类型,加载对应 Groovy 脚本 + var site = siteMapper.selectBySiteId(siteId); + String siteType = site != null && site.getSiteType() != null ? site.getSiteType().getValue() : null; + if (siteType == null) siteType = "nexusphp"; + + // 优先用站点自定义脚本,否则用类型默认脚本 + String script = null; + String scriptSource = "default"; + var latestConfig = siteConfigMapper.selectLatestBySiteId(siteId); + if (latestConfig != null && latestConfig.getScriptStoragePath() != null + && !latestConfig.getScriptStoragePath().isBlank()) { + try { + script = storageService.read(latestConfig.getScriptStoragePath()); + if (script != null && !script.isBlank()) scriptSource = "custom"; + } catch (Exception e) { + log.warn("读取自定义脚本失败 siteId={}: {}", siteId, e.getMessage()); + } + } + if (script == null || script.isBlank()) { + script = GroovyScriptEngine.loadDefaultByType(siteType); + scriptSource = "default"; + } + if (script == null || script.isBlank()) { + result.put("success", false); + result.put("message", "未找到可用脚本(站点类型: " + siteType + ")"); + return ApiResponse.success(result); + } + result.put("scriptSource", scriptSource); + + // 2. 用 HttpUtils 访问目标 URL 获取 HTML 内容 + String content; + long startMs = System.currentTimeMillis(); + try { + content = HttpUtils.INSTANCE.get(url, null); + result.put("responseTimeMs", System.currentTimeMillis() - startMs); + if (content == null || content.isBlank()) { + result.put("success", false); + result.put("message", "页面返回空内容"); + submitErrorLog(siteId, request, "EMPTY_RESPONSE", "页面返回空内容", null, url); + return ApiResponse.success(result); + } + result.put("contentLength", content.length()); + } catch (Exception e) { + result.put("responseTimeMs", System.currentTimeMillis() - startMs); + result.put("success", false); + String errMsg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + result.put("message", "HTTP 请求失败: " + errMsg); + submitErrorLog(siteId, request, "HTTP_ERROR", errMsg, null, url); + return ApiResponse.success(result); + } + + // 3. 用 Groovy 脚本解析内容 + try { + Map parsed = groovyEngine.execute(script, content, url); + boolean matched = Boolean.TRUE.equals(parsed.get("matched")); + result.put("parsed", parsed); + result.put("matched", matched); + result.put("pageName", parsed.getOrDefault("pageName", "")); + result.put("rowCount", parsed.getOrDefault("rowCount", 0)); + result.put("fieldCount", parsed.getOrDefault("fieldCount", 0)); + + if (matched) { + result.put("success", true); + result.put("message", "解析成功: " + parsed.getOrDefault("message", "")); + } else { + result.put("success", false); + String msg = parsed.get("message") != null ? parsed.get("message").toString() : "脚本返回 matched=false"; + result.put("message", "解析失败: " + msg); + // 提交错误日志 + submitErrorLog(siteId, request, "PARSE_FAILED", msg, null, url); + } + } catch (Exception e) { + result.put("success", false); + String errMsg = extractError(e); + result.put("message", "脚本执行异常: " + errMsg); + result.put("matched", false); + submitErrorLog(siteId, request, "SCRIPT_ERROR", errMsg, null, url); + } + + return ApiResponse.success(result); + } + + private void submitErrorLog(String siteId, HttpServletRequest request, + String errorType, String message, String stackTrace, String url) { + try { + String clientId = (String) request.getAttribute("clientId"); + if (clientId == null || clientId.isBlank()) { + // 从 Bearer token 中提取 email 作为 clientId + Object accountId = request.getAttribute("accountId"); + clientId = accountId != null ? "admin-" + accountId : "web-admin"; + } + errorLogService.submit(siteId, clientId, errorType, message, stackTrace, null, url); + log.info("验证失败自动提交错误日志: siteId={}, errorType={}, url={}", siteId, errorType, url); + } catch (Exception e) { + log.warn("提交错误日志失败: {}", e.getMessage()); + } + } + + private String extractError(Exception e) { + StringBuilder sb = new StringBuilder(); + sb.append(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); + for (StackTraceElement ste : e.getStackTrace()) { + String cn = ste.getClassName(); + if (cn.startsWith("Script") || cn.contains("$_run_closure") || cn.contains("$__execute")) { + sb.append("\n at line ").append(ste.getLineNumber()); + break; + } + } + if (e.getCause() != null && e.getCause() != e) { + sb.append("\nCaused by: ").append(e.getCause().getMessage()); + } + return sb.toString(); + } + @GetMapping("/api/v1/admin/site-pages") public ApiResponse>> adminList(HttpServletRequest request) { checkAdmin(request); diff --git a/web/src/api/index.js b/web/src/api/index.js index 7cbe85a..e8ff9f4 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -92,6 +92,7 @@ export default { adapter: { parse: (configId, accessResultId) => api.post('/admin/adapter/parse', { configId, accessResultId }), parseBatch: (configId, siteId) => api.post('/admin/adapter/parse-batch', { configId, siteId }), + verifyPage: (siteId, url, pageKey) => api.post('/admin/site-pages/verify', { siteId, url, pageKey }), }, health: () => api.get('/health', { baseURL: '' }), // 原始 axios 实例,供需要直接调用(如 blob 下载)的场景使用 diff --git a/web/src/views/SitePages.vue b/web/src/views/SitePages.vue index 80a4f53..0f24cb2 100644 --- a/web/src/views/SitePages.vue +++ b/web/src/views/SitePages.vue @@ -92,7 +92,16 @@
删除 - +
+ + + {{ verifyResults[idx]?.success===true ? '✓' : verifyResults[idx]?.success===false ? '✗' : '验证' }} + +
+
+ {{ verifyResults[idx].message }} + ({{ verifyResults[idx].responseTimeMs }}ms) +
@@ -110,6 +119,8 @@ const authType = ref('NONE') const defaultTypes = ['nexusphp','mtorrent','gazelle','unit3d'] const isDefaultEditor = computed(() => editSiteId.value.startsWith('default:')) const pageKeys = ref([]) +const verifying = ref({}) +const verifyResults = ref({}) const loadPageKeys = async () => { try { const r = await api.raw.get('/admin/page-keys'); pageKeys.value = r?.data || r || [] } @@ -134,6 +145,34 @@ const syncToJson = () => { editorJson.value = JSON.stringify(obj, null, 2) } const resolveUrl = (url) => url?.replace('{base}', sitesList.value.find(s=>s.siteId===editSiteId.value)?.urls?.[0] || '{base}') || '' + +const verifyEntry = async (entry, idx) => { + const resolved = resolveUrl(entry.url) + if (!resolved || resolved === entry.url) { + ElMessage.warning('请先配置站点地址,URL中需包含 {base} 占位符') + return + } + verifying.value = { ...verifying.value, [idx]: true } + verifyResults.value = { ...verifyResults.value, [idx]: { message: '验证中...' } } + try { + const siteId = editSiteId.value.startsWith('default:') ? '' : editSiteId.value + if (!siteId) { + verifyResults.value = { ...verifyResults.value, [idx]: { success: false, message: '默认模板无关联站点,请在实际站点中验证' } } + return + } + const r = await api.adapter.verifyPage(siteId, resolved, entry.key) + const data = r?.data || r + verifyResults.value = { ...verifyResults.value, [idx]: { + success: data?.success, message: data?.message || (data?.success ? '解析成功' : '解析失败'), + matched: data?.matched, responseTimeMs: data?.responseTimeMs, + pageName: data?.pageName, rowCount: data?.rowCount, fieldCount: data?.fieldCount + }} + } catch (e) { + verifyResults.value = { ...verifyResults.value, [idx]: { success: false, message: '请求异常: ' + (e.response?.data?.message || e.message) } } + } finally { + verifying.value = { ...verifying.value, [idx]: false } + } +} const merged = computed(() => (Array.isArray(sitesList.value)?sitesList.value:[]).map(s => ({ siteId: s.siteId, names: s.names, version: pagesMap.value[s.siteId]?.version || null, entryCount: pagesMap.value[s.siteId]?.entryCount || 0,