返回

Shopware 6购物车: 实现商品软删除与恢复(含代码)

php

搞定 Shopware 6 购物车:添加并处理自定义商品类型

咱们在开发 Shopware 6 模块时,有时会遇到想在购物车里搞点“新花样”的需求。比如,你希望用户从购物车删除商品时,这个商品不是真的消失,而是变成一种“临时隐藏”的状态,用户还能选择把它“复活”加回来。

你可能像下面这样,尝试装饰(Decorate)了 Shopware 的 remove 路由:

<?php declare(strict_types=1);

namespace SwagCustomCart\Core\Checkout\Cart\SalesChannel;

use Shopware\Core\Checkout\Cart\Cart;
use Shopware\Core\Checkout\Cart\LineItem\LineItem;
use Shopware\Core\Checkout\Cart\SalesChannel\AbstractCartRemoveRoute;
use Shopware\Core\Checkout\Cart\SalesChannel\CartResponse;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route(defaults={"_routeScope"={"store-api"}})
 */
class CartRemoveRouteDecorator extends AbstractCartRemoveRoute
{
    private AbstractCartRemoveRoute $decorated;

    public function __construct(AbstractCartRemoveRoute $decorated)
    {
        $this->decorated = $decorated;
    }

    public function getDecorated(): AbstractCartRemoveRoute
    {
        return $this->decorated;
    }

    /**
     * @Route("/store-api/checkout/cart/line-item", name="store-api.checkout.cart.remove-line-item", methods={"DELETE"})
     */
    public function remove(Request $request, Cart $cart, SalesChannelContext $context): CartResponse
    {
        $idsToDeleteActually = [];
        $idsFromRequest = $request->request->get('ids', []);

        if (!is_array($idsFromRequest)) {
            // 处理 ids 不是数组的情况,比如抛出异常或返回错误响应
             throw new \InvalidArgumentException('Parameter "ids" must be an array.');
        }

        foreach ($idsFromRequest as $id) {
            $lineItem = $cart->get($id);

            if (!$lineItem) {
                // 如果找不到对应的行项目,跳过或记录日志
                continue;
            }

            // 检查类型是否为 'product'
            if ($lineItem->getType() === LineItem::PRODUCT_LINE_ITEM_TYPE) {
                // 是普通商品,将其类型改为 'offCart' 实现软删除
                $lineItem->setType('offCart');
                // 可以选择性地修改标签、价格或添加自定义负载
                $lineItem->setLabel($lineItem->getLabel() . ' (已移除)'); // 例如,修改标签
                // 标记购物车已修改,以便后续处理
                $cart->markModified();
            } elseif ($lineItem->getType() === 'offCart') {
                // 如果已经是 'offCart' 类型,这次是真要删除
                $idsToDeleteActually[] = $id;
            } else {
                 // 其他类型的行项目,按原逻辑添加到实际删除列表
                 $idsToDeleteActually[] = $id;
            }
        }

        // 如果 $idsToDeleteActually 不为空,才调用原始的删除路由
        if (!empty($idsToDeleteActually)) {
             // 创建一个新的请求对象只包含需要实际删除的 IDs
            $newRequest = $request->duplicate(null, null, ['ids' => $idsToDeleteActually]);
             // 注意:确保将新请求传递给装饰的方法
             // $request->request->set('ids', $idsToDeleteActually); // 直接修改原请求可能导致意外行为,推荐创建新请求
            $response = $this->decorated->remove($newRequest, $cart, $context);
            // 需要重新加载购物车状态,因为原始的 remove 可能已修改了 cart 对象
            // 这里的 $cart 可能不是最新的,取决于 $this->decorated->remove 的内部实现
            // 确保 $response->getCart() 返回的是处理后的最新购物车状态
             return $response;

        } else {
            // 如果没有需要实际删除的商品,可能需要自己构建一个 CartResponse
            // 或者调用原始路由但不传递任何 ids (如果它能处理空 ids 的情况)
            // 这里假设我们只需返回当前的购物车状态
             return new CartResponse($cart);
        }
        
        // 注意:上面对 $request 的修改方式可能因 Symfony 版本和具体实现有差异。
        // 一个更稳妥的方式可能是创建全新的 Request 对象传递给 $this->decorated->remove。
        // 例如:
        // $newRequest = $request->duplicate(null, null, ['ids' => $idsToDeleteActually]);
        // $response = $this->decorated->remove($newRequest, $cart, $context);
        // return $response;

    }
}

上面的代码思路很直接:拦截删除请求,检查商品类型。如果是 product,就改成自定义的 offCart 类型,打上“软删除”标记。如果已经是 offCart 了,那就放行,让它真被删除。

但问题来了:改完类型后,刷新购物车一看,欸?那个 offCart 的商品不见了!好像 Shopware 不认这个自定义类型,在某个环节偷偷把它清理掉了。你可能也发现,如果不改 type,只是在 payload 里加个自定义字段,商品就能留在购物车里。

这说明,光改类型还不够,得让 Shopware 的购物车处理机制认可你的 offCart 类型才行。那具体咋办呢?

为啥直接改类型行不通?购物车处理流程的“小秘密”

要解决问题,得先知道病根在哪。Shopware 的购物车可不是个简单的列表,它有一套挺复杂的计算和处理流程。每次购物车发生变动(添加、删除、修改数量等),或者需要展示购物车内容时,Shopware 都会跑一遍这个流程。

这个流程里有两位关键角色:Cart ProcessorsCart Collectors

  • Collectors(收集器) :负责从各种来源收集购物车里的项目,比如从数据库加载商品数据、应用促销规则、计算运费等。
  • Processors(处理器) :负责处理和验证收集来的项目,计算价格、税费,处理库存,并且——关键点来了——可能会过滤掉它们不认识或认为无效的行项目(Line Items)

当你把一个商品的类型改成 offCart 时,很可能在后续的某个 Processor 处理环节,因为它不认识 offCart 这个类型,或者这个类型的商品价格、数量等不符合某些默认逻辑(比如价格为0且不是优惠券类型),就被“好心”地移除了。

所以,光在入口处改类型,没用!你得让购物车处理的核心流程知道 offCart 是个合法的、需要被保留的类型。

解决方案:让 Shopware 认识你的“offCart”类型

有几种方法可以实现这个目标,这里介绍两种主要思路。

方法一:定制 Cart Processor/Collector(推荐)

这是最“根治”也最符合 Shopware 架构的方式。咱们可以创建一个自定义的 Cart Processor,专门用来处理 offCart 类型的行项目,确保它们在购物车计算过程中不会被意外删除。

原理

通过注册一个自己的 Processor,你可以介入购物车的处理流程。在这个自定义 Processor 里,你可以检查每个行项目:

  1. 如果类型是 offCart,确保它被保留在购物车里。
  2. 你可以选择性地调整它的属性,比如把价格设置为 0(如果软删除的商品不应计入总价),或者在 Payload 里添加一些特殊标记,方便前端展示。
  3. 因为你的 Processor 知道 offCart 的存在,它就不会把它当作无效类型过滤掉。

步骤

  1. 创建你的插件
    如果你还没有为这个功能创建插件,先用命令行创建一个:

    bin/console plugin:create SwagCustomCartPlugin
    

    然后安装并激活它。

  2. 定义服务 (Service Definition)
    在你的插件 src/Resources/config/services.xml 文件里,注册你的自定义 Processor。关键在于要给它打上 shopware.cart.processor 的标签,并且可能需要指定一个合适的 priority(优先级),确保它在可能移除行项目的处理器之前或之后运行(这取决于你的具体逻辑,通常放在默认处理器的优先级附近或稍后可能更安全,以防止它被其他逻辑意外修改)。

    <?xml version="1.0" ?>
    <container xmlns="http://symfony.com/schema/dic/services"
               xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
               xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
        <services>
            <service id="SwagCustomCartPlugin\Core\Checkout\Cart\Processor\OffCartProcessor" public="true">
                <!-- 如果需要依赖其他服务,在这里注入 -->
                <!-- <argument type="service" id="shopware.core.checkout.cart.price.struct.calculate"/> -->
                <tag name="shopware.cart.processor" priority="4500"/> <!-- 优先级可以调整 -->
            </service>
    
            <!-- 别忘了注册你的路由装饰器服务 -->
             <service id="SwagCustomCartPlugin\Core\Checkout\Cart\SalesChannel\CartRemoveRouteDecorator" decorates="Shopware\Core\Checkout\Cart\SalesChannel\AbstractCartRemoveRoute" public="true">
                 <argument type="service" id="SwagCustomCartPlugin\Core\Checkout\Cart\SalesChannel\CartRemoveRouteDecorator.inner"/>
             </service>
        </services>
    </container>
    

    这里的 priority 值需要参考 Shopware 默认的 Processors 来定,找到一个合适的位置插入你的逻辑。较高的数字通常意味着较早执行。Shopware\Core\Checkout\Cart\Processor::PROCESSING_PRIORITY (5000) 是一个常见的基准。

  3. 编写 Processor 类
    在你的插件里创建这个 OffCartProcessor 类。它需要实现 Shopware\Core\Checkout\Cart\ProcessorInterface 接口。

    <?php declare(strict_types=1);
    
    namespace SwagCustomCartPlugin\Core\Checkout\Cart\Processor;
    
    use Shopware\Core\Checkout\Cart\Cart;
    use Shopware\Core\Checkout\Cart\CartBehavior;
    use Shopware\Core\Checkout\Cart\CartDataCollection;
    use Shopware\Core\Checkout\Cart\LineItem\Cart\LineItemCollection;
    use Shopware\Core\Checkout\Cart\LineItem\LineItem;
    use Shopware\Core\Checkout\Cart\ProcessorInterface;
    use Shopware\Core\System\SalesChannel\SalesChannelContext;
    
    class OffCartProcessor implements ProcessorInterface
    {
        public function process(
            CartDataCollection $data,
            Cart $original,
            Cart $toCalculate,
            SalesChannelContext $context,
            CartBehavior $behavior
        ): void {
            // 从 $toCalculate 获取当前要处理的行项目集合
            $lineItems = $toCalculate->getLineItems();
    
            foreach ($lineItems as $lineItem) {
                if ($lineItem->getType() === 'offCart') {
                    // 主要目的:确保 offCart 类型的商品不丢失
                    // 1. 它们已经被你之前的逻辑加进来了,这里不需要做什么特殊操作阻止移除,
                    //    因为默认处理器不认识它,通常也不会主动处理它。
                    //    但要确保没有其他处理器会因为它“奇怪”而移除它。
    
                    // 2. (可选) 调整价格或数量?
                    // 如果软删除的商品不应计入总价,确保它的价格是0。
                    // 注意:直接修改 LineItem 的 PriceDefinition 可能比较复杂。
                    // 更简单的方法可能是确保它在进入购物车时 PriceDefinition 就是0,
                    // 或者在你的 Processor 里把它标记一下,让后续的价格计算 Processor 忽略它。
                    // 例如,添加到 Payload:
                    $payload = $lineItem->getPayload();
                    $payload['isSoftDeleted'] = true;
                    $lineItem->setPayload($payload);
    
                    // 确保数量是正数,否则可能被移除。即使软删除,通常也保留原数量。
                    if ($lineItem->getQuantity() <= 0) {
                        // 如果需要保留,强制数量为1?或者根据业务逻辑处理
                         // $lineItem->setQuantity(1); // 示例:强制数量为1,但这可能不符合“软删除”逻辑
                         // 更合理的做法是在 getType() === 'offCart' 时,确保数量有效,或接受 0 数量
                         // Shopware 对 0 数量的行项目处理也比较特殊,需要测试
                    }
    
                    // 3. (可选) 修改显示信息?
                    // 比如标签已经在路由装饰器改了,这里可以不用再动。
    
                    // 4. 最重要的:把它放回(或保留在)最终要计算的购物车里
                    // $toCalculate->add($lineItem); // 如果之前的处理器可能移除它,这里要确保加回来
                    // 通常情况下,只要 $lineItem 还在 $lineItems 迭代中,它就会保留在 $toCalculate 里,除非被明确移除。
                    // 所以这个 Processor 的核心作用更像是“声明”:我认识 offCart,请保留它。
    
                }
            }
            // 注意: Processors 直接修改 $toCalculate 对象。
            // $toCalculate->setLineItems(new LineItemCollection($updatedLineItems)); // 如果你过滤或修改了集合,需要这样设置回去
        }
    }
    
    

进阶技巧

  • 处理器优先级(Priority) :仔细调整 services.xml 里的 priority 值非常重要。你需要分析 Shopware 核心的 Processors(比如 ProductCollector, PriceCalculator, PromotionProcessor 等)的优先级,决定你的 OffCartProcessor 应该在哪个环节介入。太早可能被后续处理器覆盖,太晚可能某些清理逻辑已经执行了。
  • 与价格计算交互 :如果 offCart 商品不应计入总价,你可能需要在 OffCartProcessor 中确保其价格定义为零,或者与 PriceCalculator 交互(例如,通过在 Payload 中设置标志,让自定义的价格计算装饰器忽略它)。
  • Payload 的妙用 :充分利用 LineItempayload 字段存储额外信息,比如软删除状态、原始价格等,方便后续处理和前端展示。

方法二:巧用 Payload,不改类型

这个方法相对简单,也利用了你观察到的现象:只修改 Payload,商品不会丢失。

原理

我们不再修改行项目的 type。它仍然是 product。但在你的路由装饰器里,当用户“删除”商品时,我们不在修改 type,而是给这个行项目的 payload 添加一个自定义标志,比如 isSoftDeleted: true

这样一来,Shopware 的核心处理流程看到的还是一个普通的 product 类型的商品,所有验证和计算都能正常进行(或者至少不会因为它不认识类型而出错)。但你的代码(包括插件逻辑和 Twig 模板)可以通过检查 payload 中的这个标志,知道这个商品其实是“软删除”状态。

步骤/代码示例

  1. 修改路由装饰器
    回到之前的 CartRemoveRouteDecorator,修改 remove 方法的逻辑:

    // ... (在 CartRemoveRouteDecorator 的 remove 方法内) ...
    
    foreach ($idsFromRequest as $id) {
        $lineItem = $cart->get($id);
    
        if (!$lineItem) {
            continue;
        }
    
        // 检查类型是否为 'product' 并且 Payload 中没有软删除标记
        $payload = $lineItem->getPayload();
        if ($lineItem->getType() === LineItem::PRODUCT_LINE_ITEM_TYPE && !isset($payload['isSoftDeleted'])) {
            // 不改类型,只在 Payload 中添加标记
            $payload['isSoftDeleted'] = true;
            $lineItem->setPayload($payload);
    
            // 可选:修改标签提示用户
            $lineItem->setLabel($lineItem->getLabel() . ' (已临时移除)');
    
            // 标记购物车已修改
            $cart->markModified();
        } elseif ($lineItem->getType() === LineItem::PRODUCT_LINE_ITEM_TYPE && isset($payload['isSoftDeleted']) && $payload['isSoftDeleted'] === true) {
             // 如果商品已标记为软删除,这次是真要删除
             $idsToDeleteActually[] = $id;
        } else {
            // 其他情况,直接添加到实际删除列表
            $idsToDeleteActually[] = $id;
        }
    }
    
    // ... (后续处理 $idsToDeleteActually 列表,调用 decorated->remove) ...
    
  2. 在前端(Twig)或其他逻辑中检查 Payload
    在购物车模板或其他需要区分这些商品的地方,你可以检查 lineItem.payload.isSoftDeleted 的值:

    {# 在购物车模板循环行项目时 #}
    {% for lineItem in page.cart.lineItems %}
        {% if lineItem.payload.isSoftDeleted is defined and lineItem.payload.isSoftDeleted %}
            {# 这是软删除的商品,特殊展示,比如灰掉,显示“恢复”按钮 #}
            <div class="cart-item is-soft-deleted">
                {{ lineItem.label }}
                {# ... 其他信息 ... #}
                <form action="{{ path('frontend.checkout.custom.restore-item') }}" method="post">
                     <input type="hidden" name="lineItemId" value="{{ lineItem.id }}">
                     {# 添加 CSRF token #}
                     {% sw_csrf 'frontend.checkout.custom.restore-item' %}
                     <button type="submit">恢复到购物车</button>
                </form>
            </div>
        {% else %}
            {# 正常的购物车商品 #}
            <div class="cart-item">
                {{ lineItem.label }}
                {# ... 其他信息和操作,比如删除按钮 #}
                <form action="{{ path('store-api.checkout.cart.remove-line-item') }}" method="post" data-form-csrf-handler="true">
                    <input type="hidden" name="ids[]" value="{{ lineItem.id }}">
                    <button type="submit">移除</button>
                </form>
            </div>
        {% endif %}
    {% endfor %}
    

注意事项

  • 语义性 :这种方法虽然简单,但在语义上不如自定义类型清晰。“软删除”的商品本质上还是 product 类型,只是通过一个附加状态来区分。
  • 价格和计算 :你需要考虑这些标记为 isSoftDeleted 的商品是否还应计入总价、参与促销计算等。如果不需要,你可能还是需要一个自定义的 Processor(或装饰现有的价格计算器)来检查这个 Payload 标志,并相应地调整计算逻辑。
  • 逻辑分散 :判断商品状态的逻辑会分散在多个地方(路由装饰器、Twig 模板、可能的 Processor),维护起来可能不如方法一集中。

如何“复活”这些临时删除的商品?

无论是用了自定义类型 offCart 还是 Payload 标记,你都需要提供一个机制让用户能恢复这些商品。这通常意味着:

  1. 创建一个新的 Controller 路由 :比如 frontend.checkout.custom.restore-item,接收要恢复的行项目 ID。
  2. 在 Controller Action 中处理恢复逻辑
    • 获取当前购物车。
    • 找到对应的行项目。
    • 如果使用方法一(自定义类型) :把行项目的类型从 offCart 改回 product。可能需要重置标签、Payload 中的标记、并确保存有有效的价格定义(如果之前清空了的话)。
    • 如果使用方法二(Payload 标记) :从行项目的 payload 中移除 isSoftDeleted 标志(或设置为 false)。重置标签。
    • 标记购物车已修改 $cart->markModified()
    • 保存购物车(通常通过调用 CartPersister 或返回 CartResponse 自动处理)。
    • 重定向用户回购物车页面,或返回更新后的购物车数据(用于 AJAX 更新)。

下面是一个极其简化的恢复 Controller Action 示例(假设使用 Payload 标记法):

<?php declare(strict_types=1);

namespace SwagCustomCartPlugin\Storefront\Controller;

use Shopware\Core\Checkout\Cart\Cart;
use Shopware\Core\Checkout\Cart\SalesChannel\CartService;
use Shopware\Core\Framework\Routing\Annotation\RouteScope;
use Shopware\Core\Framework\Validation\DataBag\RequestDataBag;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Storefront\Controller\StorefrontController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @RouteScope(scopes={"storefront"})
 */
class CustomCartController extends StorefrontController
{
    private CartService $cartService;

    public function __construct(CartService $cartService)
    {
        $this->cartService = $cartService;
    }

    /**
     * @Route("/checkout/custom/restore-item", name="frontend.checkout.custom.restore-item", methods={"POST"}, defaults={"XmlHttpRequest"=true})
     */
    public function restoreLineItem(Request $request, RequestDataBag $dataBag, SalesChannelContext $context, Cart $cart): Response
    {
        $lineItemId = $request->request->get('lineItemId');

        if (!$lineItemId) {
            // 错误处理:没有提供 ID
             return $this->json(['error' => 'Missing line item ID.'], Response::HTTP_BAD_REQUEST);
        }

        $lineItem = $cart->get($lineItemId);

        if (!$lineItem) {
            // 错误处理:购物车中找不到该 ID
            return $this->json(['error' => 'Line item not found in cart.'], Response::HTTP_NOT_FOUND);
        }

        // 假设使用 Payload 标记法
        $payload = $lineItem->getPayload();
        if (isset($payload['isSoftDeleted']) && $payload['isSoftDeleted']) {
            unset($payload['isSoftDeleted']); // 移除标记
            $lineItem->setPayload($payload);

            // 重置标签 (移除 "(已临时移除)" 等字样)
            // 这需要更健壮的逻辑,比如存储原始标签,或根据规则反向生成
             $originalLabel = str_replace(' (已临时移除)', '', $lineItem->getLabel()); // 简陋示例
            $lineItem->setLabel($originalLabel);

            // 标记购物车已修改,这样 CartService 会重新计算并持久化
            $updatedCart = $this->cartService->recalculate($cart, $context);

            // 可以返回更新后的购物车信息,或一个成功状态
            // 使用 StorefrontController::renderStorefront 从 Twig 返回更新的购物车片段更常见
             return $this->renderStorefront('@Storefront/storefront/page/checkout/cart/cart-item-list.html.twig', [
                 'page' => ['cart' => $updatedCart]
             ]);

        } else {
            // 商品不是软删除状态,无需恢复
            return $this->json(['info' => 'Item is not in a soft-deleted state.'], Response::HTTP_OK);
        }
    }
}

注意:上面只是个骨架,你需要完善错误处理、CSRF 保护、标签恢复逻辑以及响应方式(是整个页面刷新还是 AJAX 更新局部)。

安全性考虑

  • CSRF 保护 :确保所有修改购物车的操作(包括你的软删除和恢复操作)都受到 CSRF 保护。Shopware 的 Storefront 通常会自动处理表单提交的 CSRF,如果是通过 AJAX 调用自定义路由,你需要手动验证 CSRF Token。
  • 输入验证 :在处理恢复请求时,验证传入的行项目 ID 是否有效,是否真的存在于当前用户的购物车中,并且确实处于可恢复的状态。不要盲目信任客户端发送的数据。
  • 权限检查 :虽然购物车操作通常与当前会话/登录用户绑定,但要确保你的自定义逻辑没有意外引入访问控制漏洞。

选择哪种方法取决于你的具体需求、团队的技术熟悉度以及对代码维护性的考虑。自定义 Processor 是更彻底、更符合 Shopware 设计模式的方案,而 Payload 标记法更快速简单,适用于对语义性要求不高或希望尽量减少核心流程干预的情况。无论哪种,都需要仔细测试,确保它在各种场景下(比如不同商品类型、有无促销、不同用户状态等)都能按预期工作。