跳至正文
Ende Sun
返回博客

后端与架构

小 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 之间、我的代码与支付服务商之间、浏览器与服务器之间。一行看似正确的代码,往往就是在这些地方变得不再正确。