Files
t/tools/check-vue.js
root 4f5893f87a fix: 修复首页空白/失效外链/云函数越权,补齐缺失页面与种子数据
## 阻断性缺陷
- list.vue 是 0 字节空文件、slist.vue 与 search/search.vue 从未存在,
  而前者是 tabBar 首页、后者是 tabBar「搜索」页 —— 开屏即白屏。
  按 .nvue 原型与详情页契约重建三页(CSS 渐变主视觉、分类筛选、搜索历史/热搜/联想)。
- parse-image-url.js 对空封面调 undefined.startsWith 直接抛错,列表页整页崩。
- 云函数目录缺 uni-cms-articles / uni-cms-categories / uni-cms-unlock-record
  schema 与 schema.ext.js,线上内容渲染与解锁逻辑无配置可用。

## 越权与数据一致性
- uni-cms-articles:del/update/add 全部无鉴权,未登录即可删任意文章、
  改他人文章作者与阅读量。补 login + 作者归属校验,作者与计数改为服务端取值。
- comments.likeComment/relikeComment:直接采信客户端传入的 user_id,
  可冒名点赞刷计数。改为以令牌为准,并纳入事务。
- comments.updateComment:对数组取 .author_id,权限判断恒失败;
  字段名 updateTime 与 ip_location 类型与 schema 不符。
- comments.deleteComment:`!root_id === 0` 优先级错误导致计数恒不减;
  且误更新 uni-cms-articles、按不存在的 type 字段删点赞明细产生孤儿数据。
- cms-articles-like/collect:查重条件混入本次请求时间戳,防重永远失效,
  可无限重复刷计数;补唯一索引并事务化。
- cms-vote:读-改-写票数导致并发丢票,记录与统计非原子;改为事务 + 原子自增。
- cms-articles-log:忽略传入 user_id 直接返回全表,泄露全站浏览记录。
- article_info:get() 使用未定义变量必崩;读接口全部无鉴权。
- user-info:公开资料接口可查任意用户 last_login_ip。

## 资源与数据
- 全项目清空失效的签名外链(expire_at 均为 2025-03,必然 403),
  改为本地生成资源:6 套文章模板、8 个编辑器图标、2 张文章配图。
- 新增分类 / 模板 / 礼物 / 热搜词种子数据,并在 db_init.json 登记,
  同时补上点赞、收藏、投票、浏览日志的唯一索引。

## 功能
- 草稿箱:预览页拆出「发布」与「存为草稿」,作品列表按状态筛选并显示徽标。
  原实现有 4 个 tab 但只有 1 个有内容,且 article_status 在 UI 上无体现。
- 编辑中断恢复:接上原本空实现的「编辑草稿」回调,区分新建与编辑已有文章。

## 工具
- tools/audit-project.js:编码 / 页面路由 / 云调用 / 云函数鉴权 / 敏感信息检查
- tools/check-vue.js:SFC 脚本语法(词法扫描处理 import·export 与条件编译)
- tools/verify.js:一键验证;两个检查器各带自测,防止"永远通过"
- tools/gen-*.py:模板与图标资源生成脚本
2026-09-11 17:48:27 +08:00

540 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
/**
* Vue SFC 结构与脚本语法校验。
*
* audit-project.js 只做交叉引用检查,不解析 <script> 里的 JS。
* 这个脚本补上那一段:把每个 .vue 拆成 template / script / style,
* 对 <script> 做真正的语法解析,并做几项结构性检查。
*
* 用法:
* node tools/check-vue.js # 人类可读
* node tools/check-vue.js --json
* node tools/check-vue.js --selftest # 验证校验器本身有效
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.resolve(__dirname, '..');
const JSON_OUT = process.argv.includes('--json');
const SELFTEST = process.argv.includes('--selftest');
const IGNORE_DIRS = new Set(['.git', 'node_modules', 'unpackage', '.hbuilderx', '.trae', 'git']);
const errors = [];
const warnings = [];
function walk(dir, out = []) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
if (e.isDirectory()) {
if (IGNORE_DIRS.has(e.name)) continue;
walk(path.join(dir, e.name), out);
} else if (e.isFile() && e.name.endsWith('.vue')) {
out.push(path.join(dir, e.name));
}
}
return out;
}
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/');
/** 官方/第三方模块只记警告 */
const VENDOR_PREFIXES = [
'pages3/uni_modules/',
'js_sdk/',
'components/thorui/', // 第三方 UI 库整套引入
'components/hqs-popup/',
];
function isVendor(r) {
if (VENDOR_PREFIXES.some((p) => r.startsWith(p))) return true;
const m = r.match(/^uni_modules\/([^/]+)\//);
if (m && !['uni-cms-article', 'hb-comment'].includes(m[1])) return true;
return false;
}
/** 取出 SFC 的各个顶层块 */
function splitSFC(src) {
const blocks = { template: [], script: [], style: [] };
const re = /<(template|script|style)([^>]*)>/gi;
let m;
while ((m = re.exec(src))) {
const tag = m[1].toLowerCase();
const attrs = m[2];
// 自闭合的 <template /> 不是块
if (/\/\s*$/.test(attrs)) continue;
const end = src.indexOf(`</${tag}>`, m.index);
if (end === -1) continue;
blocks[tag].push({ attrs, body: src.slice(m.index + m[0].length, end), start: m.index });
re.lastIndex = end;
}
return blocks;
}
/**
* 解析脚本语法。
* SFC 的 <script> 是 ESM(含 import/export),Function 构造器解析不了,
* 因此这里把 import/export 改写成等价的可解析形式后再做语法检查。
*/
/**
* 把 ESM 源码转成一段可被 vm.Script 解析的等效代码。
*
* 关键点:不能对源码做文本替换——字符串字面量、注释、模板字符串里的
* "import"/"export" 字样都会被误伤。因此这里用词法扫描定位真正的关键字。
*/
/**
* 从 open 位置开始,找到与之配对的闭合符号的下一个位置。
* 跳过字符串、模板字符串、注释,避免被其中的括号误导。
* 返回闭合符号之后的下标,找不到返回 -1。
*/
function matchBrace(code, open, openCh, closeCh) {
let depth = 0;
let i = open;
const n = code.length;
while (i < n) {
const c = code[i];
if (c === '/' && code[i + 1] === '/') {
const nl = code.indexOf('\n', i);
i = nl === -1 ? n : nl;
continue;
}
if (c === '/' && code[i + 1] === '*') {
const end = code.indexOf('*/', i + 2);
i = end === -1 ? n : end + 2;
continue;
}
if (c === '"' || c === "'" || c === '`') {
let j = i + 1;
while (j < n) {
if (code[j] === '\\') {
j += 2;
continue;
}
if (code[j] === c) break;
j++;
}
i = j + 1;
continue;
}
if (c === openCh) depth++;
else if (c === closeCh) {
depth--;
if (depth === 0) return i + 1;
}
i++;
}
return -1;
}
function stripModuleSyntax(code) {
let out = '';
let i = 0;
let defaultCount = 0;
const n = code.length;
const isIdentChar = (c) => /[A-Za-z0-9_$]/.test(c);
while (i < n) {
const c = code[i];
// 行注释
if (c === '/' && code[i + 1] === '/') {
const end = code.indexOf('\n', i);
const stop = end === -1 ? n : end;
out += code.slice(i, stop);
i = stop;
continue;
}
// 块注释
if (c === '/' && code[i + 1] === '*') {
const end = code.indexOf('*/', i + 2);
const stop = end === -1 ? n : end + 2;
out += code.slice(i, stop);
i = stop;
continue;
}
// 字符串 / 模板字符串:原样保留,内部内容不参与关键字识别
if (c === '"' || c === "'" || c === '`') {
let j = i + 1;
while (j < n) {
if (code[j] === '\\') {
j += 2;
continue;
}
if (code[j] === c) break;
j++;
}
out += code.slice(i, Math.min(j + 1, n));
i = j + 1;
continue;
}
// import / export 关键字(必须是独立词)
const atWordStart = i === 0 || !isIdentChar(code[i - 1]);
if (atWordStart && code.startsWith('import', i) && !isIdentChar(code[i + 6] || '')) {
// import 语句一定以模块说明符(引号)结尾,后跟可选分号。
// 从 import 起找到第一个字符串字面量即为语句终点,这样
// 多行 `import {\n a,\n b\n} from 'x'` 与单行写法都能正确处理。
let j = i + 6;
let end = -1;
while (j < n) {
const ch = code[j];
if (ch === '"' || ch === "'") {
let k = j + 1;
while (k < n && code[k] !== ch) {
if (code[k] === '\\') k++;
k++;
}
end = k + 1;
break;
}
// 分号早于任何字符串说明这不是 import 语句,交给正常流程
if (ch === ';') break;
j++;
}
if (end === -1) {
out += c;
i++;
continue;
}
// 吞掉尾随分号
if (code[end] === ';') end++;
// 用等量换行占位,保持行号不变
out += code.slice(i, end).replace(/[^\n]/g, '');
i = end;
continue;
}
if (atWordStart && code.startsWith('export', i) && !isIdentChar(code[i + 6] || '')) {
let j = i + 6;
while (j < n && /[ \t]/.test(code[j])) j++;
if (code.startsWith('default', j) && !isIdentChar(code[j + 7] || '')) {
// `export default X` 的 X 若是对象/函数字面量,裸露会变成块语句或语法错误,
// 因此包成 `void ( X )` 保持表达式语义。这里扫描出 X 的结束位置后立即闭合。
let k = j + 7;
while (k < n && /[\s]/.test(code[k])) k++;
let exprEnd;
const ch = code[k];
if (ch === '{') {
exprEnd = matchBrace(code, k, '{', '}');
} else if (ch === '[') {
exprEnd = matchBrace(code, k, '[', ']');
} else if (ch === '(') {
exprEnd = matchBrace(code, k, '(', ')');
} else {
// 标识符 / 字面量:读到行尾或分号
let m = k;
while (m < n && code[m] !== '\n' && code[m] !== ';') m++;
exprEnd = m;
}
if (exprEnd === -1) {
out += c;
i++;
continue;
}
defaultCount++;
const expr = code.slice(k, exprEnd);
out += 'void (';
// 表达式内容按原样追加,但保持内部换行以维持行号
out += expr;
out += ')';
i = exprEnd;
if (code[i] === ';') i++;
continue;
}
// export const/function/class/... -> 去掉 export 关键字
out += '';
i = i + 6;
continue;
}
out += c;
i++;
}
return { code: out, defaultCount };
}
function checkScriptSyntax(code, name) {
let s = code;
// uni-app 条件编译:同一脚本里,不同平台分支会重复声明同名变量,
// 全量解析必然误报"重复声明"。丢弃 #ifdef 平台专属分支,保留通用分支。
s = s.replace(/\/\/\s*#ifdef[^\n]*\n[\s\S]*?\/\/\s*#endif/g, '');
s = s.replace(/\/\/\s*#(?:ifndef|endif)[^\n]*/g, '');
// stripModuleSyntax 只删除模块语法本身,不改变代码结构
const { code: stripped } = stripModuleSyntax(s);
try {
new vm.Script(stripped, { filename: name });
return null;
} catch (e) {
return e.message;
}
}
const lineOf = (src, idx) => src.slice(0, idx).split('\n').length;
function checkFile(file) {
const r = rel(file);
const src = fs.readFileSync(file, 'utf8');
const vendor = isVendor(r);
const report = (level, msg, line) => {
const target = vendor || level === 'warn' ? warnings : errors;
const key = `${r}|${line}|${msg}`;
if (target.some((x) => x.key === key)) return;
target.push({ key, file: r, line, msg });
};
const blocks = splitSFC(src);
if (!blocks.template.length && !blocks.script.length) {
report('error', '文件既没有 <template> 也没有 <script>,可能是空文件或损坏', 1);
return;
}
// 1) 每个 script 块做语法解析
for (const blk of blocks.script) {
if (!blk.body.trim()) {
report('error', '<script> 块为空', lineOf(src, blk.start));
continue;
}
// TS / UTS 需要对应编译器,跳过语法解析(用 JS 规则解析必然误报)
const langMatch = /\blang\s*=\s*["']([^"']+)["']/.exec(blk.attrs);
const lang = langMatch ? langMatch[1].toLowerCase() : 'js';
if (lang !== 'js' && lang !== 'javascript') {
report('warn', `<script lang="${lang}">:跳过 JS 语法校验(需 ${lang.toUpperCase()} 编译器)`, lineOf(src, blk.start));
continue;
}
const err = checkScriptSyntax(blk.body, r);
if (err) {
report('error', `脚本语法错误: ${err}`, lineOf(src, blk.start));
}
}
// 2) 整文件是否残留未闭合的 <template>(真结构性错误)
const tmplOpen = (src.match(/<template[\s>]/g) || []).length;
const tmplClose = (src.match(/<\/template>/g) || []).length;
if (tmplOpen !== tmplClose) {
report('error', `<template> 标签未闭合(开 ${tmplOpen} / 闭 ${tmplClose})`, 1);
}
// 3) Vue2 已移除的 API
const vue2Apis = [
[/\bbeforeDestroy\s*[(:]/, 'beforeDestroy 已移除,改用 beforeUnmount'],
[/\bdestroyed\s*\(/, 'destroyed 已移除,改用 unmounted'],
[/\$children\b/, '$children 已移除'],
[/\bfilters\s*:/, 'filters 已移除,改用计算属性或方法'],
[/v-on\s*:\s*\w+\.native/, 'v-on.native 已移除'],
[/Vue\.prototype\./, 'Vue.prototype 已移除,改用 app.config.globalProperties'],
];
for (const blk of blocks.script) {
for (const [re, msg] of vue2Apis) {
const m = re.exec(blk.body);
if (m) report('warn', msg, lineOf(src, blk.start) + blk.body.slice(0, m.index).split('\n').length - 1);
}
}
// 4) 页面级生命周期如果被写在 methods 内部,不会触发。
// 判断方式:该函数缩进比 methods 更深,说明确实嵌套在 methods 里。
for (const blk of blocks.script) {
const body = blk.body;
const methodsMatch = /^[ \t]*methods\s*:/m.exec(body);
if (!methodsMatch) continue;
const methodsIndent = /^([ \t]*)/.exec(methodsMatch[0])[1].length;
for (const hook of ['onReachBottom', 'onPullDownRefresh', 'onPageScroll', 'onShareAppMessage']) {
const re = new RegExp(`^([ \\t]*)${hook}\\s*\\(`, 'm');
const m = re.exec(body);
if (!m) continue;
// 缩进比 methods 更深 => 嵌在 methods 里,不会被 uni-app 触发
if (m[1].length > methodsIndent) {
report(
'error',
`${hook} 写在 methods 内不会触发,应移到页面级选项`,
lineOf(src, blk.start) + body.slice(0, m.index).split('\n').length - 1
);
}
}
}
}
function selfTest() {
const cases = [];
const check = (label, actual, expected) => cases.push({ label, ok: actual === expected, actual, expected });
// 正常 SFC 不该报错
const good = `<template><view>{{a}}</view></template>
<script>
export default { data(){ return { a: 1 } } }
</script>`;
const goodBlocks = splitSFC(good);
check('正常 SFC 能拆出 template', goodBlocks.template.length, 1);
check('正常 SFC 能拆出 script', goodBlocks.script.length, 1);
check('正常脚本语法通过', checkScriptSyntax(goodBlocks.script[0].body, 'good'), null);
// 语法错误的脚本必须被抓到
const bad = `<template><view/></template>
<script>
export default { data(){ return { a: } } }
</script>`;
const badBlocks = splitSFC(bad);
check('错误脚本语法被识别', checkScriptSyntax(badBlocks.script[0].body, 'bad') !== null, true);
// 空 script 应被识别
const empty = `<template><view/></template><script></script>`;
check('空 script 被识别', splitSFC(empty).script[0].body.trim().length, 0);
// 自闭合 template 不应被当成块
const selfClosed = `<template><view/></template>`;
check('自闭合 template 不计入块', splitSFC(selfClosed).template.filter((b) => b.body.includes('</')).length, 0);
// import/export 处理:多行、字符串内含关键字、注释内含关键字
const multiImport = `
import {
a,
b
} from '@/x'
import y from '@/y';
export default {
data() {
return { a, b, y }
}
}`;
check('多行 import 被正确剥离', checkScriptSyntax(multiImport, 'multi'), null);
const trickyStrings = `
const s = "import { a } from 'fake'"
const t = 'export default nope'
const u = \`import x from "y"\`
export default { data(){ return { s, t, u } } }`;
check('字符串中的 import/export 不误伤', checkScriptSyntax(trickyStrings, 'tricky'), null);
const trickyComments = `
// import fake from 'nope'
/* export default alsoFake */
export default { data(){ return { ok: 1 } } }`;
check('注释中的 import/export 不误伤', checkScriptSyntax(trickyComments, 'comment'), null);
const condCompile = `
export default {
methods: {
init() {
// #ifdef MP-TOUTIAO
let info = tt.getRect()
// #endif
// #ifndef MP-TOUTIAO
let info = uni.getRect()
// #endif
}
}
}`;
check('条件编译分支不误报重复声明', checkScriptSyntax(condCompile, 'cond'), null);
// 行号必须保持:剥离 import 后不应改变后续代码的行位置
const lineKeep = `import a from 'b'\nimport c from 'd'\nconst boom = )`;
const errMsg = checkScriptSyntax(lineKeep, 'linekeep');
check('语法错误仍然能被发现', errMsg !== null, true);
// 生命周期钩子缩进判定:页面级(与 methods 同级)不应报错
const pageLevelHook = `export default {
methods: {
loadMore() {}
},
onReachBottom() {
this.loadMore()
}
}`;
check('页面级 onReachBottom 不误报', indentCheck(pageLevelHook, 'onReachBottom'), false);
// 嵌在 methods 里应当报错
const nestedHook = `export default {
methods: {
onReachBottom() {
this.loadMore()
}
}
}`;
check('methods 内的 onReachBottom 被识别', indentCheck(nestedHook, 'onReachBottom'), true);
return cases;
}
/** 复用主流程的缩进判定逻辑,供自测使用 */
function indentCheck(body, hook) {
const methodsMatch = /^[ \t]*methods\s*:/m.exec(body);
if (!methodsMatch) return false;
const methodsIndent = /^([ \t]*)/.exec(methodsMatch[0])[1].length;
const m = new RegExp(`^([ \\t]*)${hook}\\s*\\(`, 'm').exec(body);
if (!m) return false;
return m[1].length > methodsIndent;
}
function main() {
const files = walk(ROOT);
if (SELFTEST) {
const cases = selfTest();
console.log('');
console.log('Vue 校验器自测:');
for (const c of cases) {
console.log(` ${c.ok ? '✓' : '✗'} ${c.label}${c.ok ? '' : `(期望 ${c.expected},实得 ${c.actual})`}`);
}
const failed = cases.filter((c) => !c.ok).length;
console.log('');
console.log(failed ? `✗ ${failed} 项失败` : '✓ 校验器工作正常');
return failed ? 1 : 0;
}
for (const f of files) checkFile(f);
if (JSON_OUT) {
console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings, scanned: files.length }, null, 2));
return errors.length ? 1 : 0;
}
console.log('');
console.log('═══════════════════════════════════════════════');
console.log(' Vue SFC 校验报告');
console.log('═══════════════════════════════════════════════');
console.log(` 扫描 ${files.length} 个 .vue 文件\n`);
const fmt = (list) =>
list.map((x) => ` ${x.file}:${x.line}\n ▸ ${x.msg}`).join('\n');
if (errors.length) {
console.log(`✗ 错误 ${errors.length} 项`);
console.log(fmt(errors));
console.log('');
}
if (warnings.length) {
console.log(`⚠ 警告 ${warnings.length} 项`);
console.log(fmt(warnings));
console.log('');
}
if (!errors.length && !warnings.length) console.log('✓ 全部通过\n');
console.log(`结论: ${errors.length} 错误 / ${warnings.length} 警告`);
console.log('═══════════════════════════════════════════════');
return errors.length ? 1 : 0;
}
process.exit(main());