[开发]帖子投票(post_poll)

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:30:44

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/post_poll/说明.md。

帖子投票(post_poll)

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

玩法

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

⚠️ 投票创建不需要预扣积分(与 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_endlayouts/main.php:40按模板(thread/show / post/create)预加载 CSS,防 FOUC
post_create_extraViews/post/create.php:72发帖页「添加投票」折叠区
thread_create_afterPostController.php:157发帖成功后落库建投票
thread_redpacket_areaViews/thread/show.php:213帖子详情页投票卡片
route_registerindex.php:295前台 API /api/post-poll/vote
admin_route_registerindex.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)。

「不发投票」是合法路径(form.js 的意图短路)

发帖表单的投票区是可选的:服务端 thread_create_after.php:26-29 对空问题静默跳过,表单字段也没有 required。前端必须同样尊重这一点。

form.js 的 submit 监听器不能无条件跑 validate() —— validate() 的第一关是「问题非空」,无条件跑会让只想发纯文本帖的用户被 preventDefault() 拦下、并被强制展开折叠区(表现为「发帖必须选投票」)。

修法(方案 A):提交时先过 hasPollIntent(),三档任一命中才算有意图:

判定档条件
① 折叠区已展开.pp-form-details[open]
② 问题非空[data-pp-question] 有值
③ 任一选项非空countFilled(box) > 0

  • 三档全不命中 → 直接放行,交给核心正常提交(服务端同样跳过建投票)。
  • 任一命中 → 照旧跑 validate(),友好提示全部保留。
  • 附带好处:用户「填了又收起」(脏数据残留)时仍会被校验拦住,不会静默丢内容。

⚠️ jsdom 回归测试注意:runScripts:'outside-only' 下 readyState === 'loading' → init() 挂在 DOMContentLoaded 上,测试必须手动派发该事件,否则监听器根本没挂上(表现为「所有应拦截的用例都放行」的假 FAIL)。

防刷 / 防越权(三重)

层机制
一人一票数据库级 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 键,键集合零差异、键序一致。

配置项一览

键默认说明
enabled1总开关
allow_all_users10 = 仅管理员可建投票
max_options10选项数上限(夹到 [2,10],硬边界)
min_options2选项数下限(夹到 [2,5])
max_choices_limit10多选最多选几项的上限(夹到 [1,10])
default_hide_result1默认投票前隐藏结果
default_expire_days0默认截止天数,0 = 不限(夹到 [0,365])

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

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 3 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录