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
@@ -1,10 +1,6 @@
const db = uniCloud.database();
const _ = db.command;
const $ = db.command.aggregate;
const {
log,
time
} = require('console');
const uniID = require('uni-id-common');
@@ -14,31 +10,35 @@ let obj = {
return new Date();
},
async getDeletedCount(commentId) {
const {
deleted
} = await db.collection('comment_like')
// count() 返回的是 total,不是 deleted
const { total } = await db.collection('comment_like')
.where({
comment_id: commentId
})
.count();
return deleted;
return total;
},
// 高并发安全的计数方法
async incrementReadCount(query) {
const t = Date.now();
try {
await this.checkRequestFrequency(query.article_id, context.CLIENTIP);
if (!query || !query.article_id) {
return { code: 400, message: '缺少 article_id' };
}
// context 在原实现里未定义,必然抛 ReferenceError 并被吞掉,阅读量永远不增长
const clientInfo = this.getClientInfo();
await this.checkRequestFrequency(query.article_id, clientInfo.clientIP);
const res = await db.collection('uni-cms-articles')
.doc(query.article_id.toString())
.update({
view_count: db.command.inc(1),
last_view_time: t
last_view_time: Date.now()
});
return {
code: 200,
data: {
view_count: res.updated ? res.data.view_count : 0
view_count: res.updated ? 1 : 0
}
};
} catch (e) {
@@ -137,17 +137,12 @@ let obj = {
map.set(node.id, node);
// 根节点判断(仅依赖 parent_id)
const isRootNode = !parentId;
// 根节点判断(仅依赖 parent_id)。
// 哨兵值可能是数字 0 或字符串 "0",字符串 "0" 是 truthy,必须显式比较。
const isRootNode = !parentId || parentId === 0 || parentId === '0';
if (isRootNode) {
roots.push(node);
}
console.log('节点预处理:', {
id: node.id,
parentId,
isRootNode
});
});
// 2. 构建树结构
@@ -156,7 +151,7 @@ let obj = {
const current = map.get(currentId);
const parentId = current.parent_id;
if (parentId) {
if (parentId && parentId !== 0 && parentId !== '0') {
const parent = map.get(parentId);
if (parent) {
parent.replies.push(current);
@@ -271,57 +266,61 @@ module.exports = {
} = await this.uniID.checkToken(clientInfo.uniIdToken);
// 参数校验
if (!query.content) {
if (!query.content || !query.content.trim()) {
return {
code: 400,
message: '评论内容不能为空'
};
}
console.log(query);
if (query.content.trim().length > 500) {
return {
code: 400,
message: '评论内容不能超过500字符'
};
}
let {
article_id,
content,
parent_id,
root_id
} = query;
console.log(root_id, parent_id);
// 处理父评论逻辑
if (parent_id && parent_id !== 0) {
// 取父评论信息。原实现无条件对 parent_id 调 doc(),
// 顶层评论未传 parent_id 时会以 doc(undefined) 报错。
let parentComment = null;
if (parent_id && parent_id !== 0 && parent_id !== '0') {
const parentRecord = await db.collection('comment')
.doc(parent_id)
.get();
if (!parentRecord.data[0]) {
parentComment = parentRecord.data && parentRecord.data[0];
if (!parentComment) {
return {
code: 404,
message: '父评论不存在'
};
}
// 回复应挂到父评论所属的根评论下,不接受客户端随意指定
root_id = (!parentComment.root_id || parentComment.root_id === 0 || parentComment.root_id === '0')
? parent_id
: parentComment.root_id;
}
const parent_comment = await db.collection("comment")
.doc(parent_id)
.get();
console.log(parent_comment);
// 构建评论数据
// 构建评论数据。schema 把 parent_id / root_id 定义为 string,
// 真实 id 也是字符串,因此哨兵统一写成 "0"。
const commentData = {
article_id,
author_id: uid,
content,
parent_id,
root_id,
parent_author_id: parent_comment.data.length > 0 ? parent_comment.data[0].author_id : 0,
parent_author_content: parent_comment.data.length > 0 ? parent_comment.data[0].content : "",
content: content.trim(),
parent_id: parent_id ? String(parent_id) : '0',
root_id: root_id ? String(root_id) : '0',
parent_author_id: parentComment ? parentComment.author_id : '',
parent_author_content: parentComment ? parentComment.content : '',
create_time: obj.getDateTime(),
update_time: obj.getDateTime(),
ip_location: {
clientIP: clientInfo.clientIP
} || {},
children:[],
clientIP: clientInfo.clientIP || ''
},
children: [],
like_count: 0,
reply_count: 0
};
@@ -334,10 +333,13 @@ module.exports = {
id
} = await db.collection('comment').add(commentData);
await db.collection('comment').doc(root_id)
.update({
reply_count: _.inc(1)
});
// 只在确实存在根评论时累加回复数,避免对客户端传入的任意 id 刷计数
if (commentData.root_id && commentData.root_id !== 0 && commentData.root_id !== '0') {
await db.collection('comment').doc(commentData.root_id)
.update({
reply_count: _.inc(1)
});
}
// 获取用户信息
const userMap = await obj.getUserInfoMap([uid]);
@@ -384,12 +386,18 @@ module.exports = {
} = await this.uniID.checkToken(clientInfo.uniIdToken);
// 参数校验
if (!query.content) {
if (!query.content || !query.content.trim()) {
return {
code: 400,
message: '评论内容不能为空'
};
}
if (query.content.trim().length > 500) {
return {
code: 400,
message: '评论内容不能超过500字符'
};
}
let {
content,
@@ -398,12 +406,13 @@ module.exports = {
} = query;
// 处理父评论逻辑
if (parent_id && parent_id !== 0) {
if (parent_id && parent_id !== 0 && parent_id !== '0') {
const parentRecord = await db.collection('comment')
.doc(parent_id)
.get();
const parent = parentRecord.data && parentRecord.data[0];
if (!parentRecord.data[0]) {
if (!parent) {
return {
code: 404,
message: '父评论不存在'
@@ -411,37 +420,37 @@ module.exports = {
}
// 继承父评论的root_id(如果父评论是根评论则使用父评论ID)
root_id = parentRecord.data[0].root_id === 0 ?
root_id = (!parent.root_id || parent.root_id === 0 || parent.root_id === '0') ?
parent_id :
parentRecord.data[0].root_id;
parent.root_id;
}
// 构建评论数据
// 构建评论数据。schema 把 parent_id / root_id 定义为 string,
// 真实 id 也是字符串,因此哨兵统一写成 "0"。
const commentData = {
article_id: query.article_id,
author_id: uid,
content,
parent_id,
root_id,
content: content.trim(),
parent_id: parent_id ? String(parent_id) : '0',
root_id: root_id ? String(root_id) : '0',
create_time: obj.getDateTime(),
update_time: obj.getDateTime(),
ip_location: {
clientIP: clientInfo.clientIP
} || {},
clientIP: clientInfo.clientIP || ''
},
like_count: 0,
reply_count: 0
};
console.log(commentData);
// 插入数据库
const {
id
} = await db.collection('comment').add(commentData);
// 更新父评论的回复计数
if (parent_id !== 0) {
// 更新父评论的回复计数(原判断 parent_id !== 0 对字符串 "0" 不成立,会误加)
if (commentData.parent_id && commentData.parent_id !== 0 && commentData.parent_id !== '0') {
await db.collection('comment')
.doc(parent_id)
.doc(commentData.parent_id)
.update({
reply_count: db.command.inc(1)
});
@@ -508,33 +517,30 @@ module.exports = {
})
.get();
if (!commentDoc.data) {
// data 为空时是空数组,必须取下标判断,否则后面的 .author_id 会抛 TypeError
const target = commentDoc.data && commentDoc.data[0];
if (!target) {
throw new Error('NOT_FOUND: 评论不存在');
}
console.log(commentDoc.data);
if (commentDoc.data[0].author_id !== uid) {
if (target.author_id !== uid) {
throw new Error('PERMISSION_DENIED: 无权限删除该评论');
}
transaction = await db.startTransaction();
await transaction.collection('comment').doc(id).remove();
// comment_like 没有 type 字段,带上该条件会一条都匹配不到,点赞明细变成孤儿
await transaction.collection('comment_like')
.where({
comment_id: id,
type: 'comment' // 添加类型过滤提高准确性
comment_id: id
})
.remove();
if (!root_id === 0) {
await transaction.collection('uni-cms-articles')
// 父评论的回复计数要减在 comment 表的父评论上。
// 原写法 `!root_id === 0` 恒为 false,且误更新了 uni-cms-articles 表,导致计数只增不减。
if (root_id && root_id !== 0 && root_id !== '0' && root_id !== id) {
await transaction.collection('comment')
.doc(root_id)
.update({
reply_count: _.inc(-1)
@@ -543,11 +549,10 @@ module.exports = {
await transaction.commit();
await db.collection('comment_operation_logs').add({
type: 'comment_delete',
uid,
comment_id: commentDoc.id,
comment_id: id,
timestamp: obj.getDateTime()
});
@@ -607,23 +612,32 @@ module.exports = {
const commentDoc = await db.collection('comment')
.doc(id)
.field({
author_id: 1
author_id: 1,
content: 1
})
.get();
if (commentDoc.data.author_id !== uid) {
// get() 返回的 data 是数组,取值必须带下标
const target = commentDoc.data && commentDoc.data[0];
if (!target) {
throw new Error('NOT_FOUND: 评论不存在');
}
if (target.author_id !== uid) {
throw new Error('PERMISSION_DENIED: 无权修改该评论');
}
// ==== 数据处理 ====
const sanitizedContent = comment.content.trim();
const maskedIP = clientInfo.clientIP.replace(/\.\d+$/, '.*');
const maskedIP = (clientInfo.clientIP || '').replace(/\.\d+$/, '.*');
// ==== 构建更新数据 ====
// 字段名与类型要与 comment.schema.json 一致:update_time 为时间戳,ip_location 为对象
const updateData = {
updateTime: obj.getDateTime(),
update_time: obj.getDateTime(),
content: sanitizedContent,
ip_location: maskedIP
ip_location: {
clientIP: maskedIP
}
};
if (comment.title) {
updateData.title = comment.title.substring(0, 50);
@@ -639,7 +653,7 @@ module.exports = {
type: 'comment_update',
operator: uid,
target_id: id,
before_content: commentDoc.data.content,
before_content: target.content,
after_content: sanitizedContent,
timestamp: obj.getDateTime()
});
@@ -655,10 +669,7 @@ module.exports = {
};
} catch (error) {
console.error(`[UPDATE_COMMENT_ERROR] ${error.message}`, {
id,
user: this.getClientInfo().uid
});
console.error(`[UPDATE_COMMENT_ERROR] ${error.message}`, { id });
return {
code: error.code || 500,
@@ -680,7 +691,8 @@ module.exports = {
console.log(query);
const commentsRes = await db.collection('comment')
.where({
root_id: 0,
// 历史数据的哨兵值有数字 0 与字符串 "0" 两种,都要能命中
root_id: _.in([0, '0']),
article_id: query.article_id
})
.field({
@@ -799,23 +811,29 @@ module.exports = {
},
async likeComment(user_id, comment_id) {
let datatime = new Date().getTime();
// 以服务端令牌为准。原实现直接采信客户端传入的 user_id,
// 可以冒用他人身份点赞并刷高 like_count。
const { uid } = await this.uniID.checkToken(this.getUniIdToken());
if (!uid) return { code: 401, msg: '请先登录' };
if (!comment_id) return { code: 400, msg: '缺少 comment_id' };
const transaction = await db.startTransaction();
try {
const exist = await transaction.collection('comment_like')
.where({
user_id: user_id,
user_id: uid,
comment_id: comment_id
}).count();
if (exist.total > 0) throw new Error('已点赞');
if (exist.total > 0) {
await transaction.rollback();
return { code: 201, msg: '已点赞' };
}
await transaction.collection('comment_like').add({
user_id: user_id,
user_id: uid,
comment_id: comment_id,
create_time: datatime
create_time: Date.now()
});
await transaction.collection('comment').doc(comment_id)
@@ -824,42 +842,42 @@ module.exports = {
});
await transaction.commit();
return {
code: 200
};
return { code: 200 };
} catch (e) {
await transaction.rollback();
return {
code: 500,
msg: e.message
};
return { code: 500, msg: e.message };
}
},
async relikeComment(user_id, comment_id) {
const { uid } = await this.uniID.checkToken(this.getUniIdToken());
if (!uid) return { code: 401, msg: '请先登录' };
if (!comment_id) return { code: 400, msg: '缺少 comment_id' };
// 删除明细与减计数必须同事务,否则删成功而减失败会让计数永久偏高
const transaction = await db.startTransaction();
try {
const likeRes = await db.collection('comment_like')
const likeRes = await transaction.collection('comment_like')
.where({
user_id: user_id,
user_id: uid,
comment_id: comment_id
}).remove();
if (likeRes.deleted === 0) throw new Error('未找到点赞记录');
if (likeRes.deleted === 0) {
await transaction.rollback();
return { code: 201, msg: '未找到点赞记录' };
}
await db.collection('comment').doc(comment_id)
await transaction.collection('comment').doc(comment_id)
.update({
like_count: _.inc(-1)
});
return {
code: 200
};
await transaction.commit();
return { code: 200 };
} catch (e) {
return {
code: 500,
msg: e.message
};
await transaction.rollback();
return { code: 500, msg: e.message };
}
},