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:模板与图标资源生成脚本
This commit is contained in:
2026-09-11 17:48:27 +08:00
parent 50f8a89d51
commit 4f5893f87a
62 changed files with 5565 additions and 1104 deletions
+539
View File
@@ -0,0 +1,539 @@
#!/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());