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: '系统异常,请重试' } } } }