作者个人作品,无商业依赖,已完整跑通云开发全链路,适合想快速上手微信小程序云开发的同学参考。

作品介绍链接

https://blog.csdn.net/2301_79976543/article/details/159732356?fromshare=blogdetail&sharetype=blogdetail&sharerId=159732356&sharerefer=PC&sharesource=2301_79976543&sharefrom=from_link

---

## 一、项目背景与需求

户外运动爱好者常常面对一个问题:装备越来越多,却不知道自己到底有哪些、买了多久、是否已经退役。我希望做一个小程序,能够:

- 快速录入装备的品牌、分类、类型、颜色、入手价、购买日期等

- 自动按分类展示,并计算持有时长

- 支持多设备登录后数据同步

- 有基本的内容安全过滤(敏感词检测)

---

## 二、技术选型

| 能力 | 实现方式 |

|------|----------|

| 框架 | uniapp(Vue),编译为微信小程序 |

| 云端 | 微信云开发(wx.cloud) |

| 云函数运行时 | wx-server-sdk |

| 数据库 | 云开发数据库(集合 gears、users) |

| 内容安全 | 腾讯云 msgSecCheck 内容安全检测 |

| 用户体系 | 微信 OPENID(由云函数获取,不另做注册登录) |

---

## 三、数据库设计

### 3.1 gears 集合(装备)

```javascript

{

  "_id": "云数据库自动生成",

  "openId": "微信OPENID",

  "brand": "品牌,如'始祖鸟'",

  "name": "装备名称,如'Alpha SV'",

  "category": "分类,衣服|裤子|鞋子|配件",

  "type": "子类型,如'冲锋衣'",

  "color": "颜色,可为空",

  "buyDate": "购买日期,格式'2024-01-15'",

  "retiredDate": "退休日期,格式'2025-03-01',可为空",

  "isRetired": false,

  "price": 2999,

  "remark": "备注,可为空",

  "createTime": "云数据库 serverDate",

  "updateTime": "云数据库 serverDate"

}

```

### 3.2 users 集合(用户资料)

```javascript

{

  "_id": "直接用 OPENID",

  "nickName": "用户自填昵称",

  "avatarUrl": "头像云存储 fileID,可为空",

  "createTime": "serverDate",

  "updateTime": "serverDate"

}

```

---

## 四、核心云函数设计

### 4.1 userProfile — 昵称/头像云端同步

多设备一致性的关键:昵称不再只存在本地 Storage,而是按 OPENID 写进 users 集合,进入页时从云端拉取并合并:

```javascript

// cloudfunctions/userProfile/index.js

const cloud = require('wx-server-sdk')

cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })

const COLLECTION = 'users'

/** 文档不存在时 SDK 可能抛错而非返回 data: null */

function isDocNotFoundError(e) {

  const msg = String((e && e.errMsg) || e || '')

  return /does not exist|不存在/i.test(msg)

}

async function getDocDataOrNull(docRef) {

  try {

    const got = await docRef.get()

    return got.data || null

  } catch (e) {

    if (isDocNotFoundError(e)) return null

    throw e

  }

}

exports.main = async (event) => {

  const openid = cloud.getWXContext().OPENID

  if (!openid) {

    return { ok: false, msg: 'no_openid' }

  }

  const db = cloud.database()

  const action = event.action

  if (action === 'get') {

    const doc = await getDocDataOrNull(db.collection(COLLECTION).doc(openid))

    if (!doc) {

      return { ok: true, profile: null }

    }

    return {

      ok: true,

      profile: {

        nickName: doc.nickName || '',

        avatarUrl: doc.avatarUrl || '',

      },

    }

  }

  if (action === 'set') {

    const patch = {}

    const hasNick = Object.prototype.hasOwnProperty.call(event, 'nickName')

    const hasAvatar = Object.prototype.hasOwnProperty.call(event, 'avatarUrl')

    if (!hasNick && !hasAvatar) {

      return { ok: false, msg: 'no_fields' }

    }

    if (hasNick) {

      const trimmed = String(event.nickName || '').trim().slice(0, 32)

      if (!trimmed) {

        return { ok: false, msg: 'invalid nickName' }

      }

      // 内容安全检测

      const checkRes = await cloud.callFunction({

        name: 'msgSecCheck',

        data: { text: trimmed, scene: 1 },

      })

      const cr = (checkRes && checkRes.result) || {}

      if (!cr.pass) {

        if (cr.reason === 'service_error' && cr.errcode === -604101) {

          return { ok: false, code: 'sec_busy', msg: '内容安全系统繁忙' }

        }

        return { ok: false, code: 'sec_reject', msg: '昵称未通过检测' }

      }

      patch.nickName = trimmed

    }

    if (hasAvatar) {

      patch.avatarUrl = String(event.avatarUrl || '').trim().slice(0, 500)

    }

    patch.updateTime = db.serverDate()

    // 首次写入时 doc().get() 抛"文档不存在"错误,用 isDocNotFoundError 判断后走 set 而非 update

    const ref = db.collection(COLLECTION).doc(openid)

    const existing = await getDocDataOrNull(ref)

    if (existing) {

      await ref.update({ data: patch })

    } else {

      await ref.set({

        data: {

          ...patch,

          createTime: db.serverDate(),

        },

      })

    }

    return { ok: true }

  }

  return { ok: false, msg: 'unknown action' }

}

```

### 4.2 writeGear — 装备写操作

支持 add / update / delete / get 四种 action,单用户上限 200 条(GEAR_MAX_PER_USER),新增时在云函数内统一做内容安全检测:

```javascript

// 伪代码示例

const GEAR_MAX_PER_USER = 200

async function countGearsForUser(db, openid) {

  const _ = db.command

  const { total } = await db.collection('gears')

    .where(_.or([{ openId: openid }, { _openid: openid }]))

    .count()

  return total

}

exports.main = async (event) => {

  // add: 插入新装备,附上 createTime/updateTime

  // update: 按 gearId 精确更新,附上 updateTime

  // delete: 按 gearId 精确删除

  // get: 按 gearId 查询单条装备

  // 各操作均校验 openid 所有权

}

```

### 4.3 listMyGears — 查询我的装备

```javascript

// cloudfunctions/listMyGears/index.js

const db = cloud.database()

const MAX = 200

exports.main = async () => {

  const openId = cloud.getWXContext().OPENID

  const col = db.collection('gears')

  let res = await col.where({ openId }).limit(MAX).get()

  // 兜底:用 _openid 字段查(兼容历史数据)

  if (!res.data || res.data.length === 0) {

    res = await col.where({ _openid: openId }).limit(MAX).get()

  }

  return { ok: true, data: res.data || [] }

}

```

---

## 五、关键实现细节

### 5.1 多设备昵称同步

客户端 onLoad 时先读本地缓存展示,再调云函数拉云端数据合并后写回本地 Storage,保证快速展示 + 多设备一致:

```javascript

// uniapp/src/common/userProfile.js

function refreshUserInfoFromCloud() {

  const local = getLocalUserInfo()

  return fetchCloudProfile().then((cloudProfile) => {

    const merged = mergeUserInfo(local, cloudProfile)

    wx.setStorageSync('userInfo', merged)

    return merged

  })

}

```

### 5.2 云数据库写入时「文档不存在」的正确处理

云开发 SDK 的 doc().get() 在文档不存在时直接抛错,而不是返回 data: null,需要自行判断错误信息:

```javascript

function isDocNotFoundError(e) {

  return /does not exist|不存在/i.test(String(e && e.errMsg || ''))

}

async function getDocDataOrNull(docRef) {

  try {

    const got = await docRef.get()

    return got.data || null

  } catch (e) {

    if (isDocNotFoundError(e)) return null

    throw e

  }

}

```

### 5.3 装备持有时长计算

```javascript

function calculateOwnTime(buyDate, retiredDate, isRetired) {

  if (!buyDate) return ''

  const buy = new Date(buyDate.replace(/-/g, '/'))

  const end = isRetired && retiredDate

    ? new Date(retiredDate.replace(/-/g, '/'))

    : new Date()

  if (isNaN(buy.getTime()) || isNaN(end.getTime())) return ''

  const diffDays = Math.floor((end - buy) / (1000 * 60 * 60 * 24))

  if (diffDays < 0) return ''

  const years = Math.floor(diffDays / 365)

  const days = diffDays % 365

  return years > 0 ? `${years}年${days}天` : `${diffDays}天`

}

```

**踩坑记录**:

- 日期字符串 `"2024-03-01"` 直接 `new Date()` 在部分 iOS 机型会报错,需要把 `-` 替换成 `/`

- 退休装备的使用时长应该计算到退休日期,而不是现在

### 5.4 分类联动选择器

选择分类后自动加载对应的类型选项:

```javascript

data() {

  return {

    categoryOptions: ['衣服', '裤子', '鞋子', '配件'],

    typeOptions: {

      '衣服': ['冲锋衣', '抓绒衣', '速干衣', '羽绒服', 'T恤', '内衣', '其他(填写)'],

      '裤子': ['冲锋裤', '速干裤', '牛仔裤', '运动裤', '其他(填写)'],

      '鞋子': ['登山鞋', '徒步鞋', '跑步鞋', '休闲鞋', '其他(填写)'],

      '配件': ['背包', '帽子', '手套', '袜子', '墨镜', '其他(填写)']

    },

    currentTypeOptions: [],

  }

},

methods: {

  onCategoryChange(e) {

    const idx = Number(e.detail.value)

    this.categoryIndex = idx

    this.currentTypeOptions = this.typeOptions[this.categoryOptions[idx]] || []

    this.typeIndex = -1

  }

}

```

---

## 六、项目结构

```

wechatproject/

├── cloudfunctions/

│   ├── userProfile/        # 用户资料同步(昵称/头像)

│   ├── writeGear/          # 装备增删改

│   ├── listMyGears/       # 拉取我的装备

│   └── msgSecCheck/       # 内容安全检测

└── uniapp/

    └── src/

        ├── pages/

        │   ├── index/         # Tab 首页

        │   ├── home/         # 首页

        │   ├── add-gear/     # 添加装备表单

        │   ├── edit-gear/    # 编辑装备

        │   ├── profile/      # 个人中心

        │   └── my-gear/      # 全部装备列表

        ├── common/

        │   └── userProfile.js  # 昵称/头像读写工具

        ├── App.vue           # 应用入口

        ├── main.js           # 入口文件

        └── pages.json        # 页面配置

```

---

## 七、遇到的坑与解决

| 问题 | 原因 | 解决 |

|------|------|------|

| 不同设备昵称不一致 | 昵称只存在 wx.setStorageSync,各设备独立 | 新增 userProfile 云函数,读写 users 集合,按 OPENID 同步 |

| doc().get() 报错退出 | 文档不存在时 SDK 抛错而非返回 null | 加 isDocNotFoundError 判断,走 set 而非 update |

| 客户端直查 gears 集合全表查不到 | 云数据库安全规则限制 | 统一走云函数 writeGear/listMyGears 查询,云函数内不过客户端权限 |

| iOS 日期解析报错 | 部分 iOS 机型不识别 `2024-01-01` 格式 | 使用 `replace(/-/g, '/')` 替换日期分隔符 |

| 分类联动后类型未重置 | 切换分类后 typeIndex 未复位 | onCategoryChange 中手动设置 `this.typeIndex = -1` |

---

---

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐