研途灵伴——联调我修了七个 Bug
写在前面
这周我们组的研途灵伴项目进入联调阶段。功能基本都搭完了,但拼到一起之后问题一个接一个地冒:接口 500、页面白屏、按钮看不清字、骨架屏永远不消失。
一共修了七个 Bug,另外有两个问题排查后发现涉及架构层面的决策,暂时没修,先记下来等团队讨论。这篇文章把每个问题的排查过程和修法都记一遍,算是给自己留个底。
一、两个 500——重构之后调用方忘改了
最先冒出来的是两个 500,都在学习会话模块。
调POST /api/v1/study-session/start的时候后端直接报 500,错误信息是CareService.on_study_start() got an unexpected keyword argument 'now'。结束会话那个接口也一样,on_mood_recorded()报了同样的错。
我去看了一下调用方的代码,study_session.py里写了self.care_service.on_study_start(user_id, now=start_time, auto_commit=False)。再去看care.py里on_study_start的签名——只有一个user_id参数,now和auto_commit根本不接受。
应该是之前某次重构改了CareService的方法签名,但调用方没跟着改。两个地方,各删一行多余的参数,问题就解决了。
这种 bug 不难查,但很典型:重构改了接口,调用方漏改了。Python 不像 TypeScript 有编译期类型检查,少一个参数运行时才报错,联调的时候才发现。
二、饮食页面白屏——数组和对象没对齐
点击"饮食记录"按钮,页面直接白屏。打开控制台一看:TypeError: Cannot read properties of undefined (reading 'find')。
顺藤摸瓜查下去,后端/api/v1/meal/menu返回的data字段直接是一个数组。但前端getMealMenus的返回类型写的是{items: MealMenu[]},父组件拿到返回值后执行menus.items,得到的是undefined,传给子组件后.find()就崩了。
改法很简单:在getMealMenus里把裸数组包一层,return { items: unwrap(response.data) },让实际返回值和类型声明对上。
这个 bug 暴露了一个联调中很常见的问题:后端觉得返回数组没问题,前端觉得返回对象更合理,两边各改各的,类型系统又拦不住运行时的结构不匹配。如果后端的接口文档或者类型定义足够严格,这种问题在开发阶段就能发现。
三、错题本标签重复——同一份数据存了两份
错题详情页里,"这道题目如何解决"区域的知识点标签出现了重复。比如"自然语言处理"出现了两次。
查了一下后端的数据流:创建错题时,knowledge_points存进了WrongQuestion模型的 JSON 字段,同时相同的值又作为WrongQuestionTag(tag_type="knowledge")存进了 tag 表。详情 API 返回时两个字段都带着这些值,前端两组都渲染,自然就重复了。
修法在前端:渲染tags的时候加一行过滤,跳过tag_value已经存在于knowledge_points中的条目。
这个不算严格的 bug,更像是数据冗余导致的展示问题。后端存了两份一样的数据,前端得自己判断该信哪一份。
四、小测再练没有图片——数据在模块间传递时丢了
从错题本点"小测再练"进入答题页,题目只有文字,没有图片。但原始的错题记录里是有图的。
问题出在数据传递链路上:错题来源的WrongQuestion有images字段,但小测走的是QuestionItem模型,这个模型没有images。后端构建小测题目 payload 的时候,只取了QuestionItem的字段,图片就这样丢了。
改法涉及后端三个地方:
_get_questions_from_wrong_review改为返回三元组(QuestionItem, origin, images),把图片一起带出来_build_start_question_payload新增images参数,写入响应- schema
QuizStartQuestionResponse新增images字段
前端也跟着改了:QuizStartQuestion类型加上images,Quiz 页面渲染题目时用<Image>组件展示。
这个问题属于典型的"数据在模块间传递时丢失"。每个模块只关心自己的模型定义,没人负责把图片从错题一路带到小测。这种问题在单独开发各自模块时不会发现,联调时才暴露。
五、聊天按钮看不清字——旧 API 在新版本上的坑
Tutor 回复消息底部有一排动作按钮:“加入错题本”"小测再练"之类的。绿色文字配深色气泡背景,肉眼几乎看不清。
查了一下ActionButtons.tsx,用的是 Ant Design 的type="primary"+ghost={true}。ghost 模式下按钮是透明背景,文字颜色继承 primary 色(teal),在深色背景上对比度不够。
一开始想走 CSS 覆盖的路子,加了.ant-btn-primary.ant-btn-ghost的样式规则。结果没用——Ant Design 5 用的是 CSS-in-Js,优先级比外部 CSS 高,样式根本覆盖不上去。
最后换成了 Ant Design 5 的新 props:color="primary"+variant="solid"。文字变白色,背景变成 teal 实心,对比度一下就够了。已完成状态的按钮用variant="outlined"保持灰色风格。
这件事让我对 Ant Design 5 的 API 体系有了更清楚的认识。type/ghost是旧写法,color/variant是新写法,两者不能混用。如果项目一开始就统一用新 API,这类问题根本不会出现。
六、情绪页面骨架屏永远不消失——这个最折腾
这个问题排查时间最长,也是我觉得最有意思的一个。
打开情绪记录页面,左侧的"今日心情打卡"表单正常显示,但右侧"最近 7 天趋势"卡片和下方"历史记录"卡片始终是骨架屏——灰色条状占位符,内容永远加载不出来。
我一开始以为是某个 API 接口挂了,但单独调三个接口都没问题。后来发现是三层问题叠在一起才产生的:
第一层:Promise.all的失败传播
MoodPage用Promise.all并行调了三个 API。Promise.all的语义是"全部成功"——只要有一个 reject,整个 Promise 就 reject。虽然外层有 try-catch-finally,finally里写了setLoading(false),但在快速重渲染的场景下存在竞态条件。
第二层:全局 store 触发的竞态
情绪页面监听了全局 store 里的moodRefreshSequence。当其他模块(比如聊天、学习会话)调用emitRefreshTargets(['mood', ...])时,这个 sequence 会递增,触发情绪页面重新加载数据。每次重新加载开头就setLoading(true),如果上一次还没加载完,新的setLoading(true)会覆盖掉finally里的setLoading(false),loading 就永远卡在 true。
第三层:组件间共享 loading 状态
MoodTrend和MoodHistory都通过loading={loading}接收同一个状态。一旦 loading 卡住,所有 Card 同时卡在骨架屏。
修法:
Promise.all改成Promise.allSettled,每个 API 独立处理成功和失败,一个挂了不影响其他- 移除
MoodTrend和MoodHistory的loading属性,组件内部自己处理空状态(显示"还没有情绪记录"之类的提示) - 清掉了不再使用的
trendLoading状态变量
改完之后,即使某个 API 超时或者报错,其他数据照常展示,骨架屏不会再卡死。
七、另外两件事
除了上面七个 Bug,这轮还做了两个小改动:
Vite 预加载:给vite.config.ts加了build.warmup.clientFiles,把主要页面组件加进预加载列表。改动不大,但能减少首次打开页面时的白屏时间。
未修复 Bug 沉淀:有两个问题排查后发现涉及架构层面的决策,暂时没修,记录到了未修复的bug/目录下:
- “错题本 correct_answer 在聊天与答疑链路中始终为空”——聊天和截图答疑来源的错题没有标准答案,需要确认"标准答案"的业务定义
- “情绪打卡提交因 CareService 调用 LLM 超时而卡死”——
on_mood_recorded触发的 care 服务会调用 LLM API,没有超时设置,导致整个请求挂起
这两个问题不是修不了,是修之前需要团队先统一口径。
八、几点感受
联调不比开发轻松。每个模块单独看都没问题,拼到一起之后各种边界问题就冒出来了。500、白屏、骨架屏卡死,这些都不是"代码写错了",而是"拼起来之后才有的病"。
竞态条件是最难查的 Bug。情绪页面那个骨架屏问题,不是逻辑错了,而是多个异步操作在特定时序下产生了不可预期的行为。时序相关的 bug 很难用单元测试覆盖,因为执行顺序是不确定的。最后是靠理清楚数据流和状态更新的时序才定位到的。
Ant Design 升级要注意 API 迁移。ghost 按钮的问题,本质是旧 API 在新版本上表现不如预期。如果项目一开始就用color/variant写法,这类问题根本不会出现。以后用新框架的时候,得先看看有没有 API 迁移指南。
数据在模块间传递时容易丢东西。小测图片缺失的问题,每个模块只关心自己的模型,没人负责把数据一路带下去。这类问题在项目初期不容易发现,联调时才暴露。如果能在模型设计阶段就考虑好跨模块的数据流,后面会省很多事。
九、还差什么
- 情绪打卡提交卡死的问题需要团队讨论后决定修法,核心是 care 服务的 LLM 调用需要加超时
- 错题本 correct_answer 为空的问题需要确认"标准答案"到底由谁提供
- 这轮主要是修 Bug,没有新增功能模块
最后
这轮联调修下来,最大的收获不是修掉了几个具体 bug,而是对"系统拼装"这件事有了更具体的体感。
单个模块开发的时候,边界是清晰的,输入输出是可控的。但多个模块拼到一起之后,时序、数据结构、状态管理之间的配合就变得复杂了。情绪页面的骨架屏问题尤其典型——Promise.all的失败传播、全局 store 触发的重渲染、组件间共享 loading 状态,三层问题叠在一起,单独看每一层都不算 bug,合在一起就是用户体验灾难。
修这种问题没有捷径,只能一层一层拆开看,找到真正的根因。打补丁只会让下一次排查更难。
