侧边栏壁纸
博主头像
无涯茶榻

茶烟半榻窗竹雨,一卷残书梦里秋

  • 累计撰写 40 篇文章
  • 累计创建 1 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

无涯茶榻大图轮换实现:每请求随机的零依赖方案

「无涯茶榻」首页第一屏那张大图,刷新一次换一张。点开分类页、标签页、归档页,换的也是同一批图。这不是主题自带的轮播组件,也不是每天改一次配置的定时任务——而是每次浏览器发起请求时,由反代层现场随机挑一张图片跳过去。

这套机制从 2026 年 11 月 7 日重构上线至今,替换掉了此前依赖第三方随机图 API 的旧方案。这篇拆解它的完整工作原理:随机是怎么产生的、图片从哪来、为什么删一张图能立刻生效、以及在什么情况下它会破图,以及破图之后怎么自愈。

一、先看最终效果:轮转的到底是什么

站点上与「首屏大图」相关的有四个页面,它们共用同一张背景图。实测抓取服务端 HTML,四页的背景图地址完全一致:

/              background-image:url(/api/random-banner.jpg)
/categories/   background-image:url(/api/random-banner.jpg)
/tags/         background-image:url(/api/random-banner.jpg)
/archives/     background-image:url(/api/random-banner.jpg)

也就是说,浏览器拿到 HTML 时并不知道自己会看到哪张图——HTML 里只有一个固定路径,真正决定用哪张图的,是浏览器随后向这个路径发起的第二次请求。

这个路径不是一张静态图片,而是一个 302 跳转器。对它连续请求 100 次,实测结果:

指标 实测值
返回状态 100 次全部 302
不同跳转目标 45 个
单张最高出现次数 6 次(banner-43.jpg、banner-03.jpg)
新增的 18 张命中比例 41%(理论上 18/50 = 36%)

命中比例与理论期望吻合,说明随机确实是均匀的,不存在某几张图被偏爱的偏差。

二、整体架构:为什么把随机逻辑放在反代层

这是整个方案里最需要解释的一个设计选择。随机图片这件事,按理说可以在 Halo 应用层做,为什么非要放到 Nginx Proxy Manager 里?

因为 Halo 有一个绕不开的特性约束:图片的静态资源响应头是 Cache-Control: max-age=31536000,也就是缓存一年。如果让浏览器直接指向某一张具体的图片地址,无论怎么改配置,浏览器都会把第一张图钉死一整年——这就是「改了大图地址但页面没变」的经典原因。

而反代层完全不在这个缓存体系内。一个 302 跳转响应只要带上禁用缓存的头,浏览器每次都会老老实实重新发起请求。这就把「静态资源强缓存」这个死结绕开了。

核心思路:让浏览器请求一个「会变化」的地址,由反代层在响应的那一刻决定具体跳到哪张图。缓存的是跳转逻辑,而不是跳转结果。

完整链路如下:

浏览器
  |  GET /api/random-banner.jpg
  v
Nginx Proxy Manager(openresty + Lua)
  |  (1) FFI readdir 现场列 /banners,取 banner-01.jpg ... banner-50.jpg
  |  (2) 随机挑一张,直连 Halo 内部端口 HEAD 确认真的能访问
  |  (3) 302 -> /upload/banner-NN.jpg(保留 query)
  v
Halo 附件目录(用户在 Console 附件页自行上传/删除)

全链路只涉及本站的 /api/ 与 /upload/,不请求任何第三方域名。

三、随机是怎么产生的

核心代码是 NPM 的 advanced_config 里的一段 Lua,挂在 location = /api/random-banner.jpg 上。完整的判断逻辑分五步。

第一步:现场列目录,而不是读清单文件

最初的实现是读一个清单文件(banner_pool.txt),但那个文件只由同步脚本更新,问题很快就暴露出来了:

  • 删除 —— 端点会对不存在的图片做一次 HEAD 校验,能实时跳过,这一条本来就没事;

  • 新增 —— 清单是冻结的,新图永远进不了轮换,而且没有任何报错。

问题出在「谁能增删图」和「图池从哪来」这两件事没有对齐:最终决定权交给了人——在 Halo Console 的附件页手动上传删除。那图池就必须变成「请求时现场发现」。

做法是把 Halo 的附件目录以只读方式挂进 NPM 容器:

# /root/data/docker_data/npm/docker-compose.yml
    volumes:
      - ../halo/data/attachments/upload:/banners:ro

然后用 Lua 的 FFI 直接调用 C 库的 opendir/readdir 遍历目录:

local ffi = require("ffi")
ffi.cdef[[
    typedef struct __dirstream DIR;
    typedef struct {
      unsigned long  d_ino;
      long           d_off;
      unsigned short d_reclen;
      unsigned char  d_type;
      char           d_name[256];
    } dirent_t;
    DIR *opendir(const char *name);
    dirent_t *readdir(DIR *dirp);
    int closedir(DIR *dirp);
]]
local C = ffi.C
local d = C.opendir(BANNER_DIR)
if d ~= ffi.NULL then
    while true do
        local e = C.readdir(d)
        if e == ffi.NULL then break end
        local n = ffi.string(e.d_name)
        local nn = n:match("^banner%-(%d%d)%.jpg$")
        if nn then
            local v = tonumber(nn)
            if v >= NN_MIN and v <= NN_MAX then pool[#pool + 1] = n end
        end
    end
    C.closedir(d)
end

这一步没有 fork、没有子进程、没有清单文件,只是一次目录遍历。结果是 Console 里的增删操作立刻生效,中间没有任何同步窗口。

注意一个容易踩的坑:宿主机上 mount --bind 做的绑定挂载不会出现在容器里。Docker 默认使用 rprivate 传播,宿主机新建的子挂载不传播进容器。必须写进 compose 文件并重建容器才生效。

第二步:严格到位的命名过滤

过滤用的正则是 ^banner%-(%d%d)%.jpg$,有三个刻意为之的约束:

  • 必须是两位补零的编号(01 到 50)。一位数的 banner-1.jpg 不匹配。

  • 必须严格落在 01–50 区间内。banner-51.jpg 会被排除。

  • 不允许任何后缀。Halo 在上传时如果发现同名文件,会自动追加一个随机后缀(banner-03-OClX.jpg),这种带后缀的文件名不匹配正则,因此永远进不了轮换池。

第三条尤其值得留意。它是一个静默失败:图确实传上去了,Console 里也看得见,但永远不会在首页出现,没有任何报错。

实测当前附件目录里 113 个文件,NPM 容器挂载点里 114 个条目(多出来的是目录项本身),其中符合命名规则的恰好 50 张。其余 63 个是历史遗留——早期用 01-liquid-sugar.jpg 这类命名,以及一串时间戳命名的图片,它们不参与轮转。

第三步:直连 Halo 内部端口做存在性校验

随机挑中一张之后,并不能直接跳过去——Halo 里可能存在「文件在、记录没了」或者「记录在、文件被连带删了」的状态。

这里有个很隐蔽的坑。最初用的是 ngx.location.capture 来探测文件是否存在,但这个 API 会命中 location / 里的 force-ssl 规则,拿到的是 301(http 跳 https)而不是 200。于是 status == 200 永远不成立,结果就是兜底图 100% 触发——端点看起来完全正常,但永远只跳同一张图。

正确做法是绕开 Nginx,直接用 ngx.socket.tcp 连 Halo 的内部端口发一个 HEAD:

local HALO_HOST = "192.3.59.208"
local HALO_PORT = 8090
local function exists(path)
    local sock = ngx.socket.tcp()
    sock:settimeout(3000)
    local ok = sock:connect(HALO_HOST, HALO_PORT)
    if not ok then sock:close() return false end
    sock:send("HEAD " .. path .. " HTTP/1.0\r\nHost: www.wycha.top\r\nConnection: close\r\n\r\n")
    local line = sock:receive("*l")
    sock:close()
    if not line then return false end
    return line:find(" 200", 1, true) ~= nil
end

校验不通过就换下一张,最多试 10 次。

第四步:保留 query 字符串

主题模板在引用这个地址时会自己追加查询参数,比如 ?pageid=<post-id>。端点把原样的 query 拼到 302 目标上,Halo 才能继续按 ?width= 生成缩略图。

实测确认这个链路是通的:

端点 ?pageid=5   ->  https://www.wycha.top/upload/banner-26.jpg?pageid=5
端点 ?width=800   ->  https://www.wycha.top/upload/banner-27.jpg?width=800

顺带一提,Halo 的 ?width= 只认四个预设值,其它任何取值都会退回 800 宽。实测:

请求的 width 实际返回字节
(无参数,原始图) 678,045
400 31,851
800 116,585
1200 253,110
1600 398,970
1920 116,585

可以看到 ?width=1920 拿到的是 800 宽的图(116,585 字节与 800 完全一致),说明这个预设表是硬编码的四个值,没有中间档。

第五步:禁用缓存

这一步是整个方案里最容易漏掉、后果也最隐蔽的一行:

add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;

如果只写了 302 跳转而不加这两个头,浏览器和 CDN 会把第一个跳转目标缓存住,之后每次访问都是同一张图。服务端日志看起来完全正常,因为它确实每次都返回了 302。这是最典型的静默失败。

四、主题侧怎么接

主题本身不需要任何改动,端点挂好后,把三个配置项指向它就行:

配置项 所属组 作用范围
big_banner_Photos beauty 首页 / 分类 / 标签 / 归档页顶部大图
random_img_api_url basic 分类、标签、文章封面为空的兜底
post_thumbnail home 文章卡片缩略图兜底

还有一个开关 basic.enable_random_img_api,它本身不参与渲染,只决定 Console 的主题设置面板里是否显示「随机图 API」这个输入框(主题的 settings.yaml 用它控制可见性)。不打开的话,模板照样能读到配置值,但你没法在界面上改。

改配置不需要重启 Halo——ConfigMap 是数据不是模板,PUT 完刷新页面就生效。只有改模板文件才需要重启,因为 Thymeleaf 不会热重载。

五、两级兜底:保证任何情况下都不破图

轮换池有可能被删空(图池全被清空、附件目录被误操作、或者 Halo 出故障)。如果端点跳到一个不存在的地址,浏览器会显示破图图标,而首屏大图是 CSS background-image——它加载失败时不会触发任何 JS 报错,页面照样 200,只是图出不来。这种故障排查起来极其费劲,因为服务端日志里什么都没有。

所以兜底是必须的,而且做了两级:

级别 路径 来源 特点
主兜底 /upload/banner-fallback.jpg Halo 附件库 用户可在 Console 自行替换
二级兜底 /themes/theme-Joe3/assets/img/author_bg.jpg 主题自带资源 Console 清理碰不到它

对应的 Lua 判断逻辑:

local target
if picked then
    target = "/upload/" .. picked .. q
elseif exists(FALLBACK) then
    target = FALLBACK .. q
else
    target = FALLBACK2 .. q
end
ngx.redirect(target, 302)

主兜底图放在附件库而不是主题目录,是为了让它可以被用户直接替换——换一张图不需要改任何代码。但附件有被误删的可能,所以再留一层主题自带的图兜底。

这两级兜底是实测验证过的,不是纸面设计。验证方法是临时把轮换池的文件改名移走(同盘 rename 是原子操作,测完原样恢复),然后观察端点行为:

故障注入 端点响应 目标图
轮换池 50 张全部移走 302 /upload/banner-fallback.jpg(72,189 字节,200)
主兜底也移走 302 /themes/.../author_bg.jpg(36,550 字节,200)
恢复轮换池 302 /upload/banner-31.jpg(回到正常轮换)

兜底图的文件名必须不匹配 banner-NN.jpg。否则它会被第一步的 readdir 收进轮转池,多出一张随机图,兜底语义直接失效。本站用的是 banner-fallback.jpg——「fallback」不是两位数字,正则不匹配。

六、数据一致性:磁盘、记录、可见

「文件在磁盘上」和「Halo 认得这个文件」是两件事。附件资源由三个环节共同构成:

  1. 物理文件:attachments/upload/ 下的实际字节;

  2. Attachment 资源:Halo 数据库里的记录,Console 附件页显示的就是它;

  3. 容器挂载:NPM 容器里 /banners 下能看到这个文件名。

任何一处不同步,端点就会出现「有的图抽中却跳不过去」或者「Console 里删了但还在轮换」。当前实测三者完全对齐:

检查面 数量
磁盘 banner-NN.jpg 文件 50
Halo 附件库 Attachment 记录 50
NPM 容器 /banners 可见 50
附件库记录有磁盘无 0
磁盘有记录无 0

Halo 的 LocalAttachmentUploadHandler 在 Attachment 记录被删时会连带删掉物理文件。这个行为在 Console 里手动删附件时很方便,但也意味着一次误操作会让两处同时消失。

七、实战踩过的坑

这一节记录的是真实出过问题的部分,以及后来怎么修的。

坑一:第三方图床证书过期,全站首屏白板

旧方案依赖外部随机图 API。2026 年 11 月 7 日,那个域名的 TLS 证书(notAfter=Nov 7 04:52:39 2026 GMT)过期,首页、分类页、标签页、归档页的大图全部白板。元素在、样式在,就是图加载不出来。

这次事故直接催生了现在的自建方案——把图片实体全部放在本站的 /upload/ 目录,零第三方依赖。顺带一个教训:页面 200 不代表页面是对的,验证图片必须查 naturalWidth > 0,而不是只看 HTTP 状态码。

坑二:ConfigMap 的 PUT 是整体替换

这是最贵的一个坑,值得完整说一遍。

Halo 主题的设置存在一个 ConfigMap 里,data 字段下按组分组,当前一共 23 个组:

ads archives aside basic beauty blogger carousel categories code_block custom
footer friends home journals leaving links navbar other photos post social tags theme

每个组的值是一个 JSON 字符串,而 PUT 接口的语义是整体替换——你 PUT 什么,数据库里就只剩什么。

某次只想改 beauty.big_banner_Photos 这一个键,代码里 GET 回来 23 个组、解析出 3 个组(basic / beauty / home)、改完键就 PUT 上去了。结果那 20 个没 PUT 的组全部被清空。

后果是全站渲染中断,而且症状极具误导性:

  • 主题模板读 theme.config.theme.enable_show_in_up 时拿到 null,SpringEL 抛 EL1001E: Type conversion problem, cannot convert from null to boolean;

  • 页面响应码还是 200——因为响应已经开始流式输出,异常发生在中途;

  • HTML 恰好在 32768 字节处被截断;

  • 首页、分类页、标签页、归档页全部变成半截页面。

「响应 200 + 内容在 32KB 处整齐截断」是 Thymeleaf 模板崩溃的典型特征,不是网络问题、不是内容太大、也不是主题坏了。

恢复过程本身就说明了为什么要留备份。Halo 的附件目录里有一份系统自带的备份 zip,里面的 extensions.data 存着所有扩展资源的完整导出,包括这个 ConfigMap 的原始内容。把它解出来、核对版本号与破坏前一致,再整体回写 23 个组,站点就恢复了。

核对时发现一个好消息:备份里的 basic / beauty / home 三组与破坏前的值逐键完全一致,唯一差异就是本次要改的那一项。也就是说备份版本就是破坏前的版本,没有丢失任何中间改动。

事后补上了三重防护,写进了管理脚本:

  • 改前断言 group 数量不少于 23;

  • PUT 前断言待提交的 group 集合与 GET 回来的一致;

  • PUT 后逐 group 逐键比对,实际变化的键必须只有目标键。

同时给脚本加了一条硬性要求:改 ConfigMap 之前,先把 GET 全量落盘存备份。只备份要改的那一个组是不够的——出问题时你会发现另外 22 个组已经没了,而你没有它们的备份。

坑三:Halo 对 HTML 路由的 HEAD 返回 404

用 curl -I 做站点探活会得到 404,让人误以为站点挂了。实际上 Halo 只对静态资源的 HEAD 正常响应,HTML 路由一律 404。页面探活必须用 GET。

坑四:清单文件是缓存,不是权威源

这是坑一和坑二的共同根源。最初的图池来自一个静态清单文件,它只是文件系统状态的缓存。一旦有人删图,清单就过期了,端点会跳到不存在的路径——破图,而且只在部分请求上出现,非常难查。

现在改成请求时现场列目录,权威源就是磁盘本身,缓存问题从根上消失了。

八、当前状态与维护入口

图池逻辑已经重构完成,全部收敛到一个脚本:

/root/.hermes/data/banner-rotate/banner_pool.py
子命令 作用
install 安装 NPM Lua 端点(幂等,可重复跑)
config 把主题三个配置项指向端点,并打开 enable_random_img_api
register 为缺 Attachment 记录的文件补建记录
verify 端到端验收:随机性 / 无破图 / 站点健康 / 无外链 / 三方一致 / 兜底可用
status 只看当前状态,不改任何东西

当前实测状态:图池 50 张、编号 01–50 无缺失无重复、总计 21.9 MB(平均 0.44 MB/张)、全部 1920×1280;主题三项配置全部指向端点;主题 CSS 无第三方图源残留;六项验收全部通过。

九、写在最后

这套方案真正想解决的问题只有一个:让首屏大图这件事不再依赖任何外部服务。图在本站附件目录里,随机在本站反代层里发生,缓存策略在本站可控,兜底路径也在本站。第三方服务挂掉、证书过期、限流、跑路——这些都不再是本站的问题。

而支撑它的其实是一个很朴素的思路:如果一个状态需要同步,就把它从「同步」变成「现场发现」;如果一个操作是整体替换,就先备份全量再动手;如果一个失败是静默的,就给它造一个能被观测出来的注入手段。

这三个原则,比这套代码本身更值得留着。

0

评论区