[开发]道具中心(props)插件 · 设计方案与 P0–P4 验证报告

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

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:合并篇(见正文内 merged_from 列表)。

道具中心(props)插件 · 设计方案与 P0–P4 验证报告

本篇由 4 篇原始文档合并去重而成:设计方案 + P0 验证 + P1–P4 验证 + UI 迭代验证。

插件目录 plugins/props|前台 /props|后台 /admin/props|插件独立库 plugins/props/data/props.sqlite(表前缀 pp_)。

一、插件定位

道具中心是积分商城类插件:用户在前台 /props 花积分买道具卡,在「我的背包」里对自己的主题使用,效果落到核心 topic_index 字段(置顶 / 精华 / 变色 / 回复可见),或直接转化为积分(积分卡 / 双倍积分卡)。运营在后台 /admin/props 配置道具、礼包、特价与用户组价、发放道具、看数据。

面向两类人:前台普通用户(买、用、回收),后台运营(配、发、看)。

关键前提:全插件周期零核心改动(app/ 未动一个字节),全部数据落在插件独立库,只有效果最终经 Thread::update() 这一核心认可的白名单出口写入。

二、设计方案

2.1 核心能力盘点(实测结论)

能力实测结论
主题级效果topic_index 已内置 is_pinned / is_highlighted / color / reply_to_view 四字段(app/SplitDB/Schema.php:780-799);写入出口 Thread::update(),白名单 app/Models/Thread.php:474-477 含这 4 列 → 正当出口,不碰红线
回复可见链路已闭环:Points::canViewContent()(app/Helpers/Points.php:265)查 viewed_replies 表,插件只需把 reply_to_view 置 1
标题颜色Thread::PRESET_TITLE_COLORS(:50)= 红/橙/绿/蓝/紫 5 预设;列表页 5 个渲染点用内联 style,详情页 app/Views/thread/show.php:34 用 CSS 类映射表 → 存预设色之外的值详情页不显示
改名 / 顶帖无出口:User::updateProfile() 只含 email + 签名,updateByAdmin() 的 SQL 不含 username;last_reply_time 只有 Thread::incrementReplyCount() 会写且污染 reply_count
字体样式核心无任何样式字段,列表页标题 DOM 无 class / data 挂钩
积分Points::award()(:161)/ Points::deduct()(:211,AND points >= :p 防并发负分)+ points_award_after 钩子(:195,在调用方事务内,补发会递归)
范式先例plugins/points_mall/:插件独立库 Plugin::db()、ensureSchema 幂等建表、扣分→发放→失败退回、库存原子扣减、同库事务、class_alias

2.2 三个决策点与最终拍板

老搭档 2026-09-23 拍板,结论直接落进代码白名单 EFFECT_TYPES:

决策点选项拍板结论
改名卡 / 顶帖卡A 核心各加 Model 方法 / B 不做 / C 插件直写(违反 §11.8-1,不推荐)永久不做
加粗 / 斜体 / 下划线卡A 核心加 title_style 字段 / B hack 复用 color 塞 CSS / C 不做永久不做(hack 依赖 e() 不转义分号这一实现细节,脆弱且详情页必然失效)
变色卡色板A 限 5 预设色 / B 任意色(详情页不生效)/ C 任意色 + 改详情页映射限 5 预设色,色板直引核心常量不硬编码

最终效果类型 6 类:pin / digest / color / reply_visible / points / double_points。rename / bump / bold / italic / underline 不在白名单,回归里显式断言 isValidEffect() === false 防将来误加。

2.3 数据表(插件库,7 张一次建全)

表关键字段索引 / 备注
pp_items道具定义:code / name / icon / price / sale_price(-1=无) / sale_start / sale_end / duration_days(0=永久) / stock(-1=不限) / per_user_limit / daily_limit / effect_value / allow_combo / sort_order / is_activeidx_pp_item_code、idx_pp_item_active
pp_item_groups用户组专属价:item_id / group_id(对应核心 user_groups.id)/ priceuniq_pp_item_group
pp_packs / pp_pack_items礼包与内容:pack_id / item_id / qtyidx_pp_pack_items
pp_inventory背包:user_id / item_id / qty / expire_at(0=不过期) / source(buy/grant/pack/recycle_back)UNIQUE(user_id, item_id) 堆叠,同行 expire_at 取最早(FIFO)
pp_logs流水:action(buy/use/recycle/grant/expire/refund) / qty / points_delta(负=消耗) / target_type / target_id / extra(JSON) / day_key(YYYY-MM-DD,供每日限购 COUNT)idx_pp_log_user、idx_pp_log_limit
pp_effects生效效果:effect_key / effect_value(原值备份,供回滚) / applied_at / expire_at(0=永久) / status(active/expired/reverted)idx_pp_fx_target、idx_pp_fx_user

  • 取价优先级:用户组专属价 → 特价(在 sale_start~sale_end 区间内)→ 原价。
  • 配置键(存核心 settings 表,Plugin::isEnabled() 读):总开关 props_cfg_enabled、props_cfg_own_thread_only(仅能作用于自己发布的主题)、props_cfg_recycle_enabled / props_cfg_recycle_ratio(70)、props_cfg_combo_enabled。P0 落地时把方案初稿的键统一为 props_cfg_ 前缀(与既有插件 floor_reply_ / seo_ 风格一致);recycle_ratio 后单列 Plugin::NUMERIC_KEYS,走 getRaw() 转 int(见第五节缺陷修复)。
  • 为什么 P0 就建全 7 张:init_after 是戳文件门控,后加表时老安装不会重跑 activate();一次建全免得到 P4 改门控逻辑,空表零成本。

2.4 核心流程

  • 购买:Points::deduct() 原子扣分 → 写 pp_inventory → 任一步失败整体退回(照 points_mall 范式);库存 UPDATE ... WHERE stock > 0 + rowCount() 防超卖;限购查 pp_logs 的 day_key。
  • 使用(组合拳):背包勾选多张卡 → 选目标主题 → 逐个校验(主题归属 / 权限 / 库存 / 过期 / 限购 / allow_combo 组合白名单)→ 插件库事务(扣背包 + 写 pp_logs + 写 pp_effects)→ 共享同一字段的效果先合成再一次 Thread::update() 落库 → 失败整体回滚。allow_combo=0 的道具不能与其他道具同批使用。
  • 到期:惰性过期,零 cron 依赖 —— 读背包时 expire_at < now 视为失效;渲染/访问主题时发现 pp_effects 过期 → 回滚 topic_index 并置 status='expired'(expireEffects() 带 status='active' 幂等守卫,不重复处理)。
  • 双倍积分卡:points_award_after 里检测生效的 double_points 效果 → Points::award() 补发等额。必须加递归守卫(钩子内静态变量 __props_double_points_busy + try/finally 释放),否则补发再次触发钩子 → 无限递归。
  • 查询预算(§11.13:单插件每请求所有 hook 查询总和 ≤ 5):列表页每行查效果会直接爆预算 → controller_view_before 里对当前页所有 target_id 批量预取 + 请求级静态缓存。

2.5 钩子与路由

钩子 9 个:init_after(建表兜底,版本戳门控见 3.1)/ route_register / route_before_dispatch(可选,注意一次请求触发两次)/ admin_route_register(route:admin 权限)/ nav_plugin_links(双触发,禁声明顶层函数,图标 16px)/ nav_user_menu_items(图标 14px)/ layout_head_end / controller_view_before / points_award_after(双倍卡)。

  • 前台路由:GET /props、/props/bag、/props/logs;POST /props/buy、/props/buy-pack、/props/use(支持 item_ids[] 组合)、/props/recycle,全部 CSRF。
  • 后台路由:GET /admin/props + POST /props/item/add|edit|delete、/props/item/group-price、/props/pack/add|edit|delete、/props/grant(发放不扣分)、/props/settings。

2.6 后台与前台口径

  • 后台 6 个 tab(道具 / 礼包 / 定价 / 发放 / 看板 / 开关)必须复用核心 .admin-tabs / .admin-tab-btn,纯 JS 切换零内联,保存后留在当前 tab,每个表单各自带 csrf 隐藏域。
  • 后台 HTTP 冒烟前置:测试会话必须写 admin_verified_at + csrf + csrf_time(Admin\BaseController:35 查 $_SESSION['admin_verified_at'],缺一个就 302 /admin/verify)。
  • 看板聚合查询走 Schema::mainIndexDb() 或插件库 —— app\Core\Database 无 fetchColumn() / prepare(),只有 query/fetchAll/fetchOne(第二参传参数数组),误用会 fatal 且伪装成「全 PASS」。
  • 前台只用核心 mn- 类 + --mn- 变量(新造变量不被 9 套主题覆盖);交互全进 assets/script.js;落在 hx- 容器内的按钮加 hx-disinherit=""。
  • 卫生门禁:全参数化 SQL / i18n 三语键集合零差异且键序一致 / UTF-8 无 BOM + LF / 零内联 on*= / activate() + init_after 戳文件兜底建表 / uninstall() 逐表 try/catch。

三、P0 验证结论(骨架 + 数据层)

结论:通过,全部门禁绿。 交付 6 个文件、7 张表、9 个索引,全程零核心改动(app/ 未动一个字节)。

验证组结果
静态检查(编码 / 残留 / 零内联 / 42 个锚点回读)全绿(编码 6/6 合格,零内联 0 命中,锚点 0 缺失)
双版本语法(PHP 8.2.9 + 8.0.2 底线)5/5 文件全通过
三语 i18n20 键 × 3 语,键集合与键序全等
运行时回归(直接调用层)47/47 PASS
戳文件门控回归13/13 PASS
A/B 反证(版本戳门控 vs 旧写法)成立,还原后 sha256 逐字节一致
HTTP 冒烟(首页/论坛/登录/搜索 4 页)7/7 PASS,error.log 零新增

三个关键设计决策:

  1. 数据层版本戳门控(优于既有插件):init_after 不再「戳文件存在即跳过」,改为内容比对 Plugin::SCHEMA_STAMP(当前 '1')—— 加表时把 stamp 改成 '2',老安装自动重跑建表。A/B 反证:旧写法在 stamp='STALE' 且已删 pp_effects 的场景下漏建表(2 条 FAIL 正是预期),新门控堵住了这个静默缺陷。
  2. 色板直引核心公开常量 Thread::PRESET_TITLE_COLORS(保留 FALLBACK_TITLE_COLORS 兜底),核心改了预设色插件自动跟随,永不漂移;实测返回值与核心逐项相等且已剔除空值。
  3. 「永久不做」的 5 类效果写进代码注释,回归显式断言 isValidEffect() === false。

与方案的偏差:表数一致(7 张);pp_items 的 UNIQUE(code, id) 改普通索引 idx_pp_item_code(同一 code 可有多道具,如「限时置顶 1 天」与「7 天」都是 pin);配置键统一 props_cfg_ 前缀;背包同行 expire_at 取最早(FIFO,首版简化);通用道具 custom 不列入 EFFECT_TYPES;戳文件门控升级为内容版本戳门控。

注:P0 报告是历史快照,Plugin.php / plugin.json / 三语语言包的 sha256 已被 P1–P4 覆盖,当前权威哈希以 P1–P4 交付清单为准。

四、P1–P4 验证结论

结论:P1–P4 全部落地,四层验证全绿,A/B 反证成立,环境还原干净,核心零改动。

P1 · 后台(先让运营能配道具)

目标:/admin/props 六个 tab(道具管理 / 礼包 / 定价 / 发放 / 数据看板 / 开关规则)。实现要点:复用 .admin-tabs,纯 JS 切换零内联,每表单带 csrf,保存后留在当前 tab。验证:能建道具、配特价与用户组价、发放到指定用户背包(grant 不扣分、写 grant 流水)。

P2 · 前台商城 + 背包 + 购买

目标:/props / /props/bag / /props/logs 三页 + 购买事务。实现要点:扣分 → 入包 → 失败退回;库存原子扣减;每人/每日限购。验证:成功购买 302 + flash + 背包 +1 + 积分 -10;三类拒绝(积分不足 / 库存不足 / 已达每人限购)各自 flash 且未扣分;缺 CSRF 403;游客三页 302 /login。

P3 · 效果应用

目标:6 类效果落地。实现要点:共享字段先合成再一次 Thread::update();惰性过期 + 回滚;双倍卡递归守卫;列表页批量预取守 §11.13 预算。验证:6 类效果全部回读 topic_index 验证落地(如置顶卡生效后 is_pinned=1);过期回滚后 status='expired' + expire 流水且不重复处理;补发流水 related_type='props' 可观测、预算耗尽不补发;成功 flash 断言防「被拒也 302」伪装成功。

P4 · 可选功能

目标:礼包 / 回收 / 用户组价 / 后台发放。验证:礼包购买 / 内容发放 / 库存通过;回收关闭时拒绝,floor(10×2×70%)=14 精确到分;组价 + 特价叠加扣分正确。

总验证矩阵

层结果
L1 静态门禁43 PASS / 0 FAIL(零内联 6 / i18n 7 / 残留 2 / 硬约束 API 6 / 结构锚点 22)
L2 编码 + 语法20 文件 UTF-8 无 BOM + LF 违规 0;php -l 8.2.9 与 8.0.2 各 16/16
L3 运行时回归110 PASS / 0 FAIL(价格 / 购买 / 礼包 / 使用 / 积分卡 / 过期回滚 / 回收 / 发放 / 统计,含核心库副作用断言)
L4 HTTP 冒烟55 PASS / 0 FAIL(游客 / 登录 / 购买 / 使用 / 回收 / 后台 26 项)
A/B 反证2 组成立,还原后 sha256 与基线逐字节一致

本轮修掉 1 个真实缺陷:Plugin.php:1315 的 $oldValues[$row['col']] 中间层下标未兜底,组合使用「双倍积分卡 + 主题级效果卡」时抛 Undefined array key "col" 污染 error.log → 改为 $oldValues[$row['col'] ?? ''],并在 :1311-1313 加注释说明为何不能只靠最外层 ??。

部署三条红线:① 不要打包 protected/.props_table_cache(带进发布包且戳值相同 → 生产端 activate() 被跳过 → 表永远建不出来);② 不要打包 plugins/props/data/(props.sqlite);③ 后续加表 / 加列必须把 SCHEMA_STAMP +1,否则老安装命中戳文件不重跑。

五、UI 迭代记录(P5)

回应 6 条体验问题,全部落地,改动 14 个文件(全部在 plugins/props/ 内),核心零改动。

  1. 数量栏太宽、购买按钮被挤成 2 行:根因是核心 .mn-input{width:100%} 与插件 .pp-qty{width:74px} 同为 (0,1,0) 优先级被压掉。先提到 input.pp-qty(0,1,1)提权,后按要求改为 flex:1 1 0 各占一半(gap 8px 占掉,实际各 (100%-8px)/2)。保留 input.pp-qty{width:64px} 基础档护住背包页回收数量框,否则它回落核心样式变宽。
  2. 背包列宽:.pp-col-pick 40→52px(装不下表头「选择」两个 CJK 字);「有效期」原先错用 .pp-col-num,新增 .pp-col-expire{width:104px;white-space:nowrap};顺手发现 .pp-col-time(150px) 是无人引用的死 CSS。
  3. 礼包卡重做:从「卡片头 + <ul> 清单」改为「图标 34px + [礼包] 徽章 + 道具胶囊(每件带 13px 小图标)+ 价格/库存 + 立即购买」,删除无引用的 .pp-pack-list。
  4. 目标主题筛选(渐进增强):下拉上方加关键词输入框(data-filter-for="pp_thread"),JS 只过滤已有 option,大小写不敏感 + trim,「不选择」恒保留,无匹配时插 disabled 提示项并描红,实时显示命中数/总数;无 JS 时下拉照常提交。用户文案经 data-* 由 PHP 注入,不在 JS 里硬编码中英文(否则 i18n 生成器「键表 == 代码引用集合」断言失败)。

5/6. 图标体系(本轮最大改动):核心 TemplateCompiler::icon() 是 482 个名字 → FA 字形码的映射表且未知名静默回落问号,极难排查。新增 Plugin::ICON_GROUPS(4 组 × 8 = 32 个内置图标,每个实测存在于字形表;6 类效果各配一个放最前:pinned / highlight / palette / eye-slash / coins / rocket)、defaultIconFor($code)(新增表单默认图标,不再是写死的 gift)、isImageIcon()(排除 //host 协议相对写法)、iconHtml()(渲染唯一入口)、isBuiltinIcon();新增 views/_icon_picker.php 可视化选择器(预览 + 4 组网格 + 自定义图片地址),道具/礼包表单共用。icon 字段三种语义按序判定:http(s):// 或 /… → <img class="pp-icon-img">;[a-z0-9-]{1,40} → 核心字形;其余 1~4 字符非 ASCII(emoji)→ 转义后原样显示(为存量数据:改造前「置顶卡」icon 存的是 💡)。核心 icon() 必然输出内联 style="font-size:20px" 会撞零内联门禁 → iconHtml() 里剥掉内联 style 改挂尺寸类 pp-icon-s{N}(选择器 i.pp-icon-s16 稳定压过 FontAwesome 的 .fa)。

顺手修掉一个真实缺陷:Plugin::getConfig() 把数值型配置 recycle_ratio 也当布尔处理 → 开回收后背包文案显示「退还 1%」(实际按 70% 退款)。新增 Plugin::NUMERIC_KEYS = ['recycle_ratio'] 作类型真源,数值键走 getRaw() 转 int。A/B 反证 + 端到端 29 PASS:旧实现 (int)true=1,新实现得 70,背包页含「70%」不含「1%」。

数据安全(本轮最重要的插曲):跑验证前发现插件库里有 7 行用户真实数据(uid=1 手工建的置顶卡 + 新手大礼包 + 购买/使用/礼包流水),而两个测试套件默认前提是空库、末尾会 DELETE FROM pp_*。新增 _props_dataguard.php:VACUUM INTO 单文件快照 + 7 表行级 JSON 备份 → 套件加显式旁路 PROPS_ALLOW_NONEMPTY=1 → 跑完 restore + verify 行级逐表比对,行级 sha256 前后一致,完全复原。教训:跨多轮测试每轮开跑前都要重取快照 —— 本轮就在两轮之间用户改了图标、买了礼包,旧快照直接还原等于回滚用户操作。

验证汇总:静态门禁 43/43、双版本 php -l 17/17、三语 i18n 168==168、运行时回归 110/110、HTTP 冒烟 55/55、图标聚焦探针 24/24、配置类型探针 29/29、购买表单布局 15/15、node --check script.js 通过;error.log 维持 155 行 / 10543 字节基线无新增。

六、遗留与后续

  1. 浏览器端交互未实测:本机无 Chromium,assets/script.js 的 Tab 切换 / data-confirm 拦截只做了静态锚点断言,未做真实 DOM 事件验证。
  2. 递归守卫未暴力反证:双倍卡守卫用「补发流水可观测 + 预算耗尽不补发」间接验证,无限递归的暴力反证会拖死进程,未做。
  3. 并发扣库存未压测:只做了单进程「原子扣减不超卖」断言,无多进程压测。
  4. deleteWithCascade 不清 points_log 是核心既有行为(非本插件缺陷),回归里按精确 user_id 补清,插件侧未改核心。
  5. 快照会过期:.workbuddy-ai/tmp/_props_db_snapshot.sqlite 只是安全网,用户每操作一次它就落后一步,再拿它还原会回滚改动。
  6. 效果 ↔ 图标联动只做「新增时按首个效果预选」;若要「切换效果下拉时图标跟着变」,倾向把 defaultIconFor() 结果渲染成 data- 属性挂在下拉上,JS 只读不推,单一真源、零 AJAX、零漂移。
  7. 说明.md 待补:仓库约定每个插件根目录都要有说明文档,发布打包前补上(按「前台投稿用」插件 zip 规范打包)。
  8. 加表必升 SCHEMA_STAMP:这是数据层唯一不能忘的维护动作(见第四节部署红线 ③)。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 3 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录