[开发]右侧栏广告位插件开发方案(`ad_slot`)

👑Lv.11 元老 🌞 骨干会员
2026-10-26 02:00:16

本文由 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,零核心改动)
2type=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–274friend_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: 全部 0

cron_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-titleassets/css/modern.css:522,531(排行榜面板同款)
前台图标(零内联)字面量 <i class="fa mn-fs-12">&#xf0c1;</i>⚠️ 禁用 $view->icon()(返回值恒带内联 style="font-size:Npx",规范 §7.3 一票否决)
后台页壳.admin-container + include('admin/_sidebar') + .admin-contentapp/Views/admin/*.php 固定结构
后台 Tab.admin-tabs / .admin-tab-btn / .admin-tab-paneapp/Views/admin/settings_system.php:26-34
后台表格.admin-table / .admin-table-kv(首列 .col-label)assets/css/admin.css:714
状态徽标.plugin-status-on / .plugin-status-offassets/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`渲染钩子落点端
sidebarright_sidebar_after站点统计上方仅 PC
sidebar_bottomsidebar_above_friend_links友链上方PC + 移动(双触发)
threadthread_redpacket_area(决策 1)主帖正文之后帖子详情页

查询预算(红线 ≤5)

步骤语句数
blocks:按 position 取启用区块(ORDER BY sort_order, id)1
items:按 block_id 批量取启用项(IN 占位符 + 强转 int)1
同请求内静态缓存(第二个钩子直接复用,不重复查)0
route_after_dispatch 曝光批量 UPDATE1
合计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">&#xf0c1;</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*)

外加三份必跑的回归套件:

  1. XSS 套件:sanitizeAdHtml() 喂 10+ 恶意样本(<script> / <img onerror> / javascript: href / <style> 里塞 img / CDATA 绕过 / 属性大小写绕过 / 二次幂等);
  2. 点击中转套件:HTTP 端到端(正常 302 / 停用 404 / 过期 404 / 不存在 404 / javascript: URL 被拒 / 计数 +1 / Location 头逐字节等于目标);
  3. 查询预算套件:断言「正常请求 ≤3 次 / 两个钩子同页 ≤3 次(静态缓存生效)」—— 用 Database::getTotalSqlCount() 当预言机。

六、风险与坑(逐条对应规范)

#风险依据处置
1钩子内用 $this->e() → HTML 渲染半截 + HTTP 200 + 无告警规范 §11.24钩子内一律 htmlspecialchars($s, ENT_QUOTES, 'UTF-8');必须发真实 HTTP + 查 protected/error.log 才算验过
2钩子文件声明顶层函数/类 → 双触发钩子第二处静默消失规范 §9.0sidebar_above_friend_links 钩子文件零顶层符号
3双触发面板 id/name 撞车 → PC 与移动端联动规范 §11.26 邻条渲染序号(nextSeq())进 id/name
4$this->redirect() 吞外链Controller.php:104中转页自己 header('Location:') + exit(§3.4)
5e() 不防 javascript: 协议规范 §11.1safeExternalUrl() 四道校验,保存 + 读取各做一遍
6type=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">&#xf0c1;</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.1class_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. 决策 1:详情页用现成的 thread_redpacket_area(零核心改动),还是给核心加新钩子 thread_content_after(+1 行、位置更准)?
  2. 决策 2:HTML 净化自建(保结构、需自测)还是复用核心(结构会被重排)?
  3. 决策 3:曝光统计用「每请求 1 条批量 UPDATE」还是「文件缓冲 + 节流」?默认开还是默认关?
  4. 决策 4:广告内容的多语言 v1 做还是留 P6?
  5. 决策 5:目录名保持 ad_slot(补 class_alias)还是改 adslot?
  6. 决策 6:排序做拖拽(+ 批量保存接口)还是只用数字输入?
  7. 默认值:默认展示条数 5 条?区块默认样式「卡片式」?过期提醒阈值 7 天?
  8. 曝光/点击的展示:后台列表直接显示数字,还是点击数字进「明细」?(v1 建议直接显示数字,明细留后续)

确认后我按 P1 → P7 分阶段做,每阶段末停下来给你看。

Last edited by flinthub at 2026-10-26 02:02
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 6 | Replies 0

All Replies (0)

No replies yet

Please Log in