feat: 脚本下载/编辑接口、前端Groovy编辑器、Groovy错误行号提取

This commit is contained in:
mediabot-pt
2026-07-01 10:43:53 +08:00
parent a674734258
commit d35c5e90c9
5 changed files with 135 additions and 13 deletions

View File

@@ -229,6 +229,52 @@ public class AdminController {
return ApiResponse.success(configService.toDTO(config)); return ApiResponse.success(configService.toDTO(config));
} }
/**
* 查看指定配置的 Groovy 脚本
*/
@GetMapping("/configs/{configId}/script")
public ApiResponse<Map<String, Object>> getConfigScript(
@PathVariable Long configId, HttpServletRequest request) {
checkAdmin(request);
SiteConfig config = siteConfigMapper.selectById(configId);
if (config == null) return ApiResponse.error(404, "Config not found");
String script = null;
if (config.getScriptStoragePath() != null) {
try { script = storageService.read(config.getScriptStoragePath()); } catch (Exception ignored) {}
}
return ApiResponse.success(Map.of("id", config.getId(), "script", script != null ? script : ""));
}
/**
* 编辑待审核配置的 Groovy 脚本
*/
@PutMapping("/configs/{configId}/script")
public ApiResponse<SiteConfigDTO> updateConfigScript(
@PathVariable Long configId,
@RequestBody Map<String, String> payload,
HttpServletRequest request) {
checkAdmin(request);
SiteConfig config = siteConfigMapper.selectById(configId);
if (config == null) return ApiResponse.error(404, "Config not found");
if (config.getStatus() != ConfigStatus.PENDING) {
return ApiResponse.error(400, "只能编辑待审核的配置");
}
String script = payload.get("script");
if (script == null) return ApiResponse.error(400, "script is required");
String scriptPath = config.getScriptStoragePath();
if (scriptPath == null || scriptPath.isBlank()) {
scriptPath = String.format("configs/%s/pending/%s.groovy", config.getSiteId(), config.getConfigHash().substring(0, 8));
config.setScriptStoragePath(scriptPath);
}
storageService.store(scriptPath, script);
siteConfigMapper.updateById(config);
log.info("Config script updated: id={}, siteId={}", configId, config.getSiteId());
return ApiResponse.success(configService.toDTO(config));
}
/** /**
* 设置用户信任等级(禁止修改管理员、禁止提升为管理员) * 设置用户信任等级(禁止修改管理员、禁止提升为管理员)
*/ */

View File

@@ -117,6 +117,29 @@ public class ConfigController {
.body(content); .body(content);
} }
/**
* 下载指定版本 Groovy 脚本(供 mediabot 使用)
*/
@GetMapping("/{siteId}/{version}/script")
public ResponseEntity<String> downloadScript(
@PathVariable("siteId") String siteId,
@PathVariable("version") String version) {
SiteConfig config = configService.getConfig(siteId, version);
if (config == null || config.getScriptStoragePath() == null) {
return ResponseEntity.notFound().build();
}
String script;
try { script = storageService.read(config.getScriptStoragePath()); }
catch (Exception e) { return ResponseEntity.status(500).body("Failed to read script"); }
if (script == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, "text/plain;charset=UTF-8")
.body(script);
}
/** /**
* 提交新配置(需 HMAC 鉴权) * 提交新配置(需 HMAC 鉴权)
*/ */

View File

@@ -41,7 +41,7 @@ public class GroovyScriptEngine {
return buildResult(result, scriptResult); return buildResult(result, scriptResult);
} catch (Exception e) { } catch (Exception e) {
result.put("matched", false); result.put("matched", false);
result.put("message", "Groovy 脚本执行异常: " + e.getMessage()); result.put("message", extractGroovyError(e));
log.error("Groovy error for url={}", url, e); log.error("Groovy error for url={}", url, e);
} }
return result; return result;
@@ -91,6 +91,25 @@ public class GroovyScriptEngine {
return result; return result;
} }
/** 从 Groovy 异常中提取含行号的错误信息 */
private String extractGroovyError(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();
// Groovy 编译后的类名通常是 Script1, Script2 或用户脚本类
if (cn.startsWith("Script") || cn.contains("$_run_closure") || cn.contains("$__execute")) {
sb.append("\n at line ").append(ste.getLineNumber());
if (ste.getFileName() != null) sb.append(" (").append(ste.getFileName()).append(")");
break;
}
}
if (e.getCause() != null && e.getCause() != e) {
sb.append("\nCaused by: ").append(e.getCause().getMessage());
}
return sb.toString();
}
public static class JsoupHelper { public static class JsoupHelper {
public Object parse(String html) { return Jsoup.parse(html); } public Object parse(String html) { return Jsoup.parse(html); }
public Object parse(String html, String baseUri) { return Jsoup.parse(html, baseUri); } public Object parse(String html, String baseUri) { return Jsoup.parse(html, baseUri); }

View File

@@ -79,6 +79,8 @@ export default {
pendingBySite: (siteId) => api.get(`/admin/configs/pending/${siteId}`), pendingBySite: (siteId) => api.get(`/admin/configs/pending/${siteId}`),
getContent: (configId) => api.get(`/admin/configs/${configId}/content`), getContent: (configId) => api.get(`/admin/configs/${configId}/content`),
updateContent: (configId, content) => api.put(`/admin/configs/${configId}/content`, { content }), updateContent: (configId, content) => api.put(`/admin/configs/${configId}/content`, { content }),
getScript: (configId) => api.get(`/admin/configs/${configId}/script`),
updateScript: (configId, script) => api.put(`/admin/configs/${configId}/script`, { script }),
approve: (configId) => api.post(`/admin/reviews/${configId}/approve`), approve: (configId) => api.post(`/admin/reviews/${configId}/approve`),
reject: (configId, comment) => api.post(`/admin/reviews/${configId}/reject`, null, { params: { comment: comment || '' } }), reject: (configId, comment) => api.post(`/admin/reviews/${configId}/reject`, null, { params: { comment: comment || '' } }),
}, },

View File

@@ -48,20 +48,33 @@
</div> </div>
</el-card> </el-card>
<!-- 配置编辑弹窗 --> <!-- 配置/脚本弹窗 -->
<el-dialog v-model="configVisible" title="适配配置" width="700px" :close-on-click-modal="false"> <el-dialog v-model="configVisible" title="适配配置" width="800px" :close-on-click-modal="false">
<template v-if="editing"> <el-tabs v-model="configTab" type="card" size="small">
<el-input v-model="editContent" type="textarea" :rows="20" style="font-family:monospace;font-size:12px" /> <el-tab-pane label="配置JSON" name="config">
<div style="margin-top:10px;display:flex;gap:8px"> <template v-if="editing">
<el-button type="primary" size="small" @click="saveEdit" :loading="saving">保存</el-button> <el-input v-model="editContent" type="textarea" :rows="20" style="font-family:monospace;font-size:12px" />
<el-button size="small" @click="cancelEdit">取消</el-button> </template>
</div> <pre v-else class="config-json" v-html="highlightedConfig"></pre>
</template> </el-tab-pane>
<pre v-else class="config-json" v-html="highlightedConfig"></pre> <el-tab-pane label="Groovy 脚本" name="script">
<div v-if="scriptLoading" style="text-align:center;padding:20px;color:#909399">加载中...</div>
<template v-else>
<el-input v-model="scriptContent" type="textarea" :rows="20" style="font-family:monospace;font-size:12px"
placeholder="// Groovy 解析脚本&#10;// 内置变量: content(页面内容), url(访问URL), jsoup, json, utils&#10;&#10;def doc = jsoup.parse(content)&#10;..." />
</template>
</el-tab-pane>
</el-tabs>
<template #footer> <template #footer>
<el-button @click="configVisible=false">关闭</el-button> <el-button @click="configVisible=false">关闭</el-button>
<template v-if="!editing && selectedConfig?.status==='pending'"> <template v-if="selectedConfig?.status==='pending'">
<el-button type="primary" size="small" @click="startEdit">编辑</el-button> <el-button v-if="configTab==='config'" type="primary" size="small" @click="editing? saveEdit() : startEdit()">
{{ editing ? '保存' : '编辑' }}
</el-button>
<el-button v-if="configTab==='config' && editing" size="small" @click="cancelEdit">取消</el-button>
<el-button v-if="configTab==='script'" type="primary" size="small" @click="saveScript" :loading="saving">
保存脚本
</el-button>
</template> </template>
</template> </template>
</el-dialog> </el-dialog>
@@ -158,8 +171,12 @@ const selectedConfigId = ref(null)
const selectedConfig = ref(null) const selectedConfig = ref(null)
const selectedConfigJson = ref('') const selectedConfigJson = ref('')
const configVisible = ref(false) const configVisible = ref(false)
watch(configVisible, (v) => { if (v) { configTab.value='config'; loadScript() } })
const configTab = ref('config')
const editing = ref(false) const editing = ref(false)
const editContent = ref('') const editContent = ref('')
const scriptContent = ref('')
const scriptLoading = ref(false)
const saving = ref(false) const saving = ref(false)
const approving = ref(false) const approving = ref(false)
const rejecting = ref(false) const rejecting = ref(false)
@@ -259,6 +276,21 @@ const refresh = async () => {
// 编辑 // 编辑
const startEdit = () => { editContent.value=selectedConfigJson.value; editing.value=true } const startEdit = () => { editContent.value=selectedConfigJson.value; editing.value=true }
const cancelEdit = () => { editing.value=false } const cancelEdit = () => { editing.value=false }
const loadScript = async () => {
if (!selectedConfig.value) return
scriptLoading.value = true
try { const r = await api.adminConfigs.getScript(selectedConfig.value.id); scriptContent.value = r.data?.script || '' }
catch { scriptContent.value = '' }
finally { scriptLoading.value = false }
}
const saveScript = async () => {
saving.value = true
try {
await api.adminConfigs.updateScript(selectedConfig.value.id, scriptContent.value)
ElMessage.success('脚本已保存'); doBatchParse()
} catch(e) { ElMessage.error(e.response?.data?.message||'保存失败') }
finally { saving.value = false }
}
const saveEdit = async () => { const saveEdit = async () => {
saving.value = true saving.value = true
try { try {