点赞时把点赞人的积分转移给被点赞者(零和转移,不凭空造分)。
模式二选一(后台「模式」):
| 模式 | 行为 |
|---|---|
| 纯点赞(不转移) | 点赞只是点赞,插件完全不动作 |
| 积分转移 | 点赞人扣分、被赞人得分;可设前台可选档位集合(≥2 档时点赞在按钮旁弹小浮层让用户选)与每日送分次数上限 |
积分不足自动降级为纯点赞,点赞本身永不被拦截。
一、安装 / 激活
- 将
plugins/like_points/放入插件目录(本仓库已就位)。 - 手动更新
plugins/plugins_cache.json,加入like_points条目(含 4 个 hooks)——否则钩子不生效。注:核心
Plugin::loadPlugins()在缓存过期(任一plugin.json比缓存新)时会自动全量重建并写回,所以通常无需手工介入;此处手工接入是为了让改动可审计。 - 后台启用插件(或确认
activated: true)。 - 首次请求时
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)。
八、已知取舍
- 跨库非原子(见 5.2)—— 极端崩溃窗口下可能「少送一次分」,不会「多送」。
- 取消赞不退 —— 站长口径。若将来要支持退分,需在
vote_after的vote === 0分支补「查占位 → 反向转移 → 删占位」,且要处理「作者已把分花掉」的欠账问题。 - 每日上限按次数(不是积分值)。
- 积分不足的点赞不补送(占位已释放,用户取消赞再点赞会重新尝试)。
- 前台浮层依赖 JS:脚本未加载时退化为「服务端按
choices决定」—— 这是刻意的降级路径,不是缺陷。 - 纯点赞模式下
choices仍原样保存(后台 UI 只是隐藏档位区,checkbox 仍随表单提交)—— 这样从「纯点赞」切回「转移」时已勾的档位不会丢;mode才是唯一判定口径,故不影响行为。 - 转移模式下
choices至少 1 项(后台保存时校验,否则报错)—— 否则前台无档可选,配置无效。