diff --git a/.zcode/plans/plan-sess_9dedb041-3430-4063-9835-8825229111bc.md b/.zcode/plans/plan-sess_9dedb041-3430-4063-9835-8825229111bc.md index 00546345..e6597c60 100644 --- a/.zcode/plans/plan-sess_9dedb041-3430-4063-9835-8825229111bc.md +++ b/.zcode/plans/plan-sess_9dedb041-3430-4063-9835-8825229111bc.md @@ -1,34 +1,33 @@ -## 背景结论(回答"为什么没接入") +## 目标 -Toast 系统(notyf)其实已在所有前台页面全局加载(handle `aksrt-notyf`,`assets/js/notyf.js` 暴露 `window.notyf(message, type)`),公开主页上举报、面板加载失败也都在用。真正的缺口: +把"点关注"从同步跑完整结算链(积分+勋章×3遍+等级,约几十条 SQL)优化为:接口只写关注表立即返回,结算在响应送达客户端之后执行。 -1. **关注/取消关注成功时零反馈**:`assets/js/follow.js:40-42` 成功后只翻转按钮状态,不调用 `notyf()`(对比文章页点赞/收藏 `article.js:225/250` 成功失败都有提示)——这是"页面没接 Toast"观感的来源。 -2. **依赖声明不规范**:`author.js` 调用了 7 处 `notyf()`,但 handle `aksrt-author`(`ScriptsPart1b.php:335`)只声明 `['loader_js']`,靠全局加载碰巧可用。 -3. **相邻 bug**:`data-aksrt-follow` 按钮在文章页作者栏(`single-author-bar.php:116`、`aksrt-single-render.php:442`)、WidgetSystem 用户卡(`UserRenderer.php:102`)、身份铭牌(`aksrt-identity.php:226`)等多处渲染,但 `follow.js` 只在用户中心/公开主页路由注册 → 这些位置按钮是死的(用户已确认一并修复)。 +## 改动内容(3 个文件) -## 改动内容 +### 1. `src/User/Metrics.php` — 删除冗余订阅 +`on_user_followed()`(Metrics.php:93-98)只做 `reevaluate_medals(following_id)`,而 `BadgeAutoAward::on_follow` 已经评估过该用户——这是同一请求内的第 3 遍重复评估。删除该方法和它的 `add_action` 注册(Metrics.php:34)。`reevaluate_medals` 方法本身保留(点赞/浏览等其他钩子仍在用)。 -### 1. `assets/js/follow.js` — 补齐成功 Toast(核心) -成功分支(`updateFollowButton(...)` 之后)增加: -```js -notyf(isFollowing ? '关注成功' : '已取消关注', 'success'); -``` -遵循 article.js 点赞/收藏既有惯例:客户端写死文案、两种状态都用 `success`(后端 `follow_toggle` 不返回 message,与点赞/收藏接口一致)。 +### 2. `src/User/BadgeAutoAward.php` — 删除对"关注者"的无效评估 +`on_follow()`(BadgeAutoAward.php:61-68)同时评估关注者和被关注者,但 `Badges::metric_value` 的全部指标分支(registration_time/checkin/new_post/comment/**followed(被关注数)**/like/views/pay_income/pay_rebate)没有任何一个依赖"关注了多少人"——关注者评估必然零产出。改为只评估 `$following_id`(保留双参数签名以兼容钩子,`unset($follower_id)`)。 -### 2. `src/Core/ScriptsPart1b.php` + `src/Core/ScriptsPart1c.php` — follow.js 提升为全局注册 -- 删除 `ScriptsPart1b.php:318-328` `$is_profile_page` 块内的 `aksrt-follow` 注册; -- 在 `ScriptsPart1c.php` 全局栈(`aksrt-runtime` 注册之后,约 165 行处)全局注册,依赖 `['aksrt-notyf', 'aksrt-runtime']`——显式声明依赖 `aksrt-runtime` 是因为 `aksrtData`(REST 地址 + nonce,follow.js IIFE 解析期就要读)localize 在该 handle 上,把目前"靠 footer 打印顺序碰巧正确"变为确定的依赖顺序。 -- 效果:文章页作者栏、任意位置的用户卡片关注按钮恢复可用,并获得同样的 Toast 反馈。脚本仅 ~100 行、单个委托点击监听,全局加载成本可忽略(与已全局加载的 notyf/runtime/core modules 一致)。 +### 3. `src/User/Follow.php` — 结算钩子延迟到响应之后 +`follow()` 里的 `do_action('aksrt_user_followed', ...)`(Follow.php:50)改为经 `register_shutdown_function` 延迟执行:关闭回调里先调 `fastcgi_finish_request()`(PHP-FPM 下存在)把响应立即送达客户端,再触发钩子跑积分/勋章/等级结算。新增一个 `private static function defer_action(string $hook, int ...$args)` 辅助方法。 -### 3. `src/Core/ScriptsPart1b.php:335` — aksrt-author 显式声明 notyf 依赖 -`['loader_js']` → `['loader_js', 'aksrt-notyf']`。 +- 非-FPM 环境(无 `fastcgi_finish_request`):退化为 shutdown 时机同步执行,行为与现状等价,无回归。 +- 不用 `wp_schedule_single_event`:WP-Cron 粒度不可控(禁用循环回执的站点要等系统 cron,积分勋章到账会延迟数分钟),shutdown 方案在所有环境都能即时结算。 +- `aksrt_user_followed` 仅 Follow.php:50 一处触发,4 个订阅者全部按传入 ID 工作、不依赖请求上下文,延迟执行安全。 +- 效果:关注接口响应路径只剩 WP REST 启动 + 1 次 SELECT + 1 次 INSERT + 1 次 COUNT(全部命中索引),勋章评估从 3 遍降为 1 遍且不再阻塞响应。 + +## 不在本次范围 + +前端乐观更新(点击瞬间翻转按钮、失败回滚)——上轮标注为可选项,用户未明确要求;异步化后反馈延迟已大幅下降,且与全站点赞/收藏的交互模式保持一致。需要可后续单独做。 ## 验证 -- `php -l` 检查两个改动的 PHP 文件。 -- 浏览器实测(桌面 + 移动):公开主页关注/取消关注弹出 success Toast、按钮翻转、粉丝数更新;未登录/网络失败路径弹 danger Toast;文章页作者栏关注按钮可点击且有提示;Tab 无刷新切换不受影响。 -- `node --test tests/widget-system/post-visual-contract.test.cjs` 确认无回归(未改标记,应通过)。 +- `php -l` 检查 3 个改动文件。 +- 浏览器实测:关注点击后按钮/Toast 应明显变快(仅剩接口往返);稍后刷新可见积分/勋章/等级照常到账(结算仍在同一请求生命周期内完成,只是后置)。 +- 取消关注路径不受影响(本就无钩子)。 ## 提交 -单条提交,遵循仓库 Conventional Commit 风格:`fix(frontend): 关注操作接入 notyf 成功提示并将关注脚本改为全局加载`。 \ No newline at end of file +单条提交:`perf(user): 关注结算异步化并去除冗余勋章评估`。 \ No newline at end of file diff --git a/src/User/BadgeAutoAward.php b/src/User/BadgeAutoAward.php index e0eea00f..90cdd1ba 100644 --- a/src/User/BadgeAutoAward.php +++ b/src/User/BadgeAutoAward.php @@ -59,12 +59,12 @@ class BadgeAutoAward { } public function on_follow(int $follower_id, int $following_id): void { + unset($follower_id); + // No medal metric depends on how many users someone follows, so only + // the followed user (whose follower count changed) can earn here. if ($following_id > 0) { $this->evaluate($following_id); } - if ($follower_id > 0) { - $this->evaluate($follower_id); - } } public function on_checkin(int $user_id): void { diff --git a/src/User/Follow.php b/src/User/Follow.php index c471cada..f1fe2fe7 100644 --- a/src/User/Follow.php +++ b/src/User/Follow.php @@ -47,10 +47,24 @@ class Follow { if ($result === false) { return false; } - do_action('aksrt_user_followed', $follower_id, $following_id); + self::defer_action('aksrt_user_followed', $follower_id, $following_id); return true; } + /** + * 在响应送达客户端之后再触发结算钩子(积分/勋章/等级), + * 避免重度订阅者阻塞关注接口。非 PHP-FPM 环境退化为 + * shutdown 时机同步执行,行为与直接 do_action 等价。 + */ + private static function defer_action(string $hook, int ...$args): void { + register_shutdown_function(static function () use ($hook, $args): void { + if (function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } + do_action($hook, ...$args); + }); + } + /** * 取消关注 */ diff --git a/src/User/Metrics.php b/src/User/Metrics.php index ce099a68..1dfcfdc4 100644 --- a/src/User/Metrics.php +++ b/src/User/Metrics.php @@ -31,7 +31,6 @@ class Metrics { add_action('aksrt_post_liked', [$this, 'on_post_liked'], 20, 3); add_action('aksrt_comment_liked', [$this, 'on_comment_liked'], 20, 3); add_action('aksrt_post_favorited', [$this, 'on_post_favorited'], 20, 3); - add_action('aksrt_user_followed', [$this, 'on_user_followed'], 20, 2); add_action('aksrt_post_viewed', [$this, 'on_post_viewed'], 20, 2); add_action('aksrt_user_signed_in', [$this, 'on_user_signed_in'], 20, 1); @@ -90,13 +89,6 @@ class Metrics { // Favorite is free-points / level only; no dedicated medal get_type. } - public function on_user_followed(int $follower_id, int $following_id): void { - unset($follower_id); - if ($following_id > 0) { - self::reevaluate_medals($following_id); - } - } - public function on_post_viewed(int $post_id, int $viewer_id = 0): void { unset($viewer_id); $post = get_post($post_id);