feat: 打通 HBuilderX CLI 构建 + 修复文章闭环静态盲区
构建: - 新增 tools/build.js:junction HBuilderX 工具链,CLI 构建 h5/mp-weixin - vue 指向补丁版 @dcloudio/uni-h5-vue(官方 npm vue 不导出 isInSSRComponentSetup) - 设 HX_APP_ROOT 避免退化成 H5 空壳产物;产物完整性校验 校验工具: - 新增 check-cloud-methods.js:acorn 解析云对象方法,比对 94 处调用点 - 新增 check-android-contract.js:Kotlin 侧云对象契约校验 - audit-project.js 修 downloadFile 误报(注释未剥离);tools/ 排除出扫描 - package.json 声明此前隐式依赖的 acorn 功能: - 补 uni-cms-articles.getPublishedArticles(安卓端依赖但此前不存在) - 修 u-parse <audio> 引用已移除组件导致 H5 构建失败
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
|
||||
const uniID = require('uni-id-common');
|
||||
const db = uniCloud.database();
|
||||
|
||||
// 密钥不要硬编码在源码里:请在 uniCloud 控制台为本云函数配置环境变量
|
||||
// (或使用 uni-config-center 统一管理),否则源码一旦外泄等于存储桶失守。
|
||||
@@ -61,18 +62,23 @@ module.exports = {
|
||||
// return res;
|
||||
// },
|
||||
// // 删除文件
|
||||
async deleteFile(data = {}) {
|
||||
const { role, errCode } = await uniID.createInstance({ context: this.getClientInfo() }).checkToken(this.getUniIdToken());
|
||||
if (errCode || !(role || []).includes('admin')) throw new Error('仅管理员可删除文件');
|
||||
let {
|
||||
fileList
|
||||
} = data;
|
||||
console.log(fileList)
|
||||
let res = await extStorageManager.deleteFile({
|
||||
fileList
|
||||
});
|
||||
console.log('deleteFile: ', res);
|
||||
return res;
|
||||
}
|
||||
async deleteFile(data = {}) {
|
||||
const payload = await uniID.createInstance({ context: this.getClientInfo() }).checkToken(this.getUniIdToken());
|
||||
if (payload.errCode) throw new Error('请先登录');
|
||||
const fileList = Array.isArray(data.fileList) ? data.fileList.filter(Boolean) : [];
|
||||
if (!fileList.length) throw new Error('缺少文件地址');
|
||||
if ((payload.role || []).includes('admin')) {
|
||||
return extStorageManager.deleteFile({ fileList });
|
||||
}
|
||||
|
||||
const articleId = data.article_id || data.articleId;
|
||||
if (!articleId) throw new Error('普通用户删除文件必须提供文章 id');
|
||||
const articleRes = await db.collection('uni-cms-articles').doc(articleId).field({ user_id: true, cmsLst: true, thumbnail: true }).get();
|
||||
const article = articleRes.data && articleRes.data[0];
|
||||
if (!article || article.user_id !== payload.uid) throw new Error('无权删除该文章文件');
|
||||
const refs = JSON.stringify(article);
|
||||
if (fileList.some(file => !refs.includes(file))) throw new Error('文件不是该文章引用资源');
|
||||
return extStorageManager.deleteFile({ fileList });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
const crypto = require('crypto')
|
||||
|
||||
function clone(value) {
|
||||
if (value === undefined || value === null) return value
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
function asString(value) {
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (value && typeof value === 'object' && typeof value.src === 'string') return value.src.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeCmsList(list) {
|
||||
if (!Array.isArray(list)) return []
|
||||
|
||||
return list.reduce((result, source) => {
|
||||
if (!source || typeof source !== 'object') return result
|
||||
const item = clone(source)
|
||||
const type = typeof item.type === 'string' ? item.type : ''
|
||||
if (!type) return result
|
||||
|
||||
if (type === 'image') {
|
||||
item.image = item.image && typeof item.image === 'object' ? item.image : {}
|
||||
item.image.src = asString(item.image.src)
|
||||
item.image.alt = typeof item.image.alt === 'string' ? item.image.alt : ''
|
||||
} else if (type === 'video') {
|
||||
item.video = item.video && typeof item.video === 'object' ? item.video : {}
|
||||
item.video.src = asString(item.video.src)
|
||||
item.video.poster = asString(item.video.poster)
|
||||
item.video.duration = Number(item.video.duration) || 0
|
||||
} else if (type === 'text' || type === 'title') {
|
||||
item.text = typeof item.text === 'string' ? item.text : ''
|
||||
item.html = typeof item.html === 'string' ? item.html : ''
|
||||
item.delta = item.delta && typeof item.delta === 'object' ? item.delta : {}
|
||||
} else if (type === 'vote') {
|
||||
item.vote = item.vote && typeof item.vote === 'object' ? item.vote : {}
|
||||
item.vote.vote_id = typeof item.vote.vote_id === 'string' && item.vote.vote_id
|
||||
? item.vote.vote_id
|
||||
: createVoteId()
|
||||
item.vote.voteTitle = typeof item.vote.voteTitle === 'string' ? item.vote.voteTitle.trim() : ''
|
||||
item.vote.voteLst = Array.isArray(item.vote.voteLst)
|
||||
? item.vote.voteLst
|
||||
.filter(option => option && typeof option.value === 'string' && option.value.trim())
|
||||
.map(option => ({ ...option, value: option.value.trim() }))
|
||||
: []
|
||||
}
|
||||
|
||||
if (type === 'image' && !item.image.src) return result
|
||||
if (type === 'video' && !item.video.src) return result
|
||||
if (type === 'text' || type === 'title') {
|
||||
const hasDelta = Array.isArray(item.delta.ops) && item.delta.ops.length > 0
|
||||
if (!hasDelta && !item.html && !item.text) return result
|
||||
}
|
||||
if (type === 'vote' && item.vote.voteLst.length < 2) return result
|
||||
|
||||
result.push(item)
|
||||
return result
|
||||
}, [])
|
||||
}
|
||||
|
||||
function createVoteId() {
|
||||
return `vote_${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`
|
||||
}
|
||||
|
||||
function textOps(item) {
|
||||
const delta = item && item.delta
|
||||
if (delta && Array.isArray(delta.ops) && delta.ops.length) return clone(delta.ops)
|
||||
if (item && item.text) return [{ insert: item.text }]
|
||||
return []
|
||||
}
|
||||
|
||||
function appendParagraphBreak(ops) {
|
||||
if (!ops.length) return ops
|
||||
const last = ops[ops.length - 1]
|
||||
if (typeof last.insert === 'string' && last.insert.endsWith('\n')) return ops
|
||||
ops.push({ insert: '\n' })
|
||||
return ops
|
||||
}
|
||||
|
||||
function cmsLstToDelta(list) {
|
||||
const cmsLst = normalizeCmsList(list)
|
||||
const ops = []
|
||||
|
||||
for (const item of cmsLst) {
|
||||
if (item.type === 'text' || item.type === 'title') {
|
||||
const itemOps = textOps(item)
|
||||
ops.push(...itemOps)
|
||||
appendParagraphBreak(ops)
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.type === 'image' && item.image.src) {
|
||||
ops.push({ insert: { image: item.image.src }, attributes: item.image.alt ? { alt: item.image.alt } : undefined })
|
||||
ops.push({ insert: '\n' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.type === 'video' && item.video.src) {
|
||||
ops.push({
|
||||
insert: { video: item.video.src },
|
||||
attributes: item.video.poster ? { poster: item.video.poster } : undefined
|
||||
})
|
||||
ops.push({ insert: '\n' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.type === 'vote' && item.vote.voteTitle) {
|
||||
ops.push({ insert: `${item.vote.voteTitle}\n` })
|
||||
}
|
||||
}
|
||||
|
||||
return { ops: ops.map(op => {
|
||||
if (op.attributes === undefined) {
|
||||
const copy = { ...op }
|
||||
delete copy.attributes
|
||||
return copy
|
||||
}
|
||||
return op
|
||||
}) }
|
||||
}
|
||||
|
||||
function getArticleText(content, cmsLst) {
|
||||
const values = []
|
||||
if (content && Array.isArray(content.ops)) {
|
||||
for (const op of content.ops) {
|
||||
if (typeof op.insert === 'string') values.push(op.insert)
|
||||
}
|
||||
}
|
||||
for (const item of normalizeCmsList(cmsLst)) {
|
||||
if (item.type === 'text' || item.type === 'title') values.push(item.text || '')
|
||||
if (item.type === 'vote') {
|
||||
values.push(item.vote.voteTitle || '')
|
||||
values.push(...item.vote.voteLst.map(option => option.value))
|
||||
}
|
||||
if (item.type === 'video') values.push(item.text || '')
|
||||
}
|
||||
return values.join('\n').trim()
|
||||
}
|
||||
|
||||
function getArticleImages(content, cmsLst) {
|
||||
const images = []
|
||||
if (content && Array.isArray(content.ops)) {
|
||||
for (const op of content.ops) {
|
||||
if (op && op.insert && typeof op.insert.image === 'string') images.push(op.insert.image)
|
||||
}
|
||||
}
|
||||
for (const item of normalizeCmsList(cmsLst)) {
|
||||
if (item.type === 'image' && item.image.src) images.push(item.image.src)
|
||||
if (item.type === 'video' && item.video.poster) images.push(item.video.poster)
|
||||
}
|
||||
return [...new Set(images)]
|
||||
}
|
||||
|
||||
function hasRenderableContent(content, cmsLst) {
|
||||
return Boolean(
|
||||
(content && Array.isArray(content.ops) && content.ops.some(op => op && op.insert !== undefined)) ||
|
||||
normalizeCmsList(cmsLst).length
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clone,
|
||||
normalizeCmsList,
|
||||
cmsLstToDelta,
|
||||
createVoteId,
|
||||
getArticleText,
|
||||
getArticleImages,
|
||||
hasRenderableContent
|
||||
}
|
||||
@@ -1,9 +1,59 @@
|
||||
const db = uniCloud.database()
|
||||
const collection = db.collection('uni-cms-articles')
|
||||
const uniID = require('uni-id-common')
|
||||
const {
|
||||
normalizeCmsList,
|
||||
cmsLstToDelta,
|
||||
createVoteId,
|
||||
hasRenderableContent
|
||||
} = require('./content-adapter')
|
||||
|
||||
const isAdmin = (payload) => (payload.role || []).includes('admin')
|
||||
|
||||
function normalizeArticlePayload(query = {}, existing = {}) {
|
||||
const edit_type = query.edit_type !== undefined ? query.edit_type : existing.edit_type
|
||||
const mobile = edit_type === 'mobile'
|
||||
const cmsLst = query.cmsLst !== undefined
|
||||
? normalizeCmsList(query.cmsLst)
|
||||
: (Array.isArray(existing.cmsLst) ? normalizeCmsList(existing.cmsLst) : [])
|
||||
const content = mobile
|
||||
? cmsLstToDelta(cmsLst)
|
||||
: (query.content && Array.isArray(query.content.ops) ? query.content : existing.content || { ops: [] })
|
||||
|
||||
if (query.article_status === 1 && !hasRenderableContent(content, cmsLst)) {
|
||||
throw new Error('正文不能为空')
|
||||
}
|
||||
|
||||
const normalizedList = cmsLst.map(item => {
|
||||
if (item.type !== 'vote' || !item.vote) return item
|
||||
return {
|
||||
...item,
|
||||
vote: {
|
||||
...item.vote,
|
||||
vote_id: item.vote.vote_id || createVoteId()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { edit_type, cmsLst: normalizedList, content }
|
||||
}
|
||||
|
||||
function valueOrExisting(query, existing, key, fallback) {
|
||||
return query[key] !== undefined ? query[key] : (existing[key] !== undefined ? existing[key] : fallback)
|
||||
}
|
||||
|
||||
/** 转义正则元字符,避免用户输入被当作正则执行 */
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function toPositiveInt(value, fallback, max) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n) || n < 0) return fallback
|
||||
const i = Math.floor(n)
|
||||
return max !== undefined ? Math.min(i, max) : i
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
_before: function () { // 通用预处理器
|
||||
@@ -63,44 +113,36 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
async update_cms_articles(query) {
|
||||
try {
|
||||
const payload = await this._requireLogin()
|
||||
const { id } = query || {}
|
||||
await this._requireOwner(id, payload)
|
||||
async update_cms_articles(query = {}) {
|
||||
try {
|
||||
const payload = await this._requireLogin()
|
||||
const { id } = query
|
||||
await this._requireOwner(id, payload)
|
||||
const oldRes = await collection.doc(id).get()
|
||||
const existing = oldRes.data && oldRes.data[0]
|
||||
if (!existing) throw new Error('文章不存在')
|
||||
const { edit_type, cmsLst, content } = normalizeArticlePayload(query, existing)
|
||||
|
||||
const {
|
||||
title,
|
||||
title_html,
|
||||
title_delta,
|
||||
thumbnail,
|
||||
p_type,
|
||||
category_id,
|
||||
temp_id,
|
||||
edit_type,
|
||||
excerpt,
|
||||
cmsLst,
|
||||
article_status
|
||||
} = query
|
||||
|
||||
const now = new Date()
|
||||
const r = await collection.doc(id).update({
|
||||
title,
|
||||
// 作者不可被客户端改写,始终以数据库中的归属为准
|
||||
title_html,
|
||||
title_delta,
|
||||
thumbnail,
|
||||
p_type,
|
||||
category_id,
|
||||
temp_id,
|
||||
edit_type,
|
||||
excerpt,
|
||||
cmsLst,
|
||||
// 修改不应重置发布时间,只更新最后修改时间
|
||||
last_modify_date: now,
|
||||
last_modify_ip: this.getClientInfo().clientIP,
|
||||
article_status
|
||||
})
|
||||
const now = new Date()
|
||||
const r = await collection.doc(id).update({
|
||||
title: valueOrExisting(query, existing, 'title', ''),
|
||||
// 作者不可被客户端改写,始终以数据库中的归属为准
|
||||
title_html: valueOrExisting(query, existing, 'title_html', ''),
|
||||
title_delta: valueOrExisting(query, existing, 'title_delta', {}),
|
||||
thumbnail: valueOrExisting(query, existing, 'thumbnail', []),
|
||||
p_type: valueOrExisting(query, existing, 'p_type', ''),
|
||||
category_id: valueOrExisting(query, existing, 'category_id', ''),
|
||||
temp_id: valueOrExisting(query, existing, 'temp_id', ''),
|
||||
edit_type,
|
||||
excerpt: valueOrExisting(query, existing, 'excerpt', ''),
|
||||
cmsLst,
|
||||
content,
|
||||
music_url_id: valueOrExisting(query, existing, 'music_url_id', {}),
|
||||
// 修改不应重置发布时间,只更新最后修改时间
|
||||
last_modify_date: now,
|
||||
last_modify_ip: this.getClientInfo().clientIP,
|
||||
article_status: valueOrExisting(query, existing, 'article_status', 0)
|
||||
})
|
||||
if (!r.updated) {
|
||||
return { code: 500, msg: '更新失败' }
|
||||
}
|
||||
@@ -118,47 +160,38 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
async add_cms_articles(query) {
|
||||
try {
|
||||
const payload = await this._requireLogin()
|
||||
async add_cms_articles(query = {}) {
|
||||
try {
|
||||
const payload = await this._requireLogin()
|
||||
const { edit_type, cmsLst, content } = normalizeArticlePayload(query)
|
||||
const title = typeof query.title === 'string' ? query.title.trim() : ''
|
||||
if (!title) throw new Error('标题不能为空')
|
||||
|
||||
const {
|
||||
title,
|
||||
title_html,
|
||||
title_delta,
|
||||
thumbnail,
|
||||
p_type,
|
||||
category_id,
|
||||
temp_id,
|
||||
edit_type,
|
||||
excerpt,
|
||||
cmsLst,
|
||||
article_status
|
||||
} = query
|
||||
|
||||
const now = new Date()
|
||||
const r = await collection.add({
|
||||
title,
|
||||
// 作者一律取登录态,不接受客户端传入
|
||||
user_id: payload.uid,
|
||||
title_html,
|
||||
title_delta,
|
||||
thumbnail,
|
||||
p_type,
|
||||
category_id,
|
||||
temp_id,
|
||||
edit_type,
|
||||
excerpt,
|
||||
// 计数类字段由服务端维护,初始为 0
|
||||
view_count: 0,
|
||||
like_count: 0,
|
||||
collect_count: 0,
|
||||
cmsLst,
|
||||
publish_date: now,
|
||||
last_modify_date: now,
|
||||
last_modify_ip: this.getClientInfo().clientIP,
|
||||
article_status
|
||||
})
|
||||
const now = new Date()
|
||||
const r = await collection.add({
|
||||
title,
|
||||
// 作者一律取登录态,不接受客户端传入
|
||||
user_id: payload.uid,
|
||||
title_html: query.title_html || '',
|
||||
title_delta: query.title_delta || {},
|
||||
thumbnail: Array.isArray(query.thumbnail) ? query.thumbnail : [],
|
||||
p_type: query.p_type || '',
|
||||
category_id: query.category_id || '',
|
||||
temp_id: query.temp_id || '',
|
||||
edit_type,
|
||||
excerpt: query.excerpt || '',
|
||||
music_url_id: query.music_url_id || {},
|
||||
// 计数类字段由服务端维护,初始为 0
|
||||
view_count: 0,
|
||||
like_count: 0,
|
||||
collect_count: 0,
|
||||
cmsLst,
|
||||
content,
|
||||
publish_date: now,
|
||||
last_modify_date: now,
|
||||
last_modify_ip: this.getClientInfo().clientIP,
|
||||
article_status: query.article_status === 1 ? 1 : 0
|
||||
})
|
||||
const res = await collection.doc(r.id).get()
|
||||
return {
|
||||
code: 200,
|
||||
@@ -175,7 +208,6 @@ module.exports = {
|
||||
|
||||
async getTemp(query) {
|
||||
try {
|
||||
await this._requireLogin()
|
||||
const { temp_id } = query || {}
|
||||
if (!temp_id) return { code: 400, msg: '缺少 temp_id' }
|
||||
const res = await db.collection("cms-temp").doc(temp_id).get()
|
||||
@@ -188,6 +220,62 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 已发布文章流(公开,无需登录)。
|
||||
*
|
||||
* 原生安卓端与游客首页依赖此方法。没有它时匿名用户只能拿到空列表,
|
||||
* 因为 get_cms_articles_for_userId 强制登录且只返回本人文章。
|
||||
*
|
||||
* 只投影列表所需字段:cmsLst / content 体积大且列表页用不到。
|
||||
* 另外 schema.ext 的 afterRead 在读取 content 字段时会顺带给 view_count 加 1,
|
||||
* 列表页不该把「出现在首页」算成一次阅读,因此这里不取 content。
|
||||
*/
|
||||
async getPublishedArticles(query = {}) {
|
||||
try {
|
||||
const offset = toPositiveInt(query.offset, 0)
|
||||
const limit = toPositiveInt(query.limit, 20, 100)
|
||||
const where = { article_status: 1 }
|
||||
|
||||
if (typeof query.category_id === 'string' && query.category_id) {
|
||||
where.category_id = query.category_id
|
||||
}
|
||||
const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : ''
|
||||
if (keyword) where.title = new RegExp(escapeRegExp(keyword), 'i')
|
||||
|
||||
const res = await collection
|
||||
.where(where)
|
||||
.field({
|
||||
title: true,
|
||||
title_html: true,
|
||||
title_delta: true,
|
||||
user_id: true,
|
||||
thumbnail: true,
|
||||
excerpt: true,
|
||||
category_id: true,
|
||||
temp_id: true,
|
||||
edit_type: true,
|
||||
p_type: true,
|
||||
music_url_id: true,
|
||||
article_status: true,
|
||||
view_count: true,
|
||||
like_count: true,
|
||||
collect_count: true,
|
||||
publish_date: true,
|
||||
last_modify_date: true
|
||||
})
|
||||
.orderBy('publish_date', 'desc')
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.get()
|
||||
|
||||
const total = await collection.where(where).count()
|
||||
return { code: 200, data: res.data, total: total.total, offset, limit }
|
||||
} catch (e) {
|
||||
console.error('getPublishedArticles 失败:', e.message)
|
||||
return { code: 500, msg: e.message }
|
||||
}
|
||||
},
|
||||
|
||||
async getTotal() {
|
||||
try {
|
||||
const res = await collection.where({ article_status: 1 }).count()
|
||||
|
||||
Reference in New Issue
Block a user