本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/广告位插件开发方案_2026-09-30.md。
右侧栏广告位插件开发方案(ad_slot)
日期:2026-09-30 | 依据:
docs/插件开发规范.md(1602 行,2026-09-28 版)状态:待确认(本文只出方案,不动手写插件代码)
建议目录名:
ad_slot—— 含下划线 → 必须加class_alias(规范 §4.1 硬性要求,见 §3.8)
一、结论摘要
在右栏(主位置)、右栏底部、帖子详情页三处,按「区块 → 广告项」两级模型渲染赞助商 / 友链 / 自推广内容;
后台提供广告项与区块的增删改、拖拽排序、启停、有效期;点击走 /ad-slot/click/{id} 中转页记数后 302 跳转。
视觉上零自造控件:面板外壳直接复用核心 .mn-panel / .mn-panel-title,后台复用 .admin-tabs / .admin-table。
必须先拍板的 6 个点(详见 §四):
| # | 决策点 | 我的建议 |
|---|---|---|
| 1 | 帖子详情页挂哪个钩子 —— 你点名的 topic_content_after 在本仓不存在 | 用现成 thread_redpacket_area(#18,零核心改动) |
| 2 | type=code 的 HTML 净化口径 —— 核心 Content::sanitizeHtml() 不适合 banner(会重排结构) | 插件自建白名单净化器 |
| 3 | 曝光统计落库方式 | 渲染时收集 → route_after_dispatch 里一条批量 UPDATE |
| 4 | 多语言 v1 做不做 | v1 只做后台 UI 三语;广告内容三语留 P6(表结构预留 JSON 列) |
| 5 | 目录名 ad_slot 还是 adslot | 保持 ad_slot + 补 class_alias(3 行) |
| 6 | 排序交互 | 拖拽(原生 HTML5 DnD,抄 nav_manager)+ 数字输入兜底 |
二、现状核查(实测,非推断)
2.1 你点名的三个钩子 —— 实测只有两个存在
$ grep -rn "Plugin::hook('right_sidebar_after'" app/ index.php
app/Views/_components/right_sidebar.php:211 ✅ 存在
$ grep -rn "Plugin::hook('sidebar_above_friend_links'" app/ index.php
app/Views/_components/right_sidebar.php:263 ✅ 存在(PC 右栏)
app/Views/_components/mobile_sidebar.php:37 ✅ 存在(移动端侧栏)→ **双触发**
$ grep -rn "topic_content_after" --include=*.php .
(零命中) ❌ **不存在**
topic_content_after是 bbs1org 的钩子名,本仓从来没有过(.workbuddy-ai/memory/2026-09-28.md:277已记过这一条)。本仓帖子详情页的可用锚点是:
thread_redpacket_area(thread/show.php:213,主帖正文/附件之后、签名之前,注入thread)与
thread_operation_after(:246,操作按钮区)。详情页内嵌广告只能落在这两个之一,或给核心加新钩子。
2.2 右栏渲染顺序(app/Views/_components/right_sidebar.php,274 行)
| 序 | 区块 | 行号 | 显示条件 |
|---|---|---|---|
| 1 | 发帖人信息栏 / 用户面板 | 41–207 | 详情页 / 其他页 |
| 2 | 🔌 right_sidebar_after(#14) | 211 | 无条件(所有带右栏页面,含帖子/博客详情页) |
| 3 | 站点统计 | 214–241 | $showSiteStats = 首页 / /forum / /forum/category/ / /blog |
| 4 | 热门标签 | 244–259 | 有 tags 且非 /tags 页 |
| 5 | 🔌 sidebar_above_friend_links(#15) | 263 | 无条件(PC 右栏 + 移动端侧栏,各一次) |
| 6 | 友情链接 | 265–274 | friend_links 插件已启用 |
「右栏」与「右栏底部」的落点因此是:
- 右栏(主位置) →
right_sidebar_after:位于站点统计上方,且只有 PC 端(mobile_sidebar.php里没有这个钩子)。 - 右栏底部 →
sidebar_above_friend_links:位于站点统计/热门标签下方、友链上方,PC + 移动端都有(双触发)。
2.3 六条硬约束(实测,直接决定设计)
① $this->redirect() 会吞掉外链 —— 点击中转不能用它
app/Core/Controller.php:100-111:
if (!str_starts_with($url, '/') || str_starts_with($url, '//') || strpos($url, '\\') !== false) {
$url = '/'; // ← 外链 https://... 被静默改写成站内首页
}→ 中转页必须自己 header('Location: ' . $url) + exit,不能用基类 redirect()。
(反过来说:核心这个防开放重定向的设计是对的,我们只是在一个受控的出口上绕过它,并且自己把协议白名单做严 —— 见 §3.4。)
② 全站 CSP 限制图片与脚本(app/Core/Controller.php:80)
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline';
img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; object-src 'none';
base-uri 'self'; frame-ancestors 'self'| 影响 | 结论 |
|---|---|
img-src 含 https:、不含 http: | 广告图必须 https,http:// 图会被浏览器静默拦截(DOM 里有、渲染为空)→ 后台保存时要校验并提示 |
img-src 不含 blob: | 后台图片预览必须走 FileReader.readAsDataURL()(data:),禁用 createObjectURL |
script-src 有 'unsafe-inline',但 | 我们的净化层会整块删除 <script>(见 §3.6)→ 赞助商的「JS 广告代码」不支持,必须在后台写明 |
frame-ancestors 'self' + 净化层删 <iframe> | 赞助商的 iframe 广告代码不支持 |
③ 游客页面永久缓存会同时影响「曝光数」和「广告即时生效」
HomeController.php:37 / ForumController.php:46,249 对未登录游客整页缓存(data/runtime/pages/*.html)。
_components/right_sidebar.php 渲染出来的广告 HTML 会被烘焙进缓存文件,于是:
- 缓存命中时 PHP 不执行 → 曝光数漏计(游客的绝大部分请求都命中缓存);
- 改广告 / 停用 / 过期后,列表页仍显示旧广告,直到清缓存。
→ 处置:后台每次保存广告/区块时主动调 PageCache::invalidate()(+ @unlink 兜底,Windows 下它可能静默失败),
并在说明文档里写明「曝光数 = 真实执行了 PHP 的请求数,不等于页面浏览量」。
④ cron / CLI 里没有插件钩子 → 「定时落库」只能靠前台请求节流
$ grep -c "Plugin::hook" cron_trigger.php cli/*.php
cron_trigger.php:0 ← 生产环境的 5 分钟云函数入口
cli/*.php: 全部 0cron_trigger.php 不走 index.php,不触发任何插件钩子 → 插件拿不到定时器。
所以曝光落库只有两条路:每请求落一次(推荐,见 §3.5 方案 A),或文件缓冲 + 前台请求按节流标记合并写入(方案 B,更复杂)。
⑤ 核心净化层不能直接拿来净化 banner
| 候选 | 实测问题 | 能不能用 |
|---|---|---|
Content::sanitizeHtml()(app/Helpers/Content.php:15) | 白名单循环后无条件跑 groupPostImages()(:177,实现 :227)→ 把 <img> 重排进 .post-img-grid 容器;img 分支把站内图 src 降级为 md_ 缩略图、强制 loading=lazy;<a> 被强加 target=_blank + rel=nofollow;<script>/<style>/<iframe> 整块删除 | ❌ 语义是「帖子正文」,会改结构 |
Content::sanitizeCssStyle()(:429) | 白名单只有 5 个属性:color / font-size / text-align / background-color / border-radius → banner 的 width / height / margin / padding / display / border 全被剥掉 | ⚠️ 可作 style 净化的基底,但要扩白名单 |
⑥ 每请求查询红线:单插件所有 hook 查询总和 ≤ 5(规范 §十三 / §11.13)→ 见 §3.3 的预算表。
2.4 可复用资产(省事清单)
| 要什么 | 用什么 | 出处 |
|---|---|---|
| 前台面板外壳 | .mn-panel / .mn-panel-title | assets/css/modern.css:522,531(排行榜面板同款) |
| 前台图标(零内联) | 字面量 <i class="fa mn-fs-12"></i> | ⚠️ 禁用 $view->icon()(返回值恒带内联 style="font-size:Npx",规范 §7.3 一票否决) |
| 后台页壳 | .admin-container + include('admin/_sidebar') + .admin-content | app/Views/admin/*.php 固定结构 |
| 后台 Tab | .admin-tabs / .admin-tab-btn / .admin-tab-pane | app/Views/admin/settings_system.php:26-34 |
| 后台表格 | .admin-table / .admin-table-kv(首列 .col-label) | assets/css/admin.css:714 |
| 状态徽标 | .plugin-status-on / .plugin-status-off | assets/css/admin.css:980-981 |
| 提示条 | .alert.alert-error / .alert-success(核心无 .alert-warning) | — |
| 拖拽排序 | 原生 HTML5 DnD(.nm-drag-handle + dragstart/dragover/drop) | plugins/nav_manager/assets/admin.js:47-85(零外部库) |
| 图片上传落盘 | Upload::file($_FILES['x'], $allowedExts, $opts) | plugins/points_mall/AdminController.php:190 |
| 站内通知 | Notification::add(int $userId, string $type, string $title, ...) | app/Helpers/Notification.php:69(没有 send()) |
| 管理员判定 | Auth::isAdmin() | app/Helpers/Auth.php:20(role==='admin' 且 group_id===4) |
三、技术设计
3.1 数据表(插件独立库 plugins/ad_slot/data/ad_slot.sqlite)
-- ① 广告位区块
CREATE TABLE IF NOT EXISTS ads_blocks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', -- 内部备注名(后台用)
title TEXT NOT NULL DEFAULT '', -- 前台显示标题
position TEXT NOT NULL DEFAULT 'sidebar', -- sidebar | sidebar_bottom | thread
style TEXT NOT NULL DEFAULT 'card', -- card | compact | media
limit_num INTEGER NOT NULL DEFAULT 5, -- 展示条数
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- ② 广告项
CREATE TABLE IF NOT EXISTS ads_items (
id INTEGER PRIMARY KEY,
block_id INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'link', -- code | image | link
title TEXT NOT NULL DEFAULT '', -- 内部备注(前台不显示)
content TEXT NOT NULL DEFAULT '', -- code:HTML / image:上传相对路径 / link:链接文字
url TEXT NOT NULL DEFAULT '', -- 跳转链接
description TEXT NOT NULL DEFAULT '',
trans TEXT NOT NULL DEFAULT '', -- 预留:多语言 JSON {"en":{"title":"","desc":"","text":""}}
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
expire_at INTEGER NOT NULL DEFAULT 0, -- 0 = 永久(unix 秒)
clicks INTEGER NOT NULL DEFAULT 0,
impressions INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- ③ 索引(⚠️ 必须写在 CREATE TABLE 之后,规范 §10.3)
CREATE INDEX IF NOT EXISTS idx_ads_items_block ON ads_items(block_id, is_active, sort_order);
CREATE INDEX IF NOT EXISTS idx_ads_blocks_pos ON ads_blocks(position, is_active, sort_order);为什么区块表与广告项表分开:一个广告项只能属于一个区块(
block_id),但区块可以整体换位置/换样式/停用 —— 满足「多区块独立配置」。可选第三张表
ads_click_log(id / ad_id / ip_hash / ua / referer / created_at):P5 再加,用于「哪些广告真有人点」的分析;v1 只用clicks计数器。
3.2 路由
// hook/route_register.php
if (!isset($router)) return;
$router->get('/ad-slot/click/{id}', ['\Plugin\adslot\FrontController', 'click']);
// hook/admin_route_register.php ⚠️ 不写 /admin 前缀,核心自动补(规范 §11.22)
if (!isset($router)) return;
$router->get ('/ad-slot', ['\Plugin\adslot\AdminController', 'index']);
$router->post('/ad-slot/item/save', ['\Plugin\adslot\AdminController', 'saveItem']);
$router->post('/ad-slot/item/del', ['\Plugin\adslot\AdminController', 'deleteItem']);
$router->post('/ad-slot/item/toggle',['\Plugin\adslot\AdminController', 'toggleItem']);
$router->post('/ad-slot/item/batch', ['\Plugin\adslot\AdminController', 'batchToggle']);
$router->post('/ad-slot/items/sort', ['\Plugin\adslot\AdminController', 'sortItems']);
$router->post('/ad-slot/block/save', ['\Plugin\adslot\AdminController', 'saveBlock']);
$router->post('/ad-slot/block/del', ['\Plugin\adslot\AdminController', 'deleteBlock']);
$router->post('/ad-slot/settings', ['\Plugin\adslot\AdminController', 'saveSettings']);路由参数由
Router::dispatch()(app/Core/Router.php:103-113)按命名捕获传参 → 控制器签名写public function click($id)。所有写操作走 PRG(POST → 302 → GET),AJAX 的排序/启停走
$this->json()+Csrf::verify()自行回 403 JSON(不用verifyOrDie(),它 echo HTML 并 exit)。
3.3 前台渲染
钩子 → 位置映射(一处配置,两个钩子文件)
| `position` | 渲染钩子 | 落点 | 端 |
|---|---|---|---|
sidebar | right_sidebar_after | 站点统计上方 | 仅 PC |
sidebar_bottom | sidebar_above_friend_links | 友链上方 | PC + 移动(双触发) |
thread | thread_redpacket_area(决策 1) | 主帖正文之后 | 帖子详情页 |
查询预算(红线 ≤5)
| 步骤 | 语句数 |
|---|---|
blocks:按 position 取启用区块(ORDER BY sort_order, id) | 1 |
items:按 block_id 批量取启用项(IN 占位符 + 强转 int) | 1 |
| 同请求内静态缓存(第二个钩子直接复用,不重复查) | 0 |
route_after_dispatch 曝光批量 UPDATE | 1 |
| 合计 | 3 ✅ |
双触发防护(sidebar_above_friend_links 会被 PC + 移动各调一次)
- 钩子文件禁声明顶层
function/class/interface/trait(规范 §9.0:一旦声明被降级为include_once,第二处静默消失); - 输出的
id/name必须带渲染序号(Plugin::nextSeq()范式,leaderboard已踩过:同name的 radio 会让 PC / 移动两份面板联动); - 钩子内
$this不可用(运行在闭包作用域,规范 §11.24)→ 一律htmlspecialchars($s, ENT_QUOTES, 'UTF-8'),图标写字面量。
渲染 HTML 骨架(三种样式只差一个 class)
金牌赞助
- 「无数据不渲染」(规范 §8 通例):区块内 0 条可用广告 → 整块不输出,不产生空面板;
- 图片型若有
description,media样式下在图片下方渲染描述(转义输出); - 所有链接一律指向中转页(
/ad-slot/click/{id}),不直链目标 URL —— 这是「点击能记数」的唯一保证。
3.4 点击中转 /ad-slot/click/{id}
public function click($id)
{
$ad = Plugin::findClickable((int)$id); // 一条查询:is_active=1 且未过期 且所属区块启用
if ($ad === null) {
\http_response_code(404); // ⚠️ 必须自己设码,view('errors/404') 只渲染不设码(软 404)
$this->view('errors/404');
return;
}
Plugin::incrClick((int)$ad['id']); // 计数(失败只记 error_log,不阻断跳转)
$target = Plugin::safeExternalUrl($ad['url']);
if ($target === '') { \http_response_code(404); $this->view('errors/404'); return; }
\header('Location: ' . $target, true, 302);
exit; // ⚠️ 不能走 $this->redirect()(会吞外链,见 §2.3①)
}四道校验(缺一不可):① 广告存在 → ② is_active=1 → ③ expire_at = 0 || expire_at > now → ④ 所属区块 is_active=1。
任一不满足 → 404(不 302 到首页,避免「点了没反应」的迷惑行为)。
safeExternalUrl() 口径(e() 只防 XSS、不防 javascript: 协议):
① 必须 ^https?:// (拒 javascript: / data: / vbscript: / 协议相对 //)
② 拒绝含 [\x00-\x20\x7f] (挡 "java\nscript:" 这类控制字符绕过)
③ 长度上限 2000 字符
④ 保存时就先校验一遍(后台拒绝非法 URL),读取时再校验一遍(纵深防御)要不要跳转提示页:v1 直接 302(最快)。若担心被判定为「跳转农场」,可加 ? 中转页(0.5s 元刷新),代价是多一次点击 → 建议先 302,需要再说。
3.5 曝光统计(三个方案,推荐 A)
| 方案 A:渲染收集 + 请求末尾一条 UPDATE(**推荐**) | 方案 B:文件缓冲 + 节流合并落库 | 方案 C:不做曝光 | |
|---|---|---|---|
| 做法 | 渲染时把 ad id 收进 Plugin::$seen(静态、按 id 去重)→ route_after_dispatch 里 UPDATE ads_items SET impressions = impressions + 1 WHERE id IN (:ids) | 写入 data/runtime/adslot_imp.json(flock),每 N 秒 / 每 M 次请求合并写库 | 只统计点击 |
| 每请求写库 | 1 条 UPDATE(且只在该页真渲染了广告时) | 0(大多数请求) | 0 |
| 一致性 | ✅ 强一致,不会丢 | ⚠️ 并发写文件可能丢计数;进程 fatal 丢缓冲 | — |
| 复杂度 | 低 | 中(要锁 + 节流标记 + 崩溃恢复) | 最低 |
| 依赖 | route_after_dispatch(已有先例 user_level) | 无 cron 可用(§2.3④) | — |
方案 A 的额外好处:写发生在响应产出之后(
index.php:321),耗时与失败都不进页面首字节。必须写进说明文档的两条口径:① 曝光数 = 执行了 PHP 的请求数,游客命中整页缓存的那部分不计(§2.3③);
② 曝光统计可在全局设置里关闭(默认关),关掉即 0 查询 —— 站长的取舍:要数据还是要极致性能。
3.6 type=code 的 HTML 净化(自建,推荐)
结论:不复用 Content::sanitizeHtml()(理由见 §2.3⑤:会跑 groupPostImages() 重排结构、img 降级为 md_、强加 lazy/nofollow)。
自建 Plugin::sanitizeAdHtml(string $html): string,与核心净化层同思路、不同参数:
| 环节 | 口径 |
|---|---|
| 载入 | DOMDocument + mb_encode_numericentity 编码高位字符 + 保护 {}(防 DOMDocument 误判自定义标签) |
| 标签白名单 | div span p br a img strong b em i u s del h2 h3 h4 ul ol li hr table thead tbody tr th td |
| 整块删除 | script / style / iframe / object / embed / form / svg / noscript / link / meta(先删再走白名单,否则 raw-text 节点的子节点是 CDATA,白名单循环对它无效) |
| 属性白名单 | href src alt title target rel class width height style |
| 事件属性 | on* 一律移除(白名单外天然被剥) |
| 协议白名单 | href/src 只放 ^https?:// 或 ^/;其余(javascript: / data: / vbscript:)删属性 |
style 值 | 扩白名单(核心 sanitizeCssStyle() 只有 5 个属性,不够):color font-size font-weight text-align line-height background background-color border border-radius width max-width height max-height margin padding display object-fit vertical-align;值里含 ( 一律拒(挡 url() / expression()),拒 position / fixed / behavior / @import |
| 外链 | target="_blank" + rel="noopener noreferrer" 强制补齐 |
| 幂等 | 二次净化结果必须逐字节相同(写进回归断言) |
| 上限 | 单条 200KB 截断(防 DOMDocument OOM) |
后台必须显式提示站长(避免「我贴的代码怎么不生效」):
「代码型广告会经过安全过滤:
<script>、<iframe>、<form>、事件属性(onclick等)、javascript:链接会被移除;图片请用 https 地址(http 会被浏览器安全策略拦截)。」
3.7 图片上传(走核心上传出口)
用 Upload::file() 直接落盘(app/Helpers/Upload.php:446,全站唯一落盘口,points_mall/AdminController.php:190 同款):
$res = \app\Helpers\Upload::file($_FILES['image'], ['jpg','jpeg','png','gif','webp','svg'], ['thumb' => 'thumb_']);
// $res = ['success'=>bool, 'error'=>string, 'url'=>string, 'rel'=>string(?)...]- 不走
/api/upload(app/Controllers/ApiController.php:150):它带canUploadAttachment(category_id, userId)版块权限门控(:357),后台场景没有category_id→ 语义不匹配,容易被权限判定拒掉。 - 存相对路径(
Upload::relFromUrl($res['url']))入库,渲染时用Upload::url($rel)生成 URL —— 这样云存储 / CDN 插件改写的upload_url钩子才能生效(直存绝对 URL 会让云存储静默失效)。 - ⚠️
makeThumbnail()返回'ok'不代表文件存在 → 用Upload::path()实测后再决定是否用缩略图。 - 缩略图禁止手拼
'thumb_' . $rel(前缀要加在 basename 上)→ 一律Upload::thumbUrl($rel)。 - 后台预览用
FileReader.readAsDataURL()(CSP 不含blob:,见 §2.3②)。
3.8 目录名含下划线 → 必须补 class_alias
ad_slot 含下划线 → 核心 Plugin::activate()/deactivate()/uninstall() 用目录名硬拼类名
(\Plugin\ad_slot\Plugin),而实际命名空间是 \Plugin\adslot → 三个方法被静默跳过
(不报错、无日志、后台还提示「操作成功」),后果是卸载留孤儿表(规范 §4.1 / §11.12)。
// plugins/ad_slot/Plugin.php —— 类定义之外、文件末尾
if (!\class_exists('Plugin\\ad_slot\\Plugin', false)) {
\class_alias(Plugin::class, 'Plugin\\ad_slot\\Plugin', false);
}若你不想背这个包袱,改成
adslot(无下划线)可天然绕开 —— 但目录名一旦发布就不好改,建议现在就定(决策 5)。无论选哪个,
hook/init_after.php都要有建表兜底(戳文件门控,别每请求跑 DDL,规范 §11.15 / 陷阱 28)。
3.9 后台页面结构(.admin-tabs 三 Tab)
| Tab | 内容 |
|---|---|
| 广告管理 | 筛选(区块 / 状态 / 搜索标题)+ 列表(类型 / 标题 / 区块 / 排序 / 启停 / 有效期 / 点击 / 曝光 / 操作)+ 拖拽排序 + 批量启停 + 新增/编辑(弹层或独立表单) |
| 区块管理 | 列表(区块名 / 前台标题 / 位置 / 样式 / 条数 / 排序 / 启停)+ 增删改 + 排序 |
| 全局设置 | 总开关 / 默认展示条数 / 是否统计点击 / 是否统计曝光 / 过期提醒天数 |
过期的视觉处理(§2.5 需求):
- 已过期 → 后台保留行、整行加
.adslot-expired灰化 + 「已过期」徽标,前台自动隐藏;提供「续期」按钮(改expire_at); - 7 天内到期 → 到期时间标红(
.adslot-soon); - 可选「站内通知提醒」:
Notification::add($adminUid, 'adslot_expire', ...),但触发时机只能挂在后台访问或route_after_dispatch的节流检查里(§2.3④ 无 cron)→ 建议 v1 不做,用列表标红即可(极简取向)。
后台零内联:核心 icon() 恒带 style="font-size:Npx",所以插件后台图标一律写字面量 <i class="fa mn-fs-16" aria-hidden="true"></i>;
「零内联」断言必须切片到 <main class="admin-content"> 之后(不能从 .admin-container 起,否则会把核心左侧栏的 34 处内联算到插件头上)。
3.10 i18n 键(三语同步,键集合零差异)
plugin.ad_slot.tab_items 广告管理
plugin.ad_slot.tab_blocks 区块管理
plugin.ad_slot.tab_settings 全局设置
plugin.ad_slot.type_code 代码
plugin.ad_slot.type_image 图片
plugin.ad_slot.type_link 链接
plugin.ad_slot.position_sidebar 右栏
plugin.ad_slot.position_sidebar_bottom 右栏底部
plugin.ad_slot.position_thread 帖子详情页
plugin.ad_slot.style_card 卡片式
plugin.ad_slot.style_compact 紧凑式
plugin.ad_slot.style_media 图文式
plugin.ad_slot.enabled 启用
plugin.ad_slot.expired 已过期
plugin.ad_slot.expiring_soon 即将过期
plugin.ad_slot.permanent 永久
plugin.ad_slot.err_url 跳转链接必须是 http(s) 地址
plugin.ad_slot.err_image_https 图片地址必须是 https(http 会被浏览器拦截)
plugin.ad_slot.msg_saved 已保存
…(完整清单在实现时同步生成,验收口径 = 三语键集合零差异,不是「值非空」)⚠️ 广告内容的多语言(标题/描述/链接文字)与这里的界面文案是两件事:前者是数据,后者是字典。
v1 只做字典三语 +
ads_items.trans列预留(决策 4)。
3.11 文件清单
plugins/ad_slot/
├── plugin.json # name/version/author/description/activated/category/icon/admin_url/hooks/permissions
├── Plugin.php # db/ddl/ddlIndexes/activate/deactivate/uninstall + 业务方法 + class_alias
├── FrontController.php # 点击中转(唯一的插件前台路由)
├── AdminController.php # 后台:列表/保存/删除/启停/排序/设置(继承 Admin\BaseController)
├── hook/
│ ├── init_after.php # 建表兜底(戳文件门控)
│ ├── route_register.php # /ad-slot/click/{id}
│ ├── admin_route_register.php # 后台路由(不写 /admin 前缀)
│ ├── right_sidebar_after.php # position=sidebar
│ ├── sidebar_above_friend_links.php # position=sidebar_bottom(双触发)
│ ├── thread_redpacket_area.php # position=thread(决策 1)
│ ├── route_after_dispatch.php # 曝光批量落库(方案 A)
│ └── layout_head_end.php # 按 $template 条件加载 CSS(带 ?v=filemtime)
├── views/
│ ├── admin_items.php / admin_blocks.php / admin_settings.php / admin_shell.php
│ └── panel.php # 前台面板渲染(钩子内 include)
├── assets/
│ ├── style.css # 前台面板 + 三种样式
│ ├── admin.css # 后台增量样式(⚠️ 覆盖核心控件要提权)
│ └── admin.js # 拖拽排序 / 批量勾选 / 弹层 / 图片预览
├── lang/{zh,en,zh_tw}.php # 键前缀 plugin.ad_slot.*
└── 说明.md # 本仓惯例文件名(不是 README.md)3.12 明确不做的(极简取向)
- ❌ 独立广告列表页(前台只有中转路由)
- ❌ 广告的「频次控制 / 定向投放 / A-B 测试」(v1 用不上,控件越少越好)
- ❌ 赞助商自助申请(那是
friend_links的领域) - ❌ 多语言广告内容编辑界面(v1 只留列)
- ❌ 过期站内通知(v1 用后台标红替代,见 §3.9)
四、需拍板的 6 个点
决策 1:帖子详情页挂哪个钩子(最关键)
| 方案 A(推荐) | 方案 B | 方案 C | |
|---|---|---|---|
| 做法 | 用现成 thread_redpacket_area(#18,thread/show.php:213) | 用 thread_operation_after(#19,:246) | 给核心新增钩子 thread_content_after(+1 行) |
| 位置 | 主帖正文/附件之后、签名之前 | 操作按钮区(点赞/收藏那一排) | 正文容器之后(最精准) |
| 核心改动 | 零 | 零 | 右栏/详情页 +1 行 + 更新规范 §9.1 表 |
| 副作用 | 广告落在主帖卡片内部,视觉上属于「楼主内容的一部分」 | 广告混在操作按钮里,观感最差 | 无 |
| 风险 | 若同时装了「红包」类插件,会与它同锚点(按 sort_order 排序) | 不推荐 | 属核心 API 变更,需单独评审 |
决策 2:type=code 的净化口径
| 方案 A(推荐)自建 `sanitizeAdHtml()` | 方案 B 复用 `Content::sanitizeHtml()` | |
|---|---|---|
| 结构 | 原样保留 banner 的 div/table 布局 | 被 groupPostImages() 重排,图片被塞进 .post-img-grid |
| 图片 | 原样输出(https 直链可用) | 站内图被降级为 md_ 缩略图、强制 loading=lazy |
| 样式 | 扩白名单,banner 的 width/margin/padding 保留 | 被剥(只剩 5 个属性) |
| 维护成本 | 需自己写 + 自己测(~120 行,配 XSS 回归套件) | 0(但语义错配) |
| 安全 | 与核心同思路(白名单 + 协议白名单 + 幂等断言) | 核心 S-1 防线,已有 65+66 项测试 |
建议 A,并且照抄核心净化层的四道防线(整块删 raw-text 元素 → 标签白名单 → 属性白名单 → 协议白名单),
交付时补一份
ad_slot自己的 XSS 回归套件(喂<script>/onerror=/javascript:/<style>里塞 img 等样本)。
决策 3:曝光统计落库方式
见 §3.5。建议方案 A(每请求最多 1 条批量 UPDATE,写在响应之后),并默认关闭曝光统计,让站长自己开。
决策 4:多语言范围
| v1 单语(推荐) | v1 就做全三语 | |
|---|---|---|
| 后台 UI 文案 | ✅ 三语(字典) | ✅ 三语 |
| 广告内容 | 只有一份(主语言) | 每个广告要填 3 份标题/描述/文字 |
| 表结构 | 预留 trans JSON 列 | 直接用 trans |
| 工作量 | 小 | 后台表单 ×3、渲染要按 I18n::current() 取值 |
决策 5:目录名
| `ad_slot`(你给的名字) | `adslot` | |
|---|---|---|
| 目录名 | 含下划线 | 无下划线 |
| 必做 | 补 3 行 class_alias(否则卸载留孤儿表) | 无需 |
| 与需求文档一致 | ✅ | ❌ |
决策 6:排序交互
| 拖拽 + 数字兜底(推荐) | 只用数字输入 | |
|---|---|---|
| 实现 | 原生 HTML5 DnD(抄 nav_manager/assets/admin.js:47-85)+ <input type="number"> | 纯表单 |
| 保存 | 拖拽后一次 POST 批量提交 [{id, sort_order}, ...] | 逐条保存 |
| 代价 | 需写 admin.js(~80 行)+ 一个批量接口 | 0 |
五、实施阶段(对齐你给的 P1–P7,每阶段末暂停确认)
| 阶段 | 内容 | 验收(可执行) |
|---|---|---|
| P1 | 骨架:plugin.json(含 admin_url=/admin/ad-slot + permissions:["route:admin","system:settings"])/ Plugin.php(db/ddl/activate/deactivate/uninstall + class_alias)/ hook/init_after.php / 最小可访问后台页(列表空态 + 建表结果只读展示)/ 三语 lang/ | 启用插件 → ad_slot.sqlite 两表 + 两索引存在;/admin/ad-slot HTTP 200(不留死链按钮);plugin_hygiene.py 全绿 |
| P2 | 广告项 CRUD + 三种类型 + 图片上传 + 拖拽排序 + 单个/批量启停 | 三种类型都能配;拖拽后刷新顺序持久;Upload::file() 落盘文件真实存在 |
| P3 | 前台:right_sidebar_after 面板 + 点击中转 | 右栏出现广告;点击 → 302 + 目标地址正确;过期/停用/不存在 → 404(状态码,不是页面文案);protected/error.log 增量 0 |
| P4 | 全局设置 + 有效期 + 后台过期标红 | 总开关关闭 → 前台零输出且零查询;过期广告前台消失、后台保留可续期 |
| P5 | 多区块 + 曝光统计 + 过期提醒 | 两个区块各自独立配置;曝光在响应后 +1(回读 DB);7 天内到期标红 |
| P6 | 多位置(右栏底部 + 详情页)+ 三种样式 + (可选)多语言 | 三处位置都渲染;三种样式可切;双触发面板 id/name 不冲突 |
| P7 | 验证交付 | 见下方「交付前门禁」 |
P7 交付前门禁(逐条可执行)
P82=/d/phpstudy_pro/Extensions/php/php8.2.9nts/php.exe
P80=/d/phpstudy_pro/Extensions/php/php8.0.2nts/php.exe
PY=C:/Users/Administrator/.workbuddy-ai/binaries/python/versions/3.13.12/python.exe
# ① 双版本语法(8.2 + 8.0 底线)
find plugins/ad_slot -name '*.php' -exec $P82 -l {} \; | grep -v "No syntax errors"
find plugins/ad_slot -name '*.php' -exec $P80 -l {} \; | grep -v "No syntax errors"
# ② JSON 合法性
$P82 -r 'json_decode(file_get_contents("plugins/ad_slot/plugin.json"),true,512,JSON_THROW_ON_ERROR);echo "OK\n";'
# ③ 三语键集合零差异 + BOM/CRLF + hooks 文件存在 + 钩子无顶层符号 + 静态零内联
$PY ~/.workbuddy-ai/skills/flinthub-plugin-dev/scripts/plugin_hygiene.py plugins/ad_slot
# ④ 全角标点紧跟 $var(本项目已复发 8 次)
$P82 ~/.workbuddy-ai/skills/flinthub-plugin-dev/scripts/php_var_cjk_check.php plugins/ad_slot
# ⑤ CSS 变量存在性(只用核心已定义的 --mn-*,10/10 样式源都有定义才可用)
# ⑥ 运行期零内联(HTTP 抓页面 → 切插件 DOM 区间 → 扫 style=" / on*)外加三份必跑的回归套件:
- XSS 套件:
sanitizeAdHtml()喂 10+ 恶意样本(<script>/<img onerror>/javascript:href /<style>里塞 img / CDATA 绕过 / 属性大小写绕过 / 二次幂等); - 点击中转套件:HTTP 端到端(正常 302 / 停用 404 / 过期 404 / 不存在 404 /
javascript:URL 被拒 / 计数 +1 /Location头逐字节等于目标); - 查询预算套件:断言「正常请求 ≤3 次 / 两个钩子同页 ≤3 次(静态缓存生效)」—— 用
Database::getTotalSqlCount()当预言机。
六、风险与坑(逐条对应规范)
| # | 风险 | 依据 | 处置 |
|---|---|---|---|
| 1 | 钩子内用 $this->e() → HTML 渲染半截 + HTTP 200 + 无告警 | 规范 §11.24 | 钩子内一律 htmlspecialchars($s, ENT_QUOTES, 'UTF-8');必须发真实 HTTP + 查 protected/error.log 才算验过 |
| 2 | 钩子文件声明顶层函数/类 → 双触发钩子第二处静默消失 | 规范 §9.0 | sidebar_above_friend_links 钩子文件零顶层符号 |
| 3 | 双触发面板 id/name 撞车 → PC 与移动端联动 | 规范 §11.26 邻条 | 渲染序号(nextSeq())进 id/name |
| 4 | $this->redirect() 吞外链 | Controller.php:104 | 中转页自己 header('Location:') + exit(§3.4) |
| 5 | e() 不防 javascript: 协议 | 规范 §11.1 | safeExternalUrl() 四道校验,保存 + 读取各做一遍 |
| 6 | type=code 存储型 XSS | 规范 §一/§11.1 | 自建白名单净化(§3.6)+ XSS 回归套件 |
| 7 | 控制器内 404 只渲染不设码 = 软 404 | 规范 §11.25 邻条 | \http_response_code(404) 写在 view() 之前;断言用 curl -w '%{http_code}' |
| 8 | 每请求查询超红线(≤5) | 规范 §十三 / §11.13 | 同请求静态缓存 + 曝光合并成 1 条 UPDATE(§3.3) |
| 9 | 曝光统计漏计(游客整页缓存) | HomeController.php:37 | 写进说明文档,并让曝光统计可关闭 |
| 10 | 改广告后前台不变(缓存烘焙) | 同上 | 后台保存后 PageCache::invalidate() + @unlink 兜底 |
| 11 | 插件 CSS 早于核心 → 同权重核心后胜 | main.php:40 vs :65 | 覆盖核心控件几何属性一律提权(input.adslot-x / .adslot-form .mn-btn) |
| 12 | 隐藏工具类被自己后面的同权重规则压掉 | 规范 §11.7 邻条 | 隐藏类一律 display:none !important |
| 13 | 引用了核心不存在的 --mn-* 变量(如 --mn-bg-hover / --mn-danger)→ 永远走 fallback,夜间不可读 | MEMORY §4 | 写前逐条 grep 核对(10 个样式源全有定义才用) |
| 14 | 图标用 $view->icon() → 恒带内联 style="font-size:Npx" | 规范 §7.3 | 字面量 <i class="fa mn-fs-12"></i> |
| 15 | 后台设置保存写 (int)($_POST[$k] ?? 0) → 字段名漂移时静默写最小值 | 规范 §11.18 邻条 | 数值键 if (!array_key_exists($k, $_POST)) continue;;验证同时回读 DB + settings_cache.php |
| 16 | 插件写 Settings 未声明 system:settings → 静默失效 | 规范 §三 | permissions: ["route:admin","system:settings"] |
| 17 | 目录名含下划线 → 卸载留孤儿表 | 规范 §4.1 | class_alias(§3.8) |
| 18 | 直存绝对 URL → 云存储插件 upload_url 钩子静默失效 | MEMORY §4 | 库里存相对路径,渲染走 Upload::url() |
| 19 | 图片 http:// 被 CSP 拦(DOM 里有、渲染为空) | Controller.php:80 | 保存时校验 https + 提示(§3.6) |
| 20 | 后台图片预览用 createObjectURL → blob: 被 CSP 拦,预览空白 | 同上 | FileReader.readAsDataURL() |
| 21 | 后台控制器 extends app\Core\Controller → 绕过二次验证门禁 | 规范 §11.28 邻条 | 继承 app\Controllers\Admin\BaseController;HTTP 冒烟必须验「有 user_id 无 admin_verified_at → 302 到 /admin/verify」 |
| 22 | 排序/启停用 AJAX 却用 Csrf::verifyOrDie() → AJAX 收到 HTML | 规范 §11.10 邻条 | Csrf::verify() 自行回 403 JSON |
| 23 | 改 hooks 清单后钩子不生效 | 规范 §三 | \app\Helpers\Plugin::refresh() |
| 24 | 部署后仍看旧右栏 | MEMORY §2 | 部署后必走后台「维护 → 清理缓存」 |
七、待你确认
- 决策 1:详情页用现成的
thread_redpacket_area(零核心改动),还是给核心加新钩子thread_content_after(+1 行、位置更准)? - 决策 2:HTML 净化自建(保结构、需自测)还是复用核心(结构会被重排)?
- 决策 3:曝光统计用「每请求 1 条批量 UPDATE」还是「文件缓冲 + 节流」?默认开还是默认关?
- 决策 4:广告内容的多语言 v1 做还是留 P6?
- 决策 5:目录名保持
ad_slot(补class_alias)还是改adslot? - 决策 6:排序做拖拽(+ 批量保存接口)还是只用数字输入?
- 默认值:默认展示条数 5 条?区块默认样式「卡片式」?过期提醒阈值 7 天?
- 曝光/点击的展示:后台列表直接显示数字,还是点击数字进「明细」?(v1 建议直接显示数字,明细留后续)
确认后我按 P1 → P7 分阶段做,每阶段末停下来给你看。