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:
2026-09-12 01:02:45 +08:00
parent 4f5893f87a
commit 3117146281
26 changed files with 2043 additions and 337 deletions
@@ -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()