feat(商品): 网盘商品改 direct 单按钮直购,自定义表单商品禁止加入购物车

加购策略
- 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 数据。
This commit is contained in:
吴昊天 2026-10-23 14:09:21 +08:00
parent 61d131b99f
commit e712f5554c
10 changed files with 200 additions and 23 deletions

View File

@ -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 信息获取

View File

@ -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

View File

@ -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'])) {

View File

@ -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' => '',
];
}

View File

@ -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` — 网盘商品扩展表

View File

@ -23,6 +23,10 @@
<view class="iconfont icon-gouwuche6 acea-row row-center-wrapper"
v-if="item.activity && (item.activity.type === '1' || item.activity.type === '2' || item.activity.type === '3')">
</view>
<!-- 不支持加入购物车(自定义表单 / 产品类型 direct 模式):显示立即购买,点击整行进详情 -->
<view class="bnt acea-row row-center-wrapper" v-else-if="item.cart_button == 0">
{{$t(`立即购买`)}}
</view>
<view v-else>
<!-- 多规格 -->
<view class="bnt acea-row row-center-wrapper" @click.stop="goCartDuo(item)"

View File

@ -599,6 +599,8 @@ const selectedBlockData = ref([]);
// readOnly 弹窗模式下存储第一次进入 goCat 时的 news 参数,供"确定"按钮复用
// 加入购物车=undefined,立即购买=true
const pendingNews = ref(undefined);
// direct(单按钮直购)模式是否已弹过规格窗:首次点击先让用户确认规格,之后点击直接下单
const directSpecOpened = ref(false);
// ---------- 海报/分销二维码(替代原 mixins/sharePoster.js)----------
// 原 mixin 写到页面 this 上的字段(posters/PromotionCode/posterImage/...)全部解构自 poster;
@ -1542,6 +1544,13 @@ function goCat(news) {
: "",
virtual_type: storeInfo.value.virtual_type,
};
// direct 模式(网盘等):不写入购物车,选中规格后直接进单品直购页
if (isDirectMode.value) {
isOpen.value = false;
attr.cartAttr = false;
goDirectBuy(q.uniqueId, q.cartNum);
return;
}
postCartAdd(q)
.then((res) => {
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;
}

View File

@ -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 | 件 | 份 | 张 |

View File

@ -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": [
{

View File

@ -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是