[开发]内置升级系统与跨站插件商店 · 规划与验证

👑Lv.11 元老 🌞 骨干会员
2026-10-24 00:34:03

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:合并篇(见正文内 merged_from 列表)。

内置升级系统与跨站插件商店 · 规划与验证

本篇由 4 篇原始文档合并去重而成:1.1.0 升级系统规划 + 1.1.2 插件商店规划 + P1–P9 验证 + 市场上下架方案。

一、内置升级系统(1.1.0)

1.1 定位与版本演进

1.1.0 的目标:把跑了 46 个版本的「人肉发布包」纪律固化成代码——后台一键检查更新、一键升级程序本体。范围演进由两次站长拍板决定:

  • 核心包与插件包分两个独立发布轨道;1.0.x 核心包顺带插件是历史包袱,1.1.0 起彻底分开(核心包不含 plugins/)。
  • 原第二部分(插件中心 P5~P8.1)于 2026-10-02 整体移出,1.1.0 最终只交付核心在线升级 P0~P4;取消 1.1.1,其内容并入 1.1.2。
  • 生产环境口径(2026-09-25 换机后):2 核 2G 宝塔 VPS + Nginx + Linux + PHP 8.2.33,单机部署。本机 Windows 验不出大小写/权限/换行,是贯穿全部设计的约束。

1.2 三条轨道

轨道位置职责
发布端(本地)cli/release_build.php打核心/插件包、算 manifest、生成 latest.json、跑门禁 7 项;只在本地跑(有上一版基线),产物走宝塔面板上传
发布源(同机)/release/core/ + /release/plugins/升级器直接读本地文件系统(不走 HTTP),/release/ 可做到对外不可见
站点端(生产)app/Helpers/Updater.php + app/Controllers/Admin/UpdateController.php拉清单 → 比对 → 预检 → 备份 → 应用 → 回滚;纯 Web 可跑

1.3 升级链路(十步)

0. 取升级锁 data/lock/update.lock(防并发;LOCK_TTL=1800s,陈旧锁可抢占)
1. 清一次页面缓存(不置维护模式,避免游客拿到指向旧文件名的缓存)
2. 预检 preflight() 9 条(根可写实写探针 / 扩展 / disable_functions / PHP 版本 / 备份目录与磁盘 / .fh_tmp 残留 / 锁 / 包 sha256 / 路径大小写)
3. 取包 download()(本地路径优先;同目录 .fh_tmp + rename 原子落地;sha256 校验)
4. 备份本次将覆盖 + 将删除的文件 → data/backup/update_<版本>_<时间戳>/,写 rollback.json
5. 写新增(add)
6. 覆盖(modify),逐个查返回值,任一失败立即 rollback
7. 删除(delete)
8. 最后写 version.php(中途失败版本号仍是旧的 → 可识别「未完成」并重试)
9. 清缓存(模板/设置/页面/插件清单)+ opcache_reset(),更新基线,释放锁

写文件一律原子替换:同目录 .fh_tmp → rename(Linux 下 rename 覆盖正在 include 的文件安全)。分批应用按 5 秒时间预算(非固定文件数,因生产 2 核),进度存 data/runtime/update_progress.json,中断必须能续或能回滚。

1.4 增量包格式与数据格式

文件要点
flinthub-core-<ver>.zip扁平结构,顶层即 app/ assets/ lang/ cli/ + 根级文件 + version.php;不含 plugins/
manifest-<ver>.jsonschema 1;files[] 逐文件 sha256+size;deleted[] 显式表达删除(解决 update/28 只能写说明让人手工删的历史痛点);signature 为 RS256/key_id
latest.jsoncore.stable / core.beta 双段(beta 可为 null);「发布」= stable = beta;同机模式 url 写站点根相对路径
data/runtime/update_state.json本地状态:channel(默认 stable)、last_check、ignored_versions
data/runtime/update_baseline.json上次成功升级后的官方清单,用于算 conflict
data/runtime/update_file_hashes.json本地哈希缓存(mtime+size 快筛 88ms vs 全量 sha256 3619ms,41× 差距)

保留策略(D-7):大版本(X.0.0)与中版本(1.X.0)全量包永久保留;累积增量包只在当前中版本轨道留一个;beta 包留最近 3 个。回滚备份 data/backup/update_* 留最近 3 次(与发布端保留是两回事)。

1.5 差异模型、锁与冲突处理

diff() 五分类:add(远端有本地无)/ modify(内容不同)/ delete(远端标记删除)/ conflict(本地哈希 ≠ 基线 → 需人工确认)/ same。conflict 非空时 apply() 直接拒绝。

路径校验 7 条(diff 与 apply 两处都做):统一分隔符 → 拒绝对 / 开头、..、空字节、盘符 → 字符白名单(放行 CJK,拒全角斜杠/bidi)→ 拒硬黑名单(config.php data/ assets/uploads/ assets/themes/ plugins/ protected/ release/)→ 拒白名单外(核心 = app/ assets/(除 uploads/themes) lang/ cli/ docs/ + 根级清单)→ 大小写按 manifest 原样(Linux 敏感,Windows 假通过)→ realpath 复核。

锁:data/lock/update.lock,lockOwner() = uid:<id>@<ip> 跨请求稳定,支持续期/自释放/抢占。

1.6 回滚策略(两条路径)

路径依赖用途
① 后台「回滚」按钮 → Updater::rollback()Updater 本身可用常规
② 宝塔文件管理器手动覆盖 data/backup/update_*/零依赖兜底:升级把 Updater.php 自己改坏时 ① 失效

备份目录必须保持站点根相对路径结构(硬约束)。回滚依 rollback.json 反向执行并还原基线(apply 会写新基线,回滚不还原基线会堵死下次升级通道——P4 实弹演练抓出的真 bug,已修)。

测试站 = 生产,故有三重保险:本机完整副本先跑通 → 首次上生产只装升级器自身(1.1.0-beta1 最小变更)→ 零依赖回滚。

1.7 P0~P4 阶段交付(全部完成)

阶段内容关键产物
P0 地基(只读)版本归一化(含 -betaN)、manifest 解析校验、路径校验、diff 五分类、preflight 骨架app/Helpers/Updater.php、cli/update_probe.php、cli/env_check.php;回归 51 断言
P1 检查更新(只读)异步检查 + 6h 缓存 + 忽略版本app/Controllers/Admin/UpdateController.php、app/Views/admin/update.php、侧栏入口、三语 +34 键
P2 发布端工具打包器 + 门禁 7 项(含大小写体检)+ D-7 清理cli/release_build.php;直接复用 Updater 白名单常量,杜绝「装得上、升不了」
P3 预检+取包(半只读)preflight 9 条实装、download+sha256、升级锁新增 fetch/preflight/unlock 路由;CLI 探针 19/19、HTTP 冒烟 23/23
P4 应用+备份+回滚(最高危)backup/apply/rollback 全支持 dry-run、分批、RSA-2048 签名(D-5)、更新源可配置沙箱 32/32、副本实弹演练 41/41、310 文件 sha256 逐一致

P4 第 6 步「发布源与站点分离」:resolveSource() 一处兜底基址从 SITE_URL 改为 DEFAULT_UPDATE_SOURCE = 'https://flinthub.top',四条 URL 一次性全对——别人的安装也能收官方更新。连带修正:/release/ 的 Nginx 口径反转,必须可被 HTTP 读(deny all 会让多租户更新全废)。

二、跨站插件商店(1.1.2)

把「插件在线安装」升级为跨站形态:官方站(Hub,flinthub.top)= 发布源;用户自建站(Client)= 消费端。顺序:本地插件中心(P-1/P0/P1)先跑通,再搭跨站(P2~P9)——地基不稳,上面全塌。

2.1 架构:同一份核心代码,两种角色

侧形态落点
Hub(官方站)插件 market_hub前台橱窗 /plughub、投稿与审核、跨站授权与包分发 API、包仓库
Client(用户站)进核心app/Helpers/Market.php + app/Controllers/Admin/MarketController.php,后台「插件商店」浏览不鉴权、安装才校验

六条硬约束:橱窗与商店分离(橱窗只展示不安装);浏览不鉴权(读本地缓存零跨站请求);两层账号独立(下载时额外校验主社区账号);Token 有效期用户自选(1/7/30/90/永久)+ 双重吊销(Hub 优先);插件包不加代码锁;性能优先。

2.2 站点发现与协议(清单)

  • 公开清单 release/plugins/index.json(schema 2):元数据 only,package.url 指向 API 而非静态路径。
  • Client 侧 Market::syncIndex() TTL 惰性同步(默认 6h,Market::INDEX_TTL=21600),失败降级用旧缓存 + 页面提示。
  • F-1 硬边界:release/ 必须公网可读(是核心更新源),故插件包不放 release/,放 data/market/packages/(data/ 公网不可读),仅经 /api/market/download 鉴权后流式输出。

2.3 安装 / 更新链路

① Client 后台 /admin/market 浏览(读 data/runtime/market_index.json,零跨站请求)
② 点安装 → 无 Token → 302 跳 Hub /market/authorize?site_url=…&state=32hex
③ Hub 要求登录主社区 → 授权页人机确认 → 下发一次性 code → 回跳 Client /admin/market/callback
④ Client 兑换 code → Token(mkt_<40hex> + secret 48hex)→ 存 data/runtime/market_token.json
⑤ 每次下载:POST /api/market/download(Token + HMAC 签名 + ts/nonce)→ Hub 校验通过流式出包
⑥ Client 收包 → sha256 校验 → 解压到 plugins// → Plugin::refresh()

更新复用 1.1.0 内核:Updater::diff/apply 参数化 root=plugins/<name>/、排除 data/,合并保留本地 activated/sort_order(否则升级会把你禁用的插件重新激活)。插件包 = flinthub-plugin-<name>-<ver>.zip,扁平结构顶层 plugins/<name>/;manifest 复用 1.1.0 schema(files[] + sha256 + signature)。

2.4 签名与校验(两套体系物理隔离)

用途方案原因
发布包/清单RSA-2048/RS256,私钥只在本地,公钥内置 app/Helpers/release_keys.php离线低频;HMAC = 密钥内置源码 = 没签
跨站 APIHMAC-SHA256(method+path+ts+nonce+sha256(body),secret 与 token 分离)高频在线;本机无 sodium,Ed25519 不可用

Token 校验顺序:存在 → revoked_at=0(吊销优先级最高)→ expires_at 未过 → 信任等级(软依赖 user_level 插件)→ 积分。Hub 侧 Token 只存 sha256,明文只在兑换成功那一刻出现。

2.5 缓存与镜像

  • Client:market_index.json(清单缓存)、market_token.json(明文凭证,建议 0600)、market_sync.json(同步状态)。
  • Hub:data/market/market.sqlite(7 表:mh_tokens / mh_codes / mh_nonces / mh_submissions / mh_download_logs / mh_plugin_stats 等)+ manifests/(逐文件 sha256)+ packages/(包体)。下载量惰性聚合(写原始日志、读时聚合)。
  • 限流:Token 级每分钟 ≤5 次下载(RateLimiter,契约 true=允许,判断前必须取反)。

三、P1–P9 验证结论(2026-10-03)

总结论:P-1 / P0 / P1–P9 全部落地,14 组验证 415 PASS / 0 FAIL。 本机无 pcntl/sodium,跨站一律真实 HTTP(php -S 起两个实例)。

  • P-1 插件包基础设施:修掉 2 个门禁 bug(#6 key 口径 cli/release_build.php:640;#7 插件包直接复用核心白名单,导致每个文件被路径校验拒绝),补写 index.json 生成器,落地 /release/plugins/ 目录。真出 community_bank 包,8 项门禁全 PASS(#8 签名 WARN 属 dry-run 预期),Updater::diff/apply 正确识别。
  • P0 插件在线升级:Updater 参数化到 plugins/<name>/,排除 data/,合并保留 activated/sort_order,升级后 Plugin::refresh()。Updater 作用域 A/B 反证 135/0 PASS。
  • P1 插件在线安装:取包 → 校验 → 解压落盘;namespace 大小写逐字节校验(50 目录全通过)+ class_exists 实测断言,防「目录名 ↔ 命名空间不一致 → Linux 500」。
  • P2~P9 跨站商店:Hub 侧交付 16 个文件的 market_hub 插件(plugin.json 6 钩子、MarketDb 7 表 + 8 索引、HubAuth、HubCatalog、ApiController、AuthorizeController、FrontController、AdminController 5 Tab、6 个 hook、7 个视图、三语各 125 键),Client 侧能力进核心。跨站端到端 42 项全绿(Hub 8931 ↔ Client 8932 必须用独立副本——同一站点跑会走「本地文件」分支全程零 HTTP,验不出跨站):code 48hex 一次性、token 库内只存 hash、重放 code 400、清单 schema 2 且 source 指向跨站 HTTP、插件完整落盘、mh_download_logs 证明包确实经 API 流出。i18n 全量 54/0、主题回归 9/0、后台升级页回归 24/0、P4 沙箱 32/0。
  • 本会话修掉两个设计错误:① HubAuth::redeemCode() 逻辑死结(初版建 code 时预建 Token 拿不出明文)→ 改为「code 只存 uid/site_url/scope,兑换成功那一刻才 issue」;② 签名字符串口径两端不一致(Client 把带 query 的完整 URL 当 path)→ 新增 Market::signPath() 统一口径(参数一律走 body,query 不入签名)。
  • F-1 边界实测:清单公网 200;无签名访问 5 个 API 一律 401;包体经 HMAC 签名 + ts/nonce 防重放 + Token 级限流后流式输出。

四、市场 Hub 上下架改造(2026-10-05,插件 1.2.0 → 1.2.1)

要解决的问题:原「下架」= unlink() 掉 manifest 一个 JSON——包体 zip 永久占盘无人回收(注释承诺的「由 P9 回收」在全库没有任何代码,是空承诺),且系统里没有任何重新上架的入口,恢复只能重走一遍投稿审核。

选定方案 C(归档目录):data/market/manifests/(在架)+ 新增 manifests-off/(已下架),下架/上架 = 目录间 rename()。两份清单生成器(HubCatalog::rebuildIndex() 与 cli/release_build.php --type=index)都只扫 manifests/,一行都不用改——彻底消除「只改一处、站长跑 CLI 重建后已下架又回来」的风险。方案 A(manifest 标志位,要改 2 处生成器)与方案 B(库表,官方插件无投稿记录覆盖不全)均否决。

落地清单(10 个文件:9 改 + 1 新建):

文件改动
Plugin.php+SUB_MANIFESTS_OFF 常量、+manifestOffPath();activate() 建该目录
HubCatalog.php+listedEntries()(逐版本枚举,替代只留最高版的 entries())、+delistedEntries()、+scanManifestDir()(只读、不碰库)
AdminController.phpdelist() 由 unlink 改 rename 归档;+delistAll()(整插件下架,底层仍逐版本)、+relist();三处写 AuditLog;TABS 加 off
hook/admin_route_register.php+POST /market-hub/delist-all、+POST /market-hub/relist
views/admin/index.php「已上架」改逐版本(后台可见 50 → 54 条,多版本分行)+「下架整个插件」;+「已下架」Tab(含上架按钮);确认框改 data-confirm
lang/{zh,en,zh_tw}.php各 +16 键(三语同序;f_package 改名 f_pkg,避免与投稿表单键撞名)
plugin.json1.2.0 → 1.2.1
assets/admin.js新建:form[data-confirm] 提交前二次确认(替代内联 onsubmit)

必须处理的坑:Windows 同进程 rename() 覆盖已存在文件会拒绝访问(先 unlink 再 rename,幂等);上架旧版本时清单「只留最高版」→ 看起来像 bug,UI 必须提示;老站不会重跑 activate() → 按需 mkdir 兜底。

验证:改动前门禁复算 PASS(清单 = manifests/ 的确定性函数,50/50 零差异,改动后复算仍成立);行为探针 20/0(可逆、幂等、冲突拒绝、index.json 逐字节还原、PRG 码与视图码表一致);HTTP 冒烟 17/0(三 Tab 全 200、无会话 302、零原始 i18n 键、零内联、admin.js 可达);plugin_hygiene.py FAIL=0;fh_i18n_check.php 55/0(138 键 ×3)。

与插件商店的关系:纯 Hub 侧插件改动。50 条清单 / 54 manifest / 54 包体零影响,第三方站点拉到清单不变,已装插件更不受影响。出 1.1.2 发布包时必须纳入(含新文件 assets/admin.js)。

五、遗留与风险

待站长拍板

  • 出 1.1.2 发布包:会写 release/core/latest.json → 直接影响线上 /admin/update/check(所有用户看到「有新版本 1.1.2」);出包后必须清 data/runtime/update_check.json(6h 缓存)。未擅自执行。
  • market_hub 是否也出插件包:默认不发布(官方站专用,普通站点装上会把自己变成 Hub)。
  • 「彻底删除」按钮(删 manifest + 包体 + 投稿记录):暂不做,破坏性操作。
  • 静态资源版本号硬编码:每次改 CSS 要手动同步 3 处,是否改用 filemtime 待定。
  • 签名口令与私钥同目录,转存密码管理器后应删除口令文件。

已知残留风险(本期不修)

  • 诱导授权面:site_url 由请求方提供,未做域名白名单/回调二次校验;缓解 = 授权页醒目展示站点 URL + 人工确认 + code 与 site_url 绑定且一次性。
  • 投稿包无 RSA 签名:Hub 运行期无发布私钥 → 社区投稿包 manifest 无 signature,Client 侧退化为「与索引登记的 sha256 一致」(索引经 HTTPS 取回)。这是有意的降级,强度弱于官方签名包。
  • data/runtime/packages/ 线性累积(约 1.8 MB/次,核心无清理逻辑):候选方案已列(倾向 Updater::prunePackages() 留最近 1~2 个),必须同时覆盖核心包与插件包。
  • 计划任务若以 root 跑,data/runtime/* 属主变 root,PHP-FPM(www) 可能写不动(既有隐患)。
  • 生产 2 核 VPS 哈希/解压慢 2~3 倍:升级必须分批 + 缓存,不可一次跑完。

回滚路径(商店)

  • 关商店:后台禁用 market_hub → market_role 回落 client,data/market/ 与库保留。
  • 完全移除:卸载插件只清设置项,不动 data/market/。
  • 代码级:version.php 改回 V1.1.1;Market.php 的改动(signPath / 签名感知 / raw 回传)是加法,回退不影响既有安装路径。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 5 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入