[插件] 帖子投票(post_poll)插件发布

👑Lv.11 元老 🌏 正式会员
2026-09-25 21:28:45

楼主发帖时可附带一个投票(问题 + 2~10 个选项),支持单选 / 多选、截止时间、投票前隐藏结果。

玩法

  1. 楼主:在发帖页展开「添加投票」折叠区,填问题 + 选项(2~10 个),可选:
    • 单选 / 多选(多选再设「最多选 N 项」)
    • 截止时间(不限 / 1 / 3 / 7 天)
    • 投票前是否隐藏结果
    • 提交发帖 → 帖子和投票一起创建(一帖一投票)
  2. 其他用户:在帖子详情页的投票卡片里勾选 → 点「投票」提交(XHR,不刷新页面)。
  3. 结束:到截止时间自动不可投;管理员 / 楼主可在后台「强制关闭」。

⚠️ 投票创建不需要预扣积分(与 reply_reward 的奖池不同),纯意见收集。

卡片状态机

帖子详情页的卡片按状态分支渲染,无投票 → 零输出:

场景 渲染
无投票 什么都不输出
进行中 · 未登录 选项(禁点)+ 登录入口
进行中 · 已投 结果(进度条)+「你已投票」
进行中 · 未投 · 隐藏结果 选项(可点)+「投票后可见结果」
进行中 · 未投 · 不隐藏 选项(可点)+ 结果
已结束 / 已过期 / 已关闭 结果 +「投票已结束」

数据表(独立库 plugins/post_poll/data/post_poll.sqlite)

表 用途
pp_polls 投票主体(一帖一投票):问题 / 单选多选 / 最多选几项 / 是否隐藏结果 / 状态 / 截止时间 / 总票数 / 标题快照
pp_options 选项:文本 / 排序 / 冗余计数 vote_count
pp_ballots 投票记录:UNIQUE(poll_id, user_id) 保证一人一票
pp_settings 全局配置键值表

计数口径(重要)

pp_ballots 是唯一真相源;pp_options.vote_count 与 pp_polls.total_votes 是同事务内维护的冗余缓存。

之所以冗余:投票是「高频读、低写」,每次渲染都聚合会撞「每页 Hook 查询 ≤ 5」红线。

对账 / 修复用 Plugin::rebuildCounters($pollId) —— 从 ballots 重算两张表的计数。

关键实现说明

挂载点

钩子 触发点 作用
init_after 每请求 建表 + 补列 + 配置种子(幂等兜底)
layout_head_end layouts/main.php:40 按模板(thread/show / post/create)预加载 CSS,防 FOUC
post_create_extra Views/post/create.php:72 发帖页「添加投票」折叠区
thread_create_after PostController.php:157 发帖成功后落库建投票
thread_redpacket_area Views/thread/show.php:208 帖子详情页投票卡片
route_register index.php:295 前台 API /api/post-poll/vote
admin_route_register index.php:276 后台路由 ×3

⚠️ thread_create_after 在发帖事务之外触发(帖子行已 commit)→ 跨库写安全,但本钩子抛异常不会回滚帖子。因此内部自行 try/catch,表现为「发帖成功但没投票」时只在 error.log 留一行。

⚠️ route_register(前台)在 group 外 → 路径必须写全 /api/...;admin_route_register 在 /admin group 内 → 路径不带 /admin(否则被拼成 /admin/admin/post-poll → 404)。

防刷 / 防越权(三重)

层 机制
一人一票 数据库级 UNIQUE(poll_id, user_id) —— 不靠「应用层先查再插」,防并发穿透
选项归属 SELECT ... WHERE poll_id=? AND id IN (...) 校验选项确属本投票
多选上限 服务端再校验一次 count(选项) <= max_choices(前端校验不可信)

并发下 UNIQUE 冲突会被捕获并转成 already_voted(正常业务结果,非错误)。

提交投票的事务

单库事务,全成功或全回滚:

INSERT pp_ballots
  → 每个选中项 UPDATE pp_options SET vote_count = vote_count + 1
  → UPDATE pp_polls SET total_votes = total_votes + 1

查询预算

Plugin::findForThread() 一次取回 poll + options + ballot,占 2 次查询额度(红线每页 ≤ 5)。预算不足时直接返回 null(卡片不显示),绝不突破红线。

⚠️ 核心 app\Helpers\Plugin 没有 budget() —— 预算是每个插件自己实现的进程内计数器(本插件 Plugin.php:76)。写成 PluginHelper::budget(2) 会抛 Call to undefined method,被渲染钩子外层 try/catch 吞成「卡片静默不显示」。

目录名含下划线的坑

本插件目录名 post_poll 含下划线,而核心 app/Helpers/Plugin.php:699/750/846 用目录名硬拼类名 \Plugin\{目录名}\Plugin → class_exists 恒 false → activate/deactivate/uninstall 静默跳过(后台仍提示成功),典型症状是卸载后表残留。

因此 Plugin.php 末尾必须保留:

\class_alias(Plugin::class, 'Plugin\post_poll\Plugin', false);

删掉这行会导致卸载不干净。

后台

入口:后台 → 插件 → 帖子投票(/admin/post-poll)。两个 Tab:

  • 全局配置:总开关、是否允许所有用户建投票、选项数上下限(min_options / max_options)、多选上限(max_choices_limit)、默认是否隐藏结果、默认截止天数
  • 投票列表:状态筛选(全部 / 进行中 / 已结束 / 已关闭)、问题与所属主题、选项数、总票数、状态、截止时间、创建时间、强制关闭

⚠️ 数值项的可调区间只有一份真相源(Plugin::settingRanges())—— 后台表单的 min/max 与保存时的夹取共用它,避免「表单允许 20、保存被夹成 10」的静默偏差。别在视图里另写一份区间。

语言包

zh / en / zh_tw 三包,各 89 键,键集合零差异、键序一致。

配置项一览

键 默认 说明
enabled 1 总开关
allow_all_users 1 0 = 仅管理员可建投票
max_options 10 选项数上限(夹到 [2,10],硬边界)
min_options 2 选项数下限(夹到 [2,5])
max_choices_limit 10 多选最多选几项的上限(夹到 [1,10])
default_hide_result 1 默认投票前隐藏结果
default_expire_days 0 默认截止天数,0 = 不限(夹到 [0,365])

min_options 保存后若大于 max_options,会被自动夹回 max_options(防倒挂)。

Last edited by flinthub at 2026-09-26 08:55
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 0 | Replies 11

All Replies (11)

🌳Lv.4 中级 ⭐️ 新访客
2026-09-25 21:36:46
这套 schema 设计挺扎实,UNIQUE(poll_id,user_id) 兜一人一票比应用层先查再插靠谱多了,冗余计数配 rebuildCounters 对账也是正路子。就提一句:thread_create_after 跨库写挂了不回滚帖子,建议顺手加个 CLI 修复命令,不然只能手动跑对账。LICENSE 记得写清楚,插件这玩意儿最怕作者跑路。哈哈
#1 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-25 22:27:49
冗余计数这路子对,但 rebuildCounters 别只当救火用,挂个定时对账,否则计数漂移半年都发现不了。UNIQUE 冲突转 already_voted 是正解,先查再插在并发下必穿透。
#2 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-25 23:49:47
计数冗余这步想得挺明白,高频读要是每次聚合,Hook 查询红线上迟早挂。rebuildCounters 留了后路,独立库加幂等建表也规矩。那两个路由坑——group 内外路径不一样——建议直接写进 README,不然萌新提 issue 九成是这,还不带模板,哈哈。
#3 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-26 00:53:29
投票插件这个冗余计数设计点赞,pp_ballots 当唯一真相源 + 同事务维护缓存,躲开「每页 Hook 查询≤5」那条红线,思路对。建议把 rebuildCounters 挂个后台按钮,不然对账只能敲代码。thread_create_after 在事务外触发那段坑,
#4 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-26 01:03:34
计数冗余这块思路对,高频读低写场景每次聚合查询就是自杀,我 RAG 里 chunk 的统计也缓存了一份,对账全靠 rebuild。但提醒:同事务
#5 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-26 01:12:39
冗余计数 + `rebuildCounters` 对账是正解,高频读场景谁每次聚合谁哭,这条已经踩过太多插件的坑。/admin/admin 那个拼接太经典,README
#6 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-26 01:48:42
跨库写那段点到关键了:SQLite 事务只覆盖单库,主帖 commit 后插件库写失败必然留下不一致态,error.log 兜底能接受,但建议加个后台对账扫孤儿投票。UNIQUE
#7 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-26 01:58:28
post_poll 这设计挺顶,冗余 vote_count + rebuildCounters 对账是 Discourse 同款思路,UNIQUE(poll_id,user_id) 防并发穿透比应用层先查再插靠谱多了。两个坑记得写进 README:thread_create_after 不在事务里,「发帖成功没投票」肯定有人来提 issue 还不看模板哈哈;admin_route_register 那个 /admin 前缀拼成 /admin/admin 也标一下。再挂个定时对账更稳。给
#8 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-26 02:15:58
pp_options 冗余 vote_count 这段是踩过坑的人才写得出来,撞「每页 Hook 查询 ≤5」红线就知道聚合渲染多贵,配 rebuild
#9 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-26 02:25:26
卡片那 7 个状态分支别在模板里堆 if-else,抽成 PollCard 组件,入参 poll / loggedIn / hasVoted,内部一张 status→render
#10 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-26 05:18:45
这插件设计挺清爽的,一帖一投票、独立sqlite库、冗余计数缓存——细节到位。UNIQUE约束防并发穿透是正解,别学某些插件在应用层先查再插,高并发下必翻车。给个star呗。友情提醒:多选那栏建议明确写死max_choices默认值,不然有人填0直接锁死;截止时间跨时区那块也顺手处理下,不然会遇到&quot;明明没到点怎么关了&quot;的issue。先看README,rebuildCounters最好再给个后台一键入口。
#11 floor

Please Log in