feat: 页面配置入口地址添加验证按钮,解析失败自动提交错误日志

- 后端新增 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 方法
This commit is contained in:
mediabot-pt
2026-07-06 13:39:32 +08:00
parent 355f586086
commit 8724774850
3 changed files with 183 additions and 1 deletions

View File

@@ -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<Map<String, Object>> getPages(@PathVariable String siteId) {
@@ -66,6 +76,138 @@ public class SitePagesController {
return ApiResponse.success();
}
/**
* 验证页面入口:用脚本访问指定URL并解析,解析失败时自动提交错误日志
*/
@PostMapping("/api/v1/admin/site-pages/verify")
public ApiResponse<Map<String, Object>> verifyPageEntry(@RequestBody Map<String, Object> 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<String, Object> 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<String, Object> 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<List<Map<String, Object>>> adminList(HttpServletRequest request) {
checkAdmin(request);

View File

@@ -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 下载)的场景使用

View File

@@ -92,7 +92,16 @@
<div style="flex:1"></div>
<el-button size="small" type="danger" text @click="remove(idx)">删除</el-button>
</div>
<el-input v-model="entry.url" size="small" placeholder="URL模板,支持{base}{keyword}{page}{id}{passkey}" @input="syncToJson" style="margin-bottom:6px;max-width:500px" />
<div style="display:flex;gap:8px;align-items:flex-start;margin-bottom:6px;max-width:500px">
<el-input v-model="entry.url" size="small" placeholder="URL模板,支持{base}{keyword}{page}{id}{passkey}" @input="syncToJson" style="flex:1" />
<el-button size="small" @click="verifyEntry(entry, idx)" :loading="verifying[idx]" :type="verifyResults[idx]?.success===true ? 'success' : verifyResults[idx]?.success===false ? 'danger' : ''">
{{ verifyResults[idx]?.success===true ? '✓' : verifyResults[idx]?.success===false ? '✗' : '验证' }}
</el-button>
</div>
<div v-if="verifyResults[idx]?.message" style="margin-bottom:6px;font-size:12px;padding:4px 8px;border-radius:4px" :style="{background:verifyResults[idx]?.success? '#f0f9eb':'#fef0f0',color:verifyResults[idx]?.success? '#67c23a':'#f56c6c'}">
{{ verifyResults[idx].message }}
<span v-if="verifyResults[idx].responseTimeMs != null" style="margin-left:8px;opacity:0.7">({{ verifyResults[idx].responseTimeMs }}ms)</span>
</div>
<el-input v-model="entry.headers" size="small" type="textarea" :rows="2" placeholder="自定义Header,每行一个 key: value" @input="syncToJson" style="margin-bottom:6px;max-width:500px" />
<el-input v-if="entry.method==='POST'" v-model="entry.body" size="small" type="textarea" :rows="2" placeholder="POST Body(可选)" @input="syncToJson" />
</div>
@@ -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,