本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/帖子内嵌投票插件开发方案_2026-09-25.md。
帖子内嵌投票插件开发方案
插件目录名:
post_poll| 拟定版本:1.0.0| 方案日期:2026-09-25依据:
docs/插件开发规范.md(§9 钩子全表、§11 坑清单)需求来源:用户 2026-09-25 口头提出「再给我开发一个投票的插件,前台的设置位置你自己选择」
一、需求与已确认决策
1.1 产品形态
帖子内嵌投票:楼主发帖时可附带一个投票(问题 + 多个选项),投票卡片渲染在主帖正文下方。
1.2 用户已拍板的 6 条
| # | 决策项 | 结论 |
|---|---|---|
| 1 | 投票形态 | 帖子内嵌(非独立投票中心页) |
| 2 | 单选/多选 | 可配(创建时选择「每人限选 1 项」或「可选多项」) |
| 3 | 登录要求 | 登录才能投(未登录只能看,看不到投票按钮) |
| 4 | 截止时间 | 可设置(到期自动停止投票) |
| 5 | 结果可见性 | 投后可看 / 投票前隐藏(避免从众效应) |
| 6 | 创建权限 | 所有人可创建(后台可开关为「仅管理员」) |
二、现状核查(已实测,非推测)
2.1 核心已有的 Vote 不能复用
| 核查项 | 结论 | 证据 |
|---|---|---|
| 核心点赞 | 是帖子/回复的赞踩(vote 取值 ±1) | app/Helpers/Vote.php:148 toggle(string $type, int $id, int $vote) |
| 表结构 | thread_votes / post_votes,UNIQUE(thread_id,user_id) | app/SplitDB/Schema.php:541/551 |
| 语义差异 | 赞踩是二值即时切换;投票是多选项 + 一次性提交 + 计数 | 目标模型不同 |
→ 不复用核心表,投票数据全部放插件自己的库。核心 vote_after 钩子与本次无关。
2.2 可用的两个核心锚点(本次的关键)
| 钩子 | 触发点 | 参数 | 用途 |
|---|---|---|---|
post_create_extra | app/Views/post/create.php:72 | 无参数 | 发帖表单里挂「添加投票」折叠区 |
thread_redpacket_area | app/Views/thread/show.php:208 | thread | 主帖正文/附件后、签名前 → 投票卡片落点 |
thread_create_after | app/Controllers/PostController.php:157 | thread_id/user_id/category_id/title | 发帖成功后拿 thread_id 建投票(事务外) |
thread_redpacket_area 的核心注释原文即写「插件锚点:主帖内嵌红包等(正文/附件后、签名前)」——
说明它本就是为「主帖内嵌插件内容」设计的通用锚点,投票卡片用它语义正确。
2.3 ⚠️ 发现的规范过期项(顺手记录,本次一并修)
| 位置 | 规范写的 | 实际 |
|---|---|---|
| §9.1 表 D 第 18 行 | thread_redpacket_area → show.php:183 | show.php:208 |
(与上一轮 reply_reward 修的那 6 处同类问题:代码演进而行号漂移。)
2.4 参考实现
| 参考 | 借什么 |
|---|---|
plugins/reply_reward/(我刚写的) | 总体骨架、post_create_extra + thread_create_after + thread_redpacket_area 三钩子联动范式、CSS 变量纪律、两阶段一致性写法 |
plugins/quiz_duel/ | 独立前台路由 + 视图组织(本次不需要独立页,仅作参照) |
plugins/red_packet/hook/thread_redpacket_area.php | 卡片渲染写法 |
三、数据模型
插件独立库:plugins/post_poll/data/post_poll.sqlite(走 Plugin::db('post_poll'),原生 PDO)
3.1 三张表
-- 投票主体(一个帖子最多一个投票)
CREATE TABLE IF NOT EXISTS pp_polls (
id INTEGER PRIMARY KEY,
thread_id INTEGER, -- 关联主题 id
creator_id INTEGER, -- 发起人
question TEXT, -- 投票问题
multi_choice INTEGER DEFAULT 0, -- 0=单选 1=多选
max_choices INTEGER DEFAULT 1, -- 多选时最多可选几项(单选恒为 1)
hide_result INTEGER DEFAULT 1, -- 1=投票前隐藏结果
status INTEGER DEFAULT 0, -- 0=进行中 1=已结束 2=已手动关闭
expire_at TEXT, -- 截止时间(NULL/空=不限)
total_votes INTEGER DEFAULT 0, -- 参与人数(去重,冗余计数)
created_at TEXT
);
-- 选项
CREATE TABLE IF NOT EXISTS pp_options (
id INTEGER PRIMARY KEY,
poll_id INTEGER,
opt_text TEXT,
opt_order INTEGER DEFAULT 0, -- 展示顺序
vote_count INTEGER DEFAULT 0 -- 冗余计数(权威值 = pp_ballots 聚合,见 §5.4)
);
-- 投票记录(一人一行,多选时记所选数量)
CREATE TABLE IF NOT EXISTS pp_ballots (
id INTEGER PRIMARY KEY,
poll_id INTEGER,
user_id INTEGER,
options TEXT, -- 所选项 id,逗号分隔(如 "3,5,7")
option_count INTEGER DEFAULT 1,
created_at TEXT
);
-- 索引(DDL 必须写在 CREATE TABLE 之后 —— 规范红线)
CREATE INDEX IF NOT EXISTS idx_pp_polls_thread ON pp_polls (thread_id);
CREATE INDEX IF NOT EXISTS idx_pp_polls_status ON pp_polls (status, expire_at);
CREATE INDEX IF NOT EXISTS idx_pp_options_poll ON pp_options (poll_id, opt_order);
CREATE UNIQUE INDEX IF NOT EXISTS uk_pp_ballots ON pp_ballots (poll_id, user_id);红线核对:
INTEGER PRIMARY KEY(非 AUTO_INCREMENT)✓TEXT存时间(非 DATETIME)✓- 索引全部在
CREATE TABLE之后 ✓ uk_pp_ballots是独立UNIQUE(不与主键冗余,避免 SQLite UPSERT 匹配失败)✓
3.2 为什么 vote_count / total_votes 是冗余字段
投票是高频读、低频写(每次页面渲染都要显示各选项票数)。
若每次渲染都 SELECT COUNT(*) ... GROUP BY option → 每请求多一次聚合查询,
撞「每页 Hook 查询 ≤ 5」红线。
权威值口径:pp_ballots 是真相源;vote_count / total_votes 是同事务内维护的缓存。
提供 rebuildCounters($pollId) 从 pp_ballots 重算,用于对账与修复(照 reply_reward 的做法)。
四、前台设置位置(本次核心设计)
「前台的设置位置你自己选择」——我的选择是:发帖表单内的折叠区,理由与落点如下。
4.1 为什么挂 post_create_extra
| 候选位置 | 评价 |
|---|---|
| 发帖表单折叠区(选定) | ✅ 语义天然对齐「发帖时附带投票」;✅ 与 reply_reward 的奖池设置同一位置,用户认知一致;✅ 钩子已存在,无需改核心 |
| 主题页卡片上的「编辑投票」 | ❌ 需额外做编辑页 + 权限校验,且「发帖后补挂投票」是低频场景 |
| 独立投票创建页 | ❌ 用户已明确选「帖子内嵌」,独立页是另一种形态 |
4.2 表单结构(post_create_extra 内,<details> 折叠)
┌─ ▸ 添加投票(可选) ─────────────────────────────┐
│ 投票问题 [___________________________] │
│ 选项 1 [___________________________] │
│ 选项 2 [___________________________] │
│ 选项 3 [___________________________] │
│ 选项 4 [___________________________] │
│ [+ 添加选项] [− 删除末项] │
│ 投票方式 ( ) 单选 ( ) 多选 最多选 [2] 项 │
│ 截止时间 [不限 ▾] │
│ 结果可见 [ ] 投票前隐藏结果 │
│ ⓘ 选项至少 2 项、最多 10 项;每帖仅 1 个投票 │
└──────────────────────────────────────────────────┘字段名(前缀 pp_ 防冲突):
pp_question / pp_options[] / pp_multi / pp_max_choices / pp_expire_days / pp_hide_result
4.3 前端交互(assets/form.js,零内联)
- 点「添加选项」→ 动态 append 一个
<input>(上限 10,到顶则禁用按钮) - 点「删除末项」→ 移除最后一项(下限 2,到底则禁用按钮)
- 切「单选/多选」→ 多选时显示「最多选 N 项」,单选时隐藏
- 实时校验:问题非空、至少 2 个非空选项、多选的 N ≤ 选项数
- 全部文案走 i18n 字典容器(
<div id="ppI18n" hidden data-i18n='{JSON}'>,规范 §7.3-6)
⚠️ **不用 on* 属性、不用内联 <script>**(规范 §7.3 P0 红线)
五、关键实现设计
5.1 三个钩子的触发链路
用户在发帖页填写投票 → 提交
↓
PostController 正常建帖(核心事务 commit)
↓
[钩子] thread_create_after(PostController.php:157,事务外)
↓ 传 thread_id
读 $_POST['pp_question'] / pp_options / ...
校验通过 → createPoll() 建投票
⚠️ 失败只 error_log,绝不影响发帖主流程
↓
用户访问该主题
↓
[钩子] thread_redpacket_area(show.php:208)
↓ 传 thread
findForThread(thread_id) → 渲染投票卡片
↓
用户点选项 → POST /api/poll/vote(插件路由)
↓
submitBallot() 事务内:写 pp_ballots + 更新 pp_options.vote_count + pp_polls.total_votes
↓
PRG 回主题页 / 或 htmx 局部刷新卡片5.2 投票提交的一致性
投票是写入两张表的关联操作(pp_ballots 插一行 + pp_options 若干行 vote_count+1),
必须包在同一个插件库事务里(同库,可原子):
$db->beginTransaction();
try {
// ① UNIQUE(poll_id,user_id) 保证一人一票 — 重复投票直接失败
INSERT INTO pp_ballots (poll_id, user_id, options, option_count, created_at) VALUES (...);
// ② 逐选项累加
foreach ($optionIds as $oid) {
UPDATE pp_options SET vote_count = vote_count + 1 WHERE id = :oid AND poll_id = :pid;
}
// ③ 参与人数 +1
UPDATE pp_polls SET total_votes = total_votes + 1 WHERE id = :pid;
$db->commit();
} catch (\Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
// 重复投票(UNIQUE 冲突)→ 返回「你已投过」
}要点:
- 一人一票靠
UNIQUE(poll_id, user_id)数据库约束保证,不靠应用层查后再插(防并发) - ⚠️ 与
reply_reward不同:这里没有跨库问题 —— 投票不涉及积分,不需要两阶段补偿,单库事务足够 - catch 内必须先
inTransaction()判断再回滚(否则抛There is no active transaction→ 500)
5.3 状态机(卡片渲染)
无投票 → 零输出(其他人)/ 「添加投票」提示(楼主,可选)
进行中 · 未登录 → 显示选项(禁点)+ 「登录后参与」按钮
进行中 · 已投票 → 显示结果(进度条 + 票数 + 百分比)+ 「你已投票」
进行中 · 未投票 · 隐藏结果 → 显示选项(可点)+ 「投票后可见结果」
进行中 · 未投票 · 不隐藏 → 显示选项(可点)+ 结果(进度条)
已截止 / 已关闭 / 已结束 → 显示结果,「投票已结束」5.4 结果展示(进度条)
┌─ 投票:你觉得新版界面怎么样? [进行中] ─┐
│ ○ 很好,明显好用了 ████████░░ 42 (53%) │
│ ● 一般,没太大感觉 █████░░░░░ 26 (33%) │
│ ○ 不如旧版 ██░░░░░░░░ 11 (14%) │
│ ───────────────────────────────────────────── │
│ 共 79 人参与 · 至 10-02 15:54 结束 │
└────────────────────────────────────────────────┘- 进度条宽度 =
vote_count / 总票数 * 100% - ⚠️ 百分比用服务端算好再输出,不要在前端用 JS 算(避免投票数变化时的精度/竞态)
- 进度条宽度是唯一允许的内联样式场景吗?—— 不是。用 CSS 自定义属性:
<div class="pp-bar" data-pct="53"> + CSS width: calc(var(--pp-pct) * 1%),
由 JS 读 data-pct 设 --pp-pct(零内联,符合规范 §7.3)
5.5 多选投票
- 多选时选项框用
<input type="checkbox">,单选用<input type="radio"> - 「最多选 N 项」:JS 侧限制勾选数(超限时禁用其余未勾选项)
- 服务端必须再校验一次
option_count <= max_choices(前端校验不可信) pp_ballots.options存逗号分隔的选项 id,option_count冗余存数量(便于统计)
5.6 截止时间处理
- 表单给「不限 / 1 天 / 3 天 / 7 天 / 15 天 / 30 天」下拉(与
reply_reward的expire_days同思路) - 存
expire_at(Y-m-d H:i:s文本) - 渲染时
expire_at <= now即视为「已截止」→ 显示结果、禁投票 - 不做定时任务(无命令行环境的现实约束)→ 惰性判断即可,不需要
refundExpired那种补偿
5.7 每页 Hook 查询预算(≤ 5,硬红线)
主题页渲染投票卡片只允许 1~2 次查询:
findForThread($threadId)—— 一次带出 poll + options(poll_id关联)findBallot($pollId, $userId)—— 当前用户是否已投、投了什么
→ 用 fetchPollWithOptions() 一次 JOIN 出全部,不要 poll 查一次、options 再查一次。
→ 走 Plugin::budget(2) 显式占额度(4 次留给其他插件)。
六、文件清单
plugins/post_poll/
├── plugin.json # activated:false,6 个钩子
├── Plugin.php # 建表 + 业务方法 + class_alias
├── PollController.php # 前台投票提交(POST /api/poll/vote)
├── AdminController.php # 后台配置
├── hook/
│ ├── init_after.php # 建表兜底
│ ├── route_register.php # 注册 POST /api/poll/vote
│ ├── admin_route_register.php # 后台路由(注意:写 /post-poll 不带 /admin 前缀)
│ ├── post_create_extra.php # 发帖表单折叠区
│ ├── thread_create_after.php # 发帖后建投票
│ ├── thread_redpacket_area.php # 主题页投票卡片
│ └── layout_head_end.php # 按页加载 CSS/JS
├── views/
│ └── admin/index.php # 后台(Tab:全局配置 / 投票列表)
├── assets/
│ ├── style.css # 卡片 + 表单 + 后台(仅 --mn-* 变量)
│ └── form.js # 选项增删 / 单选多选切换 / 零内联
├── lang/
│ ├── zh.php en.php zh_tw.php # 三语,键集合零差异
└── data/ # sqlite 落这里(含 .htaccess / web.config)预计规模:约 16 个文件、Plugin.php 约 400 行、语言键约 70~90 个。
七、⚠️ 必须避开的坑(本机已踩过的,直接抄结论)
| # | 坑 | 做法 |
|---|---|---|
| 1 | 目录名含下划线 → 生命周期方法静默跳过 | Plugin.php 末尾 必须 \class_alias(Plugin::class, 'Plugin\post_poll\Plugin', false)(规范 §11.12) |
| 2 | admin_route_register 写 /admin 前缀 → 404 | 钩子里写 /post-poll(核心已带前缀,Router.php:43 无条件拼接)(规范 §11.22,上轮刚踩) |
| 3 | $_SESSION['xxx'] 核心没写的键 → 恒空 | 取当前用户走 Auth::getCurrentUser();凡读 session 键先 grep 核心确认(规范 §11.23,上轮刚踩) |
| 4 | Csrf::verifyOrDie($token) 缺参 → 500 | 写 verifyOrDie($_POST['csrf'] ?? '') |
| 5 | 自造管理员判定 → 越权 | 只用 Auth::isAdmin()(role==='admin' 且 group_id===4,AND) |
| 6 | 改 hooks 清单后 缓存不更新 | 调 Plugin::refresh() 重建 plugins_cache.json(⚠️ 该文件是平铺结构,无 plugins 包裹层) |
| 7 | HTML 表单走 PRG,XHR 返 JSON | 投票用 htmx/fetch → 返 JSON;后台配置表单 → PRG + flash。两者不可混用 |
| 8 | .mn-btn / .mn-input 同权重后胜 | 插件内样式提权(.pp-form .pp-field / .pp-card .mn-btn) |
| 9 | CSS 变量必须 9 套主题全有 | 只从既有 --mn- 取(上轮实测 15 个变量全共有);⚠️ --mn-danger / --mn-spectrum- 不存在 |
| 10 | thread_redpacket_area 是单触发 | 无需防重复 include;但 nav_plugin_links 是双触发(本次不用它) |
| 11 | 渲染钩子禁 die/exit/header | 异常一律 try/catch + error_log,不逃逸 |
| 12 | Thread::STATUS_DELETED=1 易记反 | 渲染卡片不额外查核心表,只查插件库 |
八、验证计划
8.1 静态
- [ ]
php -l全部 PHP 文件 - [ ] 零内联双口径扫描(原文 + 剥注释)—— 限定到插件自己的产出,不整页扫
- [ ] 语言键三语比对:键集合零差异 + 键序一致 + 无空值
- [ ] CSS 变量脚本化核验:全部为 9 套主题共有
8.2 探针(CLI)
- [ ]
class_alias生效(\Plugin\post_poll\Plugin可加载) - [ ] 3 表 + 4 索引建成;
activate()幂等 - [ ]
createPoll正常路径 / 选项 < 2 拒绝 / 选项 > 10 拒绝 / 问题为空拒绝 - [ ]
submitBallot单选正常 / 多选正常 / 超过 max_choices 拒绝 / 重复投票被 UNIQUE 拦 - [ ]
vote_count与rebuildCounters()对账一致 - [ ] 截止时间过期 → 拒投
- [ ] 卸载 → 3 表全 DROP、索引零残留、可重建
8.3 HTTP 冒烟(⚠️ 必须做 —— 上轮两个 bug 都只在 HTTP 层暴露)
- [ ] 发帖页
post_create_extra渲染(HTTP 200 + 字段齐全 + i18n 容器转义正确) - [ ] 主题页投票卡片四态(未登录 / 可投 / 已投 / 已截止)
- [ ] 投票提交 → 卡片结果更新
- [ ] 后台页
/admin/post-poll必须 200,非 404(上轮踩过) - [ ] 静态资源 200(CSS/JS)
- [ ] 造会话时注意:用户
status必须是active,否则Auth.php:67直接session_destroy()
九、已确认决策(2026-09-25 定稿)
| # | 决策项 | 结论 |
|---|---|---|
| 1 | 插件名 | post_poll |
| 2 | 选项数量上限 | 2 ~ 10(少于 2 或多于 10 一律拒绝,前后端双重校验) |
| 3 | 投票可编辑 | 不做(编辑需处理「已投用户如何处理」,二期再说) |
| 4 | 后台投票列表 | 做(查看全部投票 / 按状态筛选 / 强制关闭) |
| 5 | 与积分联动 | 不做(投票的价值在表达,不在激励) |
→ 与 §1.2 的 6 条产品决策合并,共 11 条已锁定,实施中不再变更。
9.1 P3 后台「投票列表」细化(按决策 4)
复用核心 .admin-tabs(范式 app/Views/admin/settings_display.php),两个 Tab:
Tab 1 · 全局配置
| 配置项 | 默认 | 说明 |
|---|---|---|
enabled | 1 | 总开关 |
allow_all_users | 1 | 0 = 仅管理员可创建投票 |
max_options | 10 | 选项上限 |
max_choices_limit | 10 | 多选时「最多选 N 项」的上限 |
default_hide_result | 1 | 新投票默认是否隐藏结果 |
default_expire_days | 0 | 默认截止(0 = 不限) |
min_options | 2 | 选项下限 |
⚠️ 硬边界不放后台:选项 2~10 是代码级硬约束(决策 2 已定)。
后台
max_options的可调范围也夹在 2~10 内(min_options夹在 2~5),避免后台调成 50 导致 UI 崩坏 —— 照
reply_reward后台的「数值夹取区间」写法。
Tab 2 · 投票列表
- 列:ID / 问题 / 所属主题 / 发起人 / 参与人数 / 选项数 / 状态 / 截止时间 / 创建时间
- 操作:强制关闭(
status=2,与reply_reward的closePool()同思路) - 筛选:全部 / 进行中 / 已结束 / 已关闭
- 分页 +
\app\Models\User::batchGetNames()批量补发起人名(禁直查核心 users 表) - 排序:默认
id DESC
十、实施顺序(已确认,按此执行)
P0 骨架 + 建表 + class_alias + 探针1(表/索引/幂等) ← 先跑通生命周期
P1 createPoll + submitBallot + 探针2(投票逻辑/并发/边界) ← 核心业务
P2 前端三钩子(表单 / 建投票 / 卡片)+ CSS + JS + 三语
P3 后台(配置 + 投票列表 + 强制关闭)
P4 HTTP 冒烟 + 环境还原 + 规范过期行号修正每阶段做完停下来给你确认(按你的习惯)。