本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/post_poll/说明.md。
帖子投票(post_poll)
楼主发帖时可附带一个投票(问题 + 2~10 个选项),支持单选 / 多选、截止时间、投票前隐藏结果。
玩法
- 楼主:在发帖页展开「添加投票」折叠区,填问题 + 选项(2~10 个),可选:
- 单选 / 多选(多选再设「最多选 N 项」)
- 截止时间(不限 / 1 / 3 / 7 天)
- 投票前是否隐藏结果
- 提交发帖 → 帖子和投票一起创建(一帖一投票)
- 其他用户:在帖子详情页的投票卡片里勾选 → 点「投票」提交(XHR,不刷新页面)。
- 结束:到截止时间自动不可投;管理员 / 楼主可在后台「强制关闭」。
⚠️ 投票创建不需要预扣积分(与
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:213 | 帖子详情页投票卡片 |
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)。
「不发投票」是合法路径(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 键,键集合零差异、键序一致。
配置项一览
| 键 | 默认 | 说明 |
|---|---|---|
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(防倒挂)。