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
@@ -33,7 +33,7 @@
住在大城市里,"军民鱼水情"只是句日常的用语,并没有真正切实的体会。
</view>
<image
src="https://env-00jxt5y29hrd.normal.cloudstatic.cn/static/static/wz1.png?expire_at=1741142112&er_sign=639352a13dd5bb04b7fa46bb8e3f5f93"
src="/static/article/wz1.png"
mode=""></image>
<view class="tab">
1952年的冬天,我团接到上级命令,前往朝鲜战场。这兴奋劲像个麻雀,跳跃不停。经过战前培训,我们坐着向北驰去的列车。一路上歌声不断,当列车到达山东济南时,站台上摆着热气腾腾的饭菜,我们狼吞虎咽地吃着可口的饭菜,这比在列车上吃饼干要好百倍了。经过一天多的劳累,在大年除夕的那天,我们到达了安东。快速转移到一个街坊后,多数人都安排好了住所,可我们班还剩六名战士没法安排,连长还在为难。
@@ -43,7 +43,7 @@
</view>
<image
src="https://env-00jxt5y29hrd.normal.cloudstatic.cn/static/static/wz2.jpg?expire_at=1741142127&er_sign=05178b7851f3216a45b7badf85b1c7d3"
src="/static/article/wz2.png"
mode=""></image>
<view class="tab">
我们住进小屋,六个人挤在一张床上睡觉,可怎么都睡不着。平时说的"军民鱼水情",今天真的出现了吗?老乡说:"志愿军出国作战,到了安东,哪能汽车16团汽车修理工叫他们在野外露营?我俩到我娘那里住一张建群宿就行。"这几句朴实无华的话,真是"鱼水情"的真实体验。
@@ -92,71 +92,71 @@
</view>
<view class="example-body" style="justify-content:center">
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
</view>
<view class="example-body" style="justify-content:center">
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
</view>
<view class="example-body" style="justify-content:center">
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
<image
src="https://qnycdn.mymoyu.top/static/avatar.jpg"
src="/static/center/avatar.png"
style="width: 64rpx;height: 64rpx;border-radius: 50%;"></image>
</view>
@@ -176,7 +176,7 @@
setup() {
const hbComment = ref(null)
const avatar = ref("")
const templateSrc = ref("https://env-00jxt5y29hrd.normal.cloudstatic.cn/static/bg.jpg?expire_at=1741101988&er_sign=c9732cddaaead925cf110d9a11751231")
const templateSrc = ref("/static/template/default.png")
const commentData = ref(null)
const reqFlag = ref(false)
@@ -195,7 +195,7 @@
success: function(res) {
if (res.confirm) {
uni.redirectTo({
url: '/pages/login/login'
url: '/pages3/uni_modules/uni-id-pages/pages/login/login-withoutpwd'
})
}
}
+40 -2
View File
@@ -1,6 +1,10 @@
<template>
<view class="content">
<image class="index" src="https://env-00jxt5y29hrd.normal.cloudstatic.cn/static/index.jpg?expire_at=1741101691&er_sign=3bcc9c0a7c1cf915beee4bd1f8cc2884" mode="scaleToFill"></image>
<view class="splash">
<view class="emblem">★</view>
<view class="title">军歌嘹亮</view>
<view class="subtitle">军旅故事 · 老兵记忆</view>
</view>
</view>
</template>
@@ -11,6 +15,7 @@
setup() {
onLoad(() => {
setTimeout(() => {
// 首页是 tabBar 页面,必须用 switchTab
uni.switchTab({
url: "/uni_modules/uni-cms-article/pages/list/list"
})
@@ -22,8 +27,41 @@
</script>
<style lang="scss">
.index {
.content {
width: 100%;
height: calc(100vh - 58px);
}
/* 用渐变做主视觉,避免依赖会过期的签名图片链接 */
.splash {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-image: linear-gradient(160deg, #c11c1f 0%, #8a1013 45%, #5c0201 100%);
}
.emblem {
font-size: 120rpx;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 6rpx 24rpx rgba(0, 0, 0, 0.35);
margin-bottom: 24rpx;
}
.title {
font-size: 76rpx;
font-weight: 700;
color: #fff;
letter-spacing: 12rpx;
text-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.4);
}
.subtitle {
margin-top: 20rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
letter-spacing: 6rpx;
}
</style>
+132 -103
View File
@@ -1,118 +1,147 @@
<template>
<view>
<view class="">
<button @click="get()">get</button>
<button @click="del()">del</button>
<button @click="add()">add</button>
<button @click="update()">update</button>
<button @click="likeComment()">likeComment</button>
<button @click="relikeComment()">relikeComment</button>
<button @click="to()">to</button>
<view class="page">
<view class="section">
<view class="section-title">页面跳转自检</view>
<button class="btn" @click="toHome">首页(switchTab)</button>
<button class="btn" @click="toSearch">搜索页(switchTab)</button>
<button class="btn" @click="toDetail">文章详情(需 id)</button>
<button class="btn" @click="toLogin">登录页</button>
</view>
<view class="section">
<view class="section-title">云函数连通性自检</view>
<button class="btn" @click="checkAll" :loading="checking">开始检测</button>
<button class="btn" @click="clearLog">清空结果</button>
</view>
<view class="section">
<view class="section-title">结果</view>
<view class="line" v-for="(item, index) in logs" :key="index">
<text :class="item.ok ? 'ok' : 'fail'">{{ item.ok ? '✓' : '✗' }}</text>
<text class="line-text">{{ item.text }}</text>
</view>
</view>
</view>
</template>
<script>
import { onMounted } from 'vue'
/**
* 自检页:验证页面路由与云函数连通性。
* 只做只读探测,不写入任何业务数据。
*/
export default {
setup() {
onMounted(() => {
// 模拟 onLoad
})
async function get() {
let query = { article_id: "1" }
const res = await uniCloud.importObject("comments").getComments(query)
console.log("成功获取评论:", res)
}
async function del() {
let id = "map[$oid:67b45c598a5c78c37f605029]"
const res = await uniCloud.importObject("comments").deleteComment(id)
console.log(res)
}
async function add() {
try {
const uniIdToken = uni.getStorageSync('uni_id_token')
if (!uniIdToken) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
const comment = {
article_id: "67bbac72c6de56f8256d76b1",
content: "测试评论",
parent_id: 0,
}
const commentObj = uniCloud.importObject("comments")
const res = await commentObj.addComment(comment)
if (res.code === 200) {
uni.showToast({ title: '评论成功' })
console.log('评论详情:', {
id: res.data.comment_id,
depth: res.data.depth,
rootComment: res.data.root_id || '一级评论'
})
}
} catch (e) {
console.error('评论失败:', e)
const errorHandler = {
'PERMISSION_DENIED': '登录状态失效,请重新登录',
'INVALID_PARAMS': '评论内容不能为空',
'CONTENT_BLOCKED': '包含违禁词汇: ' + e.message.split(':')[1],
'PARENT_COMMENT_NOT_FOUND': '回复的评论不存在'
}
uni.showToast({
title: errorHandler[e.errorType] || '评论失败,请稍后重试',
icon: 'none'
})
}
}
async function update() {
let comment_id = "67bdf451dab53f685048dccc"
let comment = { content: "修改后" }
const res = await uniCloud.importObject("comments").updateComment(comment_id, comment)
console.log(res)
}
async function relikeComment() {
let comment_like_id = "67bdd9cd5a6f8bd7032c949a"
let comment_id = "67b466c9ce5ec9e5aa41a25d"
const res = await uniCloud.importObject("comments").relikeComment(comment_like_id, comment_id)
}
async function likeComment() {
let user_id = "67b466c9ce5ec9e5aa41a25d"
let comment_id = "67b466c9ce5ec9e5aa41a25d"
const res = await uniCloud.importObject("comments").likeComment(user_id, comment_id)
}
function to() {
uni.navigateTo({ url: "/uni_modules/uni-cms-article/pages/list/list" })
}
function to1() {
uni.navigateTo({ url: "/uni_modules/uni-cms-article/pages/detail/detail" })
}
data() {
return {
get,
del,
add,
update,
likeComment,
relikeComment,
to,
to1
checking: false,
logs: []
}
},
methods: {
log(ok, text) {
this.logs.unshift({ ok, text });
},
clearLog() {
this.logs = [];
},
// ── 路由自检 ──
toHome() {
uni.switchTab({ url: '/uni_modules/uni-cms-article/pages/list/list' });
},
toSearch() {
uni.switchTab({ url: '/uni_modules/uni-cms-article/pages/list/slist' });
},
toDetail() {
uni.navigateTo({ url: '/uni_modules/uni-cms-article/pages/detail/detail' });
},
toLogin() {
uni.navigateTo({
url: '/pages3/uni_modules/uni-id-pages/pages/login/login-withoutpwd'
});
},
// ── 云函数自检 ──
async checkAll() {
this.checking = true;
this.logs = [];
try {
await this.checkCloudFn('uni-cms-categories', 'getTags', {});
await this.checkCloudFn('gift_info', 'getGiftList', {});
await this.checkCloudFn('comments', 'getComments', { article_id: '' });
await this.checkCloudFn('cms-vote', 'getVoteInfo', { vote_id: '' });
await this.checkCloudFn('cms-articles-log', 'getHistoryList', {});
this.log(uni.getStorageSync('uni_id_token') ? true : false,
uni.getStorageSync('uni_id_token') ? '登录态:已登录' : '登录态:未登录(部分接口会拒绝,属正常)');
} finally {
this.checking = false;
}
},
async checkCloudFn(name, method, params) {
try {
const obj = uniCloud.importObject(name, { customUI: true });
if (typeof obj[method] !== 'function') {
this.log(false, `${name}.${method}() 不存在`);
return;
}
const res = await obj[method](params);
const code = res && (res.code !== undefined ? res.code : res.errCode);
const ok = code === 200 || code === '200' || code === 0 || code === '0';
this.log(ok, `${name}.${method}() → ${ok ? '正常' : `返回 ${JSON.stringify(code)}`}`);
} catch (e) {
this.log(false, `${name}.${method}() → ${e.errMsg || e.message || '调用失败'}`);
}
}
}
}
</script>
<style>
.page {
padding: 24rpx;
background-color: #f7f7f7;
min-height: 100vh;
}
.section {
background-color: #fff;
border-radius: 12rpx;
padding: 24rpx;
margin-bottom: 24rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
}
.btn {
font-size: 28rpx;
margin-bottom: 12rpx;
}
.line {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 8rpx 0;
font-size: 26rpx;
}
.ok {
color: #18b566;
margin-right: 12rpx;
}
.fail {
color: #dd524d;
margin-right: 12rpx;
}
.line-text {
flex: 1;
color: #555;
word-break: break-all;
}
</style>