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:
@@ -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 };
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user