[开发]广告位管理(adslot)插件说明

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

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/adslot/说明.md。

广告位管理(adslot)插件说明

版本:1.0.0 | 作者:FlintHub | 状态:✅ 已启用

功能:右侧栏 / 移动侧栏的多区块广告位——三种广告类型(代码 / 图片 / 链接)+ 拖拽排序 +

有效期与过期提醒 + 点击/曝光统计;代码型 banner 存原文、渲染时经自建白名单净化。


一、功能简介

1.1 后台(三 Tab)

  • 广告项:增删改 + 筛选(按区块 / 按状态 / 标题搜索)+ 拖拽排序 + 单个启停(AJAX 就地更新)+

批量启停(表单 PRG)+ 有效期日期

  • 区块:增删改 + 位置 / 样式 / 展示条数 / 排序 / 启停;区块下还有广告时拒绝删除(防孤儿广告项)
  • 设置:总开关、默认展示条数、点击统计开关、曝光统计开关、过期提醒天数

1.2 广告类型

类型值内容字段说明
代码codecontent_code(textarea,最多 200000 字符)贴 HTML 片段;存原文,渲染时净化
图片imagecontent_image_url(https 外链)/ image_file(上传)/ content_image_current(编辑时保留原图)上传走核心 Upload::file(),入库为裸相对路径
链接linkcontent_link(最多 200 字符)纯文字条目

1.3 前台展示

  • 位置:sidebar(PC 右栏,用户面板之后)、sidebar_bottom(友链区块上方,PC 右栏 + 移动侧栏各渲染一份)
  • 样式:card(卡片式)/ compact(紧凑式)/ media(图文式,额外渲染 description)
  • 渲染规则:区块无可用广告项 → 整个区块不输出(不留空容器);总开关关闭 → 面板与样式都不注入
  • 可点击条目:<a href="{BASE_PATH}/ad-slot/click/{id}">,外链不直出到前台 HTML

1.4 点击中转

GET /ad-slot/click/{id} 四道校验(全部通过才 302):① 广告存在 ② 启用 ③ 未过期 ④ 所属区块启用。

任一不过 → 404(不 302 到首页,避免「点了没反应」的迷惑行为)。

通过 → 计一次点击 → Location: {外链} + Cache-Control: no-store + X-Robots-Tag: noindex。

1.5 统计口径

  • 点击:中转页同步 UPDATE ads_items SET clicks = clicks + 1
  • 曝光:渲染期只把 id 收进 $GLOBALS['adslot_seen'](按 id 去重,同请求内两处面板只计 1 次),

落库收敛到 route_after_dispatch 钩子 → 一条批量 UPDATE;未渲染广告的请求 empty() 即返回,零查询

  • 后台列表直接显示 clicks / impressions 数字(明细留后续)

二、渲染与请求流程

请求进入
  └─ layout_head_end        → 总开关开 → 注入 assets/style.css(带 filemtime 版本号)
  └─ right_sidebar_after    → Plugin::renderPosition('sidebar')
  └─ sidebar_above_friend_links → Plugin::renderPosition('sidebar_bottom')   ← 双触发(PC + 移动)
        └─ blocksByPosition() → loadActive()
              · 首次:2 条查询(区块 + 广告项,请求内静态缓存)
              · 之后:0 条查询(同一请求内两个钩子共享缓存)
        └─ views/panel.php   → 逐条渲染;code 型调 sanitizeAdHtml(),image 型调 imageSrc()
                             → 每条调 markSeen()(曝光默认关 → 直接短路)
  └─ route_after_dispatch   → 有已展示 id → 1 条批量 UPDATE 落曝光;否则零查询

每请求查询预算:实测首次渲染 2 条、第二次 0 条、总开关关闭 0 条(规范 §十三 要求 ≤5)。


三、路由与接口

3.1 前台(hook/route_register.php)

方法路径说明登录CSRF
GET/ad-slot/click/{id}点击中转:校验 → 计数 → 302 / 404不需要不需要

3.2 后台(hook/admin_route_register.php,路径不写 /admin 前缀,核心自动补)

方法路径说明响应
GET/admin/adslot列表页(三 Tab;?tab= ?block_id= ?status= ?q=)视图
POST/admin/adslot/item/save新增 / 编辑广告项(三种类型各自字段名)302 PRG
POST/admin/adslot/item/delete删除广告项302 PRG
POST/admin/adslot/item/toggle单个启停JSON
POST/admin/adslot/item/batch批量启停(ids[] + active)302 PRG
POST/admin/adslot/item/sort拖拽排序(order[])JSON
POST/admin/adslot/block/save新增 / 编辑区块302 PRG
POST/admin/adslot/block/delete删除区块(有广告时拒绝)302 PRG
POST/admin/adslot/block/sort区块拖拽排序(order[])JSON
POST/admin/adslot/settings/save保存全局设置302 PRG

  • 传统表单:Csrf::verifyOrDie();AJAX(toggle / sort):Csrf::verify() 自行回 JSON

(verifyOrDie() 会 echo HTML 并 exit,AJAX 收到的是 HTML 不是 JSON)

  • 门禁来自继承 app\Controllers\Admin\BaseController → 构造函数完成「管理员 + 二次验证」双重校验

四、后台可配项

设置键默认范围说明
adslot_enabled1(开)0 / 1总开关;关闭后前台无面板、无样式注入,0 条查询
adslot_default_limit51 ~ 20区块未填条数时的默认展示条数
adslot_stat_click1(开)0 / 1关闭后中转仍 302,但 clicks 不增
adslot_stat_impression0(关)0 / 1曝光默认关闭(避免每请求写库)
adslot_expire_days70 ~ 365剩余天数 ≤ 该值 → 后台列表标「即将过期」;0 = 不提醒

区块级字段:position(sidebar / sidebar_bottom)、style(card / compact / media)、

limit_num(1 ~ 20)、sort_order、is_active。

广告项级字段:sort_order、is_active、expire_at(日期 → 当天 23:59:59 的时间戳,空 = 永久)。

⚠️ 保存设置的「字段缺失」口径:整数项用 array_key_exists() 缺失即跳过(不写最小值);

三个 checkbox 用隐藏哨兵 adslot_form_submitted 判定「表单提交过」,未勾选 → 写 0。


五、数据表

独立库:plugins/adslot/data/adslot.sqlite(WAL;同目录 .htaccess + web.config 拦直接访问)

表用途
ads_blocks区块:id / name / title / position / style / limit_num / sort_order / is_active / created_at / updated_at
ads_items广告项:id / block_id / type / title / content / url / description / sort_order / is_active / expire_at / clicks / impressions / created_at / updated_at

索引列
idx_ads_items_blockads_items(block_id, is_active, sort_order)
idx_ads_blocks_posads_blocks(position, is_active, sort_order)

  • 建表走内容门控:SCHEMA_VERSION = 'adslot-schema-1',戳文件 data/schema.version
  • 表/索引 DDL 均 IF NOT EXISTS,索引 DDL 排在 CREATE TABLE 之后
  • uninstall():先 DROP 两张表 → 再清 settings 里 5 个 adslot_* → 最后删戳文件

六、目录结构

plugins/adslot/
├── plugin.json                     # 7 个钩子 / permissions: route:admin, system:settings
├── Plugin.php                      # 主类:建表 / 取数 / 渲染 / 中转 / 净化 / 后台业务方法
├── AdminController.php             # 后台控制器(继承 Admin\BaseController)
├── FrontController.php             # 前台点击中转
├── hook/
│   ├── init_after.php              # 惰性建表兜底(命中戳文件即短路,零查询)
│   ├── route_register.php          # /ad-slot/click/{id}
│   ├── admin_route_register.php    # 10 条后台路由
│   ├── right_sidebar_after.php     # 右栏主广告位
│   ├── sidebar_above_friend_links.php  # 右栏底部广告位(双触发)
│   ├── route_after_dispatch.php    # 曝光批量落库(1 条 UPDATE)
│   └── layout_head_end.php         # 注入 style.css
├── views/
│   ├── panel.php                   # 前台面板($this 不可用,转义/i18n 收敛在 Plugin)
│   └── admin.php                   # 后台三 Tab + 弹层 + 表单
├── assets/  admin.css · admin.js · style.css
├── lang/    zh.php · en.php · zh_tw.php   (各 100 键,键集合与键序严格一致)
└── data/    adslot.sqlite · schema.version · .htaccess · web.config


七、注意事项

  1. 改前端静态资源必须 bump plugin.json 的 version。

asset() 发出的响应头是 Cache-Control: max-age=31536000, immutable,

?v=filemtime 是唯一破缓存手段——不 bump 就是「改了没生效,且不报错、无日志」。

  1. 改 ddlStatements() 必须同步 +1 SCHEMA_VERSION(并手删旧 data/schema.version),

否则戳文件命中,新表/新索引静默建不出来。

  1. 代码型 banner 是「存原文、渲染时净化」(与核心 formatPostContent 同模型)。

⚠️ 不能复用核心 Content::sanitizeHtml() —— 它会跑 groupPostImages() 重排结构、

把 img 降级为 md_ 缩略图并强加 loading/rel,对 banner 是破坏性的。

自建净化器:26 个标签 / 10 个属性 / 50 个 style 属性白名单 + 协议白名单;

净化层是 XSS 的唯一防线,改它必须重跑回归套件(见 §八)。

  1. 点击中转不能用基类 $this->redirect() —— app/Core/Controller.php:104 会把

「非 / 开头」的 URL 静默改写成 /(核心的防开放重定向设计),外链会被吞成站内首页。

本插件自己发 Location,并用 Plugin::safeExternalUrl() 做协议白名单。

  1. safeLocalPath() 必须同时接受两种形态:裸相对路径(2026-09/x.png,Upload::file() 的返回值)

与站内绝对路径(/assets/img/x.png)。

⚠️ 历史缺陷(已在 V1.0.0 内修复):原实现只放行 ^/(?!/) → 上传图被自己的白名单拒掉,

表现为「上传成功,但保存报『内容不能为空』」,且不写任何日志。

  1. 上传图入库为裸相对路径(不是 /assets/uploads/...)。

这样既让云存储 upload_url 钩子生效,又能被后台「清理孤儿附件」的裸相对路径正则识别为「在用」,

不会被误删。取 URL 一律走 Plugin::imageSrc() / Upload::url(),禁自行拼 UPLOAD_URL。

  1. 图片外链只允许 https:全站 CSP 是 img-src 'self' data: https:(无 http:、无 blob:),

http:// 图片会被浏览器静默拦截(DOM 里有、无报错、渲染为空)→ 保存期就拒绝。

  1. 游客整页缓存:首页 / 版块列表页对游客走 PageCache(永久缓存),会把广告 HTML 烘焙进去。

所以每次改动(保存广告项 / 区块 / 设置)都调 Plugin::invalidatePageCache()。

⚠️ 副作用:游客命中整页缓存时曝光不计数(渲染被完全绕过);要精确统计需登录态访问。

⚠️ PageCache::invalidate() 在 Windows 文件被占用时会静默失败。

  1. 渲染钩子在闭包作用域执行、$this 不可用(规范 §11.24)→ 转义与 i18n 全部收敛在

Plugin::renderPosition() / views/panel.php 内;插件控制器里用 $this->e() 是另一个作用域,两者别混。

  1. sidebar_above_friend_links 一次页面渲染触发两次(PC 右栏 + 移动侧栏)

→ 钩子文件禁止声明顶层函数/类,否则被降级为 include_once,第二处入口静默消失。

两份面板的 DOM id 靠 Plugin::nextSeq() 生成的渲染序号区分。

  1. 前台不引入灯箱:广告图点击是跳转(中转页),不是放大预览,故不需要

image_lightbox 容器与 assets/js/lightbox.js。

  1. 插件资源目录名不含下划线(adslot)→ 核心按目录名硬拼的 \Plugin\adslot\Plugin

因 PHP 类名大小写不敏感而命中,无需 class_alias;生命周期钩子不会被跳过。


八、当前状态

项值
启用状态✅ activated: true
ads_blocks 行数0
ads_items 行数0
SCHEMA_VERSIONadslot-schema-1
语言键zh / en / zh_tw 各 100 键(键集合与键序严格一致)
钩子7 个
路由前台 1 条 + 后台 10 条
查询预算首次渲染 2 条 / 同请求第二次 0 条 / 总开关关闭 0 条(实测)

回归套件(.workbuddy-ai/tmp/,改动本插件后必跑)

脚本覆盖最近结果
adslot_http_smoke.phpHTTP 端到端:静态资源 / 三重门禁 / CRUD / 前台渲染 / 点击 302+404 / 曝光开关 / 缓存失效 / i18n 泄漏 / 增量守卫PASS 87 / FAIL 0
adslot_logic_probe.php白名单边界(15+8+6 例)/ 净化器 20 样本幂等 + 危险构造零残留 / 中文不乱码 / 查询预算PASS 46 / FAIL 0
adslot_gen_lang.php三语字典单一数据源生成器(改文案改这里再重跑)—
adslot_p1_register.php重建插件缓存 + 建表 + 列结构 + 默认设置补种校验—
plugin_hygiene.py plugins/adslot三语键集合/键序/前缀、占位符、编码卫生、plugin.json、钩子顶层符号、零内联、双版本 php -l、危险函数、缓存一致性FAIL 0

已知取舍(非缺陷)

  • 曝光统计默认关闭:开启后每请求多 1 条 UPDATE(在响应产出之后执行,不影响首字节)。
  • 后台仅显示点击/曝光数字,无逐条明细页。
  • 广告内容为单语(界面 UI 三语)——按 2026-09-30 的决策执行。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 3 | Replies 1

All Replies (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 00:44:01
「代码型 banner 存原文、渲染时经自建白名单净化」这条是整套设计里最关键的安全决策——允许贴广告代码就必须净化,否则接了第三方联盟的 JS 就等于把站长权限交出去。 三点: 1. 白名单净化到底允许哪些标签?如果允许
#1 floor

Please Log in