[开发]缤纷云存储插件开发计划(bitiful_storage)

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:09:55

本文由 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)官方文档
Endpointhttps://s3.bitiful.net(必须带 https://)官方 + 第三方配置文档
Regioncn-east-1(文档明示「目前只有中国东部1可用区」)docs.bitiful.com/bitiful-s4/definitions
寻址方式虚拟托管域名(Virtual-hosted style),Path-style 必须关闭第三方配置文档
对象 URLhttps://<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 侧现状(决定方案的硬约束)

这些是已经核实过的事实,方案的每一步都被它们约束:

#事实位置对方案的影响
1Upload::file() 是唯一落盘口app/Helpers/Upload.php:426附件体系收敛层,插件必须从这里挂
2Upload::url() 是唯一 URL 出口app/Helpers/Upload.php:50想改写 URL,只有这一个地方
3Upload::thumbUrl() 先 path() 实测本地缩略图存在,不存在则回退原图 URL(零 404):343天然支持「本地不生成缩略图 → 回退原图 → 云端加处理参数」的优化路径
4Upload::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()(本地磁盘)不读 URLAttachmentController.php删本地文件会直接废掉下载;云端 URL 不能替代它
7Upload::file() 里没有任何钩子调用全文件 grep无 upload_after 可用
840 个核心钩子中没有上传类、也没有 URL 生成类规范 §9.1触发同步与改写 URL 都缺钩子
9cron_trigger.php 不触发任何插件钩子全文件 grep没有定时任务入口 → 队列只能靠「请求内排空」或「后台手动」
10Plugin::http() 只对 POST 发送 body,且返回值只有 ok/body/error、没有 HTTP 状态码app/Helpers/Plugin.php:191-273S3 必须用 PUT,且要按 4xx/5xx 区分错误 → 现有 Plugin::http() 不够用
11Plugin::http() 内含 SSRF 防线(DNS 解析 + 保留 IP 拦截)+ net:fetch 权限门同上绕过它自己写 curl = 绕过安全门 → 会被审计标记
12PageCache 只缓存列表页 + 仅游客,render() 用 ob_start 存最终 HTMLPageCache.php:119改写若发生在缓存之后,缓存里存的仍是本地 URL(正确,域无关)
13output_buffering = 0(无全局输出缓冲)php -i用 ob_start 改写整页必须在 init_after 就开缓冲,否则 route_after_dispatch 时输出已发走
14已装扩展:curl openssl gd pdo_sqlite;没有 Imagickphp -m缩略图走 GD;SigV4 用内置 hash_hmac;密钥加密用 openssl_encrypt
15已有「AES-256-GCM + 魔数前缀」的密钥加密范式(Mailer::encryptSmtpPass,密钥来自 config.php 的 MAIL_PASS_KEY)app/Helpers/Mailer.php:41-67Secret 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
20Upload::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.sqlite

5.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()):

  1. 插件未启用 / 未配好 AK-SK / 未绑定桶 → 直接返回原 URL(零开销)
  2. 传入值已是外链(http(s)://)→ 原样返回(不碰外链,与 Upload::url() 既有语义一致)
  3. 查内存白名单(进程内静态数组,status='done' 的 rel 集合,单请求最多 1 次 DB 查询)
  • 命中 → 拼 https://<bucket>.s3.bitiful.net/<key_prefix><key>
  • 未命中 → 返回本地 URL
  1. 可选「缩略图走 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.*:

  • 后台:title tab_conn tab_policy tab_queue fld_endpoint fld_region fld_bucket fld_prefix fld_ak fld_sk fld_public_base btn_test btn_save btn_drain btn_scan btn_clear test_ok test_fail stat_pending stat_done stat_failed col_rel col_variant col_status col_attempts col_error col_synced empty err_bucket_invalid err_ak_required err_key_missing err_net err_perm act_queued act_synced act_scanned act_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 人日)

步骤产出验收
14 处核心改动(§4.3)php -l 双版本 0 失败;grep -c "Plugin::hook" app/Helpers/Upload.php 由 0 → 3
2目录骨架 + plugin.json + 三语空包JSON 合法;三语键集合零差异
3Plugin.php:db() / ddl() / activate() / deactivate() / uninstall()激活后 bs_objects / bs_logs 建成;卸载后表删除、settings 无残留
4Plugin::refresh() 重建缓存plugins_cache.json 含 3 个新钩子

P1 — S3 链路打通(约 1 人日)

步骤产出验收
1S3Client.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 人日)

步骤产出验收
1SyncQueue.php(入队 / 原子取批 / 成功 / 退避失败)并发下不重复推送(rowCount() 断言)
2hook/upload_after.php + route_after_dispatch 排空发一帖带图 → 下一请求后 bs_objects 变 done,云端出现 3 档对象
3后台 Tab3:统计 / 失败重试 / 立即排空断网造失败 → 退避生效 → 恢复后自动补推
4性能断言单请求插件相关 DB 查询 ≤ 5 次(规范红线,用计数器实测)

P3 — URL 改写 + 全量补齐(约 1 人日)

步骤产出验收
1UrlRewriter.php + 两个 upload_*_url 钩子已同步文件 → CDN URL;未同步文件 → 本地 URL(零裂图);外链不被碰
2白名单内存缓存列表页 1 次查询;分页 20 图不产生 N 次查询
3后台「全量扫描补齐」分批 AJAX历史 uploads 全部入队并推完;进度可见
4PageCache 回归缓存命中页也改写正确;清缓存后一致
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/headerlayout_head_end / controller_view_before 内只 return
4上传白名单 + 强随机名完全复用核心 Upload::file(),插件不接 $_FILES
5数量原子扣减队列取批用条件 UPDATE + rowCount() 断言
6IN(...) 强转 + 占位符array_map('intval') + array_fill 生成占位符
7不覆盖 PDO 属性只用 Plugin::db()
8AJAX 必带 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 失败
JSnode --check0 失败
JSONjson_decode 严格解析OK
编码脚本扫描UTF-8 无 BOM、LF
i18n模板/控制器引用键 ↔ 三语字典全量比对缺键 0;三语键集合零差异 + 键序一致
SigV4官方测试向量 + 真实桶 PUT/GET/DELETE签名一致;HTTP 200
零内联grep0 命中
双 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 必须人工走查的场景

  1. 发帖带图(原图 > 800×600)→ 列表摘要、正文图、附件卡三处 URL 都正确
  2. 头像上传(不生成 thumb_)→ 改写正确、无 404
  3. 上传后立刻刷新(还没同步)→ 走本地 URL,不裂图
  4. 停用插件 → 全站立刻回本地
  5. 断网 → 队列退避 → 恢复后自动补推
  6. 双主题(默认浅色 + 暗夜星辰)后台页可读性

八、需要你裁决的事项

#事项选项我的建议
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;且换桶时只改一个配置项
6AK/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)的接口事实。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 4 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录