4.1 云数据库创建:新建文章记录

第三篇里我们把云函数跑通了,列表接口也能分页返回数据,但此时库里还是空的——一个图文应用如果不能发帖,和本地 Demo 没什么区别。这一节专门聊发布链路的第一段:前端点下发布按钮之后,一条文章记录是怎么进到云数据库里的。重点有三件事:字段怎么设计才不返工、写库操作为什么必须放云函数里、以及重复提交这种最容易翻车的边界情况怎么处理。本章的目录在本书目录第四篇,建议和 4.2 图片直传一起看,发帖链路才完整。

场景:发布按钮点下去之后

先把产品场景交代清楚:用户在编辑器里写 Markdown 正文,可选一张封面图,点发布按钮。我们对这条链路的要求是:

  • 草稿和正式发布要分开,草稿不进公开列表
  • 前端永远不能直接写数据库,所有写入一律走云函数
  • 同一篇文章不能因为用户手抖点两次就发两遍
  • 记录里要提前预留审核状态和排版结果的字段

最后一条是给后面几节埋路:封面图来自 4.2 的直传,审核状态由 4.3 写入,排版结果由 4.4 生成。字段现在不预留,后面每加一个功能都要改一遍表结构,还得写数据迁移脚本去洗历史数据,那是比写新代码更难受的事。独立开发者最容易在这件事上偷懒。

表结构:一次说清不留返工

在 2.3 节文章表设计的基础上,结合发布链路的实际需要,一条文章记录最终长这样:

字段 类型 作用 谁写入
_id string 主键,用前端生成的幂等键 发布函数
title string 标题,上限 50 字 发布函数
content string Markdown 原文,永久保留 发布函数
html string 预渲染 HTML,列表详情直接读 排版函数
summary string 摘要,列表页展示用 排版函数
cover string 封面图地址,没图时为空 发布函数
status number 0 草稿 1 待审核 2 通过 3 拒绝 审核函数
authorId string 发布者标识,取自登录上下文 发布函数
createdAt number 创建时间,毫秒时间戳 发布函数
updatedAt number 更新时间,每次编辑刷新 编辑函数

多说一句:content 和 html 同时存是有意的。第一版我们只存 content,列表页每条记录都要现场渲染一遍 Markdown,既慢又白白多花函数调用。改成发布时渲染一次写回 html 之后,列表和详情都只读不渲染。用存储换计算在 Serverless 里几乎总是划算的,因为函数调用按次数收钱,而存储很便宜。

方案:一个 createArticle 云函数搞定

集合权限上我们把 articles 设成了只读放开、写入关死,这样前端就算有人试出 SDK 的写法也写不进来,所有写入只能走云函数,规则由函数代码控制。2.4 权限一节细说过这套搭配,这里直接看代码:

// cloudfunctions/createArticle/index.js
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })
const db = cloud.database()

exports.main = async (event, context) => {
  const { title, content, cover, draft, postId } = event
  // 一、参数校验:云端是最后一道防线
  if (!title || !title.trim()) return { code: 1, msg: '标题不能为空' }
  if (title.length > 50) return { code: 1, msg: '标题最多 50 字' }
  if (!content || content.length > 50000) return { code: 1, msg: '正文为空或超长' }
  if (!postId) return { code: 1, msg: '缺少幂等键,请刷新重试' }

  // 二、身份从云函数上下文取,不信任前端传的 authorId
  const { OPENID } = cloud.getWXContext()
  const now = Date.now()
  const data = {
    title: title.trim(),
    content,
    html: '',
    summary: '',
    cover: cover || '',
    status: draft ? 0 : 1,
    authorId: OPENID,
    viewCount: 0,
    likeCount: 0,
    createdAt: now,
    updatedAt: now,
  }

  // 三、用指定 _id 的 set 实现幂等写入
  try {
    const exist = await db.collection('articles').doc(postId).get()
    if (exist.data.length && exist.data[0].status === 1) {
      return { code: 2, msg: '这篇文章已经发布过了' }
    }
    await db.collection('articles').doc(postId).set({ data })
    return { code: 0, id: postId }
  } catch (e) {
    console.error('createArticle 写入失败', e)
    return { code: 500, msg: '发布失败,请稍后重试' }
  }
}

前端只要配合两件事:进入编辑页就生成好幂等键,调用失败重试时复用同一个键:

// 前端发布按钮的处理函数
async function onPublish(draft) {
  const postId = uuid() // 进编辑页时生成,失败重试不换
  const res = await app.callFunction({
    name: 'createArticle',
    data: { postId, title, content, cover, draft },
  })
  if (res.result.code === 2) warn('已发布,请勿重复提交')
  if (res.result.code === 0) go(`/detail/${res.result.id}`)
}

这套写法一次解决了三个问题:身份从云函数上下文取,前端伪造不了 authorId;幂等键由前端生成,网络超时后重试不会多发;已发布的文章在被覆盖前会先拦一道。

踩坑与排查

第一个坑是时间来源。最初图省事用了 Date.now(),本地测试一切正常,上线后发现有小部分用户的发布时间对不上——云函数跑在服务器上,时间没问题,但凡是客户端传上来的时间都可能不可信。后来统一改成 db.serverDate(),时间由数据库服务端给,彻底没这事了。时间字段用服务端时间是后端的基本素养,只是被 Serverless 的易用性隐藏掉了。

第二个坑是 doc(postId).get() 在文档不存在时的行为。云开发里 get 一个不存在的 doc 不会抛错,而是返回空数组,要判断长度才知道存不存在。我一开始没细看文档,直接取了 res.data._id,结果第一次发布就报 NaN,对着日志排查了半个多小时。这类 SDK 行为细节,腾讯云实践里有整理过的对照清单,值得收藏。

第三个坑是校验顺序。有一版我把校验放在查重之后,赶上网络波动用户疯狂重试,格式不合法的请求也真的写进了库,脏数据清了半天。顺序千万别换:先校验、再查重、最后写入。

第四个坑是文档大小上限。云开发单条文档有体积限制,正文五万字符这个门槛是我们自己被用户拿一本小说糊脸之后摸出来的,当时错误信息完全看不出是文档过大。后来直接在产品层限制了正文长度,云端校验兜底。

小结

发帖这个功能看起来就一个 add 调用,真要写稳得处理身份、幂等、校验、时间、错误码五件事。核心原则一句话:永远不要信任前端,写库的活放在云函数里,字段一次留够。下一节解决发布链路的第二段——封面图怎么上传,见图片直传一节。如果你对云函数上下文和集合权限还不熟,建议先翻本书目录第三篇复习一遍。

延伸阅读