本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/缤纷云存储插件开发计划_2026-09-21.md。
缤纷云存储插件开发计划(bitiful_storage)
依据:《FlintHub 插件开发规范》(
docs/插件开发规范.md,2026-09-20 版)接入对象:缤纷云 Bitiful S4(S3 兼容对象存储) 计划日期:2026-09-21
目标形态:本地落盘不变 + 云端镜像 + 前台 URL 改写,公开桶读取免签名
一、结论先行
| 项 | 结论 |
|---|---|
| 插件目录名 | plugins/bitiful_storage/(命名空间 Plugin\BitifulStorage) |
| 建议版本 | 1.0.0(随 update/29 发布) |
| 是否需要动核心 | 需要,4 处共约 45 行(见 §四)。这是本计划唯一需要你先拍板的事 |
| 零核心改动替代方案 | 有,但有代价(见 §四.3 方案 B) |
| 数据库结构变更 | 无(核心零建表;插件用自己的独立库 bitiful_storage.sqlite) |
| 存量数据迁移 | 无需迁移;但需一次「全量补齐」把历史 assets/uploads/ 推上云 |
| 预计工作量 | 4 个阶段,合计约 4 人日(含验证与打包) |
二、缤纷云接入事实(已核实)
| 项 | 值 | 来源 |
|---|---|---|
| 协议 | S3 兼容(S4 = Smart Simple Storage Service) | 官方文档 |
| Endpoint | https://s3.bitiful.net(必须带 https://) | 官方 + 第三方配置文档 |
| Region | cn-east-1(文档明示「目前只有中国东部1可用区」) | docs.bitiful.com/bitiful-s4/definitions |
| 寻址方式 | 虚拟托管域名(Virtual-hosted style),Path-style 必须关闭 | 第三方配置文档 |
| 对象 URL | https://<bucket>.s3.bitiful.net/<key> | 官方示例 https://somebucket.s3.bitiful.net/userFiles/000001.avatar |
| 桶名规范 | 仅 a-z 0-9 -,首尾必须字母或数字,长度 4~63,全局唯一 | 官方文档 |
| Key 规范 | UTF-8,长度 1~696,不以 \ 开头,不含控制字符 | 官方文档 |
| 鉴权 | AccessKey Id + Secret(来自子账户,子账户名不得与桶名相同,否则上传失败) | 官方 + 第三方文档 |
| 读权限 | 公开桶 → 读取免签名,直接 GET 对象 URL | 用户已确认有公开桶 |
| 写权限 | 必须 AWS SigV4 签名(PHP 无官方 SDK,需自实现) | S3 协议 |
| 图片处理 | URL 即调用(CoreIX 引擎),如 ?w=400&h=300&fm=webp&q=80 | 官方文档 |
| 自定义域名 | 原生支持,可配自动证书 | 官方文档 |
| 已知限制 | 无生命周期、无服务端加密、无多版本;出口带宽有限(建议配 CDN) | 官方文档 |
⚠️ 「不支持服务端加密」+「公开桶」= 桶内所有对象可被任何人按 URL 读取。
因此绝不能把私密附件(私信附件、待审核内容、回收站帖子附件)推上公开桶 —— 这是本插件的第一条设计红线(见 §七.1)。
三、FlintHub 侧现状(决定方案的硬约束)
这些是已经核实过的事实,方案的每一步都被它们约束:
| # | 事实 | 位置 | 对方案的影响 |
|---|---|---|---|
| 1 | Upload::file() 是唯一落盘口 | app/Helpers/Upload.php:426 | 附件体系收敛层,插件必须从这里挂 |
| 2 | Upload::url() 是唯一 URL 出口 | app/Helpers/Upload.php:50 | 想改写 URL,只有这一个地方 |
| 3 | Upload::thumbUrl() 先 path() 实测本地缩略图存在,不存在则回退原图 URL(零 404) | :343 | 天然支持「本地不生成缩略图 → 回退原图 → 云端加处理参数」的优化路径 |
| 4 | Upload::relFromUrl() 按字面量 assets/uploads/ 剥离前缀,不是按 UPLOAD_URL | :310 | 若把 UPLOAD_URL 改成 CDN 域名,历史数据判定会错位 → 不要改 UPLOAD_URL |
| 5 | 缩略图两档:thumb_ 400×300(附件卡/列表摘要)、md_ 800×600(正文图) | Upload.php:151 | 镜像要按档位处理 |
| 6 | /attachment/{id} 是唯一下载路径,带权限校验;它走 Upload::path()(本地磁盘)不读 URL | AttachmentController.php | 删本地文件会直接废掉下载;云端 URL 不能替代它 |
| 7 | Upload::file() 里没有任何钩子调用 | 全文件 grep | 无 upload_after 可用 |
| 8 | 40 个核心钩子中没有上传类、也没有 URL 生成类 | 规范 §9.1 | 触发同步与改写 URL 都缺钩子 |
| 9 | cron_trigger.php 不触发任何插件钩子 | 全文件 grep | 没有定时任务入口 → 队列只能靠「请求内排空」或「后台手动」 |
| 10 | Plugin::http() 只对 POST 发送 body,且返回值只有 ok/body/error、没有 HTTP 状态码 | app/Helpers/Plugin.php:191-273 | S3 必须用 PUT,且要按 4xx/5xx 区分错误 → 现有 Plugin::http() 不够用 |
| 11 | Plugin::http() 内含 SSRF 防线(DNS 解析 + 保留 IP 拦截)+ net:fetch 权限门 | 同上 | 绕过它自己写 curl = 绕过安全门 → 会被审计标记 |
| 12 | PageCache 只缓存列表页 + 仅游客,render() 用 ob_start 存最终 HTML | PageCache.php:119 | 改写若发生在缓存之后,缓存里存的仍是本地 URL(正确,域无关) |
| 13 | output_buffering = 0(无全局输出缓冲) | php -i | 用 ob_start 改写整页必须在 init_after 就开缓冲,否则 route_after_dispatch 时输出已发走 |
| 14 | 已装扩展:curl openssl gd pdo_sqlite;没有 Imagick | php -m | 缩略图走 GD;SigV4 用内置 hash_hmac;密钥加密用 openssl_encrypt |
| 15 | 已有「AES-256-GCM + 魔数前缀」的密钥加密范式(Mailer::encryptSmtpPass,密钥来自 config.php 的 MAIL_PASS_KEY) | app/Helpers/Mailer.php:41-67 | Secret Key 落库加密直接沿用这个范式(换自己的魔数 FHBS\x01 + BITIFUL_SECRET_KEY;Mailer 用的是 FHGCM\x01,两者不冲突) |
| 16 | 规范 §二 明写:插件禁止修改核心文件 | 规范 §二 | 与「加 4 个钩子」直接冲突 → 见 §四.1 |
| 17 | 规范性能红线:单插件每请求所有 hook 的查询总和 ≤ 5 次 | 规范 §十二 | 队列排空与「已同步白名单」查询必须极度克制 |
| 18 | 规范 §11.7:插件样式**必须用既有 --mn-* 变量**(后台除外,后台无这些变量,复用 .admin-tabs) | 规范 §11.7 / §11.10 | 后台页走 .admin-tabs,前台零 UI |
| 19 | 规范 §11.11:数字键数组配置合并必须 array_replace | 规范 §11.11 | 档位开关数组用 array_replace |
| 20 | Upload::file() 的 $opts['thumb'] 支持 '' = 不生成缩略图(头像走这条) | :498 | 头像上传不会产生 thumb_,镜像时要判存在性 |
四、架构选型(核心决策)
4.1 先说冲突:规范说「插件禁止改核心」,但这个功能绕不过去
规范 §二 原文:「修改核心文件(app/、assets/css/admin.css 等)—— 插件样式/JS 必须收敛到插件自己的 assets/」。
但由 §三 第 7、8、10 条可知:核心既没有上传钩子,也没有 URL 生成钩子,Plugin::http() 还不能发 PUT。三条路都堵死,插件没有任何合法的接入点。
所以要先把这件事讲清楚,二选一:
- 选项 ①(推荐):把这次改动定性为「核心扩展点演进」,不是「插件改核心」。
即:核心新增 4 个通用扩展点(上传事件 + URL 过滤 + HTTP 方法支持),插件只是第一个使用者。
这 4 个扩展点对后续任何存储/同步/水印类插件都有用,属于平台能力补齐,应当随核心发布。
- 选项 ②:坚持「零核心改动」,改用 §4.3 的方案 B(纯插件),接受它的 3 项代价。
4.2 三个候选架构
| 方案 A:改 `UPLOAD_URL` 指向 CDN | 方案 B:纯插件(ob 改写 + 事后解析) | **方案 C:核心加扩展点(推荐)** | |
|---|---|---|---|
| 核心改动 | 改 config.php 1 行 | 0 行 | 4 处,约 45 行 |
| 上传触发 | 不触发(靠全量同步) | 内容钩子解析正文 + 后台全量补齐 | upload_after 钩子,精确、实时 |
| URL 改写 | 自动(Upload::url() 拼的就是 CDN) | init_after 起 ob_start,整页字符串替换 | upload_url / upload_thumb_url 钩子,按文件粒度 |
| 未同步文件 | 直接裂图(本地有、云上没有 → 404) | 可回退本地(需在改写时查白名单) | 可回退本地(同左) |
| 性能 | 最好 | 最差(每请求整页缓冲 + 替换;与红线 17 冲突) | 好(1 次内存集合查询,可缓存) |
| 数据风险 | 高:relFromUrl() 按 assets/uploads/ 剥离(§三.4),换域名后历史正文图判定错位;且会把绝对 URL 写进正文 | 中:正文解析会漏(封面、种子头像、孤儿文件) | 低 |
| 精准度 | — | 漏网 + 有延迟 | 精确 |
| 结论 | ❌ 不推荐 | ⚠️ 备选 | ✅ 推荐 |
4.3 方案 C 的 4 处核心改动(逐处说明必要性)
全部是只加不改语义的最小侵入;每处都给出「不做会怎样」。
改动 1 — Upload::file() 末尾加 upload_after 钩子(约 6 行)
位置:app/Helpers/Upload.php 成功分支 return [...] 之前(:511 前)。
// ★ 新增:上传成功后事件(供云存储/水印/审核类插件挂载)
// 此时文件已落盘、缩略图已生成;参数与返回结构一一对应,插件无需再查库
\app\Helpers\Plugin::hook('upload_after', [
'rel' => $rel, // '2026-09/ab12cd.png'(相对 uploads 的路径)
'filename' => $rel,
'original' => $file['name'] ?? '',
'size' => (int)$file['size'],
'type' => $mime ?? '',
'thumb' => $thumbPrefix, // 'thumb_' / 'md_' / ''(头像不生成)
'context' => (string)($opts['context'] ?? ''), // 调用方标注:avatar/attachment/cover...
]);不做会怎样:插件只能在 thread_create_after 等业务钩子里正则解析正文反推附件路径 —— 封面图、种子头像、无正文引用的附件、孤儿文件全部漏网,且要等正文保存完才入队。
改动 2 — Upload::url() 末尾加 upload_url 过滤钩子(约 5 行)
位置:app/Helpers/Upload.php:58 返回前。
$url = \rtrim(UPLOAD_URL, '/') . '/' . \ltrim(\str_replace('\\', '/', $rel), '/');
// ★ 新增:URL 过滤(引用传参,插件可改写为 CDN 地址;不改则行为与改造前完全一致)
$params = ['rel' => $rel, 'url' => $url];
\app\Helpers\Plugin::hook('upload_url', $params);
return (string)$params['url'];不做会怎样:只能用整页 ob_start 改写(方案 B),每请求多一次全页缓冲 + 字符串替换,且必须逐路由判断 Content-Type 以免污染附件下载流与 htmx 片段。
改动 3 — Upload::thumbUrl() 末尾加 upload_thumb_url 过滤钩子(约 6 行)
位置:app/Helpers/Upload.php:343 返回前。参数带 prefix,插件据此映射档位。
$thumbRel = self::thumbPath($rel, $prefix);
$url = $thumbRel !== null ? self::url($thumbRel) : self::url($rel);
// ★ 新增:缩略图 URL 过滤(带档位 prefix,供云端图片处理参数改写)
$params = ['rel' => $rel, 'thumb_rel' => $thumbRel, 'prefix' => $prefix, 'url' => $url];
\app\Helpers\Plugin::hook('upload_thumb_url', $params);
return (string)$params['url'];不做会怎样:upload_url 收到的只是最终 URL,丢失了「这是 400×300 还是 800×600」的语义 → 无法把 thumb_ 映射成 CoreIX 的 ?w=400&h=300,只能老实地把三档都推到云端(多 2 倍对象数与流量)。
改动 4 — Plugin::http() 支持任意 HTTP 方法 + 返回状态码(约 28 行)
位置:app/Helpers/Plugin.php:215-272。
file_get_contents分支:content不再限定$method === 'POST',改为有 body 就发;curl分支:POST之外支持PUT/DELETE/PATCH/HEAD(CURLOPT_CUSTOMREQUEST+CURLOPT_POSTFIELDS);- 用
$http_response_header/curl_getinfo(CURLINFO_RESPONSE_CODE)取状态码; - 返回值增加
'status' => int(向后兼容:新增键,不改ok/body/error语义)。
不做会怎样:S3 上传必须 PUT。要么改核心(本项),要么插件内裸写 curl_init() —— 那就同时绕过了 net:fetch 权限门和 SSRF 防线,规范 §十三 明确要求「插件应通过本方法发起外部 HTTP(S) 请求,替代裸 file_get_contents / curl_init」,审计必然标记。
⚠️ 本项是通用能力补齐,与「存储插件」无关的插件也受益(任何需要 PUT/DELETE 外部 API 的插件)。
4.4 若你选方案 B(零核心改动),代价清单
| 代价 | 具体表现 |
|---|---|
| 同步有延迟且会漏 | 只能挂 thread_create_after / post_create_after / thread_edit_after / post_edit_after / avatar_upload_after 解析正文;封面图(covers/)、种子头像(seed_avatars/)、未被正文引用的附件漏网,靠后台「全量扫描补齐」兜底 |
| 性能劣化 | init_after 起 ob_start() 捕获整页(output_buffering=0,见 §三.13),每请求多一次全页缓冲 + 替换;与规范红线「≤5 次查询」不冲突但 CPU 与内存上升 |
| 污染风险 | 必须逐请求判断 Content-Type(跳过 /attachment/{id} 二进制流、htmx 片段、JSON API),判断错一次就是文件损坏或页面白屏 |
| 仍无法精确改写档位 | 无 prefix 语义,只能三档全推 |
五、推荐方案详细设计(方案 C)
5.1 数据流
用户上传
│
▼
Upload::file() ── 白名单/MIME/大小校验(核心,不动)
│ ① 原图落盘 assets/uploads/2026-09/ab12cd.png
│ ② 生成 thumb_(400×300)/ md_(800×600)
│
├─► ★ upload_after 钩子
│ └─► 插件:写队列 bs_objects(status=pending)
│
▼
请求末尾(★ route_after_dispatch —— 响应已产出,index.php:321,无参数、单触发)
│
└─► 插件:排空队列(**单请求最多 1 条**,超时 5s)
└─► S3Client::putObject() ── SigV4 签名 ──► PUT https://.s3.bitiful.net/
│ 成功 → status=done,记 etag/size/sha256
└─ 失败 → status=failed,attempts++,指数退避
用户浏览
│
▼
Upload::url() / thumbUrl()
│
└─► ★ upload_url / upload_thumb_url 钩子
├─ 文件已在云端(内存白名单命中)→ 返回 https://.s3.bitiful.net/
└─ 未同步 / 插件停用 / 白名单未命中 → **返回本地 URL(零裂图)**核心设计原则:URL 改写是「按文件粒度」的,且默认回退本地。 任何一步失败都不会让页面裂图,最坏情况只是没走 CDN。
5.2 插件目录结构
plugins/bitiful_storage/
├── plugin.json # hooks 清单 + permissions
├── Plugin.php # 主类:db()/ddl()/activate()/deactivate()/uninstall() + 业务方法
├── S3Client.php # SigV4 签名 + putObject/getObject/headObject/deleteObject
├── SyncQueue.php # 队列:入队/取批/成功/失败退避/统计
├── UrlRewriter.php # URL 改写:白名单查询 + 内存缓存 + 档位映射
├── AdminController.php # 后台控制器(Tab 配置页 + 队列面板 + 全量补齐)
├── hook/
│ ├── init_after.php # 惰性建表兜底(戳文件门控,避免每请求跑 DDL)
│ ├── route_after_dispatch.php # ★ 排空队列(响应已产出,单触发)
│ ├── upload_after.php # ★ 上传成功 → 入队(只入队,不推送)
│ ├── upload_url.php # ★ 原图 URL 改写
│ ├── upload_thumb_url.php # ★ 缩略图 URL 改写(带档位)
│ ├── admin_route_register.php
│ ├── layout_head_end.php # 仅插件后台页加载 assets/
│ └── controller_view_before.php # 后台页数据注入
├── views/
│ ├── admin.php # Tab1 连接配置 / Tab2 同步策略 / Tab3 队列与补齐
│ └── _queue_table.php # 队列表格片段
├── assets/
│ ├── style.css # 后台样式(复用 .admin-tabs;--mn-* 仅用于非后台页)
│ └── script.js # Tab 切换 + 测试连接 + 手动补齐(零内联)
├── lang/
│ ├── zh.php / en.php / zh_tw.php # 三语同步,键序一致
└── data/ # Plugin::db() 自动创建
└── bitiful_storage.sqlite5.3 插件独立库表(Plugin::ensureSchema(),幂等)
-- 对象台账:一个「相对 uploads 路径 + 档位」一行
CREATE TABLE IF NOT EXISTS bs_objects (
id INTEGER PRIMARY KEY,
rel TEXT NOT NULL, -- '2026-09/ab12cd.png'(原图相对路径)
variant TEXT NOT NULL DEFAULT 'orig', -- orig | thumb_ | md_
object_key TEXT NOT NULL, -- 云端 key,如 'uploads/2026-09/ab12cd.png'
size INTEGER NOT NULL DEFAULT 0,
etag TEXT NOT NULL DEFAULT '',
sha256 TEXT NOT NULL DEFAULT '', -- 用于「内容变了才重推」
status TEXT NOT NULL DEFAULT 'pending', -- pending|done|failed
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
next_try_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
synced_at TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_bs_rel_variant ON bs_objects(rel, variant);
CREATE INDEX IF NOT EXISTS idx_bs_status_next ON bs_objects(status, next_try_at);
-- 操作日志(后台可见,便于排障;保留最近 N 条)
CREATE TABLE IF NOT EXISTS bs_logs (
id INTEGER PRIMARY KEY,
level TEXT NOT NULL DEFAULT 'info',
action TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);⚠️ 索引 DDL 必须写在
CREATE TABLE之后(规范 §10.3);activate()里ensureSchema()成功才return true;init_after再做一次惰性兜底。
5.4 队列与执行模型(应对「没有 cron」)
| 机制 | 说明 |
|---|---|
| 入队 | upload_after 钩子:按档位开关写入 orig / thumb_ / md_ 三条 pending |
| 排空时机 | ① route_after_dispatch(响应已产出)② 插件后台「立即同步」按钮 ③ 后台「全量补齐」批量跑 |
| 单请求预算 | 最多 1 条、单条超时 5s、总耗时上限 8s;超预算直接跳过,下个请求继续 |
| 退避 | 失败 attempts++,next_try_at = now + min(2^attempts, 3600) 秒;attempts > 5 标 failed 停止自动重试 |
| 幂等 | 重推前 headObject 比对 etag/sha256,一致则直接标 done(省流量) |
| 并发安全 | 取批用 UPDATE ... SET status='running' WHERE id IN (...) AND status='pending' + 校验 rowCount()(规范红线 5:原子更新) |
| 禁止事项 | ❌ 不在 layout_head_end / controller_view_before 里做网络请求(规范性能红线) |
「全量补齐」走后台按钮 + 分批(每批 50 条、AJAX 循环调用),不做 一次性同步 —— 避免
max_execution_time与浏览器超时。
5.5 URL 改写策略(关键:永不裂图)
改写判定顺序(UrlRewriter::rewrite()):
- 插件未启用 / 未配好 AK-SK / 未绑定桶 → 直接返回原 URL(零开销)
- 传入值已是外链(
http(s)://)→ 原样返回(不碰外链,与Upload::url()既有语义一致) - 查内存白名单(进程内静态数组,
status='done'的rel集合,单请求最多 1 次 DB 查询)
- 命中 → 拼
https://<bucket>.s3.bitiful.net/<key_prefix><key> - 未命中 → 返回本地 URL
- 可选「缩略图走 CoreIX」:
thumb_档命中时,改写为原图 key + 处理参数(?w=400&h=300&fm=webp&q=80),省一次对象存储
⚠️
PageCache交互:缓存里存的是改写前的 HTML(本地 URL),改写发生在缓存输出之后 —— 这正是想要的(缓存不绑域名,换 CDN 域名不用清缓存)。验证时必须覆盖「缓存命中」路径。
5.6 配置项(Settings key 清单,全部带 bitiful_storage_ 前缀)
| key | 类型 | 默认 | 说明 |
|---|---|---|---|
bitiful_storage_enabled | 开关 | 0 | 总开关(关 = 插件对核心零影响) |
bitiful_storage_endpoint | 文本 | https://s3.bitiful.net | 固定值,可改(为将来其他 S3 厂商留口) |
bitiful_storage_region | 文本 | cn-east-1 | 缤纷云当前唯一可用区 |
bitiful_storage_bucket | 文本 | — | 桶名(4~63,a-z0-9-) |
bitiful_storage_key_prefix | 文本 | uploads/ | 云端 key 前缀,避免与桶内其他内容冲突 |
bitiful_storage_access_key | 文本 | — | 子账户 AccessKey Id(明文可) |
bitiful_storage_secret_key | 密文 | — | AES-256-GCM 加密存储,密钥来自 config.php 的 BITIFUL_SECRET_KEY |
bitiful_storage_public_base | 文本 | 空 | 自定义域名 / CDN 域名;空则用 https://<bucket>.s3.bitiful.net |
bitiful_storage_rewrite | 开关 | 1 | 是否改写前台 URL |
bitiful_storage_push_variants | 数组 | ['orig','md_','thumb_'] | 推哪几档(必须 array_replace 合并,规范 §11.11) |
bitiful_storage_delete_local | 开关 | 0 | ⚠️ 推完删本地(v1.0 不实现,见 §八 决策 2) |
bitiful_storage_coreix_thumbs | 开关 | 0 | 缩略图改用 CoreIX 处理参数(P3 优化) |
bitiful_storage_max_attempts | 数字 | 5 | 失败重试上限 |
5.7 后台页面(3 个 Tab,复用核心 .admin-tabs)
| Tab | 内容 |
|---|---|
| 连接配置 | Endpoint / Region / Bucket / Key 前缀 / AK / SK(密文回显为占位符,留空表示不改)/ 自定义域名;「测试连接」按钮(HEAD 桶 + PUT/GET/DELETE 一个 _probe 小对象) |
| 同步策略 | 总开关、URL 改写开关、档位多选(原图/md_/thumb_)、CoreIX 缩略图开关、失败重试上限;「保存后留在当前 Tab」(规范 §11.10) |
| 队列与补齐 | 统计卡片(pending/done/failed)、失败列表(可单条重试/忽略)、「立即排空」「全量扫描补齐」「清空已完成记录」;AJAX 分批,带 CSRF |
plugin.json 的 admin_url 指向 /admin/bitiful-storage。
5.8 i18n 键(三语同步,键序一致)
命名遵循规范 §八 plugin.{插件名}.{语义},全部为 plugin.bitiful_storage.*:
- 后台:
titletab_conntab_policytab_queuefld_endpointfld_regionfld_bucketfld_prefixfld_akfld_skfld_public_basebtn_testbtn_savebtn_drainbtn_scanbtn_cleartest_oktest_failstat_pendingstat_donestat_failedcol_relcol_variantcol_statuscol_attemptscol_errorcol_syncedemptyerr_bucket_invaliderr_ak_requirederr_key_missingerr_neterr_permact_queuedact_syncedact_scannedact_cleared - 后台文案约定:无后缀
err_= 后台表单校验;_act= 操作结果提示(规范 §十三 附加约定)
5.9 权限声明(plugin.json)
"permissions": ["route:admin", "system:settings", "net:fetch"]route:admin— 注册后台路由system:settings—activate()写默认设置net:fetch— 调Plugin::http()访问 S3
5.10 plugin.json 草案
{
"name": "缤纷云存储",
"version": "1.0.0",
"author": "FlintHub",
"description": "缤纷云 Bitiful S4 对象存储:附件本地落盘后镜像到公开桶,前台 URL 自动改写为 CDN 地址,未同步文件自动回退本地",
"activated": false,
"category": "system",
"icon": "cloud",
"admin_url": "/admin/bitiful-storage",
"hooks": {
"init_after": "hook/init_after.php",
"route_after_dispatch": "hook/route_after_dispatch.php",
"upload_after": "hook/upload_after.php",
"upload_url": "hook/upload_url.php",
"upload_thumb_url": "hook/upload_thumb_url.php",
"admin_route_register": "hook/admin_route_register.php",
"layout_head_end": "hook/layout_head_end.php",
"controller_view_before": "hook/controller_view_before.php"
},
"permissions": ["route:admin", "system:settings", "net:fetch"]
}⚠️ 三个
upload_*钩子是本计划新增的核心钩子;hook/upload_url.php等文件绝不可声明顶层函数/类(否则被降级为include_once,见规范 §9.0)。⚠️ 排空挂在
route_after_dispatch而非init_after:init_after在请求早期,做网络推送会直接拖慢首字节;route_after_dispatch时响应已产出,耗时与失败都不进页面(计划书原稿写的是init_after,P2 实装时按「不拖慢页面」硬约束改掉)。⚠️ 改动 hooks 清单后必须
Plugin::refresh()重建plugins/plugins_cache.json。⚠️ 不注册
nav_plugin_links(存储插件无前台入口);不注册nav_pc_user_links(死钩子)。
六、分阶段实施计划
P0 — 核心扩展点 + 插件骨架(约 0.5 人日)
| 步骤 | 产出 | 验收 |
|---|---|---|
| 1 | 4 处核心改动(§4.3) | php -l 双版本 0 失败;grep -c "Plugin::hook" app/Helpers/Upload.php 由 0 → 3 |
| 2 | 目录骨架 + plugin.json + 三语空包 | JSON 合法;三语键集合零差异 |
| 3 | Plugin.php:db() / ddl() / activate() / deactivate() / uninstall() | 激活后 bs_objects / bs_logs 建成;卸载后表删除、settings 无残留 |
| 4 | Plugin::refresh() 重建缓存 | plugins_cache.json 含 3 个新钩子 |
P1 — S3 链路打通(约 1 人日)
| 步骤 | 产出 | 验收 |
|---|---|---|
| 1 | S3Client.php:SigV4 签名(hash_hmac('sha256'))+ putObject / headObject / getObject / deleteObject | 与官方文档示例向量比对签名;真实桶 PUT 成功返回 200 + ETag |
| 2 | 密钥加密:BITIFUL_SECRET_KEY + AES-256-GCM + 魔数 FHBS\x01 | 落库为密文;后台回显为占位符;留空保存不覆盖 |
| 3 | 后台 Tab1 + 「测试连接」 | 正确配置 → test_ok;错 AK / 错桶名 → 明确错误码与文案(403 / 404 / DNS) |
| 4 | 手动单文件上传/删除/HEAD | 云端可见对象;curl 公开 URL 直接 200 |
P2 — 自动同步队列(约 1 人日)
| 步骤 | 产出 | 验收 |
|---|---|---|
| 1 | SyncQueue.php(入队 / 原子取批 / 成功 / 退避失败) | 并发下不重复推送(rowCount() 断言) |
| 2 | hook/upload_after.php + route_after_dispatch 排空 | 发一帖带图 → 下一请求后 bs_objects 变 done,云端出现 3 档对象 |
| 3 | 后台 Tab3:统计 / 失败重试 / 立即排空 | 断网造失败 → 退避生效 → 恢复后自动补推 |
| 4 | 性能断言 | 单请求插件相关 DB 查询 ≤ 5 次(规范红线,用计数器实测) |
P3 — URL 改写 + 全量补齐(约 1 人日)
| 步骤 | 产出 | 验收 |
|---|---|---|
| 1 | UrlRewriter.php + 两个 upload_*_url 钩子 | 已同步文件 → CDN URL;未同步文件 → 本地 URL(零裂图);外链不被碰 |
| 2 | 白名单内存缓存 | 列表页 1 次查询;分页 20 图不产生 N 次查询 |
| 3 | 后台「全量扫描补齐」分批 AJAX | 历史 uploads 全部入队并推完;进度可见 |
| 4 | PageCache 回归 | 缓存命中页也改写正确;清缓存后一致 |
| 5 | 回退演练 | 停用插件 → 全站 URL 立刻回本地,无 404 |
P4 — 验证与发布(约 0.5 人日)
见 §七。产出 update/29/(含 4 处核心改动文件:app/Helpers/Upload.php、app/Helpers/Plugin.php)+ 插件目录 + 重建的 plugins/plugins_cache.json。
七、验证清单(对齐规范 §十二)
7.1 安全红线自查(逐条给证据)
| # | 红线 | 本插件的落实方式 |
|---|---|---|
| 1 | 禁 unserialize/eval/assert/preg_replace('/e') | 配置用 json_encode/json_decode;grep 应零命中 |
| 2 | 路径基于 __DIR__/BASE_PATH,禁 $_SERVER['DOCUMENT_ROOT'] | 文件读写一律走 Upload::path()/absPath(),插件不自己拼路径 |
| 3 | 渲染钩子禁 die/exit/header | layout_head_end / controller_view_before 内只 return |
| 4 | 上传白名单 + 强随机名 | 完全复用核心 Upload::file(),插件不接 $_FILES |
| 5 | 数量原子扣减 | 队列取批用条件 UPDATE + rowCount() 断言 |
| 6 | IN(...) 强转 + 占位符 | array_map('intval') + array_fill 生成占位符 |
| 7 | 不覆盖 PDO 属性 | 只用 Plugin::db() |
| 8 | AJAX 必带 CSRF | 后台所有 POST 带 csrf,服务端 Csrf::verifyOrDie() |
| 9 | 密钥不泄露 | SK 落库 AES-256-GCM 加密;后台只回显占位符;任何情况下不回显明文、不写入日志、不进入前端 |
| 10 | 不把私密内容推公开桶 | 白名单式:只推 assets/uploads/ 下、且不属于「回收站帖子 / 待审核内容 / 私信附件」的对象;默认排除名单可配 |
| 11 | 零内联 | `grep -nE '<style |
| 12 | 主题适配 | 后台用 .admin-tabs(无 --mn-);若有前台元素,只用既有 --mn- 变量 + 双主题走查 |
| 13 | 配置合并 | 档位数组用 array_replace |
| 14 | 钩子文件不声明顶层符号 | 全部 8 个钩子文件逐个 php -r 'include $f; include $f;' 独立进程验证 |
7.2 自动化验证
| 项 | 手段 | 通过标准 |
|---|---|---|
| 语法 | php -l(8.2.9 与 8.0.2 双版本) | 0 失败 |
| JS | node --check | 0 失败 |
| JSON | json_decode 严格解析 | OK |
| 编码 | 脚本扫描 | UTF-8 无 BOM、LF |
| i18n | 模板/控制器引用键 ↔ 三语字典全量比对 | 缺键 0;三语键集合零差异 + 键序一致 |
| SigV4 | 官方测试向量 + 真实桶 PUT/GET/DELETE | 签名一致;HTTP 200 |
| 零内联 | grep | 0 命中 |
| 双 include | 独立进程连续 include 两次 | 无 Cannot redeclare |
| 查询预算 | 在 Plugin::db() 加临时计数 | 单请求 ≤ 5 |
| HTTP 冒烟 | php -S + --noproxy '*'(本机 http_proxy 会拦 curl) | 上传→改写→下载全链路通 |
| 既有套件 | p8d_e2e.sh / test_p5_content_img.php / test_p8d_img_group.php / test_p4_counter.php | 零回归 |
| 附件体系 | /attachment/{id} 下载、回收站只读预览 | 行为与改造前一致 |
| PageCache | 清缓存前后对比 | 改写一致,缓存不绑域名 |
7.3 必须人工走查的场景
- 发帖带图(原图 > 800×600)→ 列表摘要、正文图、附件卡三处 URL 都正确
- 头像上传(不生成
thumb_)→ 改写正确、无 404 - 上传后立刻刷新(还没同步)→ 走本地 URL,不裂图
- 停用插件 → 全站立刻回本地
- 断网 → 队列退避 → 恢复后自动补推
- 双主题(默认浅色 + 暗夜星辰)后台页可读性
八、需要你裁决的事项
| # | 事项 | 选项 | 我的建议 |
|---|---|---|---|
| 1 | 是否接受 4 处核心改动?(这是本计划唯一的前置决策) | ① 接受(方案 C,推荐)<br>② 坚持零核心改动(方案 B,接受 3 项代价) | ①。理由:4 处都是通用平台能力(上传事件 / URL 过滤 / HTTP PUT),不是为存储插件特设;且方案 B 的性能与漏网问题会在生产暴露 |
| 2 | 「推完删本地」要不要做? | ① v1.0 不做(只镜像)<br>② 做,但需第 5 处核心改动(/attachment/{id} 改为 302 回源云端) | ①。理由:删本地会直接废掉带权限校验的下载路径,且回收站预览、缩略图回退都会受影响;先跑通镜像拿到收益,再评估 |
| 3 | 缩略图是否推云? | ① 三档全推(语义与本地完全一致)<br>② 只推原图 + md_,thumb_ 改用 CoreIX 处理参数(省对象数与流量) | ① 先做,② 留 P3 优化。CoreIX 的裁剪语义与 GD 不完全一致,需先比对再切 |
| 4 | 桶内 key 前缀 | 建议 uploads/(默认) | 用 uploads/,避免与桶内其他内容冲突 |
| 5 | 是否绑定自定义域名 / CDN? | ① 不绑,直接用 https://<bucket>.s3.bitiful.net<br>② 绑自定义域名(如 cdn.example.com) | ②。官方文档明示 S4 出口带宽有限、建议配 CDN;且换桶时只改一个配置项 |
| 6 | AK/SK 的加密主密钥 | 在 config.php 定义 BITIFUL_SECRET_KEY(与 MAIL_PASS_KEY 同款) | 采纳。config.php 不进发布包,由各站自行配置 |
| 7 | 私密内容排除名单 | 默认排除:回收站帖子附件、待审核内容附件、私信附件 | 采纳默认;排除名单做成可配 |
| 8 | 插件目录名 | ① bitiful_storage(贴合缤纷云)<br>② s3_storage(将来可平移到 R2/OSS) | ①,但在 S3Client.php 里留出 endpoint/region 可配的接缝,将来复制成 s3_storage 成本很低 |
九、风险登记
| 风险 | 等级 | 缓解 |
|---|---|---|
| 公开桶泄露私密附件 | 高 | §七.1 第 10 条白名单 + 默认排除名单;上线前用 curl 抽查确认排除项确实未上传 |
| SigV4 自实现签名有误 | 中 | 用官方文档测试向量 + 真实桶小对象验证;错误信息按 SignatureDoesNotMatch 单独给出提示 |
| 每请求 1 条同步拖慢页面 | 中 | 严格预算(1 条 / 5s / 总 8s);放 route_after_dispatch(响应已产出);失败即退避 |
大文件同步超时(upload_max_filesize 仅 2M 的 CLI 默认值,生产需另查) | 中 | 单条超时 5s 即放弃并退避;大文件走后台批量补齐(无请求超时约束) |
改 Upload.php 影响既有附件体系 | 高 | 钩子只加不改:无插件注册时行为与改造前逐字节等价(用 A/B 反证 + sha256 比对);改造后重跑 test_p5_content_img.php / test_p8d_img_group.php / p8d_e2e.sh |
改 Plugin::http() 影响既有调用方 | 中 | 返回值只增键不改语义;method 缺省仍为 GET、POST 行为不变;全量 grep 现有调用方逐个回归 |
| 与 PageCache 交互出隐藏 bug | 中 | 专项验证「缓存命中 / 缓存未命中 / 清缓存后」三种路径 |
| 缤纷云预览期功能变动 | 低 | endpoint/region 可配;错误文案不写死厂商话术 |
十、交付物清单
| 交付物 | 路径 |
|---|---|
| 插件全部代码 | plugins/bitiful_storage/**(约 20 个文件) |
| 核心改动 4 处 | app/Helpers/Upload.php、app/Helpers/Plugin.php |
| 插件缓存 | plugins/plugins_cache.json(重建后) |
| 发布包 | update/29/(镜像站点根结构 + version.php + 改动说明.md) |
| 版本 | V1.0.29 |
| 部署补充说明 | 需在 config.php 手工加 define('BITIFUL_SECRET_KEY', '<随机32字节>');(该文件不进包) |
本计划基于 2026-09-21 对 app/Helpers/Upload.php、app/Helpers/Plugin.php、app/Helpers/PageCache.php、app/Core/TemplateCompiler.php、index.php、cron_trigger.php 的逐行核实,以及缤纷云官方文档(docs.bitiful.com)的接口事实。