后端与架构
小 bug,大影响:Symfony 与 PrestaShop 中的三个真实案例
阅读需 8 分钟
小 bug,大影响:Symfony 与 PrestaShop 中的三个真实案例
我在工作中修过的大多数 bug,难点并不在于代码量。修复往往只需要一两行。真正难的是:代码看起来完全正确,框架也忠实地执行了指令,但症状却出现在别处——在支付服务商那里、在一个保留了旧数据的数据库里,或者在一个“保存成功”却什么都没改变的表单中。
下面三个案例来自我在 Osunasport 参与开发的 MasMusculo 健身房管理平台(Symfony)及其 PrestaShop 商店。名称和代码都做了简化,但根本原因都是真实的。
案例一:Doctrine 的 filter() 返回的是副本
症状
健身房员工在基于 EasyAdmin 的后台中编辑训练计划。一个训练计划包含多个训练日,每个训练日有一组动作行(组数、次数、休息时间)。编辑某一天的动作并保存后,训练计划出现了不一致:本应被替换的旧行依然存在,和新行混在一起。
保存的流程
在 EasyAdmin 中,每个训练日都是一个独立的集合字段;提交表单时,实体的 setter 会针对每一天各调用一次。逻辑很简单:先清空这一天的所有行,再加入表单提交过来的行;没有改动的训练日保持不变。
出问题的版本大致如下:
public function setRowsForDay(int $day, iterable $newRows): void
{
// filter() 返回的是一个新的 ArrayCollection
$rowsOfDay = $this->rows->filter(
fn (RoutineRow $row) => $row->getDay() === $day
);
$rowsOfDay->clear(); // 清空的是副本,而不是 $this->rows
foreach ($newRows as $row) {
$row->setDay($day);
$this->addRow($row);
}
}
根本原因
Collection::filter() 并不是在原集合上过滤,而是用符合条件的元素构造一个新的集合。清空这个新集合对 $this->rows——也就是 Doctrine 正在跟踪的那个集合——没有任何影响。于是旧行从未被删除,新行又被追加了进去。
修复
直接从 Doctrine 实际管理的集合中移除元素:
foreach ($this->rows->toArray() as $row) {
if ($row->getDay() === $day) {
$this->removeRow($row); // removeElement() + 关联上的 orphanRemoval
}
}
遍历 toArray() 可以避免在迭代过程中修改集合本身;关联上配置的 orphanRemoval 则让 Doctrine 在 flush 时删除这些被移除的行。
**教训:**使用 Doctrine 集合时,要时刻问自己:“我修改的是被跟踪的集合,还是一个副本?”filter()、map() 和 slice() 都会返回新对象。
案例二:Stripe 不接受 0 欧元的折扣
症状
部分通过促销活动订阅的客户会收到来自 Stripe 的错误,订阅无法完成。
根本原因
促销码通过校验后会被存入 cookie,商品价格再根据促销类型计算。有些促销设定的是一个固定的最终价格,而不是百分比折扣。当这个固定价格等于或高于商品原价时,计算逻辑仍然会生成一个“折扣”,只是金额为零(或者本应为负数)。这个值被传给了 Stripe,而 Stripe 不允许 0 欧元的折扣。
换句话说,平台试图应用一个对客户毫无好处的促销,唯一可见的结果就是支付失败。
修复
这个判断应该放在价格计算阶段,在任何数据到达支付服务商之前完成:如果最终价格不低于原价,就没有折扣可以应用。
public function calculatePrice(int $baseCents, ?Promotion $promo): int
{
if ($promo === null) {
return $baseCents;
}
$finalCents = $promo->apply($baseCents);
// 没有实际折扣:按照没有促销来处理
return $finalCents < $baseCents ? $finalCents : $baseCents;
}
在构建支付请求时,也只有当折扣大于零时才附加折扣。
教训:“什么都不做”的值仍然是值。零折扣、空数组或为空的日期,在你的业务领域里可能完全合法,却仍会被外部 API 拒绝。应该在系统边界处对它们进行规范化,而不是等到错误处理时再补救。
案例三:永远到不了服务器的空列表
症状
一个自定义的 PrestaShop 模块允许商店把某些供应商、商品或分类排除在某项功能之外。添加排除项没有问题,但把某一类排除项全部删除却不生效:保存之后,旧的排除项依然存在。
(顺便一提:在我的本地环境中,直到把 PHP 的 memory_limit 提高到 512M,这个模块的配置页面才能打开。在认定问题出在自己代码之前,值得先检查一下这类环境问题。)
根本原因
在模块的 postProcess() 中,只有当提交的值存在时才会保存排除项:
if (Tools::getValue('excluded_products')) {
$this->saveExclusions('product', Tools::getValue('excluded_products'));
}
浏览器根本不会提交一个没有任何选中项的多选框。因此,对服务器来说,“用户清空了列表”和“请求里没有这个字段”是完全一样的,本应删除旧排除项的分支就这样被跳过了。
修复
只要表单被提交,就始终处理这个字段,并把缺失的值当作空列表:
if (Tools::isSubmit('submitExclusions')) {
$ids = Tools::getValue('excluded_products', []);
$ids = is_array($ids) ? array_map('intval', $ids) : [];
$this->saveExclusions('product', $ids); // [] 表示“全部删除”
}
**教训:**没有数据本身也是一种数据。当一个表单可能合法地提交“空”时,要明确规定“空”意味着什么,而不是让一个真假判断替你做决定。
三者的共同点
这些 bug 都没有在我们自己的代码中抛出异常。每一个都是代码表面含义与实际行为之间的错位:拿到的是副本而不是引用;一个只有别人的 API 才在意的零;一个与“缺失”无法区分的空值。
它们让我养成了一个习惯:先检查边界——我的代码与 ORM 之间、我的代码与支付服务商之间、浏览器与服务器之间。一行看似正确的代码,往往就是在这些地方变得不再正确。