Files
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

884 lines
22 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.
const db = uniCloud.database();
const _ = db.command;
const $ = db.command.aggregate;
const uniID = require('uni-id-common');
let obj = {
getDateTime() {
return new Date();
},
async getDeletedCount(commentId) {
// count() 返回的是 total,不是 deleted
const { total } = await db.collection('comment_like')
.where({
comment_id: commentId
})
.count();
return total;
},
// 高并发安全的计数方法
async incrementReadCount(query) {
try {
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: Date.now()
});
return {
code: 200,
data: {
view_count: res.updated ? 1 : 0
}
};
} catch (e) {
return this.handleCountError(e);
}
},
// 频率控制核心方法
async checkRequestFrequency(articleId, ip) {
const key = `req:${articleId}:${ip}`;
const now = Date.now();
const record = await db.collection('request_logs')
.where({
key
})
.orderBy('time', 'desc')
.limit(1)
.get();
if (record.data.length > 0 && (now - record.data[0].time) < 1000) {
throw new Error('REQUEST_LIMIT');
}
await db.collection('request_logs').add({
key,
time: now,
articleId,
ip
});
},
// 统一错误处理器
handleCountError(e) {
const errorConfig = {
'REQUEST_LIMIT': {
code: 429,
message: '访问过于频繁'
},
'DATABASE_BUSY': {
code: 503,
message: '系统繁忙请重试'
}
};
return errorConfig[e.message] || {
code: 500,
message: '服务内部错误'
};
},
async getUserInfoMap(userIds) {
const res = await db.collection('uni-id-users')
.where({
_id: db.command.in(userIds)
})
.field({
_id: true,
nickname: true,
avatar_file: true
})
.get();
return new Map(res.data.map(u => [u._id, u]));
},
buildCommentTree(comments) {
if (!Array.isArray(comments) || comments.length === 0) {
console.error('无效的评论数据');
return [];
}
const map = new Map();
const roots = [];
// 1. 数据预处理
comments.forEach(comment => {
// 强制校验 _id 字段
if (!comment._id) {
console.error('评论缺少 _id 字段:', comment);
return;
}
const safeId = comment._id.toString();
const parentId = comment.parent_id;
const rootId = comment.root_id;
const node = {
...comment,
id: safeId,
replies: [],
reply_count: Number(comment.reply_count) || 0,
update_time: Number(comment.update_time) || Date.now(),
create_time: new Date(comment.create_time).getTime() || Date.now()
};
map.set(node.id, node);
// 根节点判断(仅依赖 parent_id)。
// 哨兵值可能是数字 0 或字符串 "0",字符串 "0" 是 truthy,必须显式比较。
const isRootNode = !parentId || parentId === 0 || parentId === '0';
if (isRootNode) {
roots.push(node);
}
});
// 2. 构建树结构
comments.forEach(comment => {
const currentId = comment._id.toString();
const current = map.get(currentId);
const parentId = current.parent_id;
if (parentId && parentId !== 0 && parentId !== '0') {
const parent = map.get(parentId);
if (parent) {
parent.replies.push(current);
parent.reply_count = parent.replies.length;
} else {
console.warn(`父节点 ${parentId} 不存在,将当前节点 ${currentId} 升级为根节点`);
roots.push(current);
}
}
});
// 3. 排序
return roots.sort((a, b) => {
return b.update_time - a.update_time;
});
},
}
module.exports = {
_before() {
this.uniID = uniID.createInstance({
context: this.getClientInfo()
});
if (!this.uniID) throw new Error('uniID 初始化失败');
},
async getReplyComment(query) {
try {
const clientInfo = this.getClientInfo();
const {
uid
} = await this.uniID.checkToken(clientInfo.uniIdToken)
let user_id = uid;
// 1. 优化数据库查询(仅获取根评论)
const commentsRes = await db.collection('comment')
.where({
root_id: query.root_id
})
.field({
_id: true,
content: true,
author_id: true,
parent_id: true,
root_id: true,
create_time: true,
update_time: true,
ip_location: true,
like_count: true,
reply_count: true
})
.orderBy('reply_count', 'desc') // 新增按时间倒序[9](@ref)
.get();
// 2. 数据预处理
const rawComments = Object.freeze(commentsRes.data);
// 3. 批量获取用户信息
const userIds = [...new Set(rawComments.map(c => c.author_id))];
const userMap = await obj.getUserInfoMap(userIds);
// 4. 批量查询点赞状态
const commentIds = rawComments.map(c => c._id);
const likeRes = await db.collection('comment_like')
.where({
comment_id: db.command.in(commentIds),
user_id: user_id
})
.get();
const likeSet = new Set(likeRes.data.map(l => l.comment_id));
// 5. 构建扁平化数据结构
const commentList = rawComments.map(comment => ({
id: comment._id,
_id: comment._id,
pl_user_id: comment.author_id,
owner: comment.author_id === user_id,
hasLike: likeSet.has(comment._id),
likeNum: comment.like_count || 0,
root_id: comment.root_id,
parent_id: comment.parent_id,
avatarUrl: userMap.get(comment.author_id)?.avatar_file || {},
nickName: userMap.get(comment.author_id)?.nickname || '已注销用户',
content: comment.content,
createTime: comment.create_time,
ip_location: comment.ip_location,
hasShowMore: false
}));
return {
code: 200,
data: {
commentList: commentList
}
};
} catch (e) {
console.error('获取评论失败:', e);
return {
code: 500,
message: '查询服务异常'
};
}
},
async replyComment(query) {
try {
const clientInfo = this.getClientInfo();
const {
uid
} = await this.uniID.checkToken(clientInfo.uniIdToken);
// 参数校验
if (!query.content || !query.content.trim()) {
return {
code: 400,
message: '评论内容不能为空'
};
}
if (query.content.trim().length > 500) {
return {
code: 400,
message: '评论内容不能超过500字符'
};
}
let {
article_id,
content,
parent_id,
root_id
} = query;
// 取父评论信息。原实现无条件对 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();
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;
}
// 构建评论数据。schema 把 parent_id / root_id 定义为 string,
// 真实 id 也是字符串,因此哨兵统一写成 "0"。
const commentData = {
article_id,
author_id: uid,
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: [],
like_count: 0,
reply_count: 0
};
// 插入数据库
const {
id
} = await db.collection('comment').add(commentData);
// 只在确实存在根评论时累加回复数,避免对客户端传入的任意 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]);
const userInfo = userMap.get(uid) || {};
// 构建返回数据
return {
code: 200,
data: {
id,
_id: id,
pl_user_id: uid,
owner: true,
hasLike: false,
likeNum: 0,
root_id: commentData.root_id,
parent_id: commentData.parent_id,
avatarUrl: userInfo.avatar_file || {
url: '',
name: '',
extname: ''
},
nickName: userInfo.nickname || '注销用户',
content: commentData.content,
createTime: commentData.create_time,
ip_location: commentData.ip_location,
replyCount: 0
}
};
} catch (e) {
console.error('添加评论失败:', e);
return {
code: 500,
message: '添加评论失败'
};
}
},
async addComment(query) {
try {
const clientInfo = this.getClientInfo();
const {
uid
} = await this.uniID.checkToken(clientInfo.uniIdToken);
// 参数校验
if (!query.content || !query.content.trim()) {
return {
code: 400,
message: '评论内容不能为空'
};
}
if (query.content.trim().length > 500) {
return {
code: 400,
message: '评论内容不能超过500字符'
};
}
let {
content,
parent_id = 0,
root_id = 0
} = query;
// 处理父评论逻辑
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 (!parent) {
return {
code: 404,
message: '父评论不存在'
};
}
// 继承父评论的root_id(如果父评论是根评论则使用父评论ID)
root_id = (!parent.root_id || parent.root_id === 0 || parent.root_id === '0') ?
parent_id :
parent.root_id;
}
// 构建评论数据。schema 把 parent_id / root_id 定义为 string,
// 真实 id 也是字符串,因此哨兵统一写成 "0"。
const commentData = {
article_id: query.article_id,
author_id: uid,
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 || ''
},
like_count: 0,
reply_count: 0
};
// 插入数据库
const {
id
} = await db.collection('comment').add(commentData);
// 更新父评论的回复计数(原判断 parent_id !== 0 对字符串 "0" 不成立,会误加)
if (commentData.parent_id && commentData.parent_id !== 0 && commentData.parent_id !== '0') {
await db.collection('comment')
.doc(commentData.parent_id)
.update({
reply_count: db.command.inc(1)
});
}
// 获取用户信息
const userMap = await obj.getUserInfoMap([uid]);
const userInfo = userMap.get(uid) || {};
// 构建返回数据
return {
code: 200,
data: {
id,
_id: id,
pl_user_id: uid,
owner: true,
hasLike: false,
likeNum: 0,
root_id: commentData.root_id,
parent_id: commentData.parent_id,
avatarUrl: userInfo.avatar_file || {
url: '',
name: '',
extname: ''
},
children: [],
nickName: userInfo.nickname || '注销用户',
content: commentData.content,
createTime: commentData.create_time,
ip_location: commentData.ip_location,
replyCount: 0
}
};
} catch (e) {
console.error('添加评论失败:', e);
return {
code: 500,
message: '添加评论失败'
};
}
},
async deleteComment(id, root_id) {
let transaction = null;
try {
if (!id || typeof id !== 'string' || id.length !== 24) {
throw new Error('INVALID_ID: 无效的评论ID');
}
const clientInfo = this.getClientInfo();
const {
uid
} = await this.uniID.checkToken(clientInfo.uniIdToken);
if (!uid) throw new Error('PERMISSION_DENIED: 用户未登录');
const commentDoc = await db.collection('comment')
.doc(id)
.field({
author_id: 1,
article_id: 1
})
.get();
// data 为空时是空数组,必须取下标判断,否则后面的 .author_id 会抛 TypeError
const target = commentDoc.data && commentDoc.data[0];
if (!target) {
throw new Error('NOT_FOUND: 评论不存在');
}
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
})
.remove();
// 父评论的回复计数要减在 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)
});
}
await transaction.commit();
await db.collection('comment_operation_logs').add({
type: 'comment_delete',
uid,
comment_id: id,
timestamp: obj.getDateTime()
});
console.log(1);
return {
code: 200,
message: '评论及关联数据删除成功',
data: {
deletedComment: 1,
del_id: id
}
};
} catch (e) {
// ================= 11. 事务回滚 =================
if (transaction) await transaction.rollback();
// ================= 12. 错误分类处理 =================
console.error(`[DELETE_COMMENT_ERROR] ${e.message}`, {
comment_id: id,
error: e.stack
});
const errorMap = {
'INVALID_ID': 400,
'PERMISSION_DENIED': 403,
'NOT_FOUND': 404
};
return {
code: errorMap[e.message.split(':')[0]] || 500,
message: e.message.split(':')[1]?.trim() || '系统错误',
errorType: e.message.split(':')[0] || 'UNKNOWN_ERROR'
};
}
},
// ================= 辅助方法 =================
async updateComment(id, comment) {
try {
// ==== 参数校验 ====
if (!id || typeof id !== 'string' || id.length !== 24) {
throw new Error('INVALID_ID: 评论ID格式错误');
}
if (!comment?.content?.trim() || comment.content.trim().length > 500) {
throw new Error('INVALID_CONTENT: 评论内容不能为空且不超过500字符');
}
// ==== 身份验证 ====
const clientInfo = this.getClientInfo();
const {
uid
} = await this.uniID.checkToken(clientInfo.uniIdToken);
// ==== 权限验证 ====
const commentDoc = await db.collection('comment')
.doc(id)
.field({
author_id: 1,
content: 1
})
.get();
// 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+$/, '.*');
// ==== 构建更新数据 ====
// 字段名与类型要与 comment.schema.json 一致:update_time 为时间戳,ip_location 为对象
const updateData = {
update_time: obj.getDateTime(),
content: sanitizedContent,
ip_location: {
clientIP: maskedIP
}
};
if (comment.title) {
updateData.title = comment.title.substring(0, 50);
}
// ==== 执行更新 ====
const res = await db.collection('comment')
.doc(id)
.update(updateData);
// ==== 记录操作日志 ====
await db.collection('comment_operation_logs').add({
type: 'comment_update',
operator: uid,
target_id: id,
before_content: target.content,
after_content: sanitizedContent,
timestamp: obj.getDateTime()
});
return {
code: 200,
message: '评论更新成功',
data: {
updatedId: id,
newContent: sanitizedContent,
timestamp: Date.now()
}
};
} catch (error) {
console.error(`[UPDATE_COMMENT_ERROR] ${error.message}`, { id });
return {
code: error.code || 500,
message: error.message.split(':')[1]?.trim() || '系统错误',
errorType: error.message.split(':')[0]
};
}
},
async getComments(query) {
try {
const clientInfo = this.getClientInfo();
const {
uid
} = await this.uniID.checkToken(clientInfo.uniIdToken)
let user_id = uid;
// 1. 优化数据库查询(仅获取根评论)
console.log(query);
const commentsRes = await db.collection('comment')
.where({
// 历史数据的哨兵值有数字 0 与字符串 "0" 两种,都要能命中
root_id: _.in([0, '0']),
article_id: query.article_id
})
.field({
_id: true,
content: true,
author_id: true,
parent_id: true,
root_id: true,
create_time: true,
update_time: true,
ip_location: true,
like_count: true,
reply_count: true
})
.orderBy('reply_count', 'desc')
.get();
// 2. 数据预处理
const rawComments = Object.freeze(commentsRes.data);
// 3. 批量获取用户信息
const userIds = [...new Set(rawComments.map(c => c.author_id))];
const userMap = await obj.getUserInfoMap(userIds);
// 4. 批量查询点赞状态
const commentIds = rawComments.map(c => c._id);
const likeRes = await db.collection('comment_like')
.where({
comment_id: db.command.in(commentIds),
user_id: user_id
})
.get();
const likeSet = new Set(likeRes.data.map(l => l.comment_id));
// 5. 构建扁平化数据结构
const commentList = rawComments.map(comment => ({
id: comment._id,
_id: comment._id,
pl_user_id: comment.author_id,
owner: comment.author_id === user_id,
hasLike: likeSet.has(comment._id),
likeNum: comment.like_count || 0,
root_id: comment.root_id,
parent_id: comment.parent_id,
avatarUrl: userMap.get(comment.author_id)?.avatar_file || {},
nickName: userMap.get(comment.author_id)?.nickname || '已注销用户',
content: comment.content,
createTime: comment.create_time,
ip_location: comment.ip_location,
replyCount: comment.reply_count || 0,
children: [],
hasShowMore: false
}));
return {
code: 200,
data: {
commentList: commentList // 直接返回无需构建树形结构[8](@ref)
}
};
} catch (e) {
console.error('获取评论失败:', e);
return {
code: 500,
message: '查询服务异常'
};
}
},
async getReadCount(query) {
try {
if (!query || !query.article_id) {
throw new Error('INVALID_PARAM');
}
const res = await db.collection('uni-cms-articles')
.doc(query.article_id.toString())
.field({
view_count: 1,
_id: 0
})
.get();
return {
code: 200,
data: {
view_count: res.data[0]?.view_count || 0
}
};
} catch (e) {
const errorMap = {
'INVALID_PARAM': {
code: 400,
message: '参数异常'
},
'DATABASE_ERROR': {
code: 503,
message: '服务暂不可用'
}
};
return errorMap[e.message] || {
code: 500,
data: {
view_count: 0
},
message: '统计服务异常'
};
}
},
async likeComment(user_id, comment_id) {
// 以服务端令牌为准。原实现直接采信客户端传入的 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: uid,
comment_id: comment_id
}).count();
if (exist.total > 0) {
await transaction.rollback();
return { code: 201, msg: '已点赞' };
}
await transaction.collection('comment_like').add({
user_id: uid,
comment_id: comment_id,
create_time: Date.now()
});
await transaction.collection('comment').doc(comment_id)
.update({
like_count: _.inc(1)
});
await transaction.commit();
return { code: 200 };
} catch (e) {
await transaction.rollback();
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 transaction.collection('comment_like')
.where({
user_id: uid,
comment_id: comment_id
}).remove();
if (likeRes.deleted === 0) {
await transaction.rollback();
return { code: 201, msg: '未找到点赞记录' };
}
await transaction.collection('comment').doc(comment_id)
.update({
like_count: _.inc(-1)
});
await transaction.commit();
return { code: 200 };
} catch (e) {
await transaction.rollback();
return { code: 500, msg: e.message };
}
},
}