豪言微语
长文

豪言微语 · 2026-09-12 更新日志

2026-09-12 21:16:49 预计阅读 9 分钟

今天给这个自托管的朋友圈做了 15 次提交、打了 7 个版本(v11 → v17):从图标、排版、上传链路的重构,一直到晚上落地的「文章数据一键导出 / 导入」。

✨ 视觉:从「能看」到「耐看」

  • 图标重做:favicon 从「字体拼字」换成纯几何的「时间线气泡」——对话气泡 + 虚线时间轴 + 橙色圆点,不再依赖任何字体,各平台渲染一致。
  • 一源生成全套图标:favicon.svg / 16 / 32 / .ico / apple-touch-icon / 192 / 512 全部从同一个 SVG 源导出,彻底消除多个尺寸之间的参数漂移;分享海报也改画新 logo,图片加载失败时回退文字。
  • 全站头部统一:归档、首页、详情、搜索四页的顶栏收敛成同一套——返回控件统一为「箭头 + 站名」,站名用 17px / 500 字重,去掉各页面残留的胶囊底色等不一致处理。
  • 字体只用系统字:移除 webfont 依赖,补上字体合成与字距微调,打开页面不再等待字体下载。

🧩 结构:一次「改一处,全站生效」的重构

这一轮重构的动力来自真实发生的分叉:四个模板各持一份 <head> 副本,其中归档页和搜索页已经悄悄缺少红色调色板与 console.warn 补丁;正文样式在 .markdown-body 与 .article-body 两份副本里出现参数漂移(图片圆角 12/14、引用边线色、列表缩进都对不上)。

  • 抽出 templates/_head.html(meta / 图标 / 主题色 / Tailwind 调色板 / 主题初始化)与 templates/_macros.html(返回控件、主题切换、头部外壳、图片查看器浮层);
  • 正文排版统一为一份 .markdown-body,顺手修掉「详情页图片查看器引用了旧类名导致点击放大失效」的回归;
  • 图片查看器从首页与详情页各自的一份实现(约 40 行 × 2)抽成 static/js/image-viewer.js:改为事件委托绑定,分页加载与新发布动态插入的图片自动生效,不必重新绑定;行为参数化(首页按卡片分组翻页、详情页全页一组),滚动锁定统一;
  • 静态资源按类型归位:static/css/、static/js/、static/images/,并删除 340 行已无引用的死代码 app.js;
  • 静态资源版本号同步改为「内容哈希」,git checkout 或镜像构建后 mtime 不变也能正确刷新缓存。

🚀 上传:彻底告别中转,直传对象存储

  • 原来的问题:图片视频先传到服务器再转 OSS,内网上传带宽成了瓶颈。
  • 新增 /api/upload/ticket:服务端只签发一次性预签名 PUT,浏览器直传 OSS,文件字节全程不经过本站。配套还有 scripts/upload_probe.py——一个同机同刻做 A/B 对照的探测页,用来验证直传链路。
  • 统一入口:直传失败不再默默回退到中转链路,改为退避重试(1s / 3s);旧中转实现保留但不激活,UPLOAD_DIRECT_ENABLED=0 可整体回退,无需改代码。
  • 上传链路加固:以文件头魔数判定真实类型并覆盖对象类型(拒绝 SVG,可内嵌脚本)、写入 Cache-Control / Content-Length、OSS 异常分流为 415 / 502 / 503、响应补上 key / size / content_type;发布面板换成 XHR 真实字节进度、并发 3 上传、仅 5xx 重试、发布失败可复用已上传的 URL。
  • 体积防线前置:新增 ASGI 中间件,在读取请求体之前就拦掉超限请求(上传 200MB、写接口 1MB、口令 4KB),避免匿名大体积请求把内存和 /tmp 塞满。
  • 媒体展示优化:站内 OSS 图片自动追加 x-oss-process(自动纠正方向 + 宽 720 + 质量 82)生成缩略图,列表与详情只加载缩略图,点开查看器再回退原图;动图、视频、外链、已带参数的地址一律原样返回。同时统一了相框配色,深色模式下不再出现偏蓝色块。

📖 阅读体验

  • 修掉一个长期存在的「段落间距虚高」问题:容器换行被当成空行渲染,段落之间的空白几乎是显示的两倍——现在容器用 normal,软换行只保留在段落与列表项内。
  • 详情页正文限制到约 36 字/行并居中,标题、段落、列表、引用、代码块、分割线的字号 / 字重 / 行高 / 外边距整体重调;首页长文预览的标题层级单独收一档,避免「卡片标题比正文里的 # 还小」的倒挂。
  • 海报只给长文绘制标题,「此刻」类动态不再出现「未命名文章」的兜底文案,与详情页的「此刻」处理保持一致。

🧰 运维:数据不再只躺在服务器上

  • 一键导出:新增隐藏页 /admin/data(需登录;站内没有任何公开链接指向它,只能手输地址)。点一下就把 posts/ 下全部 Markdown 原文件字节打包成 zip 下载——包含私密动态,图片视频不在包内。
  • 上传还原:选 zip 上传即可导入,文件名与 front-matter 原样保留,时间线顺序不变;同名默认跳过(避免误覆盖),需要时勾选覆盖。
  • 导入侧的防线(外部压缩包一律不可信):
    1. 只取文件名 basename,../../x.md 这类越界名逃不出目录(zip slip);
    2. 只收 .md,macOS 压缩自带的 __MACOSX/、.DS_Store 自动忽略;
    3. 必须能解析且带 date 字段,坏文件单独报错、不影响整批;
    4. 单篇解压上限 1MB,防「小体积、大解压」的 zip bomb;
    5. 先写隐藏临时文件再原子替换,不产生半截内容;有写入才失效缓存。
  • 可安全自测:把导出的 zip 原样再导入一次,全部命中同名跳过,天然幂等,不会动到任何数据。
  • 新增两个环境变量:MAX_IMPORT_SIZE(默认 32MB)与 IMPORT_MAX_FILE_SIZE(默认 1MB)。
  • OSS 孤儿清理脚本:scripts/list_orphans.py 生成只读清单,delete_orphans.py 默认 dry-run、必须手工输入对象数量才执行删除。之所以不做自动清理:生命周期规则无法判断对象是否仍被动态引用,而删除不可逆。

📊 今日数据

  • 提交:15 次
  • 版本 tag:7 个(v11 → v17)
  • 文件变更:31 个文件,+2864 / −865

新增的主要文件:templates/admin_data.html(数据管理页)、templates/_head.html、templates/_macros.html、static/js/image-viewer.js、scripts/list_orphans.py、scripts/delete_orphans.py、scripts/upload_probe.py。

今日版本时间线:

  • v11(12:17)图标重设计与全站头部排印统一
  • v12(12:30)模板抽取重构:公共 head 与宏、正文排版单点化
  • v13(12:39)图片查看器抽取与死代码清理
  • v14(12:49)静态资源目录整理
  • v15(12:59)模板宏化:头部外壳与图片查看器浮层
  • v16(13:41)上传链路加固与请求体体积前置限制
  • v17(20:57)Markdown 数据导出/导入与 /admin/data 隐藏管理页

🔭 已知边界

  • 导出只管文本:图片与视频仍留在 OSS,动态里的地址照旧指向原 Bucket;若将来更换 Bucket,需要另外做一次媒体迁移。
  • 生产环境建议补齐 AUTH_SECRET:留空时登录态只在当前进程内有效,重启即失效(启动日志会告警)。
  • CORS 仍是 ["*"](代码内有 TODO(Security)),上线前应收敛为显式域名白名单。
  • 旧的中转上传 /api/upload 保留可用,但前端已不再走它。

一句话总结:今天把「看得见的部分」(图标、排版、阅读体验)磨了一遍,把「看不见的部分」(模板与资源结构、上传链路、数据主权)重做了一遍,最后给内容上了把锁——文章数据随时可以打包带走。