本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/adslot/说明.md。
广告位管理(adslot)插件说明
版本:1.0.0 | 作者:FlintHub | 状态:✅ 已启用
功能:右侧栏 / 移动侧栏的多区块广告位——三种广告类型(代码 / 图片 / 链接)+ 拖拽排序 +
有效期与过期提醒 + 点击/曝光统计;代码型 banner 存原文、渲染时经自建白名单净化。
一、功能简介
1.1 后台(三 Tab)
- 广告项:增删改 + 筛选(按区块 / 按状态 / 标题搜索)+ 拖拽排序 + 单个启停(AJAX 就地更新)+
批量启停(表单 PRG)+ 有效期日期
- 区块:增删改 + 位置 / 样式 / 展示条数 / 排序 / 启停;区块下还有广告时拒绝删除(防孤儿广告项)
- 设置:总开关、默认展示条数、点击统计开关、曝光统计开关、过期提醒天数
1.2 广告类型
| 类型 | 值 | 内容字段 | 说明 |
|---|---|---|---|
| 代码 | code | content_code(textarea,最多 200000 字符) | 贴 HTML 片段;存原文,渲染时净化 |
| 图片 | image | content_image_url(https 外链)/ image_file(上传)/ content_image_current(编辑时保留原图) | 上传走核心 Upload::file(),入库为裸相对路径 |
| 链接 | link | content_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_enabled | 1(开) | 0 / 1 | 总开关;关闭后前台无面板、无样式注入,0 条查询 |
adslot_default_limit | 5 | 1 ~ 20 | 区块未填条数时的默认展示条数 |
adslot_stat_click | 1(开) | 0 / 1 | 关闭后中转仍 302,但 clicks 不增 |
adslot_stat_impression | 0(关) | 0 / 1 | 曝光默认关闭(避免每请求写库) |
adslot_expire_days | 7 | 0 ~ 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_block | ads_items(block_id, is_active, sort_order) |
idx_ads_blocks_pos | ads_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七、注意事项
- 改前端静态资源必须 bump
plugin.json的version。
asset() 发出的响应头是 Cache-Control: max-age=31536000, immutable,
?v=filemtime 是唯一破缓存手段——不 bump 就是「改了没生效,且不报错、无日志」。
- 改
ddlStatements()必须同步 +1SCHEMA_VERSION(并手删旧data/schema.version),
否则戳文件命中,新表/新索引静默建不出来。
- 代码型 banner 是「存原文、渲染时净化」(与核心
formatPostContent同模型)。
⚠️ 不能复用核心 Content::sanitizeHtml() —— 它会跑 groupPostImages() 重排结构、
把 img 降级为 md_ 缩略图并强加 loading/rel,对 banner 是破坏性的。
自建净化器:26 个标签 / 10 个属性 / 50 个 style 属性白名单 + 协议白名单;
净化层是 XSS 的唯一防线,改它必须重跑回归套件(见 §八)。
- 点击中转不能用基类
$this->redirect()——app/Core/Controller.php:104会把
「非 / 开头」的 URL 静默改写成 /(核心的防开放重定向设计),外链会被吞成站内首页。
本插件自己发 Location,并用 Plugin::safeExternalUrl() 做协议白名单。
safeLocalPath()必须同时接受两种形态:裸相对路径(2026-09/x.png,Upload::file()的返回值)
与站内绝对路径(/assets/img/x.png)。
⚠️ 历史缺陷(已在 V1.0.0 内修复):原实现只放行 ^/(?!/) → 上传图被自己的白名单拒掉,
表现为「上传成功,但保存报『内容不能为空』」,且不写任何日志。
- 上传图入库为裸相对路径(不是
/assets/uploads/...)。
这样既让云存储 upload_url 钩子生效,又能被后台「清理孤儿附件」的裸相对路径正则识别为「在用」,
不会被误删。取 URL 一律走 Plugin::imageSrc() / Upload::url(),禁自行拼 UPLOAD_URL。
- 图片外链只允许 https:全站 CSP 是
img-src 'self' data: https:(无http:、无blob:),
http:// 图片会被浏览器静默拦截(DOM 里有、无报错、渲染为空)→ 保存期就拒绝。
- 游客整页缓存:首页 / 版块列表页对游客走
PageCache(永久缓存),会把广告 HTML 烘焙进去。
所以每次改动(保存广告项 / 区块 / 设置)都调 Plugin::invalidatePageCache()。
⚠️ 副作用:游客命中整页缓存时曝光不计数(渲染被完全绕过);要精确统计需登录态访问。
⚠️ PageCache::invalidate() 在 Windows 文件被占用时会静默失败。
- 渲染钩子在闭包作用域执行、
$this不可用(规范 §11.24)→ 转义与 i18n 全部收敛在
Plugin::renderPosition() / views/panel.php 内;插件控制器里用 $this->e() 是另一个作用域,两者别混。
sidebar_above_friend_links一次页面渲染触发两次(PC 右栏 + 移动侧栏)
→ 钩子文件禁止声明顶层函数/类,否则被降级为 include_once,第二处入口静默消失。
两份面板的 DOM id 靠 Plugin::nextSeq() 生成的渲染序号区分。
- 前台不引入灯箱:广告图点击是跳转(中转页),不是放大预览,故不需要
image_lightbox 容器与 assets/js/lightbox.js。
- 插件资源目录名不含下划线(
adslot)→ 核心按目录名硬拼的\Plugin\adslot\Plugin
因 PHP 类名大小写不敏感而命中,无需 class_alias;生命周期钩子不会被跳过。
八、当前状态
| 项 | 值 |
|---|---|
| 启用状态 | ✅ activated: true |
ads_blocks 行数 | 0 |
ads_items 行数 | 0 |
SCHEMA_VERSION | adslot-schema-1 |
| 语言键 | zh / en / zh_tw 各 100 键(键集合与键序严格一致) |
| 钩子 | 7 个 |
| 路由 | 前台 1 条 + 后台 10 条 |
| 查询预算 | 首次渲染 2 条 / 同请求第二次 0 条 / 总开关关闭 0 条(实测) |
回归套件(.workbuddy-ai/tmp/,改动本插件后必跑)
| 脚本 | 覆盖 | 最近结果 |
|---|---|---|
adslot_http_smoke.php | HTTP 端到端:静态资源 / 三重门禁 / 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 的决策执行。