Shopware 6购物车: 实现商品软删除与恢复(含代码)
2025-04-18 10:48:25
搞定 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 Processors 和 Cart Collectors 。
- Collectors(收集器) :负责从各种来源收集购物车里的项目,比如从数据库加载商品数据、应用促销规则、计算运费等。
- Processors(处理器) :负责处理和验证收集来的项目,计算价格、税费,处理库存,并且——关键点来了——可能会过滤掉它们不认识或认为无效的行项目(Line Items) 。
当你把一个商品的类型改成 offCart
时,很可能在后续的某个 Processor 处理环节,因为它不认识 offCart
这个类型,或者这个类型的商品价格、数量等不符合某些默认逻辑(比如价格为0且不是优惠券类型),就被“好心”地移除了。
所以,光在入口处改类型,没用!你得让购物车处理的核心流程知道 offCart
是个合法的、需要被保留的类型。
解决方案:让 Shopware 认识你的“offCart”类型
有几种方法可以实现这个目标,这里介绍两种主要思路。
方法一:定制 Cart Processor/Collector(推荐)
这是最“根治”也最符合 Shopware 架构的方式。咱们可以创建一个自定义的 Cart Processor,专门用来处理 offCart
类型的行项目,确保它们在购物车计算过程中不会被意外删除。
原理
通过注册一个自己的 Processor,你可以介入购物车的处理流程。在这个自定义 Processor 里,你可以检查每个行项目:
- 如果类型是
offCart
,确保它被保留在购物车里。 - 你可以选择性地调整它的属性,比如把价格设置为 0(如果软删除的商品不应计入总价),或者在 Payload 里添加一些特殊标记,方便前端展示。
- 因为你的 Processor 知道
offCart
的存在,它就不会把它当作无效类型过滤掉。
步骤
-
创建你的插件
如果你还没有为这个功能创建插件,先用命令行创建一个:bin/console plugin:create SwagCustomCartPlugin
然后安装并激活它。
-
定义服务 (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) 是一个常见的基准。 -
编写 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 的妙用 :充分利用
LineItem
的payload
字段存储额外信息,比如软删除状态、原始价格等,方便后续处理和前端展示。
方法二:巧用 Payload,不改类型
这个方法相对简单,也利用了你观察到的现象:只修改 Payload,商品不会丢失。
原理
我们不再修改行项目的 type
。它仍然是 product
。但在你的路由装饰器里,当用户“删除”商品时,我们不在修改 type
,而是给这个行项目的 payload
添加一个自定义标志,比如 isSoftDeleted: true
。
这样一来,Shopware 的核心处理流程看到的还是一个普通的 product
类型的商品,所有验证和计算都能正常进行(或者至少不会因为它不认识类型而出错)。但你的代码(包括插件逻辑和 Twig 模板)可以通过检查 payload
中的这个标志,知道这个商品其实是“软删除”状态。
步骤/代码示例
-
修改路由装饰器
回到之前的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) ...
-
在前端(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 标记,你都需要提供一个机制让用户能恢复这些商品。这通常意味着:
- 创建一个新的 Controller 路由 :比如
frontend.checkout.custom.restore-item
,接收要恢复的行项目 ID。 - 在 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 标记法更快速简单,适用于对语义性要求不高或希望尽量减少核心流程干预的情况。无论哪种,都需要仔细测试,确保它在各种场景下(比如不同商品类型、有无促销、不同用户状态等)都能按预期工作。