diff --git a/inc/core-hooks.php b/inc/core-hooks.php index b3f71cfb..81eb2982 100644 --- a/inc/core-hooks.php +++ b/inc/core-hooks.php @@ -482,13 +482,23 @@ add_action('admin_enqueue_scripts', function ($hook) { add_action('manage_post_posts_custom_column', function ($column, $post_id) { if ($column !== 'aksrt_stats') return; - $views = (int) get_post_meta($post_id, 'views', true); - $likes = (int) get_post_meta($post_id, 'like', true); - $favorites = (int) get_post_meta($post_id, 'favorite', true); - $downloads = (int) get_post_meta($post_id, 'download_count', true); + $views = function_exists('aksrt_get_post_views') ? (int) aksrt_get_post_views($post_id) : (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, true); + $likes = function_exists('aksrt_get_post_likes') ? (int) aksrt_get_post_likes($post_id) : (int) get_post_meta($post_id, '_aksrt_like_count', true); + $favorites = function_exists('aksrt_get_post_favorites') ? (int) aksrt_get_post_favorites($post_id) : (int) get_post_meta($post_id, '_aksrt_favorite_count', true); + $downloads = (int) get_post_meta($post_id, \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY, true); + + if ($downloads <= 0) { + $legacy_downloads = (int) get_post_meta($post_id, 'download_count', true); + if ($legacy_downloads > 0) { + $downloads = $legacy_downloads; + } + } $format = function (int $n): string { - if ($n >= 10000) return round($n / 10000, 1) . '万'; + if (function_exists('aksrt_format_number')) { + return (string) aksrt_format_number($n); + } + if ($n >= 10000) return round($n / 10000, 1) . 'W+'; if ($n >= 1000) return round($n / 1000, 1) . 'K'; return (string) $n; }; @@ -510,10 +520,15 @@ add_action('pre_get_posts', function ($query) { if (!is_admin() || !$query->is_main_query()) return; $orderby = $query->get('orderby'); - $allowed = ['views', 'like', 'favorite', 'download_count']; + $allowed = [ + 'views' => \AKSRT\Core\DomainModel::POST_VIEW_COUNT_KEY, + 'like' => '_aksrt_like_count', + 'favorite' => '_aksrt_favorite_count', + 'download_count' => \AKSRT\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY, + ]; - if (in_array($orderby, $allowed, true)) { - $query->set('meta_key', $orderby); + if (isset($allowed[$orderby])) { + $query->set('meta_key', $allowed[$orderby]); $query->set('orderby', 'meta_value_num'); } }); diff --git a/tests/widget-system/admin-stats-column-contract.test.cjs b/tests/widget-system/admin-stats-column-contract.test.cjs new file mode 100644 index 00000000..48bb838e --- /dev/null +++ b/tests/widget-system/admin-stats-column-contract.test.cjs @@ -0,0 +1,99 @@ +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 hooks = read('inc/core-hooks.php') +const domain = read('src/Core/DomainModel.php') +const tags = read('inc/helpers/aksrt-frontend-tags.php') + +test('admin post list has the interaction stats column', () => { + assert.match(hooks, /manage_post_posts_columns/) + assert.match(hooks, /互动数据/) + assert.match(hooks, /manage_post_posts_custom_column/) + assert.match(hooks, /\$column !== 'aksrt_stats'/) +}) + +test('interaction counts are read from the keys the theme actually writes', () => { + // 计数一律落在这几个 meta key 上:aksrt_post_views / _aksrt_like_count / + // _aksrt_favorite_count / _aksrt_res_download_count。列回调只能用同一套键, + // 否则全列恒为 0。旧实现读 'views' / 'like' / 'favorite',仓库里没有任何写入方。 + assert.match(tags, /'_aksrt_like_count'/) + assert.match(tags, /'_aksrt_favorite_count'/) + assert.match(tags, /'aksrt_post_views'/) + + assert.match(hooks, /aksrt_get_post_views\(\$post_id\)/) + assert.match(hooks, /aksrt_get_post_likes\(\$post_id\)/) + assert.match(hooks, /aksrt_get_post_favorites\(\$post_id\)/) + + // 资源下载量只有 _aksrt_res_download_count 一个写入键。 + assert.match(hooks, /DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY/) + assert.match(domain, /RESOURCE_DOWNLOAD_COUNT_KEY\s*=\s*'_aksrt_res_download_count'/) + + // 不得再直接读这些不存在的键('download_count' 只作为历史遗留键回退读取)。 + const column = hooks.slice(hooks.indexOf('manage_post_posts_custom_column'), hooks.indexOf('Support sorting by')) + assert.doesNotMatch(column, /get_post_meta\(\$post_id, 'views'/) + assert.doesNotMatch(column, /get_post_meta\(\$post_id, 'like'/) + assert.doesNotMatch(column, /get_post_meta\(\$post_id, 'favorite'/) +}) + +test('admin stats column shares the aksrt_format_number abbreviation', () => { + // 后台列与前台必须同一套缩写口径(≥10000 → 1.2W+,≥1000 → 1.5K)。 + // 旧实现用了前台从未用过的「万」后缀。 + assert.match(hooks, /aksrt_format_number\(\$n\)/) + const column = hooks.slice(hooks.indexOf('manage_post_posts_custom_column'), hooks.indexOf('Support sorting by')) + assert.doesNotMatch(column, /'万'/) +}) + +test('sortable meta maps the public orderby keys to the stored keys', () => { + assert.match(hooks, /'views'\s*=>\s*\\AKSRT\\Core\\DomainModel::POST_VIEW_COUNT_KEY/) + assert.match(hooks, /'like'\s*=>\s*'_aksrt_like_count'/) + assert.match(hooks, /'favorite'\s*=>\s*'_aksrt_favorite_count'/) + assert.match(hooks, /'download_count'\s*=>\s*\\AKSRT\\Core\\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY/) + // meta_key 必须是真实存储键,而不是用户可见的排序键。 + assert.match(hooks, /\$query->set\('meta_key', \$allowed\[\$orderby\]\)/) +}) + +test('every counter meta key the admin column renders has a writer', () => { + // 逐键核对:列里展示的每项计数都必须在某个 helper 里真正被写入过。 + const sources = [ + 'inc/core-hooks.php', + 'inc/helpers/aksrt-frontend-tags.php', + 'src/Core/PostMetaBox.php', + 'src/Frontend/PageAdminSet.php', + ].map(read).join('\n') + + for (const key of ['aksrt_post_views', '_aksrt_like_count', '_aksrt_favorite_count']) { + assert.ok(sources.includes(`'${key}'`), `${key} is written somewhere`) + } + // 资源帖的浏览有独立键,aksrt_get_post_views() 取两者较大值。 + assert.match(tags, /_aksrt_res_view_count/) + assert.match(read('inc/helpers/aksrt-resource.php'), /RESOURCE_VIEW_COUNT_KEY|_aksrt_res_view_count/) + + // 资源下载走普通外链,主题内没有任何写入方,所以下载项只在 >0 时出现。 + // 万一将来接了下载统计,写入方必须落在同一个键上。 + assert.match(hooks, /\$downloads <= 0/) + assert.match(hooks, /\$legacy_downloads/) +}) + +test('download count stays read-only until a writer exists', () => { + // 目前没有任何 PHP 写入 _aksrt_res_download_count;若哪天新增了写入方, + // 这里会失败,提示把该键也纳入后台列的统计口径。 + const php = ['inc', 'src', 'templates'].flatMap(dir => + fs.readdirSync(path.join(root, dir), { withFileTypes: true }) + .filter(e => e.isFile() && e.name.endsWith('.php')) + .map(e => `${dir}/${e.name}`) + ).filter(f => !f.includes('core-hooks') && !f.includes('DomainModel')).map(read).join('\n') + const writers = php.match(/(?:update|add)_post_meta\([^)]*_aksrt_res_download_count/g) || [] + assert.equal(writers.length, 0, 'download count still has no writer') +}) + +test('admin column icons stay behind the Font Awesome enqueue on edit.php', () => { + assert.match(hooks, /admin_enqueue_scripts[\s\S]*?edit\.php[\s\S]*?aksrt_enqueue_fontawesome\(\)/) + for (const klass of ['fa-eye', 'fa-thumbs-up', 'fa-star', 'fa-download']) { + assert.match(hooks, new RegExp(klass)) + } +})