// 只读证据收集:后台写接口 → 前端按钮文案(含出处),供人工核对后写进 set-known-audit-logs.js // // node bin/checks/evidence.js 输出到 stdout(很长,建议重定向) // node bin/checks/evidence.js > ev.txt // // 链路:controller 端点 → api 函数(按 url+method 匹配)→ 调用该函数的 handler → 绑定该 handler 的按钮文案 const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..', '..'); const UI = path.join(ROOT, 'ruoyi-ui', 'src'); function walk(dir, out, filter) { if (!fs.existsSync(dir)) return out; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'dist') walk(p, out, filter); } else if (!filter || filter(e.name)) out.push(p); } return out; } const norm = (u) => String(u).replace(/\$\{[^}]*\}/g, '{}').replace(/\{[^}]*\}/g, '{}') .replace(/\/+/g, '/').replace(/\/$/, '').replace(/['"`;\s]/, ''); // ---------- 1. api 函数 → url/method ---------- const apiEntries = []; // {fn, url, method, module} for (const f of walk(path.join(UI, 'api'), [], (n) => n.endsWith('.js'))) { const src = fs.readFileSync(f, 'utf8'); const mod = '@/api/' + path.relative(path.join(UI, 'api'), f).replace(/\\/g, '/').replace(/\.js$/, ''); for (const m of src.matchAll(/export function (\w+)\s*\(([\s\S]{0,600}?)\n\}/g)) { const body = m[2]; const url = (body.match(/url:\s*([`'"])([^`'"]+)\1/) || [])[2]; const method = ((body.match(/method:\s*['"](\w+)['"]/) || [])[1] || 'get').toUpperCase(); if (url) apiEntries.push({ fn: m[1], url, method, module: mod, file: path.relative(ROOT, f) }); } } // ---------- 2. .vue 里的按钮 + handler → api 调用 ---------- const vueFiles = walk(path.join(UI, 'views'), [], (n) => n.endsWith('.vue')); const fnEvidence = new Map(); // fn -> [{vue,line,label}] for (const f of vueFiles) { const src = fs.readFileSync(f, 'utf8'); if (!/from ['"]@\/api\//.test(src)) continue; const rel = path.relative(ROOT, f); // 按钮:文字 / el-dropdown-item for (const m of src.matchAll(/]*?@click(?:\.native)?="([\w.]+)[^"]*"[\s\S]{0,400}?>\s*([^<>{}]{1,20}?)\s*<\/el-(?:button|dropdown-item)>/g)) { const handler = m[1].split('.')[0]; const label = m[2].trim(); const line = (src.slice(0, m.index).match(/\n/g) || []).length + 1; // handler 函数体:从定义处到下一个顶层方法定义(截断保护),比按第一个 } 截更可靠 const hm = src.match(new RegExp('(?:^|\\n)\\s*' + handler + '\\s*\\([^)]*\\)\\s*\\{')); if (!hm) continue; const rest = src.slice(hm.index + hm[0].length); const nextDef = rest.search(/\n\s{0,4}[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/); const body = rest.slice(0, nextDef < 0 ? 3000 : Math.min(nextDef, 3000)); for (const e of apiEntries) { if (new RegExp('\\b' + e.fn + '\\s*\\(').test(body)) { if (!fnEvidence.has(e.fn)) fnEvidence.set(e.fn, []); fnEvidence.get(e.fn).push({ vue: rel, line, label }); } } } } // ---------- 3. controller 端点 ---------- const isBlockMember = (l) => /^\s*(\/\/|\*|\/\*|@)/.test(l) || (/^\s*[\w.]+\s*=/.test(l) && /[,)]\s*$/.test(l)); const endpoints = []; for (const f of walk(path.join(ROOT, 'skins-service'), [], (n) => n.endsWith('Controller.java')) .concat(walk(path.join(ROOT, 'skins-promo'), [], (n) => n.endsWith('Controller.java')))) { const src = fs.readFileSync(f, 'utf8'); const base = (src.match(/@RequestMapping\(\s*(?:value\s*=\s*)?"([^"]*)"/) || [, null])[1]; if (!base || !base.startsWith('/admin')) continue; const lines = src.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const mm = lines[i].match(/^\s*@(Get|Post|Put|Delete)Mapping\b(.*)$/); if (!mm) continue; if (mm[1].toUpperCase() === 'GET' && !/reset|clear|delete|remove|update|save|apply|generate|sync|send|publish|toggle|adjust|grant|cancel|recalc/i.test(lines[i])) { // GET 默认跳过,除非名字像改数据 } let top = i; for (let j = i - 1; j >= 0; j--) { if (lines[j].trim() === '' || !isBlockMember(lines[j])) break; top = j; } const block = lines.slice(top, i).join('\n'); let name = null; for (let j = i + 1; j < Math.min(i + 14, lines.length); j++) { const s = lines[j].match(/^\s*(?:public|protected|private)\s+[\w<>\[\],\.\? ]+\s+(\w+)\s*\(/); if (s) { name = s[1]; break; } } const sub = (mm[2].match(/"([^"]*)"/) || [, ''])[1]; endpoints.push({ controller: path.basename(f, '.java'), method: name, http: mm[1].toUpperCase(), url: base + sub, human: /menu\s*=/.test(block) || /action\s*=/.test(block), hasLog: /@Log\(/.test(block), }); } } // ---------- 4. 关联 ---------- const out = []; for (const ep of endpoints) { if (ep.http === 'GET' && !/reset|clear|delete|remove|update|save|apply|generate|sync|send|publish|toggle|adjust|grant|cancel|recalc/i.test(ep.method || '')) continue; if (ep.http === 'GET' && !/reset|clear|delete|remove|update|save|apply|generate|sync|send|publish|toggle|adjust|grant|cancel|recalc/i.test(ep.url)) continue; const hit = apiEntries.find((e) => e.method === ep.http && norm(e.url) === norm(ep.url)) || apiEntries.find((e) => e.method === ep.http && norm(e.url).startsWith(norm(ep.url) + '/')); const ev = hit ? (fnEvidence.get(hit.fn) || []) : []; out.push({ ep, hit, ev }); } const state = (ep) => (!ep.hasLog ? '不记' : ep.human ? '已人话' : '旧JSON'); const onlyTodo = process.argv.includes('--todo'); console.log('controller.method | HTTP url | 现状 | 建议文案 | 证据'); for (const { ep, hit, ev } of out) { if (onlyTodo && ep.human) continue; const labels = [...new Set(ev.map((x) => x.label))]; const suggest = labels.length === 1 ? labels[0] : labels.length === 0 ? '—(无按钮证据)' : labels.map((l) => l + '?').join('/'); const evidence = ev.slice(0, 2).map((x) => `${x.vue}:${x.line}`).join(' ') || (hit ? `api ${hit.fn} 无按钮绑定` : '无 api'); console.log(`${(ep.controller + '.' + ep.method).padEnd(52)} ${ep.http.padEnd(6)} ${ep.url.padEnd(44)} ${state(ep).padEnd(6)} ${suggest.padEnd(22)} ${evidence}`); }