ECSHOP 虚拟销量完整二开方案:sales_volume_base 字段、AJAX 编辑、累计销量缓存与合规护栏
新开 ECSHOP 商城上线第一天的销量是 0,每个商品页都赤裸裸写着「累计销量:0 件」,转化率必然惨淡。绝大多数运营在这个阶段都会做一件事:给商品配一个虚拟销量基数,让前台显示「真实销量 + 虚拟销量」的混合值,营造火爆氛围。这篇文章给出 ECSHOP 2.7.x 完整的虚拟销量改造方案,覆盖数据库字段新增、后台编辑界面、前台展示模板、累计销量计算逻辑、防伪护栏(避免被截图说虚假宣传)、与活动促销的协同,以及 ECSHOP 4.x 与现代电商平台(ShopXO、Shopify)的对应做法。
虚拟销量这件事的合规边界
电商平台对虚假销量的态度
2019 年起《电商法》第十七条明确规定经营者不得做引人误解的商业宣传,虚构交易、虚假评价均违法。淘宝、京东已经把「虚假销量」纳入扣分体系。但独立站(自建商城)的法律空间相对宽松,多数情况下「在新品阶段做合理的虚拟销量」属于行业惯例。
合规建议:
- 虚拟销量数字保持在合理区间(10-100),不要堆几千几万触发用户怀疑。
- 商品页清楚标注「累计销量包含基础展示数」或者直接拿掉「累计销量」字样改成「热度」「人气值」,规避虚假承诺。
- 不要在虚拟销量基础上再做「按销量排序」的活动,否则订单纠纷会引到这个字段。
- 到一定真实销量(比如 500 单)后逐步降低虚拟销量基数,最终归零。
方案设计:表结构、配置项、累加逻辑
核心思路
不直接改 ecs_goods.click_count 这种已有字段(会污染统计),而是新增一个独立字段 sales_volume_base 保存虚拟基数。前台展示时按配置项决定:
- show_sales_type=1(真实显示):只显示真实销量。
- show_sales_type=0(虚拟显示):显示 base + 真实销量。
另一个开关 show_goods_sales 控制是否在前台展示累计销量字段(默认显示,但运营敏感期可临时关掉)。
真实销量从订单聚合
真实销量不是 ecs_goods 表里某个字段,而是从 ecs_order_info + ecs_order_goods 聚合算出。条件:订单状态为「确认」或「拆分」、配送状态为「已发货」或「已收货」、支付状态为「已付款」或「付款中」。这种过滤避免把退款订单算进去。
SQL 改造:新增字段与配置项
在后台「数据库管理-SQL 查询」里执行:
-- 1. 给 ecs_goods 表新增虚拟销量字段
ALTER TABLE `ecs_goods`
ADD `sales_volume_base` INT(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '虚拟销量基数';
-- 2. 给 ecs_shop_config 加配置项
INSERT INTO `ecs_shop_config` (`parent_id`, `code`, `type`, `store_range`, `store_dir`, `value`, `sort_order`)
VALUES ('7', 'show_goods_sales', 'select', '1,0', '', '1', '1');
INSERT INTO `ecs_shop_config` (`parent_id`, `code`, `type`, `store_range`, `store_dir`, `value`, `sort_order`)
VALUES ('3', 'show_sales_type', 'select', '1,0', '', '1', '1');
parent_id 与 sort_order 的解释
ecs_shop_config 是树形结构,parent_id 决定配置项在哪个分类下展示。常用值:
- 1:商店设置-基本设置
- 3:商店设置-显示设置
- 5:商店设置-购物流程
- 7:商店设置-商品相关
本方案 show_goods_sales 放在「商品相关」分组,show_sales_type 放在「显示设置」分组。sort_order 决定同分组内显示顺序,按需调整。
表前缀适配
多数 ECSHOP 默认前缀 ecs_,少数迁移自其它系统的会改成 ecs_v4_、shop_ 或自定义。执行前用 SHOW TABLES LIKE '%goods' 确认实际前缀,然后批量替换 SQL 里的 ecs_。
后台代码改造:让管理员能编辑虚拟销量
扩展商品列表查询字段
编辑 admin/includes/lib_goods.php,定位 goods_list 函数里的 SELECT 语句,把 sales_volume_base 加进字段列表:
$sql = "SELECT goods_id, goods_name, goods_type, goods_sn, shop_price, " .
"is_on_sale, is_best, is_new, is_hot, sort_order, goods_number, " .
"integral, sales_volume_base, " .
"(promote_price > 0 AND promote_start_date <= '$today' AND promote_end_date >= '$today') AS is_promote " .
"FROM " . $GLOBALS['ecs']->table('goods') . " AS g " .
"WHERE is_delete='$is_delete' $where " .
"ORDER BY $filter[sort_by] $filter[sort_order] " .
"LIMIT " . $filter['start'] . ",$filter[page_size]";
这一改让商品列表页能拿到 sales_volume_base 数据,模板里就能展示。
商品列表页表头加列
编辑 admin/templates/goods_list.htm,找到这一段:
{if $use_storage}
<th><a href="#"goods_number');">{$lang.goods_number}</a>{$sort_goods_number}</th>
{/if}
下面加一行:
<th><a href="#"sales_volume_base');">{$lang.sales_volume_base}</a>{$sort_sales_volume_base}</th>
列表行加可编辑单元格
同模板找到:
{if $use_storage}
<td align="right"><span>{$goods.goods_number}</span></td>
{/if}
下面加一行:
<td align="center"><span>{$goods.sales_volume_base}</span></td>
这种「点击单元格直接编辑」的交互是 ECSHOP 后台的标配,改完后商品列表里能看到「虚拟销量」一列,鼠标点击数字就能现场改。
处理 AJAX 编辑请求
编辑 admin/goods.php,找到 list_link 函数定义之前的 elseif 链,加入新的分支:
/* ----- 修改商品虚拟销量 ----- */
elseif ($_REQUEST['act'] == 'edit_sales_volume_base') {
check_authz_json('goods_manage');
$goods_id = intval($_POST['id']);
$sales_volume_base = intval(json_str_iconv(trim($_POST['val'])));
if ($sales_volume_base < 0) {
make_json_error('虚拟销量不能为负数');
exit;
}
if ($sales_volume_base > 99999) {
make_json_error('虚拟销量不应超过 99999');
exit;
}
if ($exc->edit("sales_volume_base = '$sales_volume_base', last_update=" . gmtime(), $goods_id)) {
clear_cache_files();
admin_log('虚拟销量编辑 goods_id=' . $goods_id . ' value=' . $sales_volume_base, 'edit', 'goods');
make_json_result(stripslashes($sales_volume_base));
}
}
相比原文版本我加了三处加固:
- intval 强制把输入转成整数,防止 SQL 注入。
- 负值与超大值校验,避免运营失误把销量改成 -1 或十万级数字。
- admin_log 记录每次编辑动作,事后可追溯谁在什么时候改了哪个商品的虚拟销量。
商品详情页计算累计销量
编辑 admin/goods.php,定位 goods 编辑页的 smarty 赋值部分,在 categories 那行后加:
$smarty->assign('sales_count', get_sales_count($goods_id));
在文件末尾加 get_sales_count 函数:
/**
* 商品累计销量(虚拟+真实)
* @param int $goods_id
* @return int
*/
function get_sales_count($goods_id) {
$goods_id = intval($goods_id);
/* 虚拟销量基数 */
$sales_base = (int)$GLOBALS['db']->getOne(
'SELECT sales_volume_base FROM ' . $GLOBALS['ecs']->table('goods')
. ' WHERE goods_id = ' . $goods_id
);
/* 真实销量:从订单聚合 */
$sql = 'SELECT IFNULL(SUM(g.goods_number), 0) '
. 'FROM ' . $GLOBALS['ecs']->table('order_info') . ' AS o, '
. $GLOBALS['ecs']->table('order_goods') . ' AS g '
. "WHERE o.order_id = g.order_id "
. "AND o.order_status " . db_create_in(array(OS_CONFIRMED, OS_SPLITED))
. "AND o.shipping_status " . db_create_in(array(SS_SHIPPED, SS_RECEIVED))
. "AND o.pay_status " . db_create_in(array(PS_PAYED, PS_PAYING))
. " AND g.goods_id = '$goods_id'";
$sales_count = (int)$GLOBALS['db']->getOne($sql);
/* 显示策略 */
if ($GLOBALS['_CFG']['show_sales_type']) {
return $sales_count; // 真实显示
} else {
return $sales_base + $sales_count; // 虚拟+真实
}
}
性能注意:累计销量缓存
get_sales_count 每次调用都跑一次订单聚合 SQL,对热门商品(PDP 高 PV)会造成订单表频繁全扫描。建议加缓存:
function get_sales_count($goods_id) {
$goods_id = intval($goods_id);
$cache_key = 'sales_count_' . $goods_id;
$cached = read_static_cache($cache_key);
if ($cached !== false) {
return $cached;
}
// ... 原有计算逻辑
write_static_cache($cache_key, $row['sales_volume_total']);
return $row['sales_volume_total'];
}
缓存有效期建议 5-10 分钟,订单完成时清掉对应商品的缓存。这样主流量场景下不再实时跑 SQL,性能提升 10 倍以上。
语言包扩展
后台配置项语言
编辑 languages/zh_cn/admin/shop_config.php,添加:
$_LANG['cfg_name']['show_goods_sales'] = '是否显示商品累计销量';
$_LANG['cfg_range']['show_goods_sales']['1'] = '显示';
$_LANG['cfg_range']['show_goods_sales']['0'] = '不显示';
$_LANG['cfg_name']['show_sales_type'] = '商品累计销量默认显示方式';
$_LANG['cfg_range']['show_sales_type'][1] = '真实销量';
$_LANG['cfg_range']['show_sales_type'][0] = '虚拟+真实';
后台商品管理语言
编辑 languages/zh_cn/admin/goods.php,在 goods_sn_exists 那行后加:
$_LANG['sales_volume_base'] = '虚拟销量';
前台公共语言
编辑 languages/zh_cn/common.php,在 divided_into 那行后加:
$_LANG['sales_volume_total'] = '累计销量:';
$_LANG['pcs'] = '件';
前台模板改造
商品详情页展示累计销量
编辑 themes/default/goods.dwt,定位评分图标那段:
<img src="images/stars{$goods.comment_rank}.gif" alt="comment rank {$goods.comment_rank}" />
</dd>
下面加:
{if $cfg.show_goods_sales}
<dd>
<strong>{$lang.sales_volume_total}</strong>
<font class="shop">{$sales_count}{if $goods.measure_unit}{$goods.measure_unit}{else}{$lang.pcs}{/if}</font>
</dd>
{/if}
商品列表页同步展示
商品列表(PLP)一般也展示累计销量提升列表页转化率。编辑 themes/default/goods_list.dwt 与 themes/default/category.dwt,找到商品卡片渲染段,加:
{if $cfg.show_goods_sales && $goods.sales_count}
<p class="sales-volume">已售 {$goods.sales_count}{$lang.pcs}</p>
{/if}
注意需要让 PLP 模板的 controller(多数是 category.php、search.php)在循环里给每个商品也调用 get_sales_count 计算并赋值到 sales_count 字段。
购物车页与订单确认页要不要展示
不建议。这两个页面用户已经在准备付款,再展示销量没有转化提升作用,反而占空间。
虚拟销量的运营策略
不同阶段的数字设定
从我合作过几个独立站的运营经验:
- 新品上架前 7 天:基数 30-50。让商品页不再是 0 销量,但数字克制不夸张。
- 新品上架第 8-30 天:基数 50-100。继续观察真实订单是否进入。
- 真实订单达到 50 单:基数降到 30。让真实销量逐步占主导。
- 真实订单达到 200 单:基数归 0。完全用真实销量,不再需要虚拟基数。
批量调整虚拟销量的脚本
新品集中上架时手动一个个改太慢。写个 SQL 一次性按规则赋值:
-- 给最近 7 天上架且 click_count < 100 的商品都赋虚拟销量 50
UPDATE ecs_goods
SET sales_volume_base = 50
WHERE add_time > UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL 7 DAY))
AND click_count < 100
AND sales_volume_base = 0;
降基数:
-- 真实销量超过 50 单的商品,虚拟销量减半
UPDATE ecs_goods AS g
INNER JOIN (
SELECT og.goods_id, SUM(og.goods_number) AS real_sales
FROM ecs_order_goods og
INNER JOIN ecs_order_info oi ON oi.order_id = og.order_id
WHERE oi.order_status IN (1, 5)
AND oi.shipping_status IN (1, 2)
AND oi.pay_status IN (1, 2)
GROUP BY og.goods_id
HAVING real_sales > 50
) AS s ON g.goods_id = s.goods_id
SET g.sales_volume_base = FLOOR(g.sales_volume_base / 2);
风险与防伪护栏
截图维权风险
用户截图「显示已售 100 件但实际只有 5 件」做投诉是真实存在的。三个护栏:
- 所有运营动作(包括 admin_log)保留至少 6 个月,能证明是哪个员工在哪天做了改动。
- 商品页提示文案改成「累计浏览」「关注人数」「热度」等中性词,避免「销量」明确承诺。
- 客服培训过应对话术:「累计销量包含历史浏览与意向客户数据,与实际成交单数有差异属正常显示统计口径」。
SEO 风险
虚拟销量本身不影响 SEO,但如果你做了「按销量排序」的列表页,被搜索引擎抓到的排序结果与真实订单不符,可能会让用户觉得不可信。建议「按销量排序」时改用真实销量字段,不混入虚拟基数。
数据库索引
sales_volume_base 字段如果作为排序字段使用,需要单独加索引:
ALTER TABLE ecs_goods ADD INDEX idx_sales_volume_base (sales_volume_base);
ECSHOP 4.x 与 ECMall 的对应做法
ECSHOP 4.x 重写了商品模块,sales_volume_base 字段设计要换到 goods_v4 表。SQL:
ALTER TABLE `ecs_v4_goods`
ADD `virtual_sales` INT(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '虚拟销量';
取累计销量的逻辑也要改成 4.x 的 OrderModel + GoodsModel 风格:
$orderModel = model('Order');
$realSales = $orderModel->sumGoodsByGoodsId($goods_id, [
'order_status' => ['confirmed', 'splited'],
'pay_status' => ['paid'],
]);
现代电商平台的对应做法
Shopify
Shopify 没有原生虚拟销量字段。常见做法是用 metafields 存虚拟基数,前台主题用 Liquid 计算 {{ product.metafields.custom.virtual_sales | plus: product.metafields.custom.real_sales_cached }}。
WooCommerce
WooCommerce 默认 total_sales meta 是真实销量。可以用 add_post_meta($product_id, '_virtual_sales_base', 50) 加虚拟基数,主题模板里 $total = get_post_meta($product_id, 'total_sales', true) + get_post_meta($product_id, '_virtual_sales_base', true)。
ShopXO
ShopXO 有官方插件「商品虚拟销量」直接安装就能用,无需改代码。
常见故障
故障 1:SQL 执行后报字段已存在
说明上次执行了一半中断。检查表结构 DESCRIBE ecs_goods; 看 sales_volume_base 是否已有。如果有就跳过 ALTER,直接做 INSERT 配置项。
故障 2:后台编辑虚拟销量不生效
三个排查:admin/goods.php 的 elseif 分支是否真的在 list_link 函数定义之前(顺序错会被前面的 act 拦截);check_authz_json('goods_manage') 是否报权限不足(管理员需要有商品管理权限);浏览器 Network 看 AJAX 请求是否真的发到了 goods.php?act=edit_sales_volume_base。
故障 3:前台累计销量显示成 0
多数情况是 cfg.show_goods_sales 配置没生效。检查 ecs_shop_config 表里这一行:SELECT * FROM ecs_shop_config WHERE code='show_goods_sales';,确认 value=1 而不是 value='' 或 value='0'。后台「商店设置」里手动改一次再看。
故障 4:订单状态没匹配真实销量计算条件
get_sales_count 用了 OS_CONFIRMED + OS_SPLITED,但你的运营流程可能停在 OS_UNCONFIRMED(未确认)状态。看下 ecs_order_info 的 order_status 实际分布,调整过滤条件:
SELECT order_status, COUNT(*) FROM ecs_order_info GROUP BY order_status;
故障 5:模板 dwt 改完前台没变化
清缓存。后台「系统设置-清除缓存」,或者直接 rm -rf temp/caches/*。Smarty 模板编译后会缓存到 temp 目录,不清缓存改了也看不到效果。
故障 6:累计销量显示成几万件触发用户怀疑
可能是某次批量 SQL 把所有商品的虚拟销量都赋了同一个大数。立刻 UPDATE ecs_goods SET sales_volume_base = 0; 全部归零,再分阶段重新设。同时把这个事故写进 admin_log 留痕。
常见问题解答
虚拟销量与真实销量怎么区分展示?
多数运营选择「混合显示一个总数」(虚拟+真实),用户感知不到差别。如果你想透明化,可以加一个 tooltip:「累计销量(含历史关注 N 人)」让用户能看到大致比例。但绝大多数 DTC 站点不做这种透明化。
批量给所有商品加虚拟销量会被搜索引擎当作虚假宣传降权吗?
搜索引擎不读「累计销量」这个字段(不是 schema.org 的标准字段)。但如果你在 schema.org 的 AggregateRating 里把 ratingCount 写成虚假数字,可能触发 Google 的 spam 检测。建议 Schema 字段始终用真实数据,前台展示字段用虚拟+真实。
虚拟销量数字应该设多少?
新品阶段 30-50,热销品减到 0。具体看品类:客单价 100 元以下的快消品可以 50-100;客单价 1000 元以上的耐用品 5-20 即可(数字大反而像异常)。
升级 PHP 7.4 后虚拟销量代码报错?
原版 ECSHOP 用了 mysql_* 系列函数(PHP 7+ 已废弃)。如果升级 PHP 没同步打 dedeCMS-php7 兼容补丁,goods.php 编辑虚拟销量的 AJAX 会 500。降回 PHP 5.6 或者打补丁。
能否给不同分类的商品设不同的虚拟销量上限?
可以,但 ECSHOP 没有原生支持。需要二开:在 ecs_category 表加一个字段 max_virtual_sales,admin/goods.php 编辑虚拟销量时校验不能超过该分类的上限。
虚拟销量会进入数据库备份吗?
会。mysqldump 时 sales_volume_base 字段与其它字段一起导出。如果你不希望测试环境与生产环境的虚拟销量同步,备份时显式排除:mysqldump --where="sales_volume_base=0" ecs_goods 或者备份后用 SQL 把测试库的虚拟销量重置。
虚拟销量字段能否直接用于前台「按销量排序」?
不建议。「按销量排序」用户预期看到的是真实热销,混入虚拟数字会让冷门品因高基数排到前面,破坏列表页的可信度。建议「按销量排序」时单独 SQL 查真实订单聚合后排序,不读 sales_volume_base。
清空所有虚拟销量回到原始状态怎么操作?
三步:UPDATE ecs_goods SET sales_volume_base = 0 全部归零;DELETE FROM ecs_shop_config WHERE code IN ('show_goods_sales','show_sales_type') 删掉配置项;ALTER TABLE ecs_goods DROP COLUMN sales_volume_base 删字段。模板与代码改动也回滚到改前版本(这就是为什么改前要做 git commit 或者文件备份)。
虚拟销量是否影响订单数据导出?
不影响。订单数据来自 ecs_order_info / ecs_order_goods,这两张表里没有任何虚拟字段。导出订单做财务对账永远是真实数据。
会被淘宝 / 京东 / 拼多多店铺扣分吗?
本方案是给独立站(自建 ECSHOP)用的。淘宝京东等平台店铺有自己的虚假销量识别机制,本方案不适用且严禁迁移过去用——平台扣分极重。