版本: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