从"能用"到"好用",记录每一个 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

虽然在这个特定场景下逻辑碰巧正确,但这个写法有两个问题:

  1. 语义模糊,维护者需要思考"到底是想匹配哪个ID"
  2. 如果 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'):推荐卡片只携带 namereasonrecipeIdsource从不包含 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}}">

云函数新增 deleteHistoryclearAllHistory 两个 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月

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐