LikeShop 购物车模块深度优化:登录态合并、商品失效与批量结算二开 一、原生购物车体系数据表拆解1.1 购物车表ls_cartLikeShop 的购物车数据存储在 ls_cart 表中表结构简洁核心字段如下字段名数据类型注释idint主键user_idint用户idgoods_idint商品idgoods_numsmallint购买数量item_idint规格IDselectedtinyint选中状态1-选中0-未选中create_timeint创建时间update_timeint修改时间该表的设计特点是强关联用户ID购物车记录必须绑定 user_id这意味着未登录的游客无法在服务器持久化购物车数据前端在游客态下只能将购物车数据暂存在本地localStorage 或 小程序 Storage登录后再同步到服务器。1.2 购物车与商品、订单的关联购物车本身不存储商品价格和名称这些信息在展示时通过 goods_id 和 item_id 实时从商品表和 SKU 表查询。LikeShop 多商户版采用“统一下单 订单拆分”模式购物车跨店铺下单时系统按店铺自动拆分订单运费模版根据不同店铺商品独立核算拆单的核心约束是订单必须归属到唯一商户用户在一次结算中提交多个店铺的商品系统会按 shop_id 将商品分组每个店铺生成一个独立的子订单。1.3 购物车接口体系原生购物车相关接口包括接口功能cart/add加入购物车cart/lists购物车列表cart/update修改数量/选中状态cart/delete删除购物车商品cart/selected全选/取消全选order/settlement结算页数据1.4 原生购物车体系的四个核心缺口1. 游客购物车无服务端存储ls_cart 表强制绑定 user_id游客态的购物车数据仅存在于前端本地登录后合并依赖前端主动提交缺乏服务端的合并校验和冲突处理2. 商品状态无实时校验购物车列表接口直接返回本地存储的 goods_id 和 goods_num不校验商品是否下架、SKU 是否删除、价格是否变动3. 批量结算无拆单逻辑原生结算接口将购物车中所有选中商品作为一个整体订单提交多店铺场景下缺乏按店铺拆分的逻辑4. 无购物车数量上限控制购物车表无条目数据限制用户可无限添加商品存在库存恶意占用风险二、游客与登录态购物车合并改造2.1 需求分析大部分电商都允许“先逛后登录”用户未登录时把商品加进本地购物车登录或注册后要把这辆“游客购物车”和账号里已有的“登录购物车”合成一辆合并的核心原则是以服务端登录车为基准、把游客作为增量并进去合并完成后用结构回写服务端、再清空本地游客车。2.2 数据库扩展在 ls_cart 表中新增游客标识字段支持游客态购物车的服务端存储ALTER TABLE ls_cartADD COLUMN guest_token varchar(64) NOT NULL DEFAULT COMMENT 游客标识登录后清空 AFTER user_id,ADD COLUMN shop_id int UNSIGNED NOT NULL DEFAULT 0 COMMENT 店铺ID多商户版使用 AFTER item_id,ADD COLUMN cart_source tinyint NOT NULL DEFAULT 1 COMMENT 来源1-用户主动添加2-游客合并 AFTER selected;ALTER TABLE ls_cartADD INDEX idx_guest_token (guest_token),ADD INDEX idx_user_goods_item (user_id, goods_id, item_id);guest_token 由前端在首次访问时生成UUID随每次购物车请求传递idx_user_goods_item 联合索引用于加速合并时的重复商品查询。2.3 合并算法设计合并的核心冲突场景及处理策略冲突场景处理策略同一 SKU 两边都有数量叠加goods_num相加取较大选中状态游客有、登录态无直接插入新记录登录态有、游客无保留原记录不变同一商品不同 SKU视为不同条目各自保留游客选中状态仅当登录态无该商品时继承游客选中状态核心合并逻辑class CartMergeService{/*** 登录后合并游客购物车* param int $userId 登录用户ID* param string $guestToken 游客标识* return array 合并结果统计*/public function merge($userId, $guestToken){if (empty($guestToken)) {return [merged 0, added 0];}// 查询游客购物车记录$guestItems CartModel::where(guest_token, $guestToken)-where(user_id, 0)-select();if ($guestItems-isEmpty()) {return [merged 0, added 0];}$mergedCount 0;$addedCount 0;Db::startTrans();try {foreach ($guestItems as $guestItem) {// 查询登录态是否已有相同 SKU$existing CartModel::where(user_id, $userId)-where(goods_id, $guestItem[goods_id])-where(item_id, $guestItem[item_id])-lock(true)-find();if ($existing) {// 场景一数量叠加选中状态取或值$newNum $existing[goods_num] $guestItem[goods_num];CartModel::where(id, $existing[id])-update([goods_num $newNum,selected max($existing[selected], $guestItem[selected]),update_time time(),]);$mergedCount;} else {// 场景二游客独有直接转移归属CartModel::where(id, $guestItem[id])-update([user_id $userId,guest_token ,cart_source 2,update_time time(),]);$addedCount;}}// 清理残留的游客购物车记录CartModel::where(guest_token, $guestToken)-where(user_id, 0)-delete();Db::commit();} catch (\Exception $e) {Db::rollback();Log::error(购物车合并失败 user_id{$userId}: . $e-getMessage());throw $e;}return [merged $mergedCount, added $addedCount];}}2.4 合并的触发时机与幂等处理合并操作在登录成功回调中触发建议通过事件机制解耦// 用户登录成功后触发event(UserLoginSuccess, [user_id $userId, guest_token $guestToken]);幂等性保障合并完成后清空 guest_token 对应的游客记录重复调用时 $guestItems 为空直接返回同时在前端侧合并成功后清空本地 Storage 中的游客购物车数据。2.5 商品数量上限校验合并时需要校验商品数量上限防止恶意刷单private function checkCartLimit($userId, $goodsId, $itemId, $addNum){$maxCartItems ConfigModel::get(cart_max_items, 50);$maxGoodsNum ConfigModel::get(cart_max_goods_num, 999);// 购物车条目数上限$itemCount CartModel::where(user_id, $userId)-count();if ($itemCount $maxCartItems) {throw new Exception(购物车已满请先清理部分商品);}// 单商品数量上限$currentNum CartModel::where(user_id, $userId)-where(goods_id, $goodsId)-where(item_id, $itemId)-value(goods_num) ?? 0;if ($currentNum $addNum $maxGoodsNum) {throw new Exception(单个商品最多添加{$maxGoodsNum}件);}}三、商品失效与变价实时校验改造3.1 需求分析商品下架后购物车怎么展示、价格变了以后购物车要不要实时刷新是购物车体验中最容易被忽视但最影响转化的环节正确的做法是购物车查询时会实时补齐商品标题、图片、价格、库存状态等信息提交订单时再重新做一次商品可售、价格和库存校验而不会直接信任购物车中的历史数据。3.2 失效状态定义购物车中的商品需要校验以下失效维度失效类型校验条件前端展示商品已下架goods.status ! 1或del ! 0标记“已下架”不可选中SKU 已删除goods_item记录不存在或del ! 0标记“规格已失效”库存不足stock goods_num标记“库存不足”数量自动调整价格变动price与购物车快照不一致标记“价格已更新”提示活动已结束限时折扣/秒杀活动已过期恢复原价展示3.3 购物车列表实时校验逻辑改造购物车列表接口在返回数据前逐条校验商品状态class CartLogic{public function getCartList($userId){$cartItems CartModel::where(user_id, $userId)-order(create_time, desc)-select();if ($cartItems-isEmpty()) {return [];}// 批量查询商品信息避免N1查询$goodsIds array_unique(array_column($cartItems-toArray(), goods_id));$goodsMap GoodsModel::whereIn(id, $goodsIds)-column(id, name, image, status, del, id);$itemIds array_unique(array_column($cartItems-toArray(), item_id));$itemMap GoodsItemModel::whereIn(id, $itemIds)-column(id, price, stock, spec_value_ids, id);$result [];$invalidItems [];foreach ($cartItems as $cart) {$goods $goodsMap[$cart[goods_id]] ?? null;$item $itemMap[$cart[item_id]] ?? null;// 失效判定$invalidReason null;if (!$goods || $goods[del] ! 0) {$invalidReason 商品已删除;} elseif ($goods[status] ! 1) {$invalidReason 商品已下架;} elseif (!$item || $item[del] ! 0) {$invalidReason 规格已失效;}$cartData [id $cart[id],goods_id $cart[goods_id],item_id $cart[item_id],goods_num $cart[goods_num],selected $cart[selected],goods_name $goods[name] ?? ,goods_image $goods[image] ?? ,price $item[price] ?? 0.00,stock $item[stock] ?? 0,is_invalid $invalidReason ? 1 : 0,invalid_reason $invalidReason,];// 库存不足时自动调整数量if (!$invalidReason $item[stock] $cart[goods_num]) {$cartData[goods_num] $item[stock];$cartData[stock_warning] 库存不足已调整数量;// 同步更新数据库CartModel::where(id, $cart[id])-update([goods_num $item[stock],]);}if ($invalidReason) {$invalidItems[] $cart[id];}$result[] $cartData;}return [list $result,invalid_ids $invalidItems,total count($result),];}}3.4 价格变动追踪为了追踪价格变动在 ls_cart 表中增加价格快照字段ALTER TABLE ls_cartADD COLUMN price_snapshot decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 加入时价格快照 AFTER goods_num;在加入购物车时记录当前价格列表查询时对比快照与实时价格// 价格变动检测if (!$invalidReason $item[price] ! $cart[price_snapshot]) {$cartData[price_changed] 1;$cartData[old_price] $cart[price_snapshot];// 更新快照避免重复提示CartModel::where(id, $cart[id])-update([price_snapshot $item[price],]);}3.5 结算前的二次校验购物车列表的校验只解决“展示”问题提交订单时必须做二次校验在结算接口中增加商品可售性和价格校验防止用户绕过购物车列表直接提交订单public function checkBeforeSettlement($userId, $cartIds){$cartItems CartModel::where(user_id, $userId)-whereIn(id, $cartIds)-where(selected, 1)-select();foreach ($cartItems as $cart) {$goods GoodsModel::find($cart[goods_id]);$item GoodsItemModel::find($cart[item_id]);if (!$goods || $goods[status] ! 1 || $goods[del] ! 0) {throw new Exception(商品「{$goods[name]}」已下架无法结算);}if (!$item || $item[stock] $cart[goods_num]) {throw new Exception(商品「{$goods[name]}」库存不足);}}}四、多店铺批量结算拆分改造4.1 需求分析LikeShop 多商户版支持购物车跨店铺下单类似京东、淘宝结算模式支持多店铺商品加入购物车并同时结算运费模版根据不同店铺商品独立核算订单自动根据店铺拆分原生系统的拆单逻辑已具备基础能力但存在两个核心问题1. 拆分粒度粗仅按 shop_id 拆分不支持更细粒度的拆分维度如按商品类型、按活动类型2. 金额分摊不精确优惠券和积分抵扣在拆分时采用简单均摊可能导致子订单金额与商品实际应付金额不匹配4.2 拆单核心逻辑拆单的工程实现需要遵循“先在内存里把虚拟子单算明白再落库”的原则class OrderSplitService{/*** 购物车批量结算拆单* param int $userId 用户ID* param array $cartIds 购物车ID列表* return array 拆单后的虚拟订单结构*/public function splitOrder($userId, $cartIds){$cartItems CartModel::where(user_id, $userId)-whereIn(id, $cartIds)-where(selected, 1)-select();// 第一步按店铺分组$shopGroups [];foreach ($cartItems as $cart) {$shopId $this-getShopIdByGoods($cart[goods_id]);$shopGroups[$shopId][] $cart;}// 第二步为每个店铺构建虚拟子单$virtualOrders [];foreach ($shopGroups as $shopId $items) {$goodsAmount 0;$orderGoodsList [];$totalWeight 0;foreach ($items as $cart) {$goods GoodsModel::find($cart[goods_id]);$item GoodsItemModel::find($cart[item_id]);$itemAmount $item[price] * $cart[goods_num];$goodsAmount $itemAmount;$totalWeight $goods[weight] * $cart[goods_num];$orderGoodsList[] [goods_id $cart[goods_id],item_id $cart[item_id],goods_name $goods[name],goods_num $cart[goods_num],goods_price $item[price],total_price $itemAmount,];}// 计算该店铺的运费$shippingPrice $this-calcShippingPrice($shopId, $totalWeight, $goodsAmount);$virtualOrders[$shopId] [shop_id $shopId,goods_amount $goodsAmount,shipping_price $shippingPrice,total_amount $goodsAmount $shippingPrice,order_goods $orderGoodsList,coupon_amount 0,integral_deduct 0,];}// 第三步分摊优惠券和积分抵扣$virtualOrders $this-allocateDiscount($virtualOrders, $userId);return $virtualOrders;}/*** 按金额比例分摊优惠券和积分抵扣*/private function allocateDiscount($virtualOrders, $userId){// 获取用户本单可用的优惠券抵扣金额$couponAmount $this-getCouponAmount($userId, $virtualOrders);$integralDeduct $this-getIntegralDeductAmount($userId, $virtualOrders);// 计算所有子单的商品总额$totalGoodsAmount array_sum(array_column($virtualOrders, goods_amount));if ($totalGoodsAmount 0) return $virtualOrders;$allocatedCoupon 0;$allocatedIntegral 0;$shopIds array_keys($virtualOrders);$lastShopId end($shopIds);foreach ($virtualOrders as $shopId $order) {if ($shopId $lastShopId) {// 最后一个店铺取剩余值避免精度误差$order[coupon_amount] round($couponAmount - $allocatedCoupon, 2);$order[integral_deduct] round($integralDeduct - $allocatedIntegral, 2);} else {$ratio $order[goods_amount] / $totalGoodsAmount;$order[coupon_amount] round($couponAmount * $ratio, 2);$order[integral_deduct] round($integralDeduct * $ratio, 2);$allocatedCoupon $order[coupon_amount];$allocatedIntegral $order[integral_deduct];}// 重新计算子单应付金额$order[pay_amount] max(0.01, round($order[goods_amount] $order[shipping_price]- $order[coupon_amount] - $order[integral_deduct], 2));}return $virtualOrders;}}4.3 父订单与子订单的关联LikeShop 多商户版的订单模型采用父子结构ls_order 作为主订单记录整体汇总子订单按店铺拆分建议在订单表中扩展以下字段ALTER TABLE ls_orderADD COLUMN parent_order_id int UNSIGNED NOT NULL DEFAULT 0 COMMENT 父订单ID0为父订单 AFTER id,ADD COLUMN order_group_sn varchar(32) NOT NULL DEFAULT COMMENT 订单组编号关联同一次结算的所有子单 AFTER parent_order_id;ALTER TABLE ls_orderADD INDEX idx_order_group_sn (order_group_sn),ADD INDEX idx_parent_order_id (parent_order_id);一次结算生成一个 order_group_sn 所有子订单共享该编号支持时按 order_group_sn 汇总计算总应付金额支付成功后统一更新所有子订单的支付状态。4.4 支付回调的状态同步批量结算涉及多子单的支付状态同步支付回调触发时需要按 order_group_sn 批量更新所有子订单public function onPaySuccess($orderGroupSn, $paySn){Db::startTrans();try {$orders OrderModel::where(order_group_sn, $orderGroupSn)-where(order_status, 0) // 待付款-lock(true)-select();foreach ($orders as $order) {OrderModel::where(id, $order[id])-update([order_status 1, // 待发货pay_status 1,pay_time time(),pay_sn $paySn,]);// 触发各子单的支付后置操作扣库存、发积分、拼团等event(OrderPaid, [order_id $order[id]]);}Db::commit();} catch (\Exception $e) {Db::rollback();throw $e;}}4.5 部分子单退款的处理多子单场景下某个子单退款不应影响其他子单的状态退款逻辑需要校验如果该子单已退款但父订单组中其他子单仍在进行中父订单组状态保持“部分退款”如果所有子订单都已退款父订单组标记为“全部退款”。五、完整状态机与边界异常处理5.1 购物车状态机状态触发条件后续流转有效(1)商品正常、库存充足可选中结算失效(0)商品下架/SKU删除不可选中展示失效原因库存预警(2)库存 购买数量数量自动调整提示用户价格变动(3)当前价格 ≠ 快照价格提示价格已更新5.2 边界异常处理异常一合并时游客购物车包含已下架商品。合并前不校验商品状态合并后通过购物车列表的实时校验统一处理已下架商品合并后标记为失效不影响其他有效商品的合并。异常二同一商品在不同店铺有不同价格。多商户版本中同一商品可能被多个店铺上架购物车中的 goods_id 相同但 shop_id 不同合并时需将 shop_id 纳入唯一键判断—— user_id goods_id item_id shop_id 四者相同视为同一购物车条目。异常三拆单后子单金额为0。优惠券全额抵扣时某个子单的 pay_amount 可能为0此时需要设置最低支付金额如 0.01 元或将子单标记为“0元订单”直接跳过支付流程。异常四合并并发冲突。用户在多端同时登录如手机PC两端同时触发合并操作通过 Db::startTrans() lock(true) 行锁保证合并的串行执行同时使用 guest_token 作为幂等键重复合并请求直接返回已合并结果。异常五购物车商品在结算期间被下架。用户在结算页停留较长时间期间商品被下架解决方案是在提交订单接口中增加二次校验第3.5节校验失败时返回明确的失效商品列表前端引导用户返回购物车调整。六、完整开发步骤总结6.1 数据库变更清单操作表名变更内容ALTERls_cart新增 guest_token、shop_id、cart_source、price_snapshot 字段新增 idx_guest_token 和 idx_user_goods_item 索引ALTERls_order新增 parent_order_id、order_group_sn 字段新增 idx_order_group_sn 和 idx_parent_order_id 索引6.2 后端开发清单模块文件/类工作内容数据层CartModel新增字段映射和查询 scope业务层CartMergeService新建 Service封装游客购物车合并逻辑业务层CartLogic改造 getCartList接入实时校验和失效判定业务层CartValidateService新建 Service封装商品可售性、库存、价格校验业务层OrderSplitService新建 Service封装按店铺拆单和优惠分摊逻辑业务层OrderLogic改造结算接口接入拆单逻辑事件UserLoginSuccessEvent新建事件触发购物车合并后台接口CartController新增购物车合并接口、失效商品查询接口6.3 后台前端开发清单页面工作内容购物车列表增加失效商品置灰展示、失效原因标签、价格变动提示结算页增加按店铺分组的商品展示、各店铺独立运费展示订单列表增加订单组编号展示支持按组筛选系统设置增加购物车条目上限、单商品数量上限配置项6.4 开发注意事项二开规范方面遵循 LikeShop 的“扩展优先”原则购物车合并、失效校验、拆单逻辑分别封装在独立的 Service 中不修改核心的 CartLogic 和 OrderLogic 主流程合并逻辑通过事件机制触发拆单逻辑通过结算接口的扩展点注入。数据一致性方面合并操作使用数据库事务 行锁防止并发合并导致数据丢失拆单时的金额分摊需使用 bcadd / bcsub 等高精度计算函数最后一项取剩余值以确保分摊总额与原始总额一致。兼容性方面新增字段全部设置默认值guest_token 默认为空字符串shop_id 默认为0保证存量购物车数据不受影响购物车列表接口新增的 is_invalid、price_changed 等字段使用可选返回保持向后兼容。性能优化方面购物车列表的商品信息查询使用批量查询whereIn column避免 N1 问题合并时的重复商品查询依赖 idx_user_goods_item 联合索引购物车数据可考虑引入 Redis 缓存降低数据库查询压力。