From e712f5554c89fbef3641caa18c1f2eba30df0f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=B4=E6=98=8A=E5=A4=A9?= <442384644@qq.com> Date: Fri, 23 Oct 2026 14:09:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=95=86=E5=93=81):=20=E7=BD=91=E7=9B=98?= =?UTF-8?q?=E5=95=86=E5=93=81=E6=94=B9=20direct=20=E5=8D=95=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E7=9B=B4=E8=B4=AD=EF=BC=8C=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=95=86=E5=93=81=E7=A6=81=E6=AD=A2=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E8=B4=AD=E7=89=A9=E8=BD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 加购策略 - virtual Handler::getCartStrategy() 由 cart/多品改为 cart_mode=direct、multi_item=false, 中台据此下发 cart_button=0,列表与详情只保留「立即购买」。 - 详情页 direct 模式不再写购物车:选中规格后跳单品直购页 /pages/center/order/create/index?product_id=&sku_unique=&product_type=&num=; 多规格首次点击先弹规格窗确认规格,再次点击直接下单。 - 列表卡片 cart_button=0 时展示「立即购买」,点击整行进详情。 - StoreCartServices::setCart() 增加 isDirectCartMode() 校验,new=false 时抛 「该商品不支持加入购物车,请直接购买」,防止其他入口绕过 UI 加购。 自定义表单 - CenterProductServices::hasCustomForm() 统一判定(兼容 JSON 字符串/空数组/已解码数组), 5 处 cart_button 计算与 cart_strategy 组装均按 direct 处理。 - StoreCartServices::setCart() 增加 hasCustomForm() 校验,new=false 时直接拦截。 商品图片 - getList/productDetail 的 image、recommend_image、slider_image 与 SKU pic 统一 set_file_url 补全站点域名,与后台列表口径一致,修复图片裂图。 - 同步更新 .crmeb 用户端购物车文档与虚拟商品 README、详情页 mock 数据。 --- .../references/05-用户端开发/user-cart.md | 22 ++++++- .../app/services/order/StoreCartServices.php | 43 +++++++++++++ .../product/CenterProductServices.php | 62 +++++++++++++++++-- backend/modules/product/virtual/Handler.php | 8 ++- backend/modules/product/virtual/README.md | 8 +++ .../src/components/goodClass/index.vue | 4 ++ .../uni-app/src/pages/goods_details/index.vue | 60 ++++++++++++++++-- .../src/pages/goods_details/mock/README.md | 2 +- .../src/pages/goods_details/mock/virtual.json | 6 +- .../pages/goods_details/mock/virtual.jsonc | 8 +-- 10 files changed, 200 insertions(+), 23 deletions(-) diff --git a/.crmeb/skills/crmeb-dev/references/05-用户端开发/user-cart.md b/.crmeb/skills/crmeb-dev/references/05-用户端开发/user-cart.md index 48309218f..db9ba28b7 100644 --- a/.crmeb/skills/crmeb-dev/references/05-用户端开发/user-cart.md +++ b/.crmeb/skills/crmeb-dev/references/05-用户端开发/user-cart.md @@ -269,7 +269,7 @@ public function getCartStrategy(): array | 产品类型 | cart_mode | cart_button | confirm_window | 说明 | |---------|-----------|-------------|----------------|------| | normal(实物) | cart | 1 | editable | 双按钮,规格弹窗可选 | -| virtual(虚拟) | cart | 1 | editable | 双按钮 | +| virtual(网盘) | direct | 0 | editable | 单按钮「立即购买」,选中规格后跳单品直购页(`/pages/center/order/create/index?product_id=&sku_unique=&product_type=virtual&num=`),不写购物车 | | cardkey(卡密) | cart | 1 | readonly | 双按钮,规格只读高亮 | ### 7.2 cart_button 字段转换 @@ -281,7 +281,25 @@ public function getCartStrategy(): array $storeInfo['cart_button'] = ($storeInfo['cart_strategy']['cart_mode'] ?? 'cart') === 'direct' ? 0 : 1; ``` -> 中台 `CenterProductServices` 共 5 处 cart_button 计算(`getDetail`/`buildDefaultPageConfig`/`getProducts`列表/`productDetail`/`getUserList`)需统一走 `getCartModeByType()` 辅助方法,避免带自定义表单的 normal 商品错误隐藏加入购物车按钮。 +> 中台 `CenterProductServices` 共 5 处 cart_button 计算(`getDetail`/`buildDefaultPageConfig`/`getProducts`列表/`productDetail`/`getUserList`)需统一走 `getCartModeByType()` 辅助方法。 + +**自定义表单商品不参与购物车**:商品 `eb_product_center.custom_form` 非空时,上述各处一律按 `direct` 处理,并统一用 `CenterProductServices::hasCustomForm()` 判断(兼容 JSON 字符串、`[]`、已解码数组): + +| 环节 | 行为 | +| --- | --- | +| 商品详情 | `cart_strategy.cart_mode=direct`、`cart_button=0`,底部只显示「立即购买」 | +| 商品列表(`getGoodsList`/`getUserList`) | 列表项 `cart_button=0`,前端卡片展示「立即购买」,点击整卡进详情,不再打开规格/加购弹窗 | +| 加入购物车接口 | `StoreCartServices::setCart()` 在 `new=false`(加入购物车)时直接抛「该商品需填写自定义表单,请直接购买」;`new=true`(立即购买)不受限 | + +> 原因:自定义表单需在下单环节填写,购物车无法携带表单数据;表单收集与落库见 [product-type.md 附二](../03-系统商品说明/product-type.md)。 + +**direct 模式商品不参与购物车**(网盘等按产品类型声明 `cart_mode=direct` 的商品):`StoreCartServices::setCart()` 在 `new=false` 时走 `isDirectCartMode()` 校验,直接抛「该商品不支持加入购物车,请直接购买」;`new=true`(立即购买)不受限,保证历史链路兼容。 + +| 环节 | 行为 | +| --- | --- | +| 商品详情 | `cart_button=0`,底部单按钮「立即购买」;多规格首次点击先弹规格窗,再次点击跳单品直购页 | +| 商品列表(`getProducts`/`getUserList`) | 列表项 `cart_button=0`,卡片展示「立即购买」,点击整卡进详情 | +| 加入购物车接口 | `new=false` 时抛「该商品不支持加入购物车,请直接购买」 | ### 7.3 SKU 信息获取 diff --git a/backend/app/services/order/StoreCartServices.php b/backend/app/services/order/StoreCartServices.php index b7d72fd85..52f466772 100644 --- a/backend/app/services/order/StoreCartServices.php +++ b/backend/app/services/order/StoreCartServices.php @@ -331,6 +331,14 @@ class StoreCartServices extends BaseService public function setCart(int $uid, int $product_id, int $cart_num = 1, string $product_attr_unique = '', int $type = 0, bool $new = true, int $combination_id = 0, int $seckill_id = 0, int $bargain_id = 0, int $advance_id = 0) { if ($cart_num < 1) $cart_num = 1; + // 自定义表单商品需先填写表单再下单,购物车无法承载表单数据,禁止加入购物车(立即购买 new=true 不受限) + if (!$new && $this->hasCustomForm($product_id)) { + throw new ApiException('该商品需填写自定义表单,请直接购买'); + } + // direct 加购模式的产品类型(如网盘商品)只能直接购买,禁止加入购物车(立即购买 new=true 不受限) + if (!$new && $this->isDirectCartMode($product_id)) { + throw new ApiException('该商品不支持加入购物车,请直接购买'); + } if ($type == 0) { //检查限购 $this->checkLimit($uid, $product_id, $cart_num, $new); @@ -410,6 +418,41 @@ class StoreCartServices extends BaseService } } + /** + * 判断商品是否配置了自定义表单 + * 自定义表单需要在下单环节填写,购物车无法携带表单数据 + * @param int $productId 商品ID + * @return bool + */ + protected function hasCustomForm(int $productId): bool + { + try { + $productType = app()->make(CenterProductServices::class)->getProductType($productId); + $handler = app()->make(ProductTypeRegistry::class)->get($productType); + $extend = $handler->getDetailExtend($productId); + return !empty($extend['custom_form']); + } catch (\Throwable $e) { + return false; + } + } + + /** + * 判断商品所属产品类型是否为 direct 加购模式 + * direct 模式(网盘等)只能直接下单走单品直购链路,不支持加入购物车 + * @param int $productId 商品ID + * @return bool + */ + protected function isDirectCartMode(int $productId): bool + { + try { + $productType = app()->make(CenterProductServices::class)->getProductType($productId); + $handler = app()->make(ProductTypeRegistry::class)->get($productType); + return ($handler->getCartStrategy()['cart_mode'] ?? 'cart') === 'direct'; + } catch (\Throwable $e) { + return false; + } + } + /**移除购物车商品 * @param int $uid * @param array $ids diff --git a/backend/app/services/product/CenterProductServices.php b/backend/app/services/product/CenterProductServices.php index 5881a6a47..63635e09e 100644 --- a/backend/app/services/product/CenterProductServices.php +++ b/backend/app/services/product/CenterProductServices.php @@ -936,6 +936,11 @@ class CenterProductServices extends BaseService $storeInfo['selection_spec'] = $handler->getSelectionSpec(); $storeInfo['cart_strategy'] = $handler->getCartStrategy(); + // 自定义表单商品必须填写表单后直接下单,不支持加入购物车,强制 direct 单按钮模式 + if ($this->hasCustomForm($storeInfo['custom_form'] ?? [])) { + $storeInfo['cart_strategy']['cart_mode'] = 'direct'; + } + // cart_button 由 cart_strategy.cart_mode 决定:direct 模式不展示加入购物车按钮 // 预售商品在模板中走独立分支,不影响此处判断 $storeInfo['cart_button'] = ($storeInfo['cart_strategy']['cart_mode'] ?? 'cart') === 'direct' ? 0 : 1; @@ -1095,7 +1100,8 @@ class CenterProductServices extends BaseService 'text' => '立即购买', 'action' => 'submitOrder', // cart_button 由 cart_strategy.cart_mode 决定:direct 模式不展示加入购物车按钮 - 'extra' => ['cart_button' => $this->getCartModeByType($productType) !== 'direct'], + // 自定义表单商品需填表后直接下单,同样不展示加入购物车 + 'extra' => ['cart_button' => !$this->hasCustomForm($extensions['custom_form'] ?? []) && $this->getCartModeByType($productType) !== 'direct'], ], 'submit_api' => 'center/order/create', ]; @@ -1579,6 +1585,27 @@ class CenterProductServices extends BaseService return $mode; } + /** + * 判断商品是否配置了自定义表单 + * 自定义表单商品需先填写表单才能下单,无法按普通购物车流程结算,因此不支持加入购物车 + * @param mixed $customForm 中台 custom_form 原值(JSON 字符串或已解码数组) + * @return bool + */ + protected function hasCustomForm($customForm): bool + { + if (empty($customForm)) { + return false; + } + if (is_array($customForm)) { + return !empty($customForm); + } + $decoded = json_decode((string)$customForm, true); + if (is_array($decoded)) { + return !empty($decoded); + } + return trim((string)$customForm) !== ''; + } + /** * 获取产品类型展示名称 * 通过 ProductTypeRegistry 咨询对应 Handler @@ -1802,6 +1829,10 @@ class CenterProductServices extends BaseService $item['product_type'] = $item['product_type'] ?? 'normal'; $item['type_name'] = $this->getProductTypeName($item['product_type']); $item['logistics'] = []; + // 相对路径图片补全站点域名,与 getList 口径保持一致(image 主图、recommend_image 推荐图、slider_image 轮播图为数组) + $item['image'] = set_file_url($item['image'] ?? ''); + $item['recommend_image'] = set_file_url($item['recommend_image'] ?? ''); + $item['slider_image'] = set_file_url($item['slider_image'] ?? []); // slider_image 由模型获取器自动解码 if (isset($item['cate_id']) && $item['cate_id'] !== '') { $cateIds[] = $item['cate_id']; @@ -1838,6 +1869,8 @@ class CenterProductServices extends BaseService $item['attrs'][$key] = $items; $item['attrs'][$key]['suk'] = implode(',', $items['detail']); $item['attrs'][$key]['product_price'] = $items['price']; + // 规格图片同样补全站点域名 + $item['attrs'][$key]['pic'] = set_file_url($items['pic'] ?? ''); $key += 1; } } @@ -2436,7 +2469,8 @@ class CenterProductServices extends BaseService if (!$this->vipIsOpen(!!$item['is_vip'], $vipStatus)) { $item['vip_price'] = 0; } - $item['cart_button'] = $this->getCartModeByType($item['product_type'] ?? 'normal') === 'direct' ? 0 : 1; + // 自定义表单商品不支持加入购物车(前端据此点击直接进详情) + $item['cart_button'] = $this->hasCustomForm($item['custom_form'] ?? '') || $this->getCartModeByType($item['product_type'] ?? 'normal') === 'direct' ? 0 : 1; if (count($item['star'])) { $item['star'] = bcdiv((string)array_sum(array_column($item['star'], 'product_score')), (string)count($item['star']), 1); } else { @@ -2708,9 +2742,16 @@ class CenterProductServices extends BaseService $productType = $this->getProductType($id); $handler = app()->make(ProductTypeRegistry::class)->get($productType); + // 自定义表单商品不支持加入购物车,强制 direct 单按钮模式 + $cartStrategy = $handler->getCartStrategy(); + if ($this->hasCustomForm($storeInfo['custom_form'] ?? [])) { + $cartStrategy['cart_mode'] = 'direct'; + } + $storeInfo['cart_strategy'] = $cartStrategy; + // cart_button 由 cart_strategy.cart_mode 决定:direct 模式不展示加入购物车按钮 // 预售商品在模板中走独立分支,不影响此处判断 - $storeInfo['cart_button'] = $this->getCartModeByType($productType) === 'direct' ? 0 : 1; + $storeInfo['cart_button'] = ($cartStrategy['cart_mode'] ?? 'cart') === 'direct' ? 0 : 1; $specs = $handler->getDetailSpecs($id); $productAttr = $specs['productAttr'] ?? []; @@ -3704,6 +3745,16 @@ class CenterProductServices extends BaseService //通过事件代理查询商品启用的 SKU 列表,避免 app 层直接引用 modules $attrValueResults = event('ProductTypeDataProxy', ['product_type' => 'normal', 'action' => 'getShowListByProductId', 'args' => [(int)$id]]); $attrValue = (is_array($attrValueResults) && $attrValueResults[0] !== null) ? $attrValueResults[0] : []; + // SKU 图片在库中为相对路径(/uploads/...),返回前补全站点域名,避免后台规格图裂图 + $siteUrl = sys_config('site_url'); + foreach ($attrValue as &$attrItem) { + foreach (['image', 'pic'] as $imageField) { + if (!empty($attrItem[$imageField]) && is_string($attrItem[$imageField])) { + $attrItem[$imageField] = set_file_url($attrItem[$imageField], $siteUrl); + } + } + } + unset($attrItem); return compact('storeInfo', 'attrValue'); } @@ -3995,6 +4046,7 @@ class CenterProductServices extends BaseService 'sort_order', 'is_vip', 'vip_price', + 'custom_form', ]; $list = $this->dao->getSearchList($where, $page, $limit, $field); @@ -4005,12 +4057,12 @@ class CenterProductServices extends BaseService $item['vip_price'] = '0.00'; $item['is_virtual'] = $this->isVirtualProduct($item['product_type'] ?? 'normal'); $item['presale'] = 0; - $item['custom_form'] = ''; $item['min_qty'] = 1; $item['label_list'] = ''; $item['store_info'] = ''; $item['star'] = '5.0'; - $item['cart_button'] = $this->getCartModeByType($item['product_type'] ?? 'normal') === 'direct' ? 0 : 1; + // 自定义表单商品不支持加入购物车(前端据此点击直接进详情) + $item['cart_button'] = $this->hasCustomForm($item['custom_form'] ?? '') || $this->getCartModeByType($item['product_type'] ?? 'normal') === 'direct' ? 0 : 1; // 标记活动商品 if (!empty($item['activity'])) { diff --git a/backend/modules/product/virtual/Handler.php b/backend/modules/product/virtual/Handler.php index fc544b96e..c935b6146 100644 --- a/backend/modules/product/virtual/Handler.php +++ b/backend/modules/product/virtual/Handler.php @@ -675,14 +675,16 @@ class Handler extends BaseProductTypeHandler } /** - * 加购策略契约(virtual 虚拟:双按钮 + 多品 + 默认文案) + * 加购策略契约(virtual 网盘:单按钮直接购买) + * 网盘商品支付后自动发放资源,不支持加入购物车:cart_mode=direct + * 中台据此下发 cart_button=0,列表/详情均只保留「立即购买」,下单走单品直购链路 * @return array */ public function getCartStrategy(): array { return [ - 'cart_mode' => 'cart', - 'multi_item' => true, + 'cart_mode' => 'direct', + 'multi_item' => false, 'button_text' => '', ]; } diff --git a/backend/modules/product/virtual/README.md b/backend/modules/product/virtual/README.md index b39d36728..9dfb53bbb 100644 --- a/backend/modules/product/virtual/README.md +++ b/backend/modules/product/virtual/README.md @@ -71,6 +71,14 @@ virtual/ - 已发放后不支持售后 - 仅支持退款类型 +## 用户端加购策略 + +网盘商品不支持加入购物车,只能直接购买: + +- `Handler::getCartStrategy()` 返回 `cart_mode=direct`、`multi_item=false`,中台据此下发 `cart_button=0`,列表与详情只展示「立即购买」。 +- 商品详情页点击「立即购买」跳单品直购页 `/pages/center/order/create/index?product_id=&sku_unique=&product_type=virtual&num=`,由 `CenterOrderController::confirm/create` 的 direct 链路下单,不写购物车(多规格首次点击先弹规格窗确认规格)。 +- `StoreCartServices::setCart()` 对 `cart_mode=direct` 的商品在 `new=false` 时抛「该商品不支持加入购物车,请直接购买」,防止其他入口绕过 UI 加购。 + ## 数据表 - `eb_pt_virtual` — 网盘商品扩展表 diff --git a/frontend/uni-app/src/components/goodClass/index.vue b/frontend/uni-app/src/components/goodClass/index.vue index a498c65c7..326e7d8db 100644 --- a/frontend/uni-app/src/components/goodClass/index.vue +++ b/frontend/uni-app/src/components/goodClass/index.vue @@ -23,6 +23,10 @@ + + + {{$t(`立即购买`)}} + { isOpen.value = false; @@ -1614,6 +1623,13 @@ function navigateToCreate(cartId, blockData) { * @param {*} news 是否立即购买(truthy 跳下单页,falsy 提示添加成功) */ function submitCartAdd(params, news) { + // direct 模式(网盘等):跳过加购,直接进单品直购页 + if (isDirectMode.value) { + isOpen.value = false; + attr.cartAttr = false; + goDirectBuy(params.uniqueId, params.cartNum); + return; + } postCartAdd(params) .then((res) => { isOpen.value = false; @@ -1887,18 +1903,52 @@ function goBuy() { if (isLogin.value === false) { toLogin(); } else { - // direct 模式(预售/租赁/服务):跳过规格弹窗,直接用默认 SKU 下单 + // direct 模式(网盘等):不经过购物车,选中规格后跳单品直购页 if (isDirectMode.value) { - const defaultUnique = storeInfo.value.default_sku || storeInfo.value.spec_unique || (attr.productSelect && attr.productSelect.unique) || ''; - const params = buildCartParams(defaultUnique, 1, storeInfo.value.virtual_type); - params.new = 1; - submitCartAdd(params, true); + // 多规格首次点击先弹规格窗让用户确认规格,再次点击即用当前选中规格直购 + if (hasMultiSpecValues() && !directSpecOpened.value) { + directSpecOpened.value = true; + attr.cartAttr = true; + isOpen.value = true; + return; + } + goDirectBuy( + (attr.productSelect && attr.productSelect.unique) || + storeInfo.value.default_sku || + storeInfo.value.spec_unique || + '', + attr.productSelect.cart_num || 1 + ); return; } // this.$refs.proSwiper.videoIsPause(); goCat(true); } } +/** + * 是否存在多个可选规格值(多规格商品需让用户确认规格) + * @return {boolean} + */ +function hasMultiSpecValues() { + const productAttr = attr.productAttr || []; + return productAttr.some((item) => (item.attr_values || []).length > 1); +} +/** + * 跳转单品直购下单页(不写入购物车) + * @param {string} uniqueId SKU 唯一编码 + * @param {number} cartNumArg 购买数量 + */ +function goDirectBuy(uniqueId, cartNumArg) { + const pid = storeInfo.value.id || id.value; + const productType = storeInfo.value.product_type || 'virtual'; + const num = Math.max(parseInt(cartNumArg, 10) || 1, 1); + const url = + `/pages/center/order/create/index?product_id=${pid}` + + `&sku_unique=${encodeURIComponent(uniqueId || '')}` + + `&product_type=${encodeURIComponent(productType)}` + + `&num=${num}`; + uni.navigateTo({ url }); +} function open(data) { showMenuIcon.value = data; } diff --git a/frontend/uni-app/src/pages/goods_details/mock/README.md b/frontend/uni-app/src/pages/goods_details/mock/README.md index f12a30e54..705ffcaa6 100644 --- a/frontend/uni-app/src/pages/goods_details/mock/README.md +++ b/frontend/uni-app/src/pages/goods_details/mock/README.md @@ -222,7 +222,7 @@ | virtual_type | 0 | 1(网盘) | 2(卡密) | | spec_type | 1(多规格) | 1(多规格) | 0(单规格) | | requires_shipping | true | false | false | -| cart_button | 1 | 1 | 1 | +| cart_button | 1 | 0(direct 直购) | 1 | | limit_num | 5 | 0 | 5 | | unit_name | 件 | 份 | 张 | diff --git a/frontend/uni-app/src/pages/goods_details/mock/virtual.json b/frontend/uni-app/src/pages/goods_details/mock/virtual.json index ae88d4a45..ea8c24d56 100644 --- a/frontend/uni-app/src/pages/goods_details/mock/virtual.json +++ b/frontend/uni-app/src/pages/goods_details/mock/virtual.json @@ -112,11 +112,11 @@ "cart_unit": "item" }, "cart_strategy": { - "cart_mode": "cart", - "multi_item": true, + "cart_mode": "direct", + "multi_item": false, "button_text": "" }, - "cart_button": 1 + "cart_button": 0 }, "productAttr": [ { diff --git a/frontend/uni-app/src/pages/goods_details/mock/virtual.jsonc b/frontend/uni-app/src/pages/goods_details/mock/virtual.jsonc index 742e4c97f..ec5dc8cc5 100644 --- a/frontend/uni-app/src/pages/goods_details/mock/virtual.jsonc +++ b/frontend/uni-app/src/pages/goods_details/mock/virtual.jsonc @@ -107,13 +107,13 @@ }, // ===== 加购策略 ===== - // cart_strategy: 加购按钮策略(网盘产品使用cart双按钮加购模式) + // cart_strategy: 加购按钮策略(网盘产品只能直接购买,使用 direct 单按钮模式) "cart_strategy": { - "cart_mode": "cart", // 加购模式 cart双按钮(加购+立即购买)/buy单按钮(立即购买) - "multi_item": true, // 是否支持购物车多商品合并 + "cart_mode": "direct", // 加购模式 cart双按钮(加购+立即购买)/direct单按钮(立即购买) + "multi_item": false, // 是否支持购物车多商品合并(direct 模式不支持) "button_text": "立即购买" // 自定义按钮文字(空使用默认) }, - "cart_button": 1, // 加购按钮显示 0隐藏/1显示 + "cart_button": 0, // 加购按钮显示 0隐藏/1显示(direct 模式为 0) // ===== 预售配置 ===== "presale": 0, // 是否预售 0否/1是