一、施工范围(你的 7 条要求 → 落地口径)
| # | 要求 | 性质 | 落地口径 |
|---|---|---|---|
| 1 | 统一收敛层 | 地基 | 84 处路径拼接全部收敛到统一 API(视图 55 + PHP 26 + 硬编码 3)。必须先做,否则分月与迁移无落点。 |
| 2 | 全类型分月 | 地基 | 附件 / 编辑器正文图 / 头像 / 博客封面 + seed 头像 → 全部进 YYYY-MM/。seed 头像保持 seed_avatars/YYYY-MM/ 前缀(见 3.4)。 |
| 3 | 缩略图体系 | 呈现 | thumb_ 400×300(附件卡片)+ md_ 800×600(正文图);头像不生成缩略图。 |
| 4 | 旧数据迁移 | 一次性 | 文件 + 全部字段 + 全部正文 URL 一次性彻底迁移;迁移后不存在新旧并存(见 4.4)。 |
| 5 | 附件卡片 UI | 体验 | 公共组件 + Modern 主题样式 + 系统内置类型图标 + 大小格式化 + 下载按钮 + 移动端适配。仅 Modern 主题(Xenforo 不在本次范围)。 |
| 6 | 下载计数 | 功能 | 表结构(走补丁清单)+ 原子自增;仅非图片计数;卡片展示「下载 N 次」。 |
| 7 | 灯箱 | 体验 | 全页连读多图切换 + 预载;只在详情页使用,不做全局;其他页面图片直接用缩略图。 |
二、地基一:统一收敛层(60 处 → 1 套 API)
2.1 统一 API(app/Helpers/Upload.php)
/** 相对路径 → 可访问 URL
* '2026-09/ab12….png' → '/assets/uploads/2026-09/ab12….png'
* 'seed_avatars/2026-09/1.svg' → '/assets/uploads/seed_avatars/2026-09/1.svg'
* 'covers/2026-09/x.svg' → '/assets/uploads/covers/2026-09/x.svg'
* 'ab12….png' → '/assets/uploads/ab12….png' ← 兼容旧值(迁移前/漏网数据)
* 'http(s)://…' → 原样返回(外链不动)
* '' → '' ← 便于视图直接输出 */
public static function url(string $rel): string
/** 相对路径 → 绝对文件系统路径
* 禁止 '..';白名单正则(可选命名空间 + 可选年月目录 + 安全文件名)+ realpath 前缀校验;
* 非法或文件不存在 → null。所有 unlink / readfile / file_exists / is_file 一律走它。 */
public static function path(string $rel): ?string
/** 缩略图 URL:存在缩略图文件则返回,否则回退 url()(零 404)
* $prefix: 'thumb_' = 400×300(附件卡片)、'md_' = 800×600(正文图) */
public static function thumbUrl(string $rel, string $prefix = 'thumb_'): string
/** 字节数友好格式化:1536 → '1.5 KB'、2516582 → '2.4 MB' */
public static function formatSize(int $bytes): string
/** 生成相对路径(内部):'2026-09/.' */
private static function makeRelPath(string $ext): string2.2 视图入口(app/Core/TemplateCompiler.php)
public function uploadUrl(string $rel): string // → Upload::url($rel)
public function uploadThumb(string $rel, string $p = 'thumb_'): string2.3 改造范式(前后对比)
| 场景 | 改造前 | 改造后 |
|---|---|---|
| 普通图片 | <?php echo $this->e(\UPLOAD_URL . $avatarPath); ?> | <?php echo $this->e($this->uploadUrl($avatarPath)); ?> |
| CSS 背景图 | background-image:url('<?php echo $this->cssValue(\UPLOAD_URL . $cover); ?>') | background-image:url('<?php echo $this->cssValue($this->uploadUrl($cover)); ?>') |
| 附件缩略图 | <img src="/attachment/{id}"> | <img src="/attachment/{id}?s=thumb"> |
| 附件下载直链 | <?php echo $this->e(\UPLOAD_URL . $att['filename']); ?> | <?php echo $this->e($this->url('/attachment/' . (int)$att['id'])); ?> |
e() 包裹必须保留(值来自数据库)。收敛只针对「路径拼接」这一件事,转义职责不变(项目视图规范 = 赋值时转义)。
2.4 视图层 31 处清单
| 文件 | 行号 | 类型 |
|---|---|---|
| app/Views/forum/index.php | 60, 93, 150, 182 | 头像 ×4 |
| app/Views/forum/category.php | 70, 101 | 头像 ×2 |
| app/Views/home/index_default.php | 48, 82, 140 | 头像 ×3 |
| app/Views/home/index_default.php | 127 | 博客封面(现未走 cssValue,一并修正) |
| app/Views/blog/index.php | 50, 63 | 封面 cssValue + 头像 |
| app/Views/blog/detail.php | 47, 70 | 封面 + 评论头像 |
| app/Views/blog/edit.php | 63 | 当前封面预览 |
| app/Views/thread/show.php | 89, 239 | 头像 ×2 |
| app/Views/thread/edit.php | 105 | 下载直链 → /attachment/{id} |
| app/Views/post/edit.php | 41 | 下载直链 → /attachment/{id} |
| app/Views/profile/index.php | 25 | 头像(此处原本未加 \ 前缀) |
| app/Views/search/index.php | 121 | 头像 |
| app/Views/tags/show.php | 32 | 头像 |
| app/Views/_components/right_sidebar.php | 62, 158 | 头像 ×2 |
| plugins/dice/views/index.php | 80 | 头像 |
| plugins/floor_reply/views/list.php | 42 | 头像 |
| plugins/quiz_duel/views/index.php | 45, 88 | 头像 ×2 |
| plugins/red_packet/views/detail.php | 73 | 头像 |
| plugins/user_level/views/like_list.php | 60 | 头像 |
| plugins/user_profile/views/front.php | 27 | 头像 |
2.5 PHP 层 26 处清单
| 文件 : 行 | 用途 | 改造 |
|---|---|---|
| app/Helpers/Upload.php:84,86,87,129 | 落盘 + 缩略图路径 | makeRelPath() / path() |
| app/Controllers/AttachmentController.php:85 | 下载读文件 | basename() → Upload::path() |
| app/Controllers/ApiController.php:278,280,281 | 远程拉图落盘 | 走 makeRelPath() |
| app/Controllers/BlogController.php:283,288,293,328 | 封面替换 / 删除 | Upload::path() + 顺带删缩略图 |
| app/Controllers/ProfileController.php:100,101 | 旧头像删除 | Upload::path() |
| app/Helpers/Settings.php:822,930 | 孤儿清理 + deleteAttachment | 同上(清理已递归,需回归) |
| app/Helpers/CoverTemplate.php:94 | covers 目录 | → covers/YYYY-MM/ |
| app/Models/Thread.php:742,843 | 硬删帖清附件 | 白名单放行 / + path() |
| app/Models/User.php:213,219 | seed 头像目录与返回值 | → seed_avatars/YYYY-MM/(见 3.4) |
| app/Models/User.php:614 | 级联删附件 | path() |
| plugins/content_review/Plugin.php:138,205 | 审核通过 / 驳回删头像 | path() |
| plugins/points_mall/FrontController.php:204 | 已用 realpath 前缀校验 | 替换为 path()(等价,统一入口) |
| plugins/ai_assistant/config.php:22,23 | 插件自定义 UPLOAD_PATH / URL | 改为引用核心常量,消除双份定义 |
| cli/seed_content.php:281,285 | seed 头像生成(独立实现) | 同步 seed_avatars/YYYY-MM/ |
assets/js/editor.js:2046 + editor.php:2046 | 硬编码前缀判定 | 无需改(分月后 URL 仍含 /assets/uploads/),但需回归 |
三、地基二:全类型按月分目录
3.1 目录结构
assets/uploads/
├── 2026-09/
│ ├── ab12cd34….png 附件 / 编辑器正文图 / 用户头像 / 博客封面
│ ├── thumb_ab12cd34….png 缩略图 400×300(附件卡片)
│ └── md_ab12cd34….png 中图 800×600(正文内嵌图)
├── covers/
│ └── 2026-09/
│ └── tpl_a1b2c3.svg 模板生成封面(CoverTemplate)
└── seed_avatars/
└── 2026-09/
└── 1.svg 默认种子头像(按用户注册月份)3.2 各类型处理表
| 类型 | 入口 | 分月 | 存库字段(迁移后) | 视图取值 | 缩略图 |
|---|---|---|---|---|---|
| 表单附件 | Upload::file() ← Settings::handleAttachments / ThreadController / PostController | 是 | attachments.filename = 2026-09/… | /attachment/{id}?s=thumb | thumb_ |
| 编辑器正文图 | POST /api/upload | 是 | 不入库(URL 在正文里) | 正文 HTML(渲染期改写) | md_ |
| 远程拉图 | /api/fetchImage | 是 | 不入库 | 同上 | md_ |
| 用户头像 | ProfileController::upload_avatar | 是 | users.avatar = 2026-09/… | uploadUrl($avatar) | 不需要 |
| 博客封面上传 | BlogController 的 Upload::file() | 是 | blogs.cover_image = 2026-09/… | uploadUrl($cover) | 可选(列表页省流量) |
| 模板生成封面 | CoverTemplate::saveCover() | 是 | blogs.cover_image = covers/2026-09/…svg | 同上 | SVG 无需 |
| 默认种子头像 | User::defaultAvatarPath() / cli/seed_content.php::writeAvatar() | 是 | users.avatar = seed_avatars/2026-09/{id}.svg | 同上 | 不需要 |
3.3 缩略图档位
| 前缀 | 尺寸 | 用途 | 生成时机 |
|---|---|---|---|
thumb_ | 400×300 | 附件卡片 | 上传时同步(现有逻辑) |
md_ | 800×600 | 正文内嵌图(新增) | 上传时同步(复用 makeThumbnail() 的 $maxW/$maxH) |
| 头像不生成缩略图(本身就是 96×96 的小 SVG 或用户上传的小图,再缩无意义)。 |
两档各自独立判定「原图 ≤ 目标尺寸则 skip」→ 小图不产生冗余文件,thumbUrl() 自动回退原图。
3.4 seed_avatars 分月设计
你的要求是「seed 头像也应该进 YYYY-MM/,因为默认注册的就是 seed 头像」。确认它的生成逻辑后,落地口径如下:
路径形态:seed_avatars/2026-09/{id}.svg —— 保留 seed_avatars/ 前缀,在其内部再按月分层。
为什么保留前缀(而不是直接放 2026-09/1.svg):
- plugins/task_center/Plugin.php:491 用
strpos($av, 'seed_avatars/') !== 0靠前缀判定「是否为默认头像」(任务中心用它区分「已设置头像」)。 保留前缀 → 该判断零改动;若去掉前缀,任务中心的判断会全部失效(所有用户被误判为「未设置头像」)。 - 语义清晰:一眼可辨「系统生成的默认头像」vs「用户上传的头像」,便于清理与统计。
月份来源:用户注册月份(users.created_at)。
- 注册时生成 → 当月即注册月,直接
date('Y-m')即可;同时加一个可选参数?string $createdAt = null供迁移/回填显式指定,未传则取当前月。 - 迁移老数据时 → 按该用户
created_at分配月份,确定性保持(同一用户恒定同一路径,不会漂移)。
确定性不受影响:现有逻辑是 if (!is_file($file)) file_put_contents(...) —— 路径变了,但「同 id 恒同文件、已存在则复用」的语义完全保留。
现状规模:assets/uploads/seed_avatars/ 现有 281 个 SVG,迁移时逐个按所属用户的 created_at 归月。
四、旧数据一次性彻底迁移
4.1 脚本:cli/migrate_uploads_by_month.php
用法:
php cli/migrate_uploads_by_month.php --dry-run # 只出报告,零改动(默认)
php cli/migrate_uploads_by_month.php --yes # 真正执行
php cli/migrate_uploads_by_month.php --only=files # 只移文件不改数据(分阶段用)
迁移范围(7 类):
① assets/uploads/*.ext → assets/uploads/YYYY-MM/
(thumb_ / md_ 前缀文件跟随其原图所在月份)
② assets/uploads/covers/*.svg → covers/YYYY-MM/
③ assets/uploads/seed_avatars/*.svg → seed_avatars/YYYY-MM/(按用户 created_at)
④ attachments.filename 纯文件名 → 'YYYY-MM/文件名'
⑤ users.avatar 同上;seed 头像 → 'seed_avatars/YYYY-MM/{id}.svg'
⑥ blogs.cover_image 同上;模板封面 → 'covers/YYYY-MM/x.svg'
⑦ 正文 / 内容中的 URL(6 个位置):
topic_index.excerpt_images (逗号分隔完整 URL)
extern/ 下 topic 正文 txt (按 topic.extern_path 定位)
extern/ 下 reply 正文 txt (按 reply.extern_path 定位)
blogs.content
blog_comments.content
settings 表所有值(custom_css 等可能引用图片)
输出日志 data/runtime/migrate_uploads_<时间戳>.log(逐条 src → dst)月份判定优先级
- 有库记录 → 用记录时间(
attachments.created_at/users.created_at/blogs.created_at) - 无记录(编辑器图、孤儿文件) → 文件
mtime - thumb_ / md_ 前缀 → 跟随原图月份(保证同组文件同目录)
4.2 安全设计(缺一不可)
- 默认 dry-run,必须显式
--yes才动文件 - 幂等:路径已含
YYYY-MM/的跳过,重复执行结果一致 - 精确替换:正文 URL 按「迁移清单中的文件名」逐个
preg_quote匹配,不用宽泛正则(防ab12.png误伤ab12.png.bak) - 先备份:脚本启动即检查当日备份是否存在,无则拒绝执行并打印备份命令
- 失败即停:单个文件移动失败(跨盘 / 占用)立即中止,打印已完成条数便于人工回滚
- 只移不删:任何情况下不删除文件
4.3 操作步骤(等你把生产数据覆盖到本地后)
- 你把服务器的
assets/uploads/与data/覆盖到本地。注意main_index.sqlite是 WAL → 必须连-wal/-shm一起拷,否则读不到表。 - 停站(或确认无人访问)。迁移期间不接受写入。
- 全量备份到
.workbuddy-ai/backup/20260919_附件迁移/:assets/uploads/+data/meta/+data/bucket/+data/extern/。 - 我先在备份副本上完整演练一遍(dry-run → 实际执行 → 抽样验证),确认无误。
- 对正式数据跑
--dry-run,把报告给你确认(重点看「无法判定月份的文件」数量与抽样)。 - 你确认后执行
--yes,完成后输出对账(文件数 / 目录数 / DB 改动行数)。 - 回归验证:抽 10 个附件下载、10 张正文图、5 个头像、5 个 seed 头像、5 个封面;跑孤儿清理确认不误删。
4.4 「迁移后无新旧并存」的口径
按你的要求,迁移是一次性彻底的:迁移完成后,所有数据里的路径都是新布局(含 YYYY-MM/),不存在「一部分在根目录、一部分在子目录」的混合状态。
因此迁移后:
- 视图走
Upload::url()输出的全是新路径; Upload::url()里对「无斜杠旧值」的兼容分支保留但不再是常态路径——它是防御性兜底(万一有漏网数据或第三方插件直写了旧格式),不是设计上的「两套并存」;- 迁移脚本的验收标准就是:二次 dry-run 报告里「待迁移项 = 0」,即再跑一次不会有任何改动。
五、正文图缩略图(渲染期)
落点:app/Helpers/Content.php:121-126 的 if ($tag === 'img') 分支(净化循环内)。
if ($tag === 'img') {
$node->removeAttribute('srcset');
$node->removeAttribute('sizes');
$node->setAttribute('loading', 'lazy');
$node->setAttribute('decoding', 'async');
// 站内上传图 → 换中图 + 记录原图供灯箱使用
$src = $node->getAttribute('src');
if (preg_match('#^/assets/uploads/(.+)$#', $src, $m)) {
$rel = $m[1];
$md = Upload::thumbUrl($rel, 'md_'); // 无 md_ 则回退原图
if ($md !== Upload::url($rel)) { // 确认确实换了图才写 data-fullurl
$node->setAttribute('data-fullurl', $src);
$node->setAttribute('src', $md);
}
}
}ThreadController:233 → Content::formatPostContent())→
存量帖立即生效、无需重存、不改数据;② 迁移脚本因此不必处理「正文里是否要换缩略图」,只改目录;
③ 将来调尺寸/策略只改这一处。
data-fullurl
(Content.php:53 $allowedAttrs = ['href','src','alt','title','target','rel','class','width','height','style'])。必须放行该属性,并对它的值做协议白名单校验(仅
http(s):// 或 / 开头),否则等于开了个属性注入口子。
正文图不包 <a>:thread.js 已有 .post-text-content img 全局点击委托;且 data-action 不在净化白名单里,加了也会被删。
六、非图片附件下载次数
6.1 表结构(必须走补丁清单)
ALTER TABLE attachments ADD COLUMN download_count INTEGER NOT NULL DEFAULT 0Schema::ensureColumnPatches() 的 $columnPatches 清单(app/SplitDB/Schema.php:610)。
Schema::bootstrap() 只在 data/meta 不存在时执行,存量站升级不会重跑 → 只改 CREATE TABLE 对老站完全无效。
6.2 计数语义
| 场景 | 请求 | 计数 |
|---|---|---|
| 非图片附件下载 | /attachment/{id}(非 image/*) | +1 |
| 图片原图查看 | /attachment/{id}(image/*) | 不计 |
| 图片缩略图 | /attachment/{id}?s=thumb | 不计 |
| 编辑页下载 | 改造后统一走 /attachment/{id} | +1(原先绕过权限校验,现纳入) |
UPDATE attachments SET download_count = download_count + 1 WHERE id = :id -- 原子自增,不做 read-modify-write6.3 i18n
- 新增键
attachment.download_count/attachment.download,三语同步(lang/{zh,en,zh_tw}.php,基线各 1224 键) I18n::get()缺键是return $key;→ 不报错,只在页面把键名当文案吐出来,必须跑全量比对
七、附件卡片 UI(公共组件 + Modern 样式)
7.1 组件收敛:2 处渲染 → 1 份代码
新增 app/Views/_components/attachment_card.php(模板引擎已支持 {include} → $this->include($tpl, $extra)),
入参 att / variant(main | post)/ showDownloads。
调用点:app/Views/thread/show.php(2 处:主楼 + 回帖)→ 收敛为 1 份逻辑。
组件只输出语义类 .fh-attach-*,样式由 modern.css 提供。
7.2 视觉设计(Modern)
图片卡
- 栅格:
display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:8px - 缩略图:
width:100%; aspect-ratio:4/3; object-fit:cover(去掉 200×150 硬编码) - hover:
scale(1.03)+ 主色描边;prefers-reduced-motion下禁用 - 文件名单行省略;
src="/attachment/{id}?s=thumb"
文件卡
┌────────────────────────────────────────────────┐
│ ┌────┐ project-spec-2026.pdf │
│ │PDF │ 2.4 MB · 下载 37 次 │
│ └────┘ [ ↓ 下载 ] │
└────────────────────────────────────────────────┘- 类型图标(复用系统内置图标,不新增资源):
pdf→file-pdf、zip/rar/7z→file-archive、doc/docx→file-word、xls/xlsx→file-excel、txt/md→file-alt、其他→file - 大小走
Upload::formatSize(),替代现有2048.0 KB - 下载按钮复用
.mn-btn.mn-btn-sm+download图标
7.3 移动端(@media (max-width:768px))
- 图片栅格降为
minmax(100px,1fr),480 以下 2 列 - 文件卡:图标 + 文件名占满,按钮不压缩;文件名
word-break:break-all(已有.thread-file-name可复用) - 触控目标高度 ≥ 40px
7.4 主题范围
八、灯箱(全页连读 + 预载,仅详情页)
8.1 加载范围(按你的要求)
| 口径 | |
|---|---|
| 使用范围 | 仅详情页(帖子详情 + 博客详情),不做全局,不进 layouts/main.php |
| 容器 | 收敛为公共组件 app/Views/_components/image_lightbox.php,由 2 个详情页视图各自 include(不是布局层)→ 代码从 2 份变 1 份,加载范围不变 |
| JS | 从 thread.js 拆出 assets/js/lightbox.js,仍只在详情页加载。好处:博客详情页不必再为了灯箱加载 thread.js(它含引用 / 附件 / 折叠等帖子专属逻辑) |
| 其他页面 | 图片直接用缩略图(thumbUrl()),不提供灯箱 —— 符合你的要求 |
| 关闭方式统一 | 统一为 data-action="close-lightbox" 事件委托(现在 Modern 详情页用的是内联 onclick,不一致) |
8.2 多图切换设计(全页连读)
| 能力 | 实现 |
|---|---|
| 图组收集 | 打开时现算:document.querySelectorAll('[data-fullurl]') 按 DOM 顺序全页收集,可跨楼层连续翻阅。不可缓存成静态数组——htmx 翻页只替换 #replies,静态数组会失效 |
| 预载 | 打开时预载下一张;每次切换后预载新的上一张与下一张(new Image().src = url);已缓存则瞬切无白屏 |
| PC 操作 | ← / → 键切换;左右悬浮箭头按钮;顶部 3 / 8 计数器 |
| 移动端操作 | 未放大时左右滑动 = 切换(阈值 50px,横向位移需大于纵向);放大后单指拖动 = 平移(沿用现有 lbScale > 1 分支,天然不冲突) |
| 循环 | 首张往前 → 末张;末张往后 → 首张 |
| 状态重置 | 每次切换重置 lbScale=1 / lbTx=0 / lbTy=0(沿用现有逻辑) |
| CSS | .image-lightbox img { max-width:92vw; max-height:88vh }(现为 60vw/60vh,多图阅读偏小)+ 箭头 / 计数器样式 + 移动端微调 |
touchstart 仅在 lbScale > 1 时进入 pan 模式;未放大时 lbTouch 为 null → 左右滑动完全空闲。切换逻辑只在「未放大」分支接管,不触碰现有缩放 / 平移代码。
data-fullurl 的图片。① 正文图 → 由 Content 渲染期自动加;② 附件区图片 → 视图加。
头像等小图不加该属性,避免被卷进多图连读。
九、改动文件汇总
| 文件 | 类型 | 改动 |
|---|---|---|
| app/Helpers/Upload.php | 改 | url() / path() / thumbUrl() / formatSize() / makeRelPath();file() 分月;makeThumbnail() 双档 |
| app/Core/TemplateCompiler.php | 改 | 新增 uploadUrl() / uploadThumb() |
| app/Helpers/Content.php | 改 | img 分支换 md_ + 写 data-fullurl;放行该属性并做协议校验 |
| app/SplitDB/Schema.php | 改 | $columnPatches 加 attachments.download_count |
| app/Controllers/AttachmentController.php | 改 | ?s=thumb 分支 + immutable 缓存头 + 非图片计数 + Upload::path() |
| app/Controllers/ApiController.php | 改 | upload / fetchImage 分月 |
| app/Controllers/{Thread,Post,Blog,Profile}Controller.php | 改 | 上传调用点 + 封面 / 头像删除走统一路径解析 |
| app/Helpers/Settings.php | 改 | handleAttachments / deleteAttachment / cleanupOrphanAttachments 适配 |
| app/Helpers/CoverTemplate.php | 改 | covers/YYYY-MM/ |
| app/Models/User.php | 改 | defaultAvatarPath() 加可选 $createdAt,路径 → seed_avatars/YYYY-MM/;级联删附件走 path() |
| app/Models/Thread.php | 改 | 附件删除白名单放行 / + path() |
| cli/seed_content.php | 改 | writeAvatar() 同步 seed_avatars/YYYY-MM/ |
| plugins/content_review/Plugin.php、points_mall/FrontController.php、ai_assistant/config.php | 改 | 统一路径解析 / 消除重复常量定义 |
| app/Views/_components/attachment_card.php | 新增 | 附件卡公共组件 |
| app/Views/_components/image_lightbox.php | 新增 | 灯箱公共组件(含箭头 / 计数器) |
| app/Views/ 12 个文件 | 改 | 55 处拼接收敛 + 附件区改组件 + 灯箱容器改 include |
| plugins/*/views/ 6 个文件 | 改 | 7 处头像拼接收敛 |
| assets/js/lightbox.js | 新增 | 灯箱 + 多图切换 / 预载 / 键盘 / 滑动(仅详情页加载) |
| assets/js/thread.js | 改 | 移除灯箱逻辑,保留引用 / 附件 / 折叠 |
| assets/css/modern.css | 改 | 附件卡样式 + 灯箱尺寸 / 箭头 / 计数器 + 移动端断点 |
| cli/migrate_uploads_by_month.php | 新增 | 迁移脚本 |
| lang/{zh,en,zh_tw}.php | 改 | 新增附件 / 灯箱相关键(三语同步) |
version.php、update/14/、增量补丁X.Y.Z.zip | 改/新增 | 升版 + 发布包 |
十、施工顺序(P0 → P7,逐段可验证)
| 阶段 | 内容 | 验证点 |
|---|---|---|
| P0 | 准备:备份 assets/uploads/ + data/{meta,bucket,extern};记录基线(文件数、附件行数、seed 头像 281 个、目录结构) | 备份可读、基线入档 |
| P1 | 收敛层:Upload::url/path/thumbUrl/formatSize + TemplateCompiler::uploadUrl/uploadThumb。纯新增,不动调用点 | php -l 通过;单元验证:新/旧值兼容、路径穿越(../)拦截、协议校验 |
| P2 | 拼接收敛:60 处全部替换为统一入口 | 全库 grep UPLOAD_URL / UPLOAD_PATH 仅剩 config.php 定义处;页面无 404 |
| P3 | 分月落盘:makeRelPath() 分月 + md_ 档位 + covers/YYYY-MM/ + seed_avatars/YYYY-MM/ | 新上传落入对应 YYYY-MM/;缩略图同目录;旧文件仍可访问(迁移前) |
| P4 | 下载计数:Schema 清单 + 非图片原子自增 | 存量库补列成功;下载 +1;图片查看不计 |
| P5 | 视图与交互:附件卡组件 + 灯箱组件 + lightbox.js + 双主题 CSS + 正文图缩略图 | Modern 主题正常;多图切换 / 预载 / 键盘 / 滑动可用;清缓存后生效 |
| P6 | 迁移:脚本编写 → 备份副本演练 → 你确认 → 对正式数据执行 | dry-run 报告确认;执行后抽样回归;二次 dry-run 应为「待迁移项 = 0」 |
| P7 | 发布:i18n 三语同步;升 FLINTHUB_VERSION;出 update/14/ + zip | 三语零差异;发布包结构符合约定 |
十一、风险与应对
| # | 风险 | 后果 | 应对 |
|---|---|---|---|
| 1 | 迁移不可逆(移文件 + 改库 + 改正文) | 附件 / 图片全站失效 | 默认 dry-run;先备份;先在备份副本上完整演练;精确替换不用宽泛正则;失败即停;只移不删 |
| 2 | 60 处拼接漏改 | 局部 404 | 收敛层先行;改完全库 grep UPLOAD_URL/UPLOAD_PATH 应为 0 命中(除常量定义) |
| 3 | seed 头像分月破坏 task_center 的「是否已设头像」判定 | 所有用户被误判未设头像 | 保留 seed_avatars/ 前缀 → strpos($av,'seed_avatars/') 判断零改动(这也是选择该目录形态的原因) |
| 4 | 老站 ensureColumnPatches 漏加字段 | 下载计数报错 | 清单 + marker 验证;用存量库实测 |
| 5 | 净化放行 data-fullurl 引入属性注入 | 潜在 XSS | 对该值做协议白名单(仅 http(s):// 或 / 开头) |
| 6 | 正文图 md_ 不存在 | 图裂 | thumbUrl() 内 file_exists() 判定,不存在回退原图(makeThumbnail() 返回值不可信) |
| 7 | 灯箱图组在 htmx 翻页后失效 | 切换范围不对 | 打开时现算图组,不缓存静态数组 |
| 8 | 移动端滑动切换与平移冲突 | 体验混乱 | 严格用 lbScale > 1 分支:放大 = 平移,未放大 = 切换 |
| 9 | i18n 三语不同步 | 页面吐键名 | 跑「模板引用键 ↔ 三语字典」全量比对 |
| 10 | 改视图 / CSS 后未清页面缓存 | 以为没改对 | PageCache::invalidate()(data/runtime/pages/*.html) |
| 11 | 本机 Edit 偶发「报成功但未落盘」 | 漏改导致 500 | 关键改动后回读验证,不只信返回值 |
| 12 | 本机无 Chromium / 无真机 | 移动端与灯箱手势未实机验证 | 用「HTML 结构计数 + CSS 特异性分析 + JS 分支静态推演」验证;必要时你用手机实测 |
十二、开工前最后确认
范围已锁定:仅 Modern 主题。Xenforo 主题(assets/Xenforo/)不在本次改造范围,将单独删除 —— 因此它不进收敛清单、不改视图、不改 CSS。
⚠️ 时序提醒:在 Xenforo 被删除之前,其视图仍在用旧的 UPLOAD_URL . $x 拼接。若先执行了分月落盘(P3)而 Xenforo 尚未删除,该主题下的头像/封面会 404 —— 属预期,删除主题即消失。建议在 P3 之前先删掉 Xenforo,避免这段窗口。