从零到一,构建微信小程序的用户画像、烹饪历史与私人菜谱系统
一、模块概览
个人中心是灶台导航的核心枢纽,承载了用户身份、家庭画像、烹饪记录、私人菜谱、偏好设置等能力。整个模块涉及 1个主页面 + 4个子页面 + 1个云函数 + 向量数据库同步 ,数据流从云端到界面环环相扣。

功能清单
| 子模块 | 功能点 |
|---|---|
| 个人主页 | 登录/编辑资料、头像上传、家庭成员预览、快捷入口 |
| 家庭成员 | 增删改、口味偏好、忌口管理、角色标签(成人/儿童/老人) |
| 烹饪历史 | 分页列表、3天自动过期、长按删除、一键清空 |
| 私人菜谱 | 增删改、表单解析、向量数据库同步、AI语义检索 |
| 偏好设置 | 用餐人数步进器、语音播报开关、计时提醒方式、默认成员 |
二、用户身份:无感登录的设计哲学
微信小程序云开发有一个关键特性:只要用户打开了小程序,cloud.getWXContext().OPENID 就自动可用 ,不需要调用 wx.login() 或任何授权弹窗。
这意味着我们的"登录"其实不是认证,而是个性化------让用户设置昵称和头像。
云端自动建档
每次云函数调用前,ensureUserExists 会检查用户文档是否存在:
javascript
async function ensureUserExists(openid) {
try {
await db.collection('users').doc(openid).get()
} catch (e) {
// 文档不存在,自动创建
const selfMemberId = `m_self_${openid.slice(-6)}`
await db.collection('users').add({
data: {
_id: openid, // openid 直接作为文档主键
nickname: '',
avatarUrl: '',
members: [{
memberId: selfMemberId,
name: '自己',
role: 'adult',
preferences: [],
allergies: [],
dislikes: []
}],
settings: {
defaultPeople: 2,
voiceBroadcast: true,
timerAlertType: 'vibrate',
defaultRole: ''
}
}
})
}
}
设计要点:
_id = openid,天然隔离用户数据,查询时不需要额外索引- 自动创建默认成员"自己",避免空状态
- 默认设置预填充,用户开箱即用
前端登录态同步
登录状态的判断很简单------有没有昵称:
javascript
syncLoginState() {
const app = getApp()
const userInfo = app.globalData.userInfo
this.setData({
isLoggedIn: !!(userInfo && userInfo.nickname)
})
}
没有昵称时,个人中心头部显示"点击登录";有了昵称就显示用户信息。功能完全不受影响,因为 openid 始终存在。
头像上传到云存储
微信提供的 open-type="chooseAvatar" 按钮可以合规获取用户头像,但返回的是临时路径,需要上传到云存储才能持久化:
javascript
async confirmLogin() {
const { tempNickname, tempAvatarUrl } = this.data
if (!tempNickname.trim()) {
wx.showToast({ title: '请输入昵称', icon: 'none' })
return
}
let avatarUrl = tempAvatarUrl
// 临时路径需要上传到云存储
if (avatarUrl && !avatarUrl.startsWith('cloud://')) {
const res = await wx.cloud.uploadFile({
cloudPath: `avatars/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.png`,
filePath: avatarUrl
})
avatarUrl = res.fileID
}
await callFunction('userProfile', {
action: 'updateProfile',
data: { nickname: tempNickname, avatarUrl }
})
getApp().setUserInfo(tempNickname, avatarUrl)
}
cloudPath 用时间戳 + 随机串保证唯一,上传后拿到 fileID(cloud:// 开头)存入数据库。
三、家庭画像:让AI懂你的胃

家庭成员是灶台导航的特色功能。每个成员有角色(成人/儿童/老人)、口味偏好和忌口,这些数据在 AI 对话时会被注入上下文:
[当前用户上下文] 当前用餐人:小明;口味偏好:清淡、甜;忌口:花生、辣椒
数据结构
javascript
{
memberId: 'm_1715xxxx_a3f2b1', // 唯一标识
name: '小明',
role: 'child', // adult | child | elder
preferences: ['清淡', '甜'], // 口味偏好
allergies: ['花生', '辣椒'], // 忌口
dislikes: [] // 不爱吃的
}
角色标签的动态渲染
三种角色对应三种颜色的标签,在个人中心预览和成员管理页统一使用:
javascript
getRoleLabel(role) {
const map = {
adult: { label: '成人', color: '#FF7E45' },
child: { label: '儿童', color: '#4CAF50' },
elder: { label: '老人', color: '#9C27B0' }
}
return map[role] || map.adult
}
WXML 中动态绑定样式:
xml
<view class="member-chip" wx:for="{{familyMembers}}" wx:key="memberId"
style="background: {{item.roleLabel.color}}20; color: {{item.roleLabel.color}}">
<text>{{item.roleLabel.label}}</text>
<text>{{item.name}}</text>
</view>
这里用到了一个小技巧:颜色值拼接透明度后缀 color + '20' 生成半透明背景色,标签和文字同色系,视觉统一。
四、烹饪历史:自动过期的列表实践
烹饪历史的设计有两个核心需求:自动清理过期数据 和 用户主动删除。
服务端时间过滤 + 异步清理
云函数查询时只返回最近3天的记录,同时异步清理过期数据:
javascript
async function getHistory(openid, params) {
const limit = params.limit || 10
const offset = params.offset || 0
// 只查询最近3天
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)
const whereCondition = {
openid,
cookDate: _.gte(threeDaysAgo)
}
const { total } = await db.collection('history').where(whereCondition).count()
const { data } = await db.collection('history')
.where(whereCondition)
.orderBy('cookDate', 'desc')
.skip(offset)
.limit(limit)
.get()
// 异步清理过期记录,不阻塞响应
cleanExpiredHistory(openid, threeDaysAgo).catch(e => {
console.warn('[userProfile] 清理过期烹饪历史失败:', e.message)
})
return { code: 0, message: 'success', data: { total, records: data } }
}
为什么异步清理? 清理操作可能涉及多条记录删除,如果 await 等待会显著拖慢接口响应。用户要的是数据列表,清理是"顺便"做的事,失败也不影响主流程。
分页加载
前端实现了经典的 下拉刷新 + 上拉加载更多:
javascript
// 下拉刷新
onPullDownRefresh() {
this.setData({ page: 1, records: [], hasMore: true })
this.loadHistory(true).then(() => wx.stopPullDownRefresh())
},
// 上拉加载更多
onReachBottom() {
if (this.data.hasMore && !this.data.isLoading) {
this.setData({ page: this.data.page + 1 })
this.loadHistory(false)
}
},
// 加载逻辑
async loadHistory(isRefresh) {
const limit = this.data.limit
const offset = (this.data.page - 1) * this.data.limit
const res = await callFunction('userProfile', {
action: 'getHistory', data: { limit, offset }
})
if (res.success) {
const newRecords = res.data.records || []
const hasMore = (this.data.page * this.data.limit) < res.data.total
this.setData({
records: isRefresh ? newRecords : [...this.data.records, ...newRecords],
hasMore
})
}
}
isRefresh 控制是替换还是追加,避免下拉刷新时数据重复。
长按删除 + 一键清空
交互上采用长按删除单条 + 清空按钮全部删除的双模式:
xml
<!-- 长按删除 -->
<view class="history-card" bindlongpress="deleteRecord" data-id="{{item._id}}">
<!-- 清空按钮 -->
<view class="clear-all-btn" bindtap="clearAllHistory">清空全部</view>
删除操作都有二次确认弹窗,防止误操作:
javascript
deleteRecord(e) {
const recordId = e.currentTarget.dataset.id
wx.showModal({
title: '删除记录',
content: '确定要删除这条烹饪记录吗?',
confirmColor: '#FF7E45',
success: async (res) => {
if (res.confirm) {
const result = await callFunction('userProfile', {
action: 'deleteHistory', data: { recordId }
})
if (result.success) {
wx.showToast({ title: '已删除', icon: 'success' })
this.setData({ page: 1, records: [] })
this.loadHistory(true)
}
}
}
})
}
删除后全量刷新而非局部移除,保证分页数据一致性。
五、私人菜谱:从表单解析到向量检索
私人菜谱是最复杂的子模块,涉及自由文本解析、结构化存储、向量数据库同步三个层面。
表单解析:自由文本 → 结构化数据
小程序不适合做复杂的动态表单(添加食材行、删除步骤等),所以我们选择了文本域输入 + 规则解析的方案:
javascript
// 解析食材:每行一个,"食材名 用量" 格式
function parseIngredients(text) {
if (!text || !text.trim()) return []
return text.trim().split('\n')
.filter(line => line.trim())
.map(line => {
const parts = line.trim().split(/[,,\s]+/)
const name = parts[0] || ''
const amount = parts.slice(1).join(' ') || '适量'
return { name, amount }
})
}
// 解析步骤:每行一步,自动去除序号前缀
function parseSteps(text) {
if (!text || !text.trim()) return []
return text.trim().split('\n')
.filter(line => line.trim())
.map((line, i) => ({
stepNo: i + 1,
description: line.trim().replace(/^\d+[.、)\]]\s*/, ''),
duration: 0,
tips: ''
}))
}
反向的 join* 函数用于编辑时回填:
javascript
function joinIngredients(ingredients) {
return (ingredients || []).map(i => `${i.name} ${i.amount}`).join('\n')
}
function joinSteps(steps) {
return (steps || []).map((s, i) => `${i + 1}. ${s.description}`).join('\n')
}
这套双向转换让用户在文本框里自由书写,提交时自动结构化,编辑时再还原为文本。
向量数据库同步:让AI能搜到私人菜谱
私人菜谱不只是存到数据库,还要同步到 Qdrant 向量数据库,这样 AI 对话时就能通过语义检索找到用户的私人菜谱:
javascript
async function addPrivateRecipe(openid, recipeData) {
// 1. 存入数据库
const { _id } = await db.collection('recipes').add({
data: {
openid,
name: recipeData.name,
isPrivate: true,
// ... 其他字段
}
})
// 2. 异步同步到向量数据库(不阻塞响应)
qdrantPrivate.upsertPrivateRecipe(recipe, openid).catch(err => {
console.error('[userProfile] Qdrant同步失败:', err)
})
return { code: 0, message: '保存成功', data: null }
}
向量化时,拼接菜谱的文本信息生成 embedding:
javascript
async function upsertPrivateRecipe(recipe, openid) {
// 拼接可检索文本(不含步骤和用量,聚焦语义关键词)
const searchText = [
recipe.name,
recipe.description,
(recipe.tags || []).join(' '),
recipe.category,
(recipe.ingredients || []).map(i => i.name).join(' ')
].filter(Boolean).join(' ')
// 调用 Embedding API
const vector = await getEmbedding(searchText)
// 用确定性 ID 写入 Qdrant
const pointId = hashCode(recipe.recipeId) % 2147483647
await qdrantClient.upsert('private_recipes', {
points: [{
id: pointId,
vector,
payload: { recipeId: recipe.recipeId, openid, name: recipe.name }
}]
})
}
确定性ID的设计 :用 hashCode(recipeId) 生成 Qdrant 中的 point ID,保证同一个菜谱多次 upsert 是更新而非新增。这是向量数据库与关系型数据库的一个重要区别------没有天然的唯一键约束。
六、偏好设置:即时持久化的交互模式
设置页的交互设计有一个关键决策:没有保存按钮,每次变更立即写入云端。
javascript
onVoiceToggle(e) {
const voiceBroadcast = e.detail.value
this.setData({ voiceBroadcast })
this.saveSetting('voiceBroadcast', voiceBroadcast)
},
saveSetting(key, value) {
callFunction('userProfile', {
action: 'updateSettings',
data: { [key]: value } // 动态属性名,只传变更的字段
})
}
云函数端用点号路径更新嵌套字段,避免覆盖整个 settings 对象:
javascript
async function updateSettings(openid, settingsData) {
const updateData = {}
const allowedFields = ['defaultPeople', 'voiceBroadcast', 'timerAlertType', 'defaultRole']
allowedFields.forEach(field => {
if (settingsData[field] !== undefined) {
updateData[`settings.${field}`] = settingsData[field]
}
})
await db.collection('users').doc(openid).update({ data: updateData })
}
settings.voiceBroadcast 这种点号路径是云数据库支持的深层更新语法,只修改指定字段而不影响其他设置。
用餐人数步进器
没用 input type="number",而是实现了 +/- 步进器,触感更好:
xml
<view class="stepper">
<view class="stepper-btn {{defaultPeople <= 1 ? 'disabled' : ''}}" bindtap="decreasePeople">-</view>
<view class="stepper-value">{{defaultPeople}}人</view>
<view class="stepper-btn {{defaultPeople >= 20 ? 'disabled' : ''}}" bindtap="increasePeople">+</view>
</view>
javascript
decreasePeople() {
if (this.data.defaultPeople <= 1) return
const defaultPeople = this.data.defaultPeople - 1
this.setData({ defaultPeople })
this.saveSetting('defaultPeople', defaultPeople)
}
每次点击都即时保存,用户无感知延迟。
七、数据安全:所有权校验的统一模式
所有删除和修改操作都遵循查询 → 校验 → 执行三步模式:
javascript
async function deleteHistory(openid, recordId) {
// 1. 查询
const { data } = await db.collection('history').doc(recordId).get()
// 2. 校验所有权
if (!data || data.openid !== openid) {
return { code: 1003, message: '记录不存在', data: null }
}
// 3. 执行删除
await db.collection('history').doc(recordId).remove()
return { code: 0, message: '已删除', data: null }
}
私人菜谱的校验更严格,同时验证 openid 和 isPrivate:
javascript
const recipe = data
if (!recipe || recipe.openid !== openid || !recipe.isPrivate) {
return { code: 1003, message: '菜谱不存在或无权限', data: null }
}
这防止了通过篡改请求参数越权操作他人数据的可能。
八、统一封装:callFunction 的优雅抽象
整个模块的云函数调用都经过 utils/cloud.js 的统一封装:
javascript
async function callFunction(name, data = {}) {
try {
const res = await wx.cloud.callFunction({ name, data })
const result = res.result
if (result && result.code === 0) {
return { success: true, data: result.data, message: 'success' }
} else {
const msg = (result && result.message) || '操作失败'
return { success: false, data: null, message: msg }
}
} catch (error) {
return { success: false, data: null, message: '网络错误,请重试' }
}
}
统一的好处:
- 业务代码只需
if (res.success)判断,不用关心code、result嵌套 - 网络错误和业务错误统一处理
- 错误消息对用户友好,不是原始的
err.message
九、视觉体系:灶台味的统一色板
整个个人中心模块遵循一套"灶台"色系:
| 用途 | 色值 | 说明 |
|---|---|---|
| 主色 | #FF7E45 |
暖橙色,按钮/强调 |
| 渐变起始 | #FF7E45 |
顶部渐变 |
| 渐变终止 | #FF5722 |
深橙色 |
| 页面背景 | #FDF5E6 |
麦色/米色 |
| 卡片阴影 | 0 2rpx 12rpx rgba(0,0,0,0.04) |
轻投影 |
| 图标背景 | 各类淡色 | 成员=#FFF3ED、历史=#E8F5E9、菜谱=#FFF8E1 |
成员角色标签的三色体系贯穿始终:
css
/* 成人 - 暖橙 */
.member-chip[data-role="adult"] { background: #FF7E4520; color: #FF7E45; }
/* 儿童 - 清绿 */
.member-chip[data-role="child"] { background: #4CAF5020; color: #4CAF50; }
/* 老人 - 雅紫 */
.member-chip[data-role="elder"] { background: #9C27B020; color: #9C27B0; }
颜色 + 透明度后缀(20 = 12.5% alpha)生成半透明背景,标签和文字同色系,简洁统一。
十、踩过的坑
1. 云函数 action 名不匹配
前端调用 action: 'clearAllSessions',但云函数里写的是 action === 'clearAll',导致请求穿透到 message 校验,报"消息内容不能为空"。
教训:action 名是前后端隐式契约,最好定义常量统一管理。
2. 微信 chooseAvatar 返回临时路径
open-type="chooseAvatar" 返回的是 http://tmp/... 临时路径,重启小程序后失效。必须上传到云存储获取 cloud:// 路径才能持久化:
javascript
if (avatarUrl && !avatarUrl.startsWith('cloud://')) {
const res = await wx.cloud.uploadFile({
cloudPath: `avatars/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.png`,
filePath: avatarUrl
})
avatarUrl = res.fileID
}
3. Qdrant 同步失败不影响主流程
向量数据库同步可能因为网络或 API 限制失败,但这不应该阻塞用户操作。所有 Qdrant 调用都用 fire-and-forget 模式:
javascript
qdrantPrivate.upsertPrivateRecipe(recipe, openid).catch(err => {
console.error('[userProfile] Qdrant同步失败:', err)
})
菜谱已存入数据库,向量同步失败只影响 AI 检索,下次更新时会重新同步。
4. 分页删除后的数据不一致
删除单条记录后,如果只是从本地数组 splice 移除,后续的分页 offset 就会错位。所以删除后选择全量刷新:
javascript
this.setData({ page: 1, records: [] })
this.loadHistory(true) // 从第一页重新加载
总结
个人中心模块的开发,本质上是在解决三个问题:
- 身份归一:用 openid 作为天然主键,"登录"只是个性化的锦上添花
- 数据生命周期:3天/7天自动过期 + 用户主动删除,兼顾存储成本和用户控制感
- 结构化与自由的平衡:表单用自由文本输入 + 规则解析,降低用户填写门槛;存储用结构化对象,保证程序可处理
项目地址 :Gitee/ZaoTaiNavigation
团队名称 :倒灶了队
更新时间:2026年5月