楼主发帖时可附带一个投票(问题 + 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: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(防倒挂)。