【灶台导航】优化纠错实录
项目初版完成后,看似功能完备,实际使用中却暴露出各种问题:按钮点了没反应、AI生成一直失败、保存的数据是空的……这篇文章完整记录了从发现问题到定位根因到修复验证的全过程,涵盖交互逻辑纠错、AI链路加固、数据完整性保障、历史生命周期管理、UI细节打磨五个维度。
从"能用"到"好用",记录每一个 bug 定位、体验优化和功能补全的真实过程
前言
项目初版完成后,看似功能完备,实际使用中却暴露出各种问题:按钮点了没反应、AI生成一直失败、保存的数据是空的……这篇文章完整记录了从发现问题到定位根因到修复验证的全过程,涵盖交互逻辑纠错、AI链路加固、数据完整性保障、历史生命周期管理、UI细节打磨五个维度。
一、交互逻辑纠错:三个"点了没反应"的 bug
1.1 菜谱卡片选择:find 条件的逻辑陷阱
现象:选中未收录的菜谱卡片后点击"添加选中",没有反应。
定位:selectRecipe() 和 addSingleRecipe() 中查找菜谱的 find 条件有逻辑缺陷:
// 修改前 — 有隐患的写法
recipe = msg.recommendations.find(r => this._recipeKey(r) === (clickedRecipeId || r.recipeId));
(clickedRecipeId || r.recipeId) 看起来是"用点击的ID或菜谱自带的ID",但当 clickedRecipeId 有值时,|| 右侧永远不会执行——这行代码等价于 this._recipeKey(r) === clickedRecipeId。
虽然在这个特定场景下逻辑碰巧正确,但这个写法有两个问题:
- 语义模糊,维护者需要思考"到底是想匹配哪个ID"
- 如果
clickedRecipeId为空字符串(falsy),会意外匹配r.recipeId,造成错选
修复:简化为明确的单一条件,加空值防护:
// 修改后 — 清晰明确
selectRecipe(e) {
const clickedRecipeId = e.currentTarget.dataset.recipeId;
if (!clickedRecipeId) return; // 防御性检查
let recipe = null;
for (let i = this.data.chatMessages.length - 1; i >= 0; i--) {
const msg = this.data.chatMessages[i];
if (msg.recommendations && msg.recommendations.length > 0) {
recipe = msg.recommendations.find(r => this._recipeKey(r) === clickedRecipeId);
if (recipe) break;
}
}
if (!recipe) return;
// ... 后续选中逻辑
}
教训:|| 在条件表达式中容易被误读为"或"逻辑,但实际是"空值合并"。当意图是精确匹配时,不要用 || 拼接两个不同来源的值。
1.2 “保存菜谱”:空数据就入库了
现象:点击"保存菜谱"按钮,菜谱确实保存了,但打开私人菜谱一看——没有食材、没有步骤、没有做法,只有菜名。
定位:saveToMyRecipes() 的逻辑是直接构建 recipeData 对象,然后检查 recipe.fullRecipe 是否存在来填充详情字段。问题在于:
- 数据库菜谱(
source: 'database'):推荐卡片只携带name、reason、recipeId、source,从不包含fullRecipe - 未收录菜谱(
source: 'not-found'):同样没有fullRecipe
两种情况下 fullRecipe 都是 undefined,所有详情字段保持默认空值:
// 修改前 — fullRecipe 不存在时,全部为空
const recipeData = {
name: recipe.name,
description: recipe.reason || '',
prepTime: 0,
cookTime: 0,
difficulty: '中等',
ingredients: [], // 空!
steps: [], // 空!
tags: [],
category: 'other'
};
if (recipe.fullRecipe) { // database 和 not-found 的菜谱永远进不来
// ... 填充详情
}
修复:按菜谱来源分流处理,确保每种情况都有完整数据:
async saveToMyRecipes(e) {
// ... 查找 recipe ...
// 未收录菜谱:先AI生成,再保存
if (recipe.source === 'not-found') {
wx.showModal({
title: '菜谱未收录',
content: `"${recipe.name}"不在系统菜谱库中,需要先生成完整做法才能保存。`,
confirmText: '生成并保存',
success: async (res) => {
if (res.confirm) await this._generateAndSaveRecipe(recipe);
}
});
return;
}
// 数据库菜谱:先拉取完整详情,再保存
if (recipe.source === 'database' && !recipe.fullRecipe && recipe.recipeId) {
await this._fetchAndSaveRecipe(recipe);
return;
}
// AI生成菜谱但缺fullRecipe:提示重新生成
if (recipe.source === 'ai-generated' && !recipe.fullRecipe) {
wx.showModal({
title: '菜谱信息不完整',
content: `"${recipe.name}"缺少完整做法信息,是否让AI重新生成?`,
confirmText: '重新生成',
success: async (res) => {
if (res.confirm) await this._generateAndSaveRecipe(recipe);
}
});
return;
}
// 有 fullRecipe,直接保存
this._doSaveRecipe(recipe, clickedRecipeId);
}
其中 _fetchAndSaveRecipe 调用已有的 getRecipeDetail 云函数补全数据:
async _fetchAndSaveRecipe(recipe) {
wx.showLoading({ title: '获取菜谱详情...' });
const res = await callFunction('getRecipeDetail', { recipeId: recipe.recipeId });
if (res.success && res.data) {
const dbRecipe = res.data;
recipe.fullRecipe = {
name: dbRecipe.name,
description: dbRecipe.description || '',
prepTime: dbRecipe.prepTime || 0,
cookTime: dbRecipe.cookTime || 0,
difficulty: dbRecipe.difficulty || '中等',
ingredients: dbRecipe.ingredients || [],
steps: dbRecipe.steps || [],
tags: dbRecipe.tags || [],
category: dbRecipe.category || 'other'
};
// 同步更新 chatMessages 中的推荐项
// ...
}
this._doSaveRecipe(recipe, this._recipeKey(recipe));
}
教训:不要假设数据总是完整的。在入库前对每条数据做来源检查和完整性校验,按来源走不同的补全路径。
1.3 "清空全部"报错:action 名不匹配
现象:在会话历史页点击"清空全部",弹出"清空失败"提示。控制台报 [chat] 业务错误: 消息内容不能为空。
定位:前端调用 action: 'clearAllSessions',但云函数里匹配的是 action === 'clearAll'。两个名字不一致,导致请求跳过了所有 action 分支,穿透到 message 校验逻辑。
// 云函数中的 action 路由
if (action === 'clearAll') { // 前端传的是 'clearAllSessions',不匹配!
return await clearAllSessions(openid)
}
// ... 没有匹配到任何 action,继续往下执行 ...
if (!message || !message.trim()) { // message 当然为空,报错
return { code: 1001, message: '消息内容不能为空' }
}
修复:兼容两种 action 名:
if (action === 'clearAll' || action === 'clearAllSessions') {
return await clearAllSessions(openid)
}
教训:action 名是前后端的隐式契约,没有编译期检查。最好定义常量模块统一管理,或者至少在云函数入口处列出所有支持的 action 做校验。
二、AI 链路加固:生成菜谱为什么总是失败
2.1 问题全貌
用户点击未收录菜谱的 “+” 按钮后,弹出"AI生成菜谱中…“,然后显示"生成失败”。整个链路有三个断裂点:
前端 _generateAndAddRecipe()
→ callFunction('chat', { message: '请生成"X"的完整菜谱做法' })
→ 云函数 callDeepSeekAPI()
→ 断裂点1: DeepSeek API 超时(30s)
→ 断裂点2: AI返回 action="recommend" 而非 "generateRecipe"
→ 断裂点3: fallbackResponse() 不返回 fullRecipe
→ 前端检查 res.data.recommendations[].fullRecipe
→ 不存在 → "生成失败"
2.2 修复1:超时配置分级
生成菜谱需要 AI 输出完整的食材列表和步骤,token 数远超普通对话。原配置统一 30 秒超时、512 max_tokens,对生成长文本的场景完全不够。
修改前:
const TIMEOUT_CONFIG = {
deepseekApi: 30000, // 统一 30 秒
cloudFunction: 34000 // 云函数 35 秒
}
const DEEPSEEK_CONFIG = {
maxTokens: 512,
maxTokensGenerate: 2048
}
修改后:
const TIMEOUT_CONFIG = {
deepseekApi: 30000, // 普通对话 30 秒
deepseekApiGenerate: 55000, // 生成菜谱 55 秒
cloudFunction: 59000 // 云函数总超时 59 秒
}
const DEEPSEEK_CONFIG = {
maxTokens: 512, // 普通:推荐/追问
maxTokensGenerate: 4096 // 生成:确保 JSON 不被截断
}
API 请求中根据 generateMode 选择不同超时:
timeout: {
request: generateMode
? TIMEOUT_CONFIG.deepseekApiGenerate // 55s
: TIMEOUT_CONFIG.deepseekApi // 30s
}
package.json 中的云函数超时也从 35 秒改为 60 秒:
{ "cloudfunction": { "timeout": 60 } }
2.3 修复2:强化生成指令
即使 generateMode 为 true,AI 有时仍返回 action: "recommend" 而非 "generateRecipe",不包含 fullRecipe。原因是 system prompt 中只说了"当用户确认需要AI生成做法时使用 generateRecipe",但没有在当轮对话中强调。
修复:在生成模式下,追加一条强调消息:
if (generateMode) {
messages.push({
role: 'user',
content: '【重要提醒】你必须使用action="generateRecipe",并在recommendations中填入fullRecipe完整数据(包含ingredients和steps)。不要用action="recommend"。'
})
}
这条消息放在用户原始消息之后,相当于在当前轮次最后再次强调输出要求,大大提高了 AI 遵从的概率。
2.4 修复3:生成模式专属兜底
原来的 fallbackResponse() 只返回关键词匹配的推荐菜谱,不包含 fullRecipe。API 超时或出错时,生成模式也走了这个兜底,导致前端一定找不到 fullRecipe。
修复:生成模式下走独立的 generateFallbackRecipe():
} catch (err) {
// 生成菜谱模式下,使用本地模板生成兜底菜谱
if (generateMode) {
return await generateFallbackRecipe(userMessage)
}
return await fallbackResponse(userMessage)
}
generateFallbackRecipe 的三级降级策略:
async function generateFallbackRecipe(userMessage) {
// 第一级:尝试从数据库查找
const { data } = await db.collection('recipes')
.where({ name: db.RegExp({ regexp: dishName, options: 'i' }) })
.limit(1).get()
if (data && data.length > 0) {
return {
action: 'generateRecipe',
recommendations: [{ ...data[0], source: 'database', fullRecipe: { ... } }]
}
}
// 第二级:返回基础模板菜谱
return {
action: 'generateRecipe',
recommendations: [{
name: dishName,
source: 'ai-generated',
fullRecipe: {
// 4步通用做法模板
steps: [
{ stepNo: 1, description: '准备食材,洗净切好备用', duration: 300 },
{ stepNo: 2, description: '锅中倒油烧热,放入主料翻炒', duration: 180 },
{ stepNo: 3, description: '加入调味料,翻炒均匀', duration: 120 },
{ stepNo: 4, description: '出锅装盘即可', duration: 60 }
]
}
}]
}
}
2.5 修复4:超时兜底的返回格式
云函数入口处的超时 catch 也有问题。原来直接返回 fallbackResponse(message),返回的对象没有 code: 0,导致前端 callFunction 封装判定 success: false:
// 修改前 — 没有统一返回格式
return await fallbackResponse(message)
// 返回 { reply, action, recommendations } — 没有 code 字段!
修复:包装为标准格式,并区分生成/普通模式:
// 修改后 — 标准格式 + 模式区分
const fallback = isGenerateMode
? await generateFallbackRecipe(message)
: await fallbackResponse(message)
return { code: 0, message: 'success', data: { sessionId: sessionId || null, ...fallback } }
2.6 前端容错加强
前端 _generateAndAddRecipe() 也做了多项改进:
// 改进1:响应解析优先匹配菜名
let generated = null;
if (res.data.recommendations && res.data.recommendations.length > 0) {
generated = res.data.recommendations.find(r => r.fullRecipe && r.name === recipe.name)
|| res.data.recommendations.find(r => r.fullRecipe); // 兜底:任意含 fullRecipe 的项
}
// 改进2:保留 _key 确保选择状态一致
generated._key = this._recipeKey(recipe);
msg.recommendations[idx] = { ...generated };
// 改进3:生成后从选中列表移除
const selectedRecipes = { ...this.data.selectedRecipes };
delete selectedRecipes[this._recipeKey(recipe)];
// 改进4:失败时输出更多诊断信息
console.warn('生成菜谱:AI未返回fullRecipe,响应数据:', JSON.stringify(res.data).substring(0, 200));
整个 AI 生成链路的加固总结:
| 断裂点 | 原因 | 修复手段 |
|---|---|---|
| DeepSeek 超时 | 30s 不够生成长文本 | 分级超时:普通30s / 生成55s |
| AI 不返回 generateRecipe | prompt 不够强调 | 追加强调消息 |
| fallback 无 fullRecipe | 通用兜底不含菜谱数据 | 生成模式专属兜底 |
| 超时返回格式错误 | 缺少 code 字段 | 包装为标准格式 |
三、烹饪完成体验:从"卡住"到"闭环"
3.1 原始问题
做菜页面最后一道菜完成后,无论评分还是跳过评分,都调用 wx.navigateBack()。但做菜页面是通过 wx.switchTab 从首页跳过来的,navigateBack 行为不可预期。而且页面上各种计时器、全局统筹状态都没有清理,残留数据可能影响下次使用。
3.2 修复方案
新增 _finishAllCooking() 方法,做三件事:停 → 清 → 走。
_finishAllCooking() {
// 1. 停:停止所有计时器和语音
this.stopTimer();
this.stopVoice();
this.stopGlobalTimer();
// 2. 清:重置全部页面数据
this.setData({
recipeIds: [],
recipes: [],
currentRecipeIndex: 0,
currentRecipe: {},
currentStep: 1,
totalSteps: 0,
steps: [],
currentStepData: {},
isTimerRunning: false,
timerSeconds: 0,
timerDisplay: '00:00',
emergencyInput: '',
emergencyAnswer: '',
emergencyTips: [],
isEmergencyLoading: false,
isVoiceOn: false,
showScheduleModal: false,
schedule: null,
scheduleSummary: '',
scheduleStarted: false,
globalTimer: 0,
globalTimerDisplay: '00:00',
currentActions: [],
parallelHints: [],
showRatingPanel: false,
tempRating: 0
});
// 3. 走:提示 → 跳转
wx.showToast({ title: '烹饪完成,辛苦了!', icon: 'success', duration: 2000 });
setTimeout(() => {
wx.switchTab({ url: '/pages/index/index' });
}, 1500);
}
同时修复了 completeCooking() 中,最后一道菜时"跳过评分"也走 _finishAllCooking()(原来跳过评分走 navigateBack):
// 修改前 — 跳过评分走 navigateBack
this.showRatingModal(() => {
this.saveCookingHistory();
wx.navigateBack();
}, () => {
this.saveCookingHistory();
wx.navigateBack(); // 行为不可预期
});
// 修改后 — 统一走 _finishAllCooking
this.showRatingModal(() => {
this.saveCookingHistory();
this._finishAllCooking();
}, () => {
this.saveCookingHistory();
this._finishAllCooking(); // 无论评分与否,都清理数据
});
四、历史数据生命周期管理
4.1 需求
- 会话历史保留最近 7 天,烹饪历史保留最近 3 天
- 过期数据自动清理,无需用户操心
- 提供主动删除能力(单条 + 全部)
4.2 服务端:查询时过滤 + 异步清理
两个历史模块采用相同的模式——在查询接口中加时间过滤,同时触发异步清理:
会话历史(7天):
async function listSessions(openid) {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const { data: sessions } = await db.collection('sessions')
.where({
openid,
updateTime: _.gte(sevenDaysAgo) // 只返回7天内的
})
.orderBy('updateTime', 'desc')
.limit(50)
.get()
// 异步清理过期会话,不阻塞响应
cleanExpiredSessions(openid, sevenDaysAgo).catch(e => {
console.warn('[chat] 清理过期会话失败:', e.message)
})
return { code: 0, data: { sessions } }
}
烹饪历史(3天):
async function getHistory(openid, params) {
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)
const whereCondition = {
openid,
cookDate: _.gte(threeDaysAgo) // 只返回3天内的
}
// ... 分页查询 ...
cleanExpiredHistory(openid, threeDaysAgo).catch(e => {
console.warn('[userProfile] 清理过期烹饪历史失败:', e.message)
})
return { code: 0, data: { total, records } }
}
为什么是"查询时清理"而非"定时任务"? 微信小程序云开发没有原生的定时任务能力。查询时清理是一种务实方案——用户越活跃,清理越及时;长期不用的用户数据留着也无害,下次访问时自然清理。
4.3 前端:删除与清空
两个历史页面都增加了长按删除和清空全部的能力:
会话历史页:
<!-- 顶部操作栏 -->
<view class="top-bar">
<view class="clear-all-action" wx:if="{{sessions.length > 0}}" bindtap="clearAllSessions">
<text>清空全部</text>
</view>
<view class="new-chat-action" bindtap="startNewChat">
<text>+ 新对话</text>
</view>
</view>
<!-- 提示 -->
<view class="retention-tip" wx:if="{{sessions.length > 0}}">
<text>对话记录保留7天,长按可删除单条</text>
</view>
<!-- 卡片长按删除 -->
<view class="session-card" bindlongpress="deleteSession" data-id="{{item._id}}">
烹饪历史页:
<view class="top-actions" wx:if="{{records.length > 0}}">
<text class="retention-text">记录保留3天,长按可删除单条</text>
<view class="clear-all-btn" bindtap="clearAllHistory">清空全部</view>
</view>
<view class="history-card" bindlongpress="deleteRecord" data-id="{{item._id}}">
云函数新增 deleteHistory 和 clearAllHistory 两个 action,删除操作都有所有权校验:
async function deleteHistory(openid, recordId) {
const { data } = await db.collection('history').doc(recordId).get()
if (!data || data.openid !== openid) {
return { code: 1003, message: '记录不存在' }
}
await db.collection('history').doc(recordId).remove()
return { code: 0, message: '已删除' }
}
五、UI 细节打磨
5.1 会话历史文本溢出
lastMessage 内容过长时会超出卡片容器。
根因:.session-preview 虽有 overflow: hidden; text-overflow: ellipsis; white-space: nowrap,但在 flex 布局中,没有显式宽度约束时子元素会被内容撑开。
修复:给父容器加 overflow: hidden,给文本加 width: 100%:
.session-info { flex: 1; min-width: 0; overflow: hidden; }
.session-title {
flex: 1; min-width: 0; /* 去掉固定 max-width,改为自适应 */
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.session-preview {
width: 100%; /* 显式约束宽度 */
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
Flex 布局中 min-width: 0 是关键——flex 子项默认 min-width: auto,内容再长也不会收缩到 0 以下,省略号就不会生效。
5.2 系统菜谱隐藏"保存菜谱"按钮
系统已收录的菜谱本身就在数据库中,不需要保存到私人菜谱。但原来所有卡片都显示"保存菜谱"按钮,容易误导用户。
修复:在 WXML 中按 source 过滤:
<!-- 修改前 -->
<view class="recipe-save-btn" wx:if="{{!savedRecipeIds[recipe.recipeId || recipe.name]}}"
catchtap="saveToMyRecipes">保存菜谱</view>
<!-- 修改后 — 系统已收录菜谱不显示 -->
<view class="recipe-save-btn"
wx:if="{{recipe.source !== 'database' && !savedRecipeIds[recipe.recipeId || recipe.name]}}"
catchtap="saveToMyRecipes">保存菜谱</view>
<view class="recipe-saved-tag"
wx:elif="{{recipe.source !== 'database' && savedRecipeIds[recipe.recipeId || recipe.name]}}">已保存</view>
一行条件改动,减少了无意义的 UI 元素,也避免了用户对"为什么要保存已有菜谱"的困惑。
六、问题定位方法论总结
回顾这些 bug 的定位过程,有几种模式反复出现:
6.1 “链路追踪法”
AI 生成失败这个问题,从前端到云函数到 DeepSeek API,整条链路有多个断裂点。定位方式是从最终表现倒推:
前端显示"生成失败"
← 前端代码找不到 fullRecipe
← 云函数返回的数据里没有 fullRecipe
← 可能1:AI 没返回 generateRecipe action
← 可能2:AI 超时,走了 fallbackResponse
← 可能3:fallbackResponse 返回格式不对,前端判定 success=false
每个"←"就是一个检查点,用 console.log 或断点逐一验证,就能快速缩小范围。
6.2 “数据追踪法”
"保存菜谱数据为空"这个 bug,直接看数据流就能定位:
推荐卡片 → { name, reason, recipeId, source } ← 数据从这里来
↓
recipe.fullRecipe ← undefined! ← 断裂点在这里
↓
recipeData = { ingredients: [], steps: [] } ← 空数据入库
推荐卡片是轻量级的摘要对象,从不携带完整菜谱。代码假设它有 fullRecipe,但这个假设在两种来源(database 和 not-found)下都不成立。
6.3 “契约检查法”
“清空全部"报错"消息内容不能为空”,明显是请求穿透到了不该到的逻辑分支。这种情况第一时间检查前后端的接口契约——action 名是否对齐、参数是否完整。
项目地址:Gitee/ZaoTaiNavigation
团队名称:倒灶了队
更新时间:2026年5月
更多推荐



所有评论(0)