diff --git a/inc/helpers/aksrt-pay.php b/inc/helpers/aksrt-pay.php index f7515e80..5c90404e 100644 --- a/inc/helpers/aksrt-pay.php +++ b/inc/helpers/aksrt-pay.php @@ -296,8 +296,8 @@ if (!function_exists('aksrt_get_download_box_meta')) { 'view_count' => max(0, $view_count), 'virtual_sales' => $virtual_sales, 'sales_volume' => max(0, $sales_volume), - // legacy: virtual (pay_cuont) + real sales_volume; fall back to downloads when no sales yet. - 'sales_count' => $show_sales ? ($virtual_sales + max(0, $sales_volume, $download_count)) : 0, + // 展示销量 = 真实订单 + 后台「销量浮动」(pay_count)。 + 'sales_count' => $show_sales ? aksrt_get_content_sales_count($p, $pay_meta) : 0, 'show_sales' => $show_sales, 'download_limit_over_price' => $over_price, 'require_login' => !empty($pay_meta['require_login']), @@ -352,6 +352,30 @@ if (!function_exists('aksrt_bump_content_sales_volume')) { } } +/** + * Total sales shown to visitors: real paid orders plus the configured 销量浮动. + * + * pay_count is the only field behind the 后台「销量浮动」输入框(资源 metabox 与 + * 快捷编辑都写这个键),此前没有任何读取方用它,导致设了不生效。所有展示销量 + * 的地方都必须走这个口径,不要再各自拼 virtual_sales / sales_volume。 + * + * @param array $pay_meta 已取得的付费 meta,传空则按 $post_id 现取。 + */ +if (!function_exists('aksrt_get_content_sales_count')) { + function aksrt_get_content_sales_count($post = null, array $pay_meta = []): int { + $post_id = $post instanceof \WP_Post ? (int) $post->ID : (int) $post; + if ($post_id <= 0) { + return 0; + } + if ($pay_meta === []) { + $pay_meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post) : []; + } + $real = max(0, (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true)); + $float = max(0, (int) ($pay_meta['pay_count'] ?? 0)); + return max(0, $real + $float); + } +} + /** * Whether guest cash purchase is allowed (legacy pay_no_logged_in + pay_limit=0). */ @@ -1348,8 +1372,7 @@ if (!function_exists('aksrt_get_paywall_html')) { if ($doc === '') { $doc = sprintf(__('此内容为%s,请付费后查看', 'aksrt'), $type_label); } - $sales = max(0, (int) ($meta['virtual_sales'] ?? 0)); - $sales += max(0, (int) get_post_meta($post->ID, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true)); + $sales = aksrt_get_content_sales_count($post, $meta); $tag = $is_widget ? '' . esc_html($type_label) . '' : '
' . esc_html($type_label) . '
'; @@ -1537,8 +1560,7 @@ if (!function_exists('aksrt_get_paid_box_html')) { $order_type = (int) ($order_types[$pay_type] ?? 1); $type_label = (string) ($type_labels[$pay_type] ?? __('付费内容', 'aksrt')); $title = get_the_title($post); - $sales = max(0, (int) ($meta['virtual_sales'] ?? 0)); - $sales += max(0, (int) get_post_meta($post->ID, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true)); + $sales = aksrt_get_content_sales_count($post, $meta); $sales_label = function_exists('aksrt_format_number') ? aksrt_format_number($sales) : number_format_i18n($sales); $count = $sales > 0 ? '' . esc_html(sprintf(__('已售 %s', 'aksrt'), $sales_label)) . '' : ''; $order = aksrt_get_current_content_paid_order($post); diff --git a/inc/helpers/aksrt-resource.php b/inc/helpers/aksrt-resource.php index b720760d..13866ce3 100644 --- a/inc/helpers/aksrt-resource.php +++ b/inc/helpers/aksrt-resource.php @@ -84,7 +84,8 @@ if (!function_exists('aksrt_get_resource_meta')) { 'download_count' => max(0, max($download_count, $legacy_download_count)), 'view_count' => max(0, max($resource_view_count, $common_view_count)), 'virtual_sales' => $virtual_sales, - 'sales_count' => $show_sales ? ($virtual_sales + max(0, max($download_count, $legacy_download_count))) : 0, + // 展示销量 = 真实订单 + 后台「销量浮动」(pay_count),与内容帖同一口径。 + 'sales_count' => $show_sales ? aksrt_get_content_sales_count($p, $pay_meta) : 0, 'show_sales' => $show_sales, 'require_login' => !empty($pay_meta['require_login']), 'require_captcha' => !empty($pay_meta['require_captcha']), diff --git a/src/Admin/Native/PostListEnhance.php b/src/Admin/Native/PostListEnhance.php index dc1fc9e9..d927f091 100644 --- a/src/Admin/Native/PostListEnhance.php +++ b/src/Admin/Native/PostListEnhance.php @@ -166,8 +166,11 @@ class PostListEnhance { $price_text = $this->currency_symbol() . ':' . (string) ($pay_meta['price'] ?? '0.00'); } - $sales_volume = max(0, (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true)); + // 列表列与前台同一口径:真实订单 + 后台「销量浮动」(pay_count)。 + // 真实销量取订单表统计,_aksrt_sales_volume 只是订单流水累加的副本。 $real_sales = $this->real_sales_count($post_id); + $meta_sales = max(0, (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_SALES_VOLUME_KEY, true)); + $sales_volume = max($real_sales, $meta_sales) + max(0, (int) ($pay_meta['pay_count'] ?? 0)); $real_html = $real_sales > 0 ? ' [' . esc_html(sprintf(__('真实销量%s', 'aksrt'), $real_sales)) . ']' : ''; diff --git a/tests/widget-system/sales-volume-float-contract.test.cjs b/tests/widget-system/sales-volume-float-contract.test.cjs new file mode 100644 index 00000000..64085c28 --- /dev/null +++ b/tests/widget-system/sales-volume-float-contract.test.cjs @@ -0,0 +1,56 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '../..') +const read = file => fs.readFileSync(path.join(root, file), 'utf8') + +const pay = read('inc/helpers/aksrt-pay.php') +const resource = read('inc/helpers/aksrt-resource.php') +const list = read('src/Admin/Native/PostListEnhance.php') +const domain = read('src/Core/DomainModel.php') + +test('sales count has a single read path that adds the 销量浮动', () => { + // 展示销量只有一个口径:真实订单 + 后台「销量浮动」(pay_count)。 + // 此前各调用点各自拼 virtual_sales / sales_volume,pay_count 写进去却没人读。 + assert.match(pay, /function aksrt_get_content_sales_count\(/) + assert.match(pay, /RESOURCE_SALES_VOLUME_KEY/) + assert.match(pay, /\$pay_meta\['pay_count'\]/) + // 两部分都必须下限为 0,避免负的浮动把销量拉成负数。 + assert.match(pay, /max\(0, \$real \+ \$float\)/) +}) + +test('every sales_count producer goes through that read path', () => { + // 内容帖、资源帖、付费墙、付费盒四处不得再各自拼装销量。 + assert.match(pay, /'sales_count'\s*=>\s*\$show_sales \? aksrt_get_content_sales_count\(\$p, \$pay_meta\) : 0/) + assert.match(resource, /'sales_count'\s*=>\s*\$show_sales \? aksrt_get_content_sales_count\(\$p, \$pay_meta\) : 0/) + assert.match(pay, /\$sales = aksrt_get_content_sales_count\(\$post, \$meta\)/) + + // 不得再出现按 virtual_sales 现拼销量的旧写法。 + assert.doesNotMatch(pay, /\$sales\s*=\s*max\(0, \(int\) \(\$meta\['virtual_sales'\] \?\? 0\)\);/) + assert.doesNotMatch(pay, /\$virtual_sales \+ max\(0, \$sales_volume, \$download_count\)/) + assert.doesNotMatch(resource, /\$virtual_sales \+ max\(0, max\(\$download_count, \$legacy_download_count\)\)/) +}) + +test('pay_count is declared, sanitized and consumed end to end', () => { + // 删掉任何一段,这个字段就又变成「设了不生效」的摆设。 + assert.match(domain, /'pay_count'\s*=>\s*0/, 'default value exists') + assert.match(domain, /\$meta\['pay_count'\]\s*=\s*\(int\)/, 'sanitize exists') + + // 后台两个入口都写这个键:资源 metabox 与文章列表快捷/批量编辑。 + assert.match(read('src/Core/ResourceMetaBox.php'), /'pay_count'/) + assert.match(list, /case 'pay_count':/) + + // 快捷编辑按整数运算,与读取口径的 max(0,(int)) 一致。 + const branch = list.slice(list.indexOf("case 'pay_count':"), list.indexOf('case \'price\':')) + assert.match(branch, /apply_operation/) + assert.match(branch, /max\(0, \(int\) \$new\)/) +}) + +test('the admin sales column shows 真实 + 浮动, not the raw meta', () => { + // 列表列与前台同一口径:真实订单 + pay_count,否则后台与前台数字不一致。 + assert.match(list, /\$sales_volume = max\(\$real_sales, \$meta_sales\) \+ max\(0, \(int\) \(\$pay_meta\['pay_count'\] \?\? 0\)\)/) + // 真实销量取订单表统计,不能只有 meta 副本。 + assert.match(list, /\$real_sales = \$this->real_sales_count\(\$post_id\)/) +})