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

98 lines
2.7 KiB
JavaScript

/**
* 图片地址解析:云存储 / 扩展存储(七牛)→ 可直接渲染的临时 URL
*
* 后台可能返回三种地址:
* cloud:// uniCloud 云存储,用 uniCloud.getTempFileURL 换取临时地址
* qiniu:// 扩展存储(七牛),走 ext-storage-co 云对象
* http(s):// 已是公网地址,原样返回
*
* 历史实现在 article.thumbnail 为空时会对 undefined 调 startsWith 直接抛错,
* 导致列表页整页白屏,这里统一做空值防护。
*/
function parseEditorImage(blocks = []) {
const images = []
const list = Array.isArray(blocks) ? blocks : [blocks]
for (const block of list) {
if (!block) continue
const { insert = {}, attributes = {} } = block
const { 'data-custom': custom = '' } = attributes
const parseCustom = custom.split('&').reduce((obj, item) => {
const [key, value] = item.split('=')
if (key && value) obj[key] = value
return obj
}, {})
if (!insert.image) continue
images.push({
src: insert.image,
source: parseCustom.source ? parseCustom.source : insert.image
})
}
return images
}
/** 归一化成去重后的字符串数组 */
function normalize(value) {
const list = Array.isArray(value) ? value : value ? [value] : []
return list.filter((item) => typeof item === 'string' && item)
}
/** 把单个地址换成临时 URL;失败时退回原地址,不抛出 */
async function toTempUrl(src) {
if (typeof src !== 'string' || !src) return src
if (src.startsWith('cloud://')) {
try {
const res = await uniCloud.getTempFileURL({ fileList: [src] })
const file = res && res.fileList && res.fileList[0]
return (file && file.tempFileURL) || src
} catch (e) {
console.error('云存储地址解析失败:', src, e)
return src
}
}
if (src.startsWith('qiniu://')) {
try {
const extCo = uniCloud.importObject('ext-storage-co')
const res = await extCo.getTempFileURL({ src })
const file = res && res.fileList && res.fileList[0]
return (file && file.tempFileURL) || src
} catch (e) {
console.error('扩展存储地址解析失败:', src, e)
return src
}
}
return src
}
/**
* 解析媒体库 / 编辑器中的图片
* @param images 图片地址,字符串或数组
* @param type {string} 解析类型 media: 媒体库, editor: 编辑器
* @returns {Promise<{src: string, source: string}[]>}
*/
export async function parseImageUrl(images = [], type = 'media') {
let list
if (type === 'editor') {
list = parseEditorImage(images).map((item) => item.source)
} else {
list = normalize(images)
}
if (!list.length) return []
const resolved = await Promise.all(list.map(toTempUrl))
return resolved.map((src, i) => ({
src: src || list[i],
source: list[i]
}))
}