[开发]帖子内嵌投票插件开发方案

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

本文由 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_extraapp/Views/post/create.php:72无参数发帖表单里挂「添加投票」折叠区
thread_redpacket_areaapp/Views/thread/show.php:208thread主帖正文/附件后、签名前 → 投票卡片落点
thread_create_afterapp/Controllers/PostController.php:157thread_id/user_id/category_id/title发帖成功后拿 thread_id 建投票(事务外)

thread_redpacket_area 的核心注释原文即写「插件锚点:主帖内嵌红包等(正文/附件后、签名前)」——

说明它本就是为「主帖内嵌插件内容」设计的通用锚点,投票卡片用它语义正确。

2.3 ⚠️ 发现的规范过期项(顺手记录,本次一并修)

位置规范写的实际
§9.1 表 D 第 18 行thread_redpacket_area → show.php:183show.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 次查询:

  1. findForThread($threadId) —— 一次带出 poll + options(poll_id 关联)
  2. 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)
2admin_route_register 写 /admin 前缀 → 404钩子里写 /post-poll(核心已带前缀,Router.php:43 无条件拼接)(规范 §11.22,上轮刚踩)
3$_SESSION['xxx'] 核心没写的键 → 恒空取当前用户走 Auth::getCurrentUser();凡读 session 键先 grep 核心确认(规范 §11.23,上轮刚踩)
4Csrf::verifyOrDie($token) 缺参 → 500写 verifyOrDie($_POST['csrf'] ?? '')
5自造管理员判定 → 越权只用 Auth::isAdmin()(role==='admin' 且 group_id===4,AND)
6改 hooks 清单后 缓存不更新调 Plugin::refresh() 重建 plugins_cache.json(⚠️ 该文件是平铺结构,无 plugins 包裹层)
7HTML 表单走 PRG,XHR 返 JSON投票用 htmx/fetch → 返 JSON;后台配置表单 → PRG + flash。两者不可混用
8.mn-btn / .mn-input 同权重后胜插件内样式提权(.pp-form .pp-field / .pp-card .mn-btn)
9CSS 变量必须 9 套主题全有只从既有 --mn- 取(上轮实测 15 个变量全共有);⚠️ --mn-danger / --mn-spectrum- 不存在
10thread_redpacket_area 是单触发无需防重复 include;但 nav_plugin_links 是双触发(本次不用它)
11渲染钩子禁 die/exit/header异常一律 try/catch + error_log,不逃逸
12Thread::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 · 全局配置

配置项默认说明
enabled1总开关
allow_all_users10 = 仅管理员可创建投票
max_options10选项上限
max_choices_limit10多选时「最多选 N 项」的上限
default_hide_result1新投票默认是否隐藏结果
default_expire_days0默认截止(0 = 不限)
min_options2选项下限

⚠️ 硬边界不放后台:选项 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 冒烟 + 环境还原 + 规范过期行号修正

每阶段做完停下来给你确认(按你的习惯)。

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

全部回复 (0)

暂无回复

请 登录