【开源】微信小程序·户外装备记录器「运动装备」技术开发复盘
作者个人作品,无商业依赖,已完整跑通云开发全链路,适合想快速上手微信小程序云开发的同学参考。
作品介绍链接
---
## 一、项目背景与需求
户外运动爱好者常常面对一个问题:装备越来越多,却不知道自己到底有哪些、买了多久、是否已经退役。我希望做一个小程序,能够:
- 快速录入装备的品牌、分类、类型、颜色、入手价、购买日期等
- 自动按分类展示,并计算持有时长
- 支持多设备登录后数据同步
- 有基本的内容安全过滤(敏感词检测)
---
## 二、技术选型
| 能力 | 实现方式 |
|------|----------|
| 框架 | 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` |
---
---
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)