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:
@@ -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);
|
||||
|
||||
@@ -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 下载)的场景使用
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user