构建: - 新增 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 构建失败
333 lines
9.9 KiB
JavaScript
333 lines
9.9 KiB
JavaScript
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 () { // 通用预处理器
|
||
this.uniID = uniID.createInstance({
|
||
context: this.getClientInfo()
|
||
})
|
||
},
|
||
|
||
/** 校验登录并返回 payload;未登录抛错 */
|
||
async _requireLogin() {
|
||
const payload = await this.uniID.checkToken(this.getUniIdToken())
|
||
if (payload.errCode) throw new Error('登录状态失效,请重新登录')
|
||
return payload
|
||
},
|
||
|
||
/** 校验当前用户是该文章作者(或管理员) */
|
||
async _requireOwner(id, payload) {
|
||
if (!id) throw new Error('缺少文章 id')
|
||
const res = await collection.doc(id).field({ user_id: true }).get()
|
||
const doc = res.data && res.data[0]
|
||
if (!doc) throw new Error('文章不存在')
|
||
if (doc.user_id !== payload.uid && !isAdmin(payload)) {
|
||
throw new Error('无权操作他人文章')
|
||
}
|
||
return doc
|
||
},
|
||
|
||
async del_cms_articles(query) {
|
||
try {
|
||
const payload = await this._requireLogin()
|
||
const { id } = query || {}
|
||
await this._requireOwner(id, payload)
|
||
await collection.doc(id).remove()
|
||
return { code: 200 }
|
||
} catch (e) {
|
||
return { code: 500, msg: e.message }
|
||
}
|
||
},
|
||
|
||
async get_cms_articles_for_userId(query) {
|
||
try {
|
||
// 只能查自己的文章列表;管理员可指定他人
|
||
const payload = await this._requireLogin()
|
||
const requested = query && query.user_id
|
||
if (requested && requested !== payload.uid && !isAdmin(payload)) {
|
||
return { code: 403, msg: '无权查看他人文章' }
|
||
}
|
||
const user_id = isAdmin(payload) && requested ? requested : payload.uid
|
||
const res = await collection.where({
|
||
user_id: user_id,
|
||
"edit_type": "mobile"
|
||
}).get()
|
||
return { code: 200, data: res.data }
|
||
} catch (e) {
|
||
console.error(e.message)
|
||
return { code: 500, msg: e.message }
|
||
}
|
||
},
|
||
|
||
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 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: '更新失败' }
|
||
}
|
||
const res = await collection.doc(id).get()
|
||
return {
|
||
code: 200,
|
||
res: {
|
||
data: res.data[0],
|
||
_id: id,
|
||
user_id: payload.uid
|
||
}
|
||
}
|
||
} catch (e) {
|
||
return { code: 500, msg: e.message }
|
||
}
|
||
},
|
||
|
||
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 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,
|
||
res: {
|
||
data: res.data[0],
|
||
_id: r.id,
|
||
user_id: payload.uid
|
||
}
|
||
}
|
||
} catch (e) {
|
||
return { code: 500, msg: e.message }
|
||
}
|
||
},
|
||
|
||
async getTemp(query) {
|
||
try {
|
||
const { temp_id } = query || {}
|
||
if (!temp_id) return { code: 400, msg: '缺少 temp_id' }
|
||
const res = await db.collection("cms-temp").doc(temp_id).get()
|
||
return {
|
||
code: 200,
|
||
data: res.data[0]
|
||
}
|
||
} catch (e) {
|
||
return { code: 500, msg: e.message }
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 已发布文章流(公开,无需登录)。
|
||
*
|
||
* 原生安卓端与游客首页依赖此方法。没有它时匿名用户只能拿到空列表,
|
||
* 因为 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()
|
||
return {
|
||
code: 200,
|
||
message: 'success',
|
||
total: res.total
|
||
}
|
||
} catch (err) {
|
||
return {
|
||
code: 500,
|
||
message: err.message
|
||
}
|
||
}
|
||
},
|
||
|
||
// 文章访问日志服务
|
||
async detailLog(query) {
|
||
try {
|
||
const payload = await this._requireLogin()
|
||
const articleId = query && query.articleId
|
||
if (!articleId) return { code: -1, msg: '缺少文章 ID' }
|
||
|
||
// 必须按「用户 + 文章」定位记录。只按 article_id 查询会命中别人的行,
|
||
// 导致多人共用一条日志、自己的阅读时间永远不更新。
|
||
const userId = payload.uid
|
||
const where = { user_id: userId, article_id: articleId }
|
||
const { data: existingLog } = await db.collection('cms-articles-log').where(where).get()
|
||
const now = new Date()
|
||
|
||
if (existingLog.length > 0) {
|
||
await db.collection('cms-articles-log')
|
||
.doc(existingLog[0]._id)
|
||
.update({
|
||
last_view_time: now,
|
||
view_count: db.command.inc(1)
|
||
})
|
||
} else {
|
||
await db.collection('cms-articles-log').add({
|
||
user_id: userId,
|
||
article_id: articleId,
|
||
view_count: 1,
|
||
first_view_time: now,
|
||
last_view_time: now
|
||
})
|
||
}
|
||
|
||
return { code: 0, msg: '日志记录成功' }
|
||
} catch (err) {
|
||
console.error('日志记录失败:', err)
|
||
return { code: -1, msg: '系统异常,请重试' }
|
||
}
|
||
}
|
||
}
|