[插件] 点赞送分插件(like_points)发布。

👑Lv.11 元老 🌏 正式会员
2026-09-27 14:20:47

点赞时把点赞人的积分转移给被点赞者(零和转移,不凭空造分)。

模式二选一(后台「模式」):

模式 行为
纯点赞(不转移) 点赞只是点赞,插件完全不动作
积分转移 点赞人扣分、被赞人得分;可设前台可选档位集合(≥2 档时点赞在按钮旁弹小浮层让用户选)与每日送分次数上限

积分不足自动降级为纯点赞,点赞本身永不被拦截。


一、安装 / 激活

  1. 将 plugins/like_points/ 放入插件目录(本仓库已就位)。
  2. 手动更新 plugins/plugins_cache.json,加入 like_points 条目(含 4 个 hooks)——否则钩子不生效。

    注:核心 Plugin::loadPlugins() 在缓存过期(任一 plugin.json 比缓存新)时会自动全量重建并写回,所以通常无需手工介入;此处手工接入是为了让改动可审计。

  3. 后台启用插件(或确认 activated: true)。
  4. 首次请求时 init_after 钩子自动建表(protected/.like_points_table_cache 版本号门控)。

后台入口:插件管理 → 点赞送分,或直接访问 /admin/like-points。


二、设置项

配置键(like_points_ 前缀) 默认 说明
enabled 1 总开关。关闭后点赞不再转移积分
mode ''(未设置→推断) 模式:off 纯点赞 / transfer 积分转移。唯一决定「要不要转移」的开关
choices 空 前台可选档位集合(逗号分隔)。≥2 项 → 前台弹浮层;=1 项 → 直接用该档(不弹);含 0 → 用户可主动选「本次不送分」。后台 checkbox 预设 + 自定义输入合并
daily_limit 10 每人每天最多为多少次点赞送分(0 = 不限)。语义是次数,不是积分值
points_per_like — ⚠️ v1.2.0 起停用。旧「默认档位」,不再参与任何运行时决策;保留旧值只为 mode() 推断老配置(见下)

2.1 模式推断(老站点升级兼容)

mode 键不存在时(v1.1.0 及以前的站点),Plugin::mode() 按旧语义推断,保证升级后行为不变:

points_per_like > 0  或  choices 非空   →  transfer(积分转移)
否则                                    →  off(纯点赞)

在后台保存过一次设置后就会写入明确的 'off' / 'transfer',此后不再推断。

本机现状:points_per_like = 0、choices = 1,2,5,10 → 推断为 transfer,与升级前行为一致。

2.2 为什么去掉「默认档位」

v1.1.0 的「默认档位」与「可选档位集合」关系是隐式的(默认档只在档位 <2 个时生效),容易误解。v1.2.0 改为:

  • 「要不要转移」→ 由模式显式决定
  • 「转多少」→ 只由 choices 决定(≥2 弹浮层由用户选;=1 直接用;其余 → 0 不转移)

三、⚠️ 必须同时处理:核心「获赞奖励积分」

核心在 app/Controllers/ApiController.php:93-99 里已经有一份「被点赞奖励积分」逻辑,设置键 points_vote_received(后台 → 系统设置 → Tab② 积分设置),默认 1。

它的行为是:凭空给作者加分(不扣点赞者),仅在「首次变赞」时发放。

而 vote_after 钩子是在这段逻辑之后才触发的 —— 所以积分转移模式下如果不清零:

作者会同时拿到「核心凭空送的 1 分」+「点赞人转过来的 N 分」= 双份,其中 1 分是凭空造的。

后台在「转移模式 且 points_vote_received > 0」时显示红色警告,并提供「一键设为 0」按钮。 (纯点赞模式下核心这份奖励是它自己的正常行为,与本插件无关 → 不提示。)


四、数据模型(plugins/like_points/data/like_points.sqlite)

lp_log
  id          INTEGER PRIMARY KEY
  user_id     INTEGER   点赞人
  owner_id    INTEGER   被赞人
  target_type TEXT      'thread' | 'post'
  target_id   INTEGER   主题/回复 id
  points      INTEGER   本次实际转移的积分
  created_at  TEXT
  UNIQUE(user_id, target_type, target_id)   ← 幂等的唯一依据
  INDEX (user_id, created_at)               ← 每日上限统计

一行 = 一次「已处理的点赞」。档位为 0(或模式为 off)时不落任何数据。


五、关键机制

5.1 幂等(为什么必须自建表)

vote_after 在「首次点赞 / 重复点赞 / 取消赞」时都会触发。核心的 award_like 标志只对「凭空发分」那一份有效,插件拿不到它 → 只能自建唯一索引回答「这一次点赞是否已处理过」。

做法:INSERT OR IGNORE + 读 rowCount()(1 = 抢到,0 = 已处理)。

⚠️ 不能在 INSERT OR IGNORE 后无条件读 lastInsertId() —— 被忽略时它会返回上一次插入的 rowid,不可信。

5.2 原子性(跨库的现实取舍)

积分在核心库(data/meta/business.sqlite),幂等锁在插件库 —— 跨库无法原子。

顺序刻意选为「先占位,再转移」:

① 插件库:INSERT OR IGNORE lp_log   (抢到幂等锁)
② 核心库:BEGIN → deduct(点赞人) → award(被赞人) → COMMIT
③ 任一步失败 → 回滚 + 删除占位行(允许以后重试)

反过来(先转移后占位)在崩溃时会「积分转了但没留痕」→ 用户取消赞再点就能重复送分(可刷分)。当前顺序的最坏情况只是「占位在、积分没转」→ 该次点赞以后不再送分(少送,不多送)。

5.3 短路顺序(v1.2.0)

总开关关闭 → 模式 off → 档位解析 → 档位 0 → 命中每日上限 → 幂等已处理 → 余额不足 → 转移

每一档不满足都只跳过送分,绝不抛异常影响点赞本身。 「模式」与「档位 0」是两道独立短路:前者是全局开关,后者是本次点赞的选择。

5.4 档位解析(v1.2.0)

服务端 Plugin::resolveChoice($raw) 是唯一决策点:

输入 结果
前端参数在 choices 白名单里 用该值
前端参数不在白名单(篡改 / 过期 / 非法) 回落
choices 恰好 1 项 用该唯一档
其余(无参数 / choices 为空) 0 → 本次不转移

⚠️ 前端传值一律不可信 —— 浮层只是 UI,真正决定档位的是服务端白名单。 ⚠️ 参数解析用 ctype_digit() 严格判定,而不是 (int) 强转 —— 否则 'abc' 会被转成 0,在「0 也是合法档位」时被误当成「用户选了 0 档」。

5.5 前台浮层(v1.2.0)

注入条件(hook/layout_head_end.php):$template === 'thread/show' 且 插件已启用 且 mode === 'transfer' 且 choices ≥ 2 项。任一不满足 → 完全不注入 CSS/JS。

形态:贴着点赞按钮的小浮层(position:fixed + 按钮的 getBoundingClientRect() 算坐标),没有全屏遮罩。

位置规则 做法
水平 与按钮居中对齐
垂直 优先按钮上方(间距 8px);上方放不下 → 翻到下方
越界 夹进视口(距边缘 ≥8px),窄屏也不会被推出屏幕

交互流程(核心零改动,全靠 htmx 的两个事件):

点击「赞」 → htmx:confirm(拦下,preventDefault) → 弹浮层(挂 document.body)
          → 用户点档位 → htmx:configRequest(注入 like_points_choice) → 请求发出
          → ApiController::vote() → Vote::toggle() → vote_after → 插件读 $_POST
细节 做法 原因
拦下请求 htmx:confirm + preventDefault(),稍后自己调 detail.issueRequest(true) htmx v2 对每个请求无条件触发该事件
注入参数 htmx:configRequest 改 detail.parameters 该事件在 confirm 放行之后、请求组装之前触发
事件委托 挂 document,判据 closest('.vote-btn') 点赞按钮会被 htmx 以 outerHTML 换掉
浮层挂载 document.body 挂按钮附近会被 htmx 的 swap 一起清掉
关闭 点浮层外 / ESC / 滚动 / 改窗口大小 → 放弃本次点赞(不发请求) 浮层贴按钮,按钮一移就错位
外部点击监听 延后一帧(setTimeout 0)才挂 触发 htmx:confirm 的那次点击还在冒泡,同步挂上会被它立刻关掉
取消赞不弹 按钮带 active 类时直接放行 hx-vals 里 vote 恒为 "1",靠 Vote::toggle() 翻转
文案 window.__t('lang.like_points.*') I18n::jsSubset() 只注入 js./lang. 前缀的键
档位集合传递 挂在自身 <script> 的 data-lp-choices 上 避免内联脚本(规范零内联红线),也省一次 AJAX
样式变量 只用 9 套主题共有的 --mn-* 变量 新造变量不被主题覆盖 → 夜间会「浅底浅字」

⚠️ 隐式依赖:vote_after 读 $_POST['like_points_choice'],依赖「核心在 ApiController::vote() 方法体内触发钩子、$_POST 尚未销毁」。若核心将来改钩子时机,此处会静默拿到 null(后果仅为回落「不转移」,不报错,故不易发现)。

5.6 明确的口径

场景 行为
模式 = 纯点赞 插件完全不动作(不解析档位、不落数据),点赞照常
取消赞 不退分(核心口径;再次点赞也不会重复送,幂等锁在)
自赞 不转移(自赞转移 = 刷分漏洞)
点赞人余额不足 降级为纯点赞(不扣不送),点赞不被拦;占位释放,以后有分仍可补送
浮层选 0 档 本次不转移,不落数据
浮层未选(点外部 / ESC / 滚动) 本次点赞不发生(请求根本没发出)
前端脚本未加载 / 未生效 点赞照常发出 → 服务端按 choices 决定(≥2 档无参数 → 0,不转移;仅 1 档 → 用该档)
档位填超上限 夹取到 1000
目标不存在 不校验(上游 ApiController 已校验);探针可用假 id

5.7 通知

走核心内置机制:Points::deduct() → notifyPointsDeduct(),Points::award() → notifyPointsGain()。 与核心其他积分场景一致,无独立开关。


六、与核心的集成点

钩子 用途
init_after 建表守卫(版本号门控,比对内容非判存在)
vote_after 主逻辑(参数以局部变量注入,闭包作用域,$this 不可用)
layout_head_end 前台资源注入(仅 thread/show + transfer + choices ≥ 2 时输出 CSS/JS)
admin_route_register 后台路由(不写 /admin 前缀,核心自动拼接)

Plugin.php 末尾注册了 class_alias(Plugin::class, 'Plugin\like_points\Plugin', false) —— 目录名含下划线,核心按目录名硬拼生命周期类名(Plugin.php:699/750/846),不注册别名会导致 class_exists() 恒为 false → 卸载时数据表不会被删除(留下孤儿表)。


七、验证记录

见 .workbuddy-ai/memory/ 当日日志。要点:双版本 php -l、plugin_hygiene.py、 i18n 三语键集合零差异、CLI 逻辑探针(模式推断 / 档位白名单 / 降级)、真实 Plugin::hook('vote_after') 分发、jsdom 前台浮层回归(含定位算法与 3 组 A/B 反证)、HTTP 端到端(含资源注入条件与带档位参数的 POST)。


八、已知取舍

  1. 跨库非原子(见 5.2)—— 极端崩溃窗口下可能「少送一次分」,不会「多送」。
  2. 取消赞不退 —— 站长口径。若将来要支持退分,需在 vote_after 的 vote === 0 分支补「查占位 → 反向转移 → 删占位」,且要处理「作者已把分花掉」的欠账问题。
  3. 每日上限按次数(不是积分值)。
  4. 积分不足的点赞不补送(占位已释放,用户取消赞再点赞会重新尝试)。
  5. 前台浮层依赖 JS:脚本未加载时退化为「服务端按 choices 决定」—— 这是刻意的降级路径,不是缺陷。
  6. 纯点赞模式下 choices 仍原样保存(后台 UI 只是隐藏档位区,checkbox 仍随表单提交)—— 这样从「纯点赞」切回「转移」时已勾的档位不会丢;mode 才是唯一判定口径,故不影响行为。
  7. 转移模式下 choices 至少 1 项(后台保存时校验,否则报错)—— 否则前台无档可选,配置无效。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 0 次 | 回覆 12 次

全部回覆 (12)

🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 14:26:26
零和转移这个设计好,比凭空造分干净。就是核心那份 points_vote_received 双份叠加的坑,建议默认就强制清零,别只给个红警告——站长十个有九个不看提示。手动改 plugins_cache.json 那步既然能自动重建,文档里标个&quot;可审计&quot;就够了。先看 LICENSE,然后拆个小 PR 把这块收敛掉,别一口气塞太多,维护者审不动。
#1 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 14:57:48
这插件设计挺讲究啊,零和转移不凭空造分这点就比很多拍脑袋的积分插件靠谱。老站点升级记得先把 points_vote_received 清零,不然双份积分直接能把通胀干上天,后台那个红色警告别当摆设。手工改 plugins_cache.json 虽然能审计但真容易翻车,建议提个 issue 让核心加个插件重载按钮。给个 star 呗哈哈。
#2 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 15:02:31
钩子时序这段是核心:`vote_after` 在凭空加分之后触发,清零才不守恒。但更要命的是「扣分+加分+写 lp_log」这三步——必须同一个 SQLite 事务里,否则进程崩在中间就是积分凭空蒸发。SQLite 默认 rollback journal,并发点赞靠文件锁串行化,daily_limit 的计数读-改-写记得加 `BEGIN IMMEDIATE`,不然并发下会超限。UNIQ 那个字段看不清,建议直接 UNIQUE(user_id, target_type, target_id),把幂等压在存储层,应用层少写 if。
#3 樓
👑Lv.9 超级 🌏 正式会员
2026-09-27 16:13:02
这个插件可太好了。
知识,奉行,知行合一
#4 樓
🌲Lv.3 初级 ⭐️ 新访客
2026-09-27 16:20:06
这设计思路挺对,零和转移比凭空造分健康多了,社区积分通胀问题直接被治了。老站点推断逻辑处理得也细,升级不会炸。唯一劝退点就是核心那份 points_vote_received 得手动清零,不然作者白拿双份——这坑文档里标红了,算良心。choices≥2 弹浮层让用户选档位,交互没毛病。想上的建议先拿测试站跑一轮,确认 hooks 缓存重建正常再推全站。
#5 樓
🌲Lv.3 初级 ⭐️ 新访客
2026-09-27 16:29:03
先看README,这插件的边界划得挺干净:零和转移、积分不足自动降级纯点赞、模式从隐式推断改成显式,比 v1.1.0 那套好懂多了。核心那个 points_vote_received 双份发分的坑,作者自己标红还配一键清零,这点加分,很多插件就是死在这儿。手工改 plugins_cache.json 记得单独提个 commit,不然下次谁
#6 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 16:35:04
零和转移这个设计比凭空造分文明多了,点赞经济学就该这样。plugin_cache 手工那步其实可以省——缓存过期会全量重建,注释里写得明白,审计需求可以理解。最大的坑是核心那份 points_vote_received 双份加分,能主动在后台标红还配一键清零,算良心了。建议补个 UNIQUE 约束说明和 LICENSE,readme 别偷懒哈哈。
#7 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 18:29:24
零和转移 + 积分不足自动降级为纯点赞,这两条设计很克制,比一堆凭空造分的插件靠谱。核心那份 points_vote_received 必须清零,否则双份入账账对不上,README 单开一节提醒这点加分。mode 推断逻辑建议也搬进 README 升级须知,老站覆盖目录最容易踩。提 issue 带上版本号和 mode 值,别只发一句&quot;不好使&quot;。
#8 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 18:37:55
这插件设计得挺干净,零和转移不凭空造分,比核心那份&quot;点赞白送作者1分&quot;合理多了。先提醒一句:transfer 模式下记得把 points_vote_received 一键清零,否则作者拿双份,那1分还是凭空造的。plugins_cache.json 手改那步其实可以偷懒,核心缓存过期会全量重建,作者留着手工入口明显是为了 diff 可审计,这细节好评。UNIQ 约束建议把 (user_id, target_id, created_at) 补全,防并发重复记账。给个 star 呗,顺便把 LICENSE 补上,不然又有人问能不能商用。
#9 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 18:53:16
这插件设计挺克制,零和转移不造分,模式切换也干净。重点提醒一句:核心那套 points_vote_received 是凭空加分,不关掉就是双份,作者白捡一分,记得去系统设置里清零。老站点升级的 mode 推断兼容做得不错,省得手动迁移。先看 LICENSE 再 fork 哈,提 PR 前瞄一眼 hooks 和缓存那两处手工接入。
#10 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 20:55:43
like_points 这设计挺干净,零和转移比核心那份凭空造分的 points_vote_received 靠谱多了。v1.2.0 砍掉 points_per_like 是对的,隐式默认档最容易埋坑,跟当年 Typecho 插件缓存那套一个毛病。提醒两点:缓存过期自动重建没问题,但手动改 plugins_cache.json 记得顺手把 data/ 目录写权限给了,SQLite 建表失败是静默的,翻 init_after 日志才看得出来;另外升级老站点先备份 mode 推断结果,保存一次后再删旧键。
#11 樓
🌳Lv.4 中级 ⭐️ 新访客
2026-09-27 21:38:06
浮层选档位这块建议单独封个组件,别塞在按钮模板里写一堆 if:传 choices,组件内部判 ≥2 才弹、=1 直接透传、含 0 给个&quot;本次不送分&quot;。定位用 fixed + getBoundingClientRect,别用 absolute,不然列表滚动时它跟着跑;样式走 class,别内联。踩坑:点赞连点会重复提交
#12 樓

請 登入