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
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env node
'use strict';
/**
* 一键验证:跑完所有检查器并汇总。
*
* node tools/verify.js # 全部检查
* node tools/verify.js --quick # 跳过耗时的编码扫描
*
* 退出码 0 表示全部通过,可用于 CI 或提交前自检。
*/
const { spawnSync } = require('child_process');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const QUICK = process.argv.includes('--quick');
const CHECKS = [
{
name: '静态审计',
script: 'tools/audit-project.js',
desc: '编码 / 页面路由 / 云调用 / 云函数鉴权 / 敏感信息',
},
{
name: 'Vue SFC 校验',
script: 'tools/check-vue.js',
desc: '脚本语法 / 生命周期位置 / Vue2 残留 API',
},
];
const SELFTESTS = [
{ name: '静态审计自测', script: 'tools/audit-project.js', args: ['--selftest'] },
{ name: 'Vue 校验自测', script: 'tools/check-vue.js', args: ['--selftest'] },
];
function run(script, args = []) {
const r = spawnSync(process.execPath, [path.join(ROOT, script), ...args], {
encoding: 'utf8',
cwd: ROOT,
});
return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
}
function summarize(out) {
// 从报告末尾抓 "结论: N 错误 / M 警告"
const m = /结论:\s*(\d+)\s*错误\s*\/\s*(\d+)\s*警告/.exec(out);
if (m) return { errors: Number(m[1]), warnings: Number(m[2]) };
// 自测输出
const f = /✗\s*(\d+)\s*项失败/.exec(out);
if (f) return { errors: Number(f[1]), warnings: 0 };
return null;
}
let failed = 0;
console.log('');
console.log('═══════════════════════════════════════════════');
console.log(' 军歌嘹亮 · 一键验证');
console.log('═══════════════════════════════════════════════');
// 第一阶段:先确认检查器本身可信
console.log('');
console.log('【检查器自测】');
for (const t of SELFTESTS) {
const { code, out } = run(t.script, t.args);
const ok = code === 0;
if (!ok) failed++;
console.log(` ${ok ? '✓' : '✗'} ${t.name}`);
if (!ok) {
console.log(
out
.split('\n')
.filter((l) => l.includes('✗'))
.map((l) => ' ' + l.trim())
.join('\n')
);
}
}
// 第二阶段:跑实际检查
console.log('');
console.log('【项目检查】');
const results = [];
for (const c of CHECKS) {
if (QUICK && c.name.includes('静态')) continue;
const { code, out } = run(c.script);
const s = summarize(out);
const ok = code === 0;
if (!ok) failed++;
results.push({ ...c, ok, summary: s });
const stat = s ? `${s.errors} 错误 / ${s.warnings} 警告` : code === 0 ? '通过' : '失败';
console.log(` ${ok ? '✓' : '✗'} ${c.name.padEnd(14)} ${stat}`);
}
// 第三阶段:数据与资源完整性
console.log('');
console.log('【数据与资源】');
const fs = require('fs');
const dataChecks = [
['分类种子数据', 'uniCloud-alipay/database/uni-cms-categories.init_data.json'],
['文章模板数据', 'uniCloud-alipay/database/cms-temp.init_data.json'],
['礼物种子数据', 'uniCloud-alipay/database/gifts.init_data.json'],
['热搜词数据', 'uniCloud-alipay/database/opendb-search-hot.init_data.json'],
['模板图片资源', 'static/template/default.png'],
['编辑器图标', 'static/editor-icons/text.png'],
['文章配图', 'static/article/wz1.png'],
];
for (const [label, rel] of dataChecks) {
const abs = path.join(ROOT, rel);
const exists = fs.existsSync(abs);
let detail = '';
if (exists) {
try {
const content = fs.readFileSync(abs, 'utf8');
if (rel.endsWith('.json')) {
const arr = JSON.parse(content);
detail = Array.isArray(arr) ? `${arr.length} 条` : '对象';
} else {
detail = `${(fs.statSync(abs).size / 1024).toFixed(1)} KB`;
}
} catch (e) {
detail = `解析失败: ${e.message}`;
}
}
if (!exists) failed++;
console.log(` ${exists ? '✓' : '✗'} ${label.padEnd(14)} ${detail}`);
}
// schema 部署完整性:主表 schema 缺失会导致线上集合无 schema 约束
const schemaChecks = [
['文章表', 'uniCloud-alipay/database/uni-cms-articles.schema.json'],
['分类表', 'uniCloud-alipay/database/uni-cms-categories.schema.json'],
['解锁记录表', 'uniCloud-alipay/database/uni-cms-unlock-record.schema.json'],
['文章内容扩展', 'uniCloud-alipay/database/uni-cms-articles.schema.ext.js'],
];
for (const [label, rel] of schemaChecks) {
const abs = path.join(ROOT, rel);
const exists = fs.existsSync(abs);
if (!exists) failed++;
console.log(` ${exists ? '✓' : '✗'} ${label.padEnd(14)} ${exists ? '已部署' : '缺失'}`);
}
// schema 与种子数据是 JSONC(uniCloud 允许行注释),需要用宽松方式解析
function parseJSONC(text) {
let out = '';
let i = 0;
const n = text.length;
while (i < n) {
const c = text[i];
// 字符串原样保留
if (c === '"') {
let j = i + 1;
while (j < n) {
if (text[j] === '\\') { j += 2; continue; }
if (text[j] === '"') break;
j++;
}
out += text.slice(i, Math.min(j + 1, n));
i = j + 1;
continue;
}
// 行注释:换成等量空白以保持结构
if (c === '/' && text[i + 1] === '/') {
const end = text.indexOf('\n', i);
const stop = end === -1 ? n : end;
out += ' '.repeat(stop - i);
i = stop;
continue;
}
out += c;
i++;
}
return JSON.parse(out);
}
// 所有 schema 与 db_init 都必须能被解析
const schemaFiles = fs
.readdirSync(path.join(ROOT, 'uniCloud-alipay/database'))
.filter((f) => f.endsWith('.schema.json') || f === 'db_init.json');
let badSchema = 0;
for (const f of schemaFiles) {
const abs = path.join(ROOT, 'uniCloud-alipay/database', f);
try {
const obj = parseJSONC(fs.readFileSync(abs, 'utf8'));
if (!obj || typeof obj !== 'object') throw new Error('根节点不是对象');
} catch (e) {
badSchema++;
failed++;
console.log(` ✗ ${f.padEnd(14)} ${e.message}`);
}
}
if (!badSchema) {
console.log(` ✓ ${'schema 解析'.padEnd(14)} ${schemaFiles.length} 个文件全部有效`);
}
// db_init 必须登记所有种子数据表,否则线上初始化会漏表
const initPath = path.join(ROOT, 'uniCloud-alipay/database/db_init.json');
try {
const init = parseJSONC(fs.readFileSync(initPath, "utf8"));
const registered = Object.keys(init);
const expected = [
'uni-cms-categories',
'cms-temp',
'gifts',
'opendb-search-hot',
'cms-articles-like',
'cms-articles-collect',
'cms-vote',
'cms-articles-log',
];
const missing = expected.filter((t) => !registered.includes(t));
if (missing.length) failed++;
console.log(
` ${missing.length ? '✗' : '✓'} ${'db_init 登记'.padEnd(14)} ${
missing.length ? '缺少: ' + missing.join(', ') : `${registered.length} 张表`
}`
);
} catch (e) {
failed++;
console.log(` ✗ db_init 登记 解析失败: ${e.message}`);
}
console.log('');
console.log('═══════════════════════════════════════════════');
console.log(failed ? `✗ ${failed} 项未通过` : '✓ 全部通过');
console.log('═══════════════════════════════════════════════');
process.exit(failed ? 1 : 0);