16 - 图片生命周期与私人图床(方案 · 实现规格)¶
状态:后端与 WebUI 已发布(image-host 1.0.15);插件侧已随 4.28.0 发布(含清理/对账/来源上报;AI 看图/批量/工具图类型标签见 17-图片引用标准与AI图库交互方案),经 483 个插件单测 + 57 个后端单测,见 §35。 全文三段:第一部分 = 方案(决策 / 契约 / 规则 / 分期);第二部分 = 实现细化(文件划分 / 路由表 / SQL / 伪码 / 函数签名 / PR 级任务拆解);第三部分 = 批量能力与插件同步(批量上传 / 存图库 / 标签 / 删除 / 变更日志 / 性能实测)。规则与写法冲突时以第一部分为准。 一句话目标:图片在源链接失效前被捕获;字节不落进海豹存储;图床既是 AI 的取图源,也是可管理的私人图库。 关联:
03-核心模块详解、04-工具系统、06-数据存储、08-相关后端项目、09-注意事项与常见问题。 涉及两个仓库:本仓库(插件)与aiplugin4-backends(后端,源码在shop分支backends/下)。 4.27.0 起:「后端」页签的地址项已合并为「后端配置」JSON(v1),本文中的「后端 → 图床」「图床Token」「图片转base64」等均为旧版称呼;当前配置以 08-相关后端项目 与 README 为准。
0. 决策摘要¶
| # | 决策 | 理由 |
|---|---|---|
| 1 | 抓取时机与存储位置解耦:图床只解决"字节放哪儿",不解决"什么时候抓" | 时机才是当前真正踩的坑(见 §2) |
| 2 | 另开 image-host 后端,不扩展 image-url-to-base64 |
i2b64 是无状态转换器,可能做成公共 API;不能给它加持久化职责(见 §5.1) |
| 3 | 图床接受 URL 自己抓(一跳),主链路不经 base64 | base64 是纯过路费:后端多一份 +33% 字符串、插件 goja 再多一份(见 §5.2) |
| 4 | 图床兼做 base64 出口(GET /i/<id>/b64) |
只吃 base64 的模型直接向图床要,i2b64 在配了图床后完全退出(见 §5.2) |
| 5 | id = sha256(字节),/i/<id> 即可拼出 URL |
哈希校验去重天然成立 + 不可猜 + 与扩展名无关(见 §5.4) |
| 6 | 引入 SQLite 元数据层(Python 标准库,无额外依赖) | 来源 / 标签 / 重命名 / 搜索必须有结构化存储;热路径 /i/<id> 不查库(见 §5.4) |
| 7 | PNG / GIF 原格式保存,不转码;另存缩略图 | 动画 GIF 不能丢帧;"体积够小、访问够快"靠去重 + 缩略图 + immutable 缓存(见 §5.6、§5.7) |
| 8 | 两边都有删除权:插件按引用释放,图床按 cap/LRU 淘汰 | 两侧都主动清理才健康;安全性由引用计数保证,而不是把删除权收归一方(见 §5.9) |
| 9 | 手动上传的图永不自动删(origin=manual) |
人放进来的东西不能被机器清掉;WebUI 可把抓来的图"收进图库"转为 manual |
| 10 | 一个服务三个面:REST(插件)+ MCP(AI)+ WebUI(人),共享同一份 SQLite | 同一份数据,分进程只会带来跨服务鉴权与一致性成本 |
| 11 | WebUI 必须鉴权;/i/*、/t/* 保持公开 |
画廊能列出全部图片 → "ID 不可猜"失效,必须用登录挡住(见 §5.8、§5.10) |
| 12 | 插件新增 1 项配置:「后端 → 图床」地址(默认空 = 不启用) | 与"没配图床保持现状"直接对应 |
| 13 | 图床必须做完整 SSRF 加固(决策 3 的代价) | 接受 URL 就等于获得出网能力,这是唯一的非零攻击面(见 §5.10) |
1. 目标与非目标¶
目标¶
- 图片在源链接失效前被捕获,AI 不再"看不见图"。
- 字节不落进海豹
storage.db(当前每张转过的图会带几百 KB base64)。 - 图床成为可管理的私人图库:按来源可查、可重命名、可打标签、可删除。
- AI 能通过 MCP 检索与管理图库。
- 两端存储都有界,且不会互相清掉对方还需要的东西。
- 图床故障不成为硬依赖(逐级回退,最坏退回现状)。
非目标¶
- 不做通用图床/CDN:不做多租户、不做对外分享页、不做 S3 后端(P7 可选)。
- 不做语义/视觉搜索(CLIP 之类):搜索限定在名称 / 标签 / 来源元数据。
- 不做实时转码(
?w=&h=&fmt=那套):慢且吃 CPU;缩略图预生成,原图原样发。 - 不改「识别图片时将url转换为base64」的语义(保持 永不 / 自动 / 总是 三态)。
- 不改
image-url-to-base64的任何行为(它的加固是关联但独立的事项,见 §5.1)。
2. 现状盘点¶
2.1 三条链路¶
| 链路 | 位置 | 行为 |
|---|---|---|
| 收到消息 | src/resource/image.ts:404-443 → createUrlImage(:326-335) |
只登记 URL + imageId,并立即落盘;不转 base64。识图条件命中才走 imageToText |
| 识图 | src/resource/image.ts:268-297 |
「总是」→ 先转 base64 再识图;「自动」→ 先识图,失败才转 |
| 组包给模型 | src/utils/message.ts:499-536 |
URL 型图片调 urlToBase64()(10s 超时),不受「识别图片时将url转换为base64」配置控制 |
结论:转换点是"第一次真的把它当图片用"的那一刻,而不是"收到的那一刻"。
2.2 现有兜底(已实现,不要重复造)¶
- 永久性失败判定:
isPermanentUrlError命中4\d{2} Client Error/url has expired(src/resource/image.ts:191-195)。 - 阻断:1 小时冷却、连续 3 次永久放弃(
src/resource/image.ts:139-144)。 - 模型报下载失败 → 标记 + 降级重发一轮(
src/agent/agent.ts:323-360、markUrlBrokenByUrlssrc/resource/image.ts:172-184)。 - 渲染期降级:上下文存的是
[img:id]文本,死图自动退化为文本标签。
2.3 缺口¶
| 缺口 | 证据 |
|---|---|
image_<id> 只增不减 |
全仓库无删除路径;Image.save(src/resource/image.ts:361-363)只有 storageSet |
| 无法枚举存储键 | types/seal.d.ts:495-504 只有 storageInit / storageClose / storageSet / storageGet,没有 remove / list |
| base64 落进 storage | JSON.stringify(img) 含 base64 |
| 转换时机滞后 | 见 §2.1 |
| "图已删除"与"模型够不到"被混为一谈 | 现有降级一律标 urlBroken,会把"模型够不到图床"误判为"图没了"(§6.6 必修) |
| 历史图片无法枚举回收 | 没有 storageList,只能靠 agent_<name> 里持久化的 sessionService.sessionMap(src/agent/agent.ts:601、src/session/session_service.ts:26)尽力枚举 |
2.4 一条硬约束:插件自己读不了二进制¶
海豹的 goja fetch 只有 text() / json(),text() 会按 UTF-8 解码把原始字节替换成 U+FFFD 永久损坏(08-相关后端项目)。插件永远无法自己下载图片字节。
推论:抓取必须由后端完成(决策 3);插件需要字节时也必须向后端要 base64(决策 4 的 /i/<id>/b64,或未配图床时的 i2b64)。
3. 设计原则¶
- 职责分离:无状态工具(i2b64)不持有数据;有状态服务(image-host)持有数据,但要为自己获得的能力付安全代价。
- 主链路不过 base64:能不中转就不中转。
- 一个真相:图片的元数据(来源/名称/标签)只存在于图床;插件只存
hostedUrl与自己的回收索引。 - 两边都有删除权,靠引用计数保证安全:谁都可以清自己那一侧,但"删除文件"这件事必须看还有没有别人引用(§5.9)。
- 人放进来的东西机器不能清:
origin=manual永不自动删。 - 增强而非硬依赖:没配图床 = 完全现状;图床挂了 = 逐级回退。
- 判据同构:两端都是"最近使用 + 上限",预算不同(字节 vs 条目)。
4. 架构总览¶
QQ 消息(带 rkey 短时效直链)
│
├─ 建 Image 条目 + 立即落盘(现状不变)
│
└─[图床已配置 且 是短时效链接]──► POST {图床}/api/upload
{ url, ref:插件imageId,
source:{平台/群/用户/消息/会话/时间} }
│
流式下载 + 边下边算 sha256
│
┌─────────────────┴─────────────────┐
▼ ▼
200 { id, url: "/i/<id>", b64_url } 502 { upstream_status: 404 }
│ │
│ 标 urlBroken(现有规则)
▼
插件把 url 拼到对外地址上再用(后端只给相对路径,不给公网前缀):
· image-host.publicUrl(IP/域名)可用 → 模型与协议端直接用该绝对地址
· publicUrl 空且 url 是回环 → 发图取本地文件、模型回退内联 base64
图床的三个面(同一份 SQLite、同一个进程)
REST /api/* /i/* /t/* ← 插件(抓取、取图、取 b64、释放引用、对账)
MCP /mcp ← AI(搜索 / 重命名 / 打标签)
WebUI / ← 人(鉴权:画廊 / 来源 / 删除 / 重命名 / 标签 / 上传)
5. 后端方案:image-host¶
5.1 为什么另开,而不是扩展 i2b64¶
| 维度 | 扩展 i2b64 | 另开 image-host(采用) |
|---|---|---|
| 公共服务可行性 | 被破坏:无状态工具一旦落盘用户图片,就不能作为公共 API 开放(滥用、合规、责任面) | i2b64 保持纯净,随时可公开 |
| 发版节奏 | 图床改动会牵动公共 API 的版本 | 各自独立 |
| 安全面 | 抓取 + 持久化 + 分发 + 管理界面 + MCP 全耦合在一个进程 | 攻击面可单独评估与加固 |
| 依赖 | 需 Pillow / imageio | Flask + requests + Pillow(+ 标准库 sqlite3) |
| 代价 | — | 多一个端口与 registry 条目 |
i2b64 的加固(超时 / 大小上限 / scheme 校验 / 私网拦截)不属于本方案,但因为"可能做成公共 API",其优先级反而更高——列为关联独立事项,单独提 PR。
5.2 为什么图床自己抓,且兼做 b64 出口¶
| 维度 | 两跳(i2b64 → 图床) | 一跳(图床自己抓,采用) |
|---|---|---|
| base64 开销 | 后端多一份 +33% 字符串;插件 goja 里再多一份;上传 body 里第三份 | 零:流式下载直接落盘,边下边算 sha256,全程不进内存 |
| 网络跳数 | 2 | 1 |
| 依赖数 | i2b64 与图床都必须在线 | 只有图床必须在线 |
| 插件内存 | 需在 goja 里持有大字符串 | 只发一个 URL |
| 安全面 | 图床可做到"零出网、SSRF 为零" | 图床获得出网能力 → 必须完整 SSRF 加固(§5.10) |
b64 出口为什么放图床:字节本来就在图床手上,GET /i/<id>/b64 只是把它 base64 一下;再让 i2b64 去下载一遍是重复劳动。于是:
配了图床之后,i2b64 完全退出——抓取、内联 b64、论坛上传所需字节,全部由图床提供。i2b64 只在未配置图床时继续承担现状职责。
5.3 服务概览¶
| 项 | 值 |
|---|---|
目录(shop 分支) |
backends/image-host/ |
| 文件 | backend.json、requirements.txt、imagehost.py、webui.html、mcp.py |
| 端口 | 46680(紧邻 46678 / 46679) |
| 依赖 | Flask + requests + Pillow(sqlite3 / hashlib / os 为标准库) |
| 版本 | 1.0.0 |
| 数据 | <data_dir>/image-host/:image.db(SQLite)+ shards/(原图)+ thumbs/(缩略图)+ tmp/ |
| 定位 | 私人图床 + 图库:抓取落库、只读分发、b64 出口、WebUI 管理、MCP 工具 |
5.4 数据模型(SQLite)¶
-- 一张图 = 一份字节(哈希校验去重:id 就是 sha256,同字节必然同 id)
CREATE TABLE blob (
id TEXT PRIMARY KEY, -- sha256(原图字节),64 hex
format TEXT NOT NULL, -- png / jpg / gif / webp / bmp
mime TEXT NOT NULL, -- 由 magic bytes 判定,不信任声明
size INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_access_at INTEGER NOT NULL, -- LRU 依据
name TEXT, -- 重命名(可空)
origin TEXT NOT NULL, -- plugin = 插件抓的(可自动清)/ manual = 人放的(永不自动清)
orphan_at INTEGER, -- 最后一次失去全部引用的时间(淘汰排序用)
thumb_path TEXT
);
-- 引用:一次上传 = 一条引用。同一张图被转发 N 次 = 1 份字节 + N 条引用
CREATE TABLE ref (
id TEXT NOT NULL, -- → blob.id
ref TEXT NOT NULL, -- 插件侧 imageId(全局唯一);手动上传时由后端生成
platform TEXT, -- QQ / DISCORD / ...
group_id TEXT, user_id TEXT, message_id TEXT, session_id TEXT,
at INTEGER, -- 消息时间
raw_url TEXT, -- 原始(可能已失效的)链接,排障用
PRIMARY KEY (id, ref)
);
CREATE TABLE tag (
id TEXT NOT NULL, tag TEXT NOT NULL,
PRIMARY KEY (id, tag)
);
CREATE INDEX idx_blob_access ON blob(last_access_at);
CREATE INDEX idx_blob_orphan ON blob(orphan_at);
CREATE INDEX idx_ref_blob ON ref(id);
CREATE INDEX idx_ref_group ON ref(group_id, at);
CREATE INDEX idx_tag_tag ON tag(tag);
去重(哈希校验):id = sha256(字节),所以"同字节 → 同 id → 目标文件已存在 → 不再写第二份",只是追加一条 ref。这就是哈希校验去重,没有别的机制。它同时带来两个好处:不可猜(256 bit)与 URL 与扩展名无关(/i/<id> 自足)。
为什么按 blob 而不是按上传建条目:QQ 里同一张图被转发极常见,去重是"体积够小"的最大来源;名称/标签挂 blob(同一张图的不同转发不该有两套标签),来源挂 ref(转发史要留痕)。
引用为什么必须存在:它同时解决两件事——① 多个插件条目可能指向同一个 blob,删谁都不能连带删掉别人;② "两边都有删除权"必须有个不误删的判据(§5.9)。
5.5 接口契约¶
读写分离的鉴权边界:/i/*、/t/* 公开(模型与协议端不带 header);/api/*(除 health)、/、/mcp 需 token。
公开读¶
| 路由 | 说明 |
|---|---|
GET /i/<id> |
原图。Content-Type 取自库;Cache-Control: public, max-age=31536000, immutable;支持 ETag / If-None-Match → 304 |
GET /i/<id>/b64 |
{ "base64": "...", "format": "png" } —— 供只吃 base64 的模型(插件 ensureBytes 用) |
GET /t/<id> |
缩略图(WebUI 画廊用) |
GET /api/health |
{ ok, image_host:true, cap_mb, used_mb, count, trash, changes_head, version, webui } |
鉴权写 / 管理¶
POST /api/upload —— 三种入参形态
// ① 抓取(插件主用):ref 必填 = 插件侧 imageId,作为这条引用的标识
{ "url": "https://multimedia.nt.qq.com.cn/...&rkey=...",
"ref": "a1b2c3",
"source": { "platform":"QQ", "group_id":"...", "user_id":"...",
"message_id":"...", "session_id":"...", "at": 1730000000000 } }
// ② 已有字节(备用;插件主链路不使用)
{ "base64": "....", "format": "png", "ref": "a1b2c3", "source": { ... } }
// ③ multipart(WebUI 手动上传):ref 可省略,后端生成;origin 强制为 manual
file=<二进制> + source=<JSON 字符串>
// resp 200
{ "id": "9f2c...e1", // sha256
"url": "/i/9f2c...e1", // 相对路径,插件拼后端地址
"b64_url": "/i/9f2c...e1/b64",
"thumb_url": "/t/9f2c...e1",
"format": "png", "size": 184320,
"dedup": true, // true = 哈希命中已有字节,只追加了一条 ref
"ref": "a1b2c3" }
错误契约(插件靠 upstream_status 分类永久/可重试):
400 { "error": "bad_url" } // scheme 非法 / 私网 / 解析失败
400 { "error": "unsupported_format" } // magic bytes 不在白名单
413 { "error": "too_large", "size": 20971520 } // 超单文件上限(流式中断)
502 { "error": "fetch_failed", "upstream_status": 404 } // 上游 4xx/5xx 原样透出
507 { "error": "disk_full" } // 淘汰后仍放不下(见 §5.9 手动图保护)
- 图床不重试:一次尝试 + 超时即返回;重试策略归插件(复用现有冷却 / 退避 / 三次放弃)。
- 上游
404/400/ QQ 的download url has expired→ 插件按现有isPermanentUrlError规则判永久 →urlBroken。 - 上游
429/5xx→ 可重试 → 走冷却。
POST /api/release(插件释放自己的引用 —— 插件的删除权)
// req —— 按 ref 释放,不是按 id!按 id 会连带删掉别人对这个 blob 的引用
{ "refs": ["a1b2c3", "d4e5f6"] }
// resp
{ "released": 2, "blobs_freed": 1 } // blobs_freed = 释放后变成"零引用"的 blob 数
POST /api/exists(插件对账 —— 得知图床侧删了什么)
// req
{ "refs": ["a1b2c3", "..."] }
// resp
{ "missing": ["..."] } // 已不存在的 ref(blob 被清 或 ref 已被释放);插件据此标 urlBroken / 清条目
PATCH /api/image/<id>(重命名 / 标签 / 收进图库)
{ "name": "群友发的猫",
"add_tags": ["猫","表情包"], "remove_tags": ["待分类"],
"origin": "manual" } // "收进图库" = 改成 manual,从此不自动删
DELETE /api/image/<id>(手动删除,立即生效,连字节一起删)
GET /api/list(WebUI 与 MCP 共用)
GET /(WebUI,鉴权)、/mcp(MCP Streamable HTTP,鉴权)
5.6 抓取与存储实现¶
<data_dir>/image-host/
image.db # SQLite
shards/ab/<id>.<ext> # 原图(ab = id 前两位)
thumbs/ab/<id>.webp # 缩略图
tmp/<uuid>.part # 下载中;完成后 os.replace 原子落位
抓取流程(流式,不进内存):
- 校验 URL:scheme ∈
http/https;解析全部 A/AAAA 记录,拒绝私网 / 回环 / 链路本地(§5.10)。 requests.get(url, stream=True, timeout=(5,20), allow_redirects=True),每一跳重定向都重做校验,上限 3 跳。- 边读边算
sha256并写tmp/<uuid>.part;累计超MAX_FILE立即中断 →413。 - 用已读前 8 字节判 magic bytes →
format/mime;不在白名单 →400。 - 哈希校验:目标文件已存在 → 删临时文件,
dedup: true,不写第二份字节,只追加一条ref。 - 否则
os.replace原子落位,并生成缩略图(Pillow,最大边THUMB_MAX默认 512,等比缩放,输出 WebP;GIF 取首帧)。缩略图失败不阻断主流程,只记日志。 - 写入
blob(origin=plugin)+ref行。
PNG / GIF 处理约定:
- 原图一律不转码:PNG 存 PNG、GIF 存 GIF,动画 GIF 不丢帧(这是"兼容 gif"的硬要求)。
- 缩略图单独生成,GIF 取首帧 → WebP,不覆盖原图。
- 可选(P7):JPEG 存前剥 EXIF(省体积 + 隐私);静态 GIF 的 b64 出口支持
?format=png(复用 i2b64 既有语义,给不接受 GIF 的模型用)。
5.7 "体积够小、访问够快"的三条来源¶
| 手段 | 收益 |
|---|---|
哈希校验去重(id = sha256) |
同一张图多群转发只存一份字节——省空间的最大头 |
| 预生成缩略图 | 画廊不必加载原图;/t/<id> 是几十 KB 级 |
| immutable + ETag | 内容寻址 URL 永不变内容,浏览器/反代/CDN 可永久缓存;304 省流量 |
| (不做)实时转码 | ?w=&h=&fmt= 每次请求都转码,慢且吃 CPU —— 明确不做 |
5.8 WebUI¶
| 项 | 设计 |
|---|---|
| 鉴权 | 登录页 + 密码(PASSWORD env);签名 cookie 保持会话。未设密码 = 关闭 WebUI(避免误暴露),只留 REST/MCP |
| 画廊 | 瀑布流/网格,缩略图 /t/<id>,分页 + 懒加载 |
| 每张图展示 | 缩略图、名称、标签、来源(平台 / 群 / 用户 / 消息 ID / 时间,多条来源可展开)、origin(插件抓的 / 手动上传)、体积、格式、创建时间 |
| 筛选/搜索 | 单一搜索框(名称 / 标签 / ID / 群号统一模糊搜索)+ 来源筛选(全部 / 插件抓的 / 手动上传);点标签卡片把标签名填进搜索框 |
| 操作 | 上传(拖拽 / 选择文件 → origin=manual)、收进图库(把插件抓的图改成 origin=manual)、重命名、增删标签、删除(单张 + 批量圈选) |
| 实现 | 后端直出单页 HTML + 少量原生 JS,不引入前端框架(保持"轻量") |
权限三层:
/i/*/t/*公开 → 模型与协议端可用;/鉴权 → 人能浏览管理;/api/*写操作鉴权 → 只有插件与管理员能改。
5.9 生命周期与交叉清理¶
两侧各自的清理权¶
| 谁 | 怎么删 | 作用范围 |
|---|---|---|
| 插件 | POST /api/release { refs } |
只释放自己的引用;字节是否消失由引用计数决定 |
| 图床(自动) | cap + LRU 淘汰 | 见下面的删除顺序 |
| 图床(手动) | WebUI / DELETE /api/image/<id> |
立即删,无约束(人说了算) |
引用计数是安全底线¶
为什么插件必须按 ref 释放而不是按 id:哈希去重意味着多个插件条目可能指向同一个 blob(同一张图被转发多次)。若按 id 释放,会连带抹掉其他条目的引用,那些条目就会莫名其妙变成死图。
图床的删除顺序(cap 压力下)¶
origin=plugin且零引用的,按orphan_at最旧优先 —— 插件已经明确不要了- 仍不够时,
origin=plugin且仍有引用的,按last_access_at最旧优先 —— 图床有删除权,被删的引用由插件下次对账(exists)得知 origin=manual永不自动删。如果手动上传的内容本身就把 cap 占满 → 新上传直接507,而不是删手动图
决策 9 的代价要认下来:
origin=manual的图只能由人删。极端情况下图床会因为"手动图占满"而拒绝新抓取,这是刻意的——机器不该清掉人放进来的东西。 无宽限期:release立即生效。安全性不靠等待,而靠引用计数(还有别人引用就不会消失)+origin=manual(人放的不会被清)。
交叉清理(双向信号)¶
插件 → 图床:POST /api/release { refs }
时机:插件自己的索引淘汰掉某条时(它已不再引用这张图)
效果:立即释放该引用;若归零则该 blob 进入"可淘汰"队列
图床 → 插件:POST /api/exists { refs }
时机:插件启动时 + 每 6 小时 + 索引 trim 后
效果:插件把 missing 的标 urlBroken / 清条目,不再产生 image_url
注意
exists用ref而不是id:插件关心的是"我那条引用还在不在",而不是"这个 blob 还在不在"。
已知泄漏与兜底¶
插件侧若发生数据丢失(重装、storage 被清、.ai forget 之后),它的 ref 永远不会被 release → 那些 blob 会长期留在图床里。兜底:WebUI 按 origin=plugin + 最后访问时间排序,人可批量清理;或 P7 提供"释放 N 天未访问的插件引用"的管理动作。
5.10 安全模型(决策 3 的代价,必修)¶
| 面 | 处理 |
|---|---|
| scheme | 仅 http/https;拒绝 file://、gopher:// 等 |
| SSRF(IP) | 解析全部 A/AAAA,任一命中私网 / 回环 / 链路本地 / 保留段 → 400 |
| SSRF(重定向) | 每一跳都重新校验;上限 3 跳(否则可用 302 绕过) |
| SSRF(DNS 重绑定) | 私人部署 + token 下可接受;更强时开启 host allowlist |
| host allowlist(可选) | 默认关闭;开启后只允许白名单域名(如 *.qpic.cn、multimedia.nt.qq.com.cn)。公网部署建议开启 |
| 鉴权边界 | 公开:/i/*、/t/*、/api/health;鉴权:/api/* 其余、/、/mcp |
| 上传鉴权 | AIPLUGIN4_BACKEND_TOKEN(Bearer 或 X-Token);MCP 走同一 token(插件 MCP 配置支持 headers) |
| WebUI 鉴权 | 密码 + 签名 cookie;未设密码则整个 WebUI 关闭 |
| ID 不可猜 | 内容寻址 256 bit。但画廊可列出全部 → 不可猜性对"人"失效,故 WebUI 必须鉴权 |
| 路径穿越 | 只认 ^[0-9a-f]{64}$;shards/thumbs 路径由 id 推导,不接受外部路径 |
| 内容类型 | magic bytes 复核,不接受声明即信 |
| 体积 | 流式大小上限 + 总容量上限 + 写入前淘汰;失败路径清理 tmp/*.part |
| 资源占用 | 流式落盘(内存与图片大小无关);并发抓取信号量(默认 4);缩略图生成也走信号量 |
| XSS(WebUI) | 名称/标签渲染必须转义(用户可控内容) |
公网部署必须:设 token + 设 WebUI 密码 + 开启 host allowlist + 反向代理限流 + HTTPS。未设 token 时后端等于公开的 SSRF 代理,README 需明确警告。 隐私提示:
/i/一旦公网可读,等于"有链接就能看图",QQ 私聊图片也在内。不可猜 ID 只是缓解;画廊有密码才挡住了"翻全部"。README 需写明。
5.11 MCP 工具¶
后端在 /mcp 暴露 Streamable HTTP,当前 6 个工具:
| 工具 | 参数 | 返回 |
|---|---|---|
image_search |
query?(名称/标签/来源模糊匹配)、tags?、platform?、group_id?、tool?、server?、since?、scope?(gallery 默认长期图库 / plugin 插件抓取 / all 全部)、limit?、offset? |
文本列表:id / 名称 / 标签 / 来源摘要 / 工具 / 服务器 / url / 时间;每条附 [img:ID];来源/工具/类型是元数据,不写标签 |
image_rename |
id、name |
结果文本 |
image_tag |
id? / ids?[](二选一)、add?[]、remove?[] |
结果文本(支持批量) |
image_keep |
id? / ids?[] |
收进图库(不再被自动清理) |
image_unkeep |
id? / ids?[] |
移出图库(重新进入自动淘汰候选) |
image_tags |
q?(标签名模糊过滤)、limit?(≤200,默认 50)、offset? |
标签清单:标签 ×次数,带总数与下一页 offset |
[img:图床ID]可直接发送:image_search返回的是图床 sha256 id,插件 4.28.0 起会把它规范化为[img:h<sha256>]并解析成虚拟Image(hostedId),发送时用图床稳定地址(publicUrl或非回环的url+/i/<id>);不落插件 storage、不重复上传。旧上下文里的图床图片发送也统一优先用该地址(原始 QQ 链接过期也不影响)。对外地址用image-host.publicUrl(IP/域名,复制配置时自动生成);没有它且url是回环时自动降级(发图落本地文件、模型用 base64)。
- 插件侧零代码:按
04-工具系统的 MCP 方式在「MCP → MCP服务器」的mcpServers里加一台image-host(url: http://127.0.0.1:46680/mcp+ token header),工具自动tools/list发现并注册(来源分组 =image-host)。 - 不做
image_delete工具:删除属于人的决定,放在 WebUI(AI 误删的代价不对称)。 - 工具返回只给 id 与元数据,不给大段 base64;AI 要看图用
image_view(多模态直接看,≤4 张)或image_to_text(文字描述,≤3 张)。 - 不做
image_delete工具:删除属于人的决定,放在 WebUI(AI 误删的代价不对称)。 - 工具返回只给 id 与元数据,不给大段 base64;AI 要看图时用
[img:图片ID]走既有链路。
5.12 配置项(env,沿用 AIPLUGIN4_* 风格)¶
| env | 默认 | 说明 |
|---|---|---|
AIPLUGIN4_IMAGE_HOST_DIR |
<repo>/data/image-host |
数据目录 |
AIPLUGIN4_IMAGE_HOST_CAP_MB |
2048 |
总容量(原图 + 缩略图) |
AIPLUGIN4_IMAGE_HOST_MAX_FILE_MB |
8 |
单文件上限 |
AIPLUGIN4_IMAGE_HOST_WEBUI |
on |
WebUI 开关(on/off,重启生效) |
AIPLUGIN4_IMAGE_HOST_PASSWORD |
123456 |
WebUI 密码 |
AIPLUGIN4_IMAGE_HOST_ALLOW_HOSTS |
空 | 域名白名单,逗号分隔;空 = 只做 IP 级 SSRF 拦截 |
AIPLUGIN4_IMAGE_HOST_THUMB_MAX |
512 |
缩略图最大边 |
AIPLUGIN4_IMAGE_HOST_FETCH_TIMEOUT |
5,20 |
连接/读取超时(秒) |
AIPLUGIN4_IMAGE_HOST_FETCH_CONCURRENCY |
4 |
并发抓取上限 |
AIPLUGIN4_BACKEND_TOKEN |
空 | 复用,API 与 MCP 鉴权 |
5.13 后端侧差异清单¶
| # | 仓库 / 文件 | 改动 |
|---|---|---|
| 1 | aiplugin4-backends(shop 分支)backends/image-host/imagehost.py |
新增:REST + 流式抓取 + 哈希去重 + SQLite + 引用计数 + 缩略图 + cap/LRU + §5.10 全部加固 |
| 2 | 同上 backends/image-host/webui.html |
新增:单页画廊 |
| 3 | 同上 backends/image-host/mcp.py |
新增:3 个 MCP 工具 |
| 4 | 同上 backends/image-host/backend.json |
新增 |
| 5 | 同上 backends/image-host/requirements.txt |
新增(Flask + requests + Pillow) |
| 6 | 同上 backends.json |
新增条目(name image-host、port 46680、source shop 分支) |
| 7 | 同上 CHANGELOG.md / README.md |
新条目 + 后端清单 + 公网部署 / 隐私 / SSRF / WebUI 密码警告 |
| — | backends/image-url-to-base64/* |
不动(加固另开 PR) |
6. 插件方案¶
6.0 v1 实施范围(已全部落地)¶
做(全部完成):
- 配置三项:「图床」(地址,空 = 完全现状)、「图床Token」(后端 API Token)、「图片清理天数」(默认 30,0 = 不清理)。
Image新字段hostedUrl/hostedId/hostedAt/lastUsedAt/sourceMeta(全部进validKeysMap)。- 生成图片对象即上传:
createUrlImage(收消息建图)对所有 http(s) 图片 fire-and-forget 上传,不等会话触发;生图/表情包落地时同样立即转存。 - 生图拿到 base64 后也转存图床(
src/media/task_queue.ts落地处 + 表情包工具),成功后清空 base64。 - 有图床时不再持久化 base64:
Image.save在写 storage 前去掉base64(内存里可留一份给本轮请求用)。 - 有图床时不依赖 i2b64:
urlToBase64()走图床GET /i/<id>/b64;图片还没入库就先POST /api/upload再取;图床失败按现有冷却/永久失败规则降级,不回头调 i2b64。 - 模型侧统一用图床对外地址:
image-host.publicUrl(IP/域名,复制配置时自动生成)可用时,textToMultimodalContent/ 识图 / 以图生图直接用remoteUrl,论坛发图仍取字节上传;只有publicUrl为空且url是回环地址时才回退ensureBytes()取内联字节,取不到就降级文本标签。 - 协议端发图(
getSendFile())优先用「图床地址 + hostedUrl」,避免 QQ 链接过期;图床配成回环时取/i/<id>/b64落本地文件,绝不把回环地址发给协议端。 - 图片索引 + 定时清理(
src/resource/image_index.ts/image_gc.ts):64 片索引,启动 60s 后跑一轮、之后每 6 小时一轮;超过「图片清理天数」未使用的记录删掉,并把 ref 交给POST /api/release;失败进待补发队列,下次重试。 - 图床删除对账:增量拉
/api/changes(游标持久化),delete_ref/delete_blob/purge_blob命中后把对应图片标记为不可用并清掉图床字段(不再白试 404)。 - 来源字段完整上报:
platform/group_id/user_id/message_id/session_id/at/raw_url(消息 id 从handleReceipt/deferReceipt透传)。 - exists 引用对账:每轮清理前用
POST /api/exists批量确认索引里带图床 id 的图片;缺失的标失效(软删除因 ref 仍在,不会误伤)。 - 上传并发上限 4:合并转发大量图片时不会一次打爆后端。
交叉清理约束(已实现并被测试覆盖):
- 清理只删「插件自己这条时间线」的记录:内存里正在用的图片绝不删;删记录后才释放 ref。
- 释放按 ref 幂等:重复释放只是
released=0;图床侧只有引用归零才可能回收字节。 - 同哈希:多张图片共享一份字节,各自一条 ref;清理一张不影响另一张。
- 图床侧删除(WebUI/容量淘汰/回收站过期)→ 插件通过 change_log + exists 双通道感知并标记失效,不会和 GC 互相踩。
- 每轮顺序:变更对账 → exists 对账 → 清理(release)→ 补发待释放。
明确不做的(与图床无关/无收益):
- i2b64 加固:独立事项;配了图床后插件不再调用它。
.ai forget --all立即释放:清上下文不等于删图片,图片仍可能被后续引用;由定时清理按"最后使用时间"兜底。- 插件侧主动删除图片:删除权在图床 WebUI(AI 误删代价不对称),插件只做「过期清理 + 引用交还」。
6.1 数据模型¶
// Image 新增字段
hostedUrl: string; // 图床路径(相对,如 "/i/<sha256>";空 = 未入库)
hostedId: string; // 图床 id(= sha256),取 b64 / release 用
hostedAt: number; // 入库时间(ms),0 = 未入库
lastUsedAt: number; // 最后使用时间(ms),清理任务据此判断过期
sourceMeta: string; // 来源信息 JSON(platform/group_id/user_id/message_id/at)
后端 1.0.5 起不再有
PUBLIC_BASE_URL与reachable:接口返回的url一律是相对路径,插件自己拼后端地址(Config.backend.IMAGE_HOST),需要公网域名时也在插件侧拼。因此没有"模型可达性"这个概念了——模型要图时由插件取字节转 base64,协议端要图时插件给「后端地址 + 相对路径」。必须同步加进
validKeysMap(src/resource/image.ts:34-48),否则revive会静默丢弃(src/utils/utils.ts:383-390只拷贝validKeysMap里列出的键)。
新增存储键:
| 键 | 内容 |
|---|---|
image_idx:<00..3f> |
索引分片(64 片),每条 { id: {u: 最后使用时间, h?: 图床 blob id} }(已实现) |
image_host_cursor |
图床 change_log 消费游标(已实现) |
image_release_pending |
图床不可达时待补发的 ref 列表(已实现) |
图片的名称/标签/来源不落插件——那是图床的真相(§3 原则 3)。插件只存
sourceMeta(上报用)与自己的索引。 插件侧的imageId就是图床里的ref(§6.4),这是两边能对上账的唯一钥匙。
6.2 新增配置项(唯一一项)¶
| 配置 | 分组 | 默认 | 说明 |
|---|---|---|---|
| 图床 | 后端 | 空 | 私人图床地址(aiplugin4-backends 的 image-host)。url 是插件访问图床接口的地址(可为 http://127.0.0.1:46680);publicUrl 是给 QQ 协议端与模型的对外地址(IP 或域名,如 https://img.example.com,由启动器「图床对外地址」自动生成)。留空 = 完全现状(仍用「图片转base64」) |
| 图床Token | 后端 | 空 | image-host 的 API Token(后端卡片里的 Token)。上传/取 b64 需要;留空时写接口 401 |
两项都可以用 .ai refresh 之类让配置缓存失效(Config.invalidate('backend'),TTL 60s)。
MCP 那台 image-host 走「MCP → MCP服务器配置」独立配置(不在本项内),与 md-html-render 同款。
6.3 渲染优先级(get src() / getSendFile(),src/resource/image.ts)¶
# 对外地址可用(image-host.publicUrl,或 url 本身非回环):直接给稳定地址
hostedUrl → publicUrl + hostedUrl // 如 http://192.168.1.10:46680/i/<id>
url → url
# 对外地址不可用(publicUrl 空且 url 回环)才降级:模型取内联字节、发图取本地文件
ensureBytes() → GET 图床地址 + hostedB64Url // 比走 i2b64 少一次外部下载
getSendFile() → GET b64 出口 → seal.base64ToImage() → 本地文件路径
CQCode(src/resource/image.ts:91-95)同步改为 hostedUrl 优先(NapCat 是本机/局域网,取得到本地图床),其次 url / path。
6.4 抓取时机(一跳 + 引用 + 来源上报)¶
if (图床已配置 && Image.isTemporaryUrl(url) && 无进行中请求)
fire-and-forget capture(url, ref, source) // 不阻塞消息入库
capture(url, ref, source):
POST {图床}/api/upload { url, ref, source }→{ id, url: "/i/<id>", b64_url: "/i/<id>/b64", dedup }ref= 插件的imageId。这是引用计数的钥匙:同一张图被转发两次会有两个imageId,图床侧就是一份字节 + 两条 ref,谁都不会连带删掉谁。- 返回的
url是相对路径,插件存hostedUrl,用时拼上Config.backend.IMAGE_HOST。 - 写
hostedUrl/hostedId/hostedAt,清空 base64(图床已有字节);模型要图时ensureBytes()走GET /i/<id>/b64。 - 失败分类(用
upstream_status): 400 unsupported_format/502 + upstream_status 4xx(非 429)→ 永久 → 复用现有规则标urlBroken502 + upstream_status 429/5xx、连接错误、507→ 可重试 → 走现有冷却- 任何失败都不改字段,退回现状链路
触发时机(v1):
| 场景 | 位置 | 行为 |
|---|---|---|
| 收到消息里的图片 | Image.createUrlImage() 内 |
所有 http(s) 图片 fire-and-forget 上传(不只临时链接——图床本来就是给图库用的,统一入库更简单) |
| 生图(url 结果) | src/media/task_queue.ts 落地处 |
走 urlToBase64() → 已含入库;拿到字节后再清 base64 |
| 生图(base64 结果) | 同上 + tool_meme.ts |
img.adoptBase64ToHost():POST base64 → 成功清空 base64、写 hosted 字段 |
| 表情包 | tool_meme.ts |
同上;并补 Image.imageMap + Image.save(当前不落库) |
source 的来源字段(已全部上报):
| 字段 | 取值 |
|---|---|
platform |
getPlatform(sessionId) |
group_id / user_id |
ctx.group / ctx.player |
message_id |
getRecordMessageId(ctx, msg)(经 transformArrayToContent 透传) |
session_id |
getSessionId(ctx) |
at |
建图时间 |
raw_url |
原始链接(图床侧排障用) |
规则:
- 并发上限 4;复用现有冷却 / 三次放弃机制。
- 图床未配置(
Config.backend.IMAGE_HOST为空)时完全不走这些分支,行为与现在一致。 - 图床配置了就不再调 i2b64(哪怕图没入库):
urlToBase64()先尝试入库再取 b64;失败按冷却/永久失败降级,不回退 i2b64。
6.5 组包路径改动(src/utils/message.ts:746-783)¶
if (image.hosted && ImageHost.externalEnabled) → 协议端与模型都用「图床对外地址 + hostedUrl」
else if (image.hosted) → 发图 getSendFile() 落本地文件;模型 ensureBytes() 取内联字节
else if (type==='url' && !base64 && !isBase64RetryBlocked()) → 现状
imageToText 同样:image-host.publicUrl 可用时把 remoteUrl 交给识图模型;只有 publicUrl 为空且 url 是回环时才先 ensureBytes(),拿不到字节就跳过识图(回环地址模型够不到)。
6.6 降级:对外地址不可用才取字节 / 降级文本标签¶
图床对外地址用 image-host.publicUrl(IP/域名,复制配置时自动生成):模型与协议端都直接用 remoteUrl,没有 base64 过路费。只有 publicUrl 为空且 url 是回环地址(127.0.0.1 / localhost,仅插件自身可达)时才降级:发图走 getSendFile() 取 /i/<id>/b64 落本地文件;模型走 ensureBytes() 取内联字节,取不到(图床挂了 / 图被删 / 没入库成功)就退化成 [img:ID] 文本标签,绝不把回环 URL 交给模型(那会让整轮请求报错)。
markUrlBrokenByUrls 同时匹配 img.url 与 img.remoteUrl(图床地址),避免模型报错时图床死链漏标、反复重发。
6.7 三级清理(插件侧,行使插件的删除权)¶
| 级 | 对象 | 规则 | 依赖 |
|---|---|---|---|
| L1 | urlBroken 的图 |
清 base64 + 清 raw,只留 description / rawDescription |
无(零风险,先做) |
| L2 | 索引条目 | lastUsedAt 最旧 → 超上限或超期限 → 删 image_<id> + 从 imageMap 剔除 + POST /api/release { refs: [imageId] } |
索引 |
| L3 | 有 hostedUrl 的图 |
base64 随时可丢(能重取) |
图床 |
- 按
ref释放,不按 blob id(§5.9):同一张图的另一条转发引用不会被误伤。 - 释放后不立即导致文件消失——图床还要看引用数、
origin、cap 压力(§5.9)。 - 若 release 请求失败(图床不可达),本地条目照删,但把 ref 记进
image_gc_state.pendingRelease,下次对账时补发(否则会漏引用,形成泄漏)。
删除写法:ext.storageSet('image_'+id, '')。这是本仓库的删除惯例(06-数据存储 中 session_sub 即"删除时写空串"),而 Image.get(src/resource/image.ts:349-350)遇空串返回 null,正好是"已删除"语义。seal 无 storageRemove。
6.8 touch 点、ensureBytes 与节流¶
touch 点(都是已在跑的路径):渲染成 image_url 内容块、imageToText、read_raw kind=image、resolve_special_id、论坛发帖、表情包、媒体任务结果发送、Image.ensureBytes()。
Image.ensureBytes(): Promise<string>(新增统一入口):
base64 → 直接用
hostedB64Url → GET {图床}/i/<id>/b64 ← 配了图床时唯一路径
hostedUrl → GET {图床}/i/<id>/b64(同上,URL 已含 id)
url → POST {i2b64}/image-to-base64 ← 未配图床时的现状路径
配了图床后 i2b64 不再被调用。
ensureBytes是唯一需要字节的入口,替换现在各处直接读image.base64的地方:src/tool/tools/web/tool_forum.ts:108-121、src/tool/tools/image/tool_image.ts:30、tool_meme.ts相关分支。
节流(必须做,否则写放大比现在更糟):内存里更新 lastUsedAt;仅当距上次落盘 > 6h 才 save,或搭其他 save 的顺风车。索引里的 u 跟着同一频率更新。
6.9 索引、trim 与对账¶
现成先例:src/agent/subagent/store.ts:118-139 的 trim——保留上限 + 清理最旧已结束记录,完全不扫描上下文,因为它维护了索引。
分片(解决写放大)
- 键
image_idx:<00..3f>,bucket = parseInt(sha1(imageId).slice(0,2),16) % 64。 - append:只重写所属那一片(≈ 30–50 KB),不是重写全量索引。
- trim(低频):读全部 64 片 → 全局按
u排序 → 淘汰 → 只重写受影响的片。
trim 触发:启动后延迟一次;距上次 > 1h 或 新增 ≥ 200 张。
对账:启动时 + 每 6h + trim 后,POST {图床}/api/exists { refs } → missing 的标 urlBroken / 清条目;同时补发 pendingRelease。
为什么必须有索引:seal 没有
storageList,无法枚举image_<id>键 → 不建索引就永远找不到要删的对象。
6.10 失败回退链(配了图床时)¶
每级失败落下一级,任何情况下不静默丢图、不整轮 400。
7. 配置项汇总¶
7.1 插件侧¶
| 配置 | 分组 | 默认 | 新增? |
|---|---|---|---|
| 图床 | 后端 | 空 | 新增 |
| image-host(MCP 服务器) | MCP | 无 | 新增(照 md-html-render 的写法,含 token header) |
| 图片转base64 | 后端 | http://127.0.0.1:46678 |
不变(仅未配图床时使用) |
7.2 后端侧¶
见 §5.12。
8. 失效模式矩阵¶
| # | 场景 | 判定 | 表现 | 自愈 | 日志 |
|---|---|---|---|---|---|
| 1 | 抓取前链接已死 | 502 + upstream_status 4xx(非 429) |
文本标签 | 永久 urlBroken |
图片x的URL已确认不可用 |
| 2 | 图床文件被清 | exists 返回 missing 或 GET 404 |
文本标签 | 标 urlBroken,一次 |
[image-host] 已清理 |
| 3 | 需要内联但取不到字节 | GET /i/<id>/b64 失败 |
首次失败一轮 | 按网络错误重试,仍失败本轮不带图 | [image-host] |
| 4 | 图床没启动 / 不可达 | 连接错误 | 走现状链路 | 冷却后重试 | [image-host] 不可达 |
| 5 | 上游限速 / 5xx | 502 + upstream_status 429/5xx |
走现状链路 | 冷却后重试 | [image-host] 上游可重试 |
| 6 | 磁盘满 / 超单文件 | 413 / 507 |
走现状链路 | cap 淘汰后恢复;手动图占满则持续 507 | [image-host] 落库失败 |
| 7 | 格式不在白名单 | 400 unsupported_format |
走现状链路 | 不重试 | [image-host] 格式不支持 |
| 8 | 未配置图床地址 | 启动探测跳过 | 完全现状 | — | [image-host] 未启用 |
| 9 | 同一张图被转发两次 | dedup: true |
正常 | 两条 ref 各自独立,release 一条不影响另一条 | [image-host] 哈希命中 |
| 10 | release 请求失败 | 网络错误 | 本地条目已删 | 记入 pendingRelease,对账时补发 |
[image-gc] release 待补发 |
| 11 | WebUI 未设密码 | 启动检查 | WebUI 路由关闭 | 设置密码 | [image-host] WebUI 已关闭 |
| 12 | MCP 未配置 / token 错 | 插件 MCP 连接失败 | 仅 AI 管理能力缺失,不影响取图 | 修配置 | 插件 MCP 日志 |
9. 迁移与兼容¶
| 项 | 处理 |
|---|---|
| 未配置图床 | 零行为变化(默认空) |
| 老 Image 数据无新字段 | revive 后取构造函数默认值(hostedUrl='')→ 行为 = 现状,零迁移 |
| 索引不存在 | 从上线时刻起增量维护 |
| 历史存量无法自动回收 | seal 无 storageList,枚举不到旧 image_<id> 键 |
| 历史存量的一次性播种 | 唯一枚举源是 agent_<name> 里持久化的 sessionService.sessionMap(src/agent/agent.ts:601)→ 遍历其上下文抽 [img:id] 建初始索引。尽力而为,只覆盖"被引用过"的图 |
| 未被任何上下文引用的历史孤儿 | 永远留在 storage 里,物理上无法枚举 → 列入"已知取舍" |
| 播种方式 | 默认不自动播种(避免大实例启动卡顿);.ai image adopt 手动触发(P7) |
| 后端升级 | image-host 是新增后端,不影响既有后端;backends.json 新增条目即可 |
| SQLite 结构升级 | PRAGMA user_version + 启动时按版本迁移(图床自己的事,与海豹存储无关) |
| 插件数据丢失后的引用泄漏 | ref 永不 release → blob 长期留存;兜底见 §5.9 末 |
10. 观测与日志¶
- 插件日志 tag:
[image-gc](trim:扫描/淘汰/剩余;release 待补发)、[image-host](探测/抓取/哈希命中)、[image-reconcile](对账:查询/缺失)。 - 图床日志:抓取失败原因(含
upstream_status)、淘汰(零引用 / 有引用 / manual 跳过 计数)、缩略图失败。 - 启动打印一行(插件):
图床=启用/未启用 可达=是/否 索引=N 条。 - P7 可选:
.ai image stats→ 条目数 / 带 base64 数 / 图床命中率 / 不可达模型列表。
11. 测试要点¶
插件单测¶
src优先级三态;CQCode优先hostedUrl。- LRU trim 边界:等于上限 / 超上限 / 全部
urlBroken/ 全部未超期。 - 分片索引:写入只改所属片、trim 只重写受影响片、分布均匀。
- touch 节流:6h 内重复使用只落盘一次。
revive新字段不丢(回归validKeysMap遗漏)。- 可达性降级:
GET hostedUrl200 → 不标urlBroken且记模型;404 → 标urlBroken。 markUrlBrokenByUrls能匹配hostedUrl。ensureBytes三级回退;配了图床时断言不调用 i2b64。- 失败分类:
upstream_status4xx → 永久;429/5xx → 可重试。 - 交叉清理:trim 淘汰 → 触发
release { refs };release 失败 → 进pendingRelease并在对账时补发;对账missing→ 标死。 - 来源上报:
source六个字段齐全(含消息 ID 透传)。 - 未配置图床时全链路零变化(回归保护)。
后端单测(image-host)¶
- 流式落盘:内存占用与图片大小无关;
sha256与实际字节一致。 - PNG 原样保存;动画 GIF 不丢帧(帧数与上传一致);缩略图取 GIF 首帧。
- 哈希去重:同字节第二次上传 →
dedup: true、只追加一条 ref、tmp无残留、磁盘只多一份字节。 - 引用计数(核心):
- 同一 blob 两条 ref → release 一条 → 字节仍在、
blobs_freed=0 - release 两条 → 归零 → 置
orphan_at、blobs_freed=1 - 按 ref 释放不会连带删掉另一条 ref
- cap 淘汰顺序:零引用
origin=plugin(按orphan_at)→ 有引用origin=plugin(按last_access_at)→origin=manual跳过。 - 手动图占满 cap → 新上传
507,且手动图仍在。 - 单文件上限
413(流式中断);磁盘满507。 - 鉴权边界:
/i/*、/t/*、/api/health免鉴权;/api/*其余、/、/mcp需 token。 - WebUI:未设密码时
/关闭;设密码后未登录 401、登录后可浏览;"收进图库"把origin改为 manual 后不再被自动清。 - magic bytes 与声明不符 →
400;非白名单格式 →400。 - 路径穿越与非法 id →
404。 - SSRF:
file://、127.0.0.1、10.x、169.254.169.254、IPv6 回环 →400;302 跳私网 →400;超 3 跳 →400。 - host allowlist 开启时非白名单域名 →
400。 - 上游 4xx/5xx →
502 + upstream_status,且不内部重试。 /i/<id>/b64返回的 base64 解码后与原图字节完全一致(含 GIF)。- MCP:
tools/list恰好 6 个工具;image_search默认scope=gallery(插件抓取要scope=plugin/all),能按标签/来源/工具/服务器命中;image_keep/image_unkeep/image_rename/image_tag落库。 - WebUI XSS:名称/标签含
<script>时被转义。 ETag/If-None-Match→304。
集成¶
- 起图床 + 配地址 → 发 QQ 图 → 断言:
hostedUrl有值、image_<id>里 无 base64、组包把「后端地址 + hostedUrl」交给协议端、全程没有调用 i2b64、图床里能看到该图的来源。 - 停图床 → 断言回退现状且不报错。
- 手工删掉图床文件 → 断言走 §8 #2 并自愈。
- 断言模型收到的是 b64 内联而非任何 URL。
- 同一张图在群里转发两次 → 断言图床只有一份字节、两条 ref;插件淘汰其中一条 → 另一条仍可用。
- WebUI 把一张抓来的图"收进图库"→ 断言它在 cap 压力下不被自动删。
12. 实施分期(每期可独立上线)¶
| 期 | 内容 | 依赖 | 收益 |
|---|---|---|---|
| P1 | 插件存储治理:新字段 + 分片索引 + L1 清理 + L2 LRU trim + touch 节流 + ensureBytes |
无 | 止住 storage.db 无界增长(现在就疼的那部分) |
| P2 | 图床核心:REST(upload / /i/ / /i/<id>/b64 / /t/ / health)+ 流式抓取 + 哈希去重 + 引用计数 + SQLite + 缩略图 + cap/LRU + §5.10 加固;backends.json 注册 |
无 | 图床可用、可取图、可取 b64(可独立于插件验证) |
| P3 | 插件接图床:新增「图床」配置 + /api/health 探测 + 收到即抓(带 ref/source)+ src/CQCode 优先级 + 可达性降级 |
需要 P2 | 解决时机问题(AI 不再看不见图) |
| P4 | 交叉清理:release / exists + pendingRelease 补发 + 插件对账 |
需要 P2 P3 | 两端都能主动清理且不误删 |
| P5 | WebUI 画廊(鉴权 + 来源 + 重命名 + 标签 + 删除 + 上传 + 收进图库) | 需要 P2 | 人能管理图库 |
| P6 | MCP 三个工具 + 插件 MCP 配置 | 需要 P2 P5(标签/名称已存在) | AI 能检索管理图库 |
| P7 | 可选:.ai image stats、.ai image adopt、S3 后端、EXIF 剥离、静态 GIF 的 b64 ?format=png、批量清理长期未访问的插件引用 |
— | 运维/进阶 |
| 独立 | i2b64 加固(超时 / 大小 / scheme / 私网) | — | 为"做成公共 API"扫清障碍;与本方案并行 |
P4/P5/P6 顺序可调:P5(WebUI)建议早于 P6(MCP),因为标签/名称先得有个顺手的入口,MCP 才有东西可操作。
13. 参数默认值¶
| 常量 / env | 建议值 | 位置 |
|---|---|---|
IMAGE_GC_MAX_ENTRIES |
20000 | 插件 |
IMAGE_GC_MAX_AGE_DAYS |
180 | 插件 |
IMAGE_TOUCH_THROTTLE_MS |
6h | 插件 |
IMAGE_GC_TRIM_INTERVAL_MS |
1h | 插件 |
IMAGE_GC_TRIM_EVERY_N |
200 | 插件 |
IMAGE_IDX_SHARDS |
64 | 插件 |
IMAGE_CAPTURE_CONCURRENCY |
4 | 插件 |
| 对账间隔 | 6h | 插件 |
| 抓取请求超时(插件侧) | 15s | 插件 |
AIPLUGIN4_IMAGE_HOST_CAP_MB |
2048 | 后端 |
AIPLUGIN4_IMAGE_HOST_MAX_FILE_MB |
8 | 后端 |
AIPLUGIN4_IMAGE_HOST_THUMB_MAX |
512 | 后端 |
AIPLUGIN4_IMAGE_HOST_FETCH_TIMEOUT |
5,20 |
后端 |
AIPLUGIN4_IMAGE_HOST_FETCH_CONCURRENCY |
4 | 后端 |
| 重定向上限 | 3 | 后端 |
| 淘汰目标水位 | CAP × 0.9 |
后端 |
| 图床端口 | 46680 | 后端 |
待拍板:插件条目上限、LRU 期限、图床 cap。其余均有明确建议值。
14. 明确不做 / 已知取舍¶
| 项 | 原因 |
|---|---|
| 扩展 i2b64 | 它可能做成公共 API,不能持有用户数据(§5.1) |
| 主链路经 base64 中转 | 三份 base64 的搬运成本换不来对等收益(§5.2) |
实时转码(?w=&h=&fmt=) |
每次请求都转码,慢且吃 CPU;缩略图预生成已够用 |
| 语义/视觉搜索(CLIP) | 要引入本地模型、吃内存;搜索限定元数据(P7 可议) |
image_delete MCP 工具 |
AI 误删代价不对称;删除留在 WebUI(人的决定) |
| 插件保存名称/标签 | 元数据真相只在图床,避免双写不一致(§3 原则 3) |
| 删除宽限期 | release 立即生效;安全性靠引用计数 + origin=manual,不靠等待 |
| 引用计数 | 插件侧不维护全局引用计数(context_edit_replace 会改文本导致漂移);但图床侧的引用计数必须做(它就是 ref 表) |
| 全量引用扫描 | 用 lastUsedAt 代理"还在被用",O(1) 且更准 |
| URL 健康轮询 | 三种死法已有零成本判定;轮询证明不了模型侧可达 |
| 历史孤儿图片的自动回收 | seal 无 storageList,物理上枚举不到 |
| 手动图占满时仍接受新上传 | 宁可 507 也不清人放进来的东西(§5.9) |
| 图床零出网 | 与"主链路不过 base64"互斥,选择后者 + §5.10 加固 |
15. 改动点索引¶
插件(本仓库)¶
| # | 文件 | 改动 |
|---|---|---|
| 1 | src/resource/image.ts |
新字段 + validKeysMap;src / CQCode 优先级;markUrlBrokenByUrls 匹配 hostedUrl;imageToText 提前返回;ensureBytes() |
| 2 | src/resource/image.ts:404-443 |
收到即抓(一跳 + ref/source 上报 + fire-and-forget + 并发上限 + upstream_status 分类) |
| 3 | src/utils/string.ts:383、src/session/session.ts:339-360 |
把 msg / messageId 透传进 transformArrayToContent(来源上报需要) |
| 4 | src/resource/image_gc.ts(新增) |
分片索引 + L1 清理 + L2 trim + touch 节流 + release { refs } + pendingRelease 补发 + exists 对账 |
| 5 | src/utils/message.ts:499-536 |
hostedUrl 可达时直接交给模型,跳过转换 |
| 6 | src/agent/agent.ts:323-360 |
图片下载失败分支:GET hostedUrl 区分 404 与"够不到" |
| 7 | src/config/configs/backend.ts |
新增「图床」配置项(默认空) |
| 8 | src/tool/tools/web/tool_forum.ts、src/tool/tools/image/tool_image.ts、tool_meme.ts |
改用 ensureBytes() |
| 9 | 图床探测 | /api/health 探测 + 缓存(可放 src/model 或新模块) |
| 10 | MCP 配置(05-命令与配置 的 MCP 分组) |
新增 image-host 一台(url + token header) |
后端(aiplugin4-backends,shop 分支)¶
| # | 文件 | 改动 |
|---|---|---|
| 1 | backends/image-host/imagehost.py |
新增:REST + 流式抓取 + 哈希去重 + SQLite + 引用计数 + 缩略图 + cap/LRU + §5.10 加固 |
| 2 | backends/image-host/webui.html |
新增:单页画廊(鉴权) |
| 3 | backends/image-host/mcp.py |
新增:image_search / image_rename / image_tag |
| 4 | backends/image-host/backend.json |
新增 |
| 5 | backends/image-host/requirements.txt |
新增(Flask + requests + Pillow) |
| 6 | backends.json |
新增条目(name image-host、port 46680、files) |
| 7 | CHANGELOG.md / README.md |
新条目 + 后端清单 + 公网部署 / 隐私 / SSRF / WebUI 密码警告 |
| — | backends/image-url-to-base64/* |
不动 |
---¶
第二部分:实现细化¶
第一部分是"做成什么样"(决策、契约、规则、分期);第二部分是"具体怎么写"(文件划分、路由表、SQL、伪码、签名、任务拆解)。 实现时以本文为准;与第一部分冲突时以第一部分为准(规则优先于写法)。
16. 后端实现细化:backends/image-host/¶
16.1 文件与职责¶
| 文件 | 估计行数 | 职责 |
|---|---|---|
imagehost.py |
~220 | Flask app、路由表、鉴权、参数校验、错误码映射、启动 |
store.py |
~260 | SQLite schema/迁移、blob/ref/tag CRUD、容量与淘汰、缩略图、路径推导 |
fetch.py |
~150 | 安全抓取:URL 校验、IP 黑名单、逐跳重定向、流式落盘、magic bytes |
webui.html |
~260 | 单页画廊(原生 JS,内联 CSS) |
mcp.py |
~180 | MCP Streamable HTTP:initialize / tools/list / tools/call |
backend.json |
9 | 后端元数据 |
requirements.txt |
3 | flask / requests / pillow |
多文件可行性已确认:launcher 以
cwd=backend.dir运行python <entry>(aiplugin4-backends/launcher.py:797-799),所以同目录import store, fetch, mcp可用。backend.json字段与image-url-to-base64一致(name / description / type / entry / deps / port / version,见其backend.json:1-9);backends.json的files必须列全 7 个文件,否则下载不全。 launcher 注入的环境变量:AIPLUGIN4_BACKEND_PORT、AIPLUGIN4_BACKEND_HOST、AIPLUGIN4_BACKEND_TOKEN(launcher.py:780-782)。
16.2 路由表(完整)¶
| 方法 | 路径 | 鉴权 | 用途 |
|---|---|---|---|
| POST | /api/upload |
token | 抓取 / 上传(JSON 或 multipart) |
| POST | /api/release |
token | 释放引用(插件的删除权) |
| POST | /api/exists |
token | 对账(图床 → 插件) |
| PATCH | /api/image/<id> |
token | 名称 / 标签 / origin |
| DELETE | /api/image/<id> |
token | 手动删除 |
| GET | /api/list |
token | 列表(WebUI 与 MCP 共用) |
| GET | /api/health |
公开 | 探测(精确匹配,不参与前缀) |
| GET | /i/<id> |
公开 | 原图 |
| GET | /i/<id>/b64 |
公开 | base64 出口 |
| GET | /t/<id> |
公开 | 缩略图 |
| GET | / |
cookie | WebUI 页面 |
| POST | /login / /logout |
— / cookie | 会话 |
| GET/POST | /mcp |
token | MCP Streamable HTTP |
鉴权判定(顺序敏感):
PUBLIC_PREFIX = ("/i/", "/t/")
def auth_ok(req) -> bool:
p = req.path
if p == "/api/health" or p.startswith(PUBLIC_PREFIX): return True
if p == "/login": return True
if p == "/" or p == "/logout": return cookie_ok(req) # WebUI
if p.startswith("/mcp"): return token_ok(req) # MCP 走 token
return token_ok(req) # /api/* 其余
token_ok:Authorization: Bearer <t>或X-Token: <t>;AIPLUGIN4_BACKEND_TOKEN为空时拒绝一切写操作(返回 401),而不是放行。与现有 i2b64 的差别:那边 token 为空 = 不鉴权;这里 token 为空 = 写操作全禁。因为本服务持有数据且能出网,默认拒绝比默认放行安全。
cookie_ok:ih=<hmac>,hmac = HMAC_SHA256(secret, "ih");secret由PASSWORD派生(pbkdf2固定盐,进程启动时算一次)。未设PASSWORD→/与/login直接404。
16.3 DDL 与迁移¶
PRAGMA journal_mode=WAL; -- 并发读不阻塞写
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
CREATE TABLE blob (
id TEXT PRIMARY KEY,
format TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_access_at INTEGER NOT NULL,
name TEXT,
origin TEXT NOT NULL CHECK (origin IN ('plugin','manual')),
orphan_at INTEGER,
thumb_path TEXT
);
CREATE TABLE ref (
id TEXT NOT NULL REFERENCES blob(id) ON DELETE CASCADE,
ref TEXT NOT NULL,
platform TEXT, group_id TEXT, user_id TEXT, message_id TEXT, session_id TEXT,
at INTEGER, raw_url TEXT,
PRIMARY KEY (id, ref)
);
CREATE TABLE tag (
id TEXT NOT NULL REFERENCES blob(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (id, tag)
);
CREATE INDEX idx_blob_access ON blob(last_access_at);
CREATE INDEX idx_blob_orphan ON blob(orphan_at);
CREATE INDEX idx_blob_origin ON blob(origin);
CREATE INDEX idx_ref_blob ON ref(id);
CREATE INDEX idx_ref_group ON ref(group_id, at);
CREATE INDEX idx_tag_tag ON tag(tag);
PRAGMA user_version = 1;
迁移:
SCHEMA_VERSION = 1
def migrate(con):
con.executescript(PRAGMAS)
v = con.execute("PRAGMA user_version").fetchone()[0]
if v < 1:
con.executescript(DDL_V1)
con.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
# 未来:if v < 2: ...(只追加,不改旧列)
ON DELETE CASCADE保证删 blob 时 ref/tag 一起清,不会留孤儿行。- 每个请求一个连接(
sqlite3.connect(db),WAL 下并发读安全);写操作用全局threading.Lock串行化,容量检查与淘汰必须在同一把锁里完成,否则并发上传会超卖磁盘。
16.4 核心 SQL(把 §5.9 的规则写成查询)¶
-- 淘汰候选 tier 1:插件抓的、零引用、按孤儿时间最旧
SELECT id, size FROM blob b
WHERE origin='plugin'
AND NOT EXISTS (SELECT 1 FROM ref r WHERE r.id=b.id)
ORDER BY COALESCE(orphan_at, created_at) ASC
LIMIT ?;
-- 淘汰候选 tier 2:插件抓的(仍有引用),按最久未访问
SELECT id, size FROM blob b
WHERE origin='plugin'
ORDER BY last_access_at ASC
LIMIT ?;
-- 手动图占用(用于 507 判定与日志)
SELECT COALESCE(SUM(size),0) FROM blob WHERE origin='manual';
-- 引用归零判定(release 后)
SELECT COUNT(*) FROM ref WHERE id=?;
-- 列表 / 搜索(/api/list 与 MCP 共用)
SELECT b.*,
(SELECT COUNT(*) FROM ref r WHERE r.id=b.id) AS ref_count,
(SELECT GROUP_CONCAT(t.tag) FROM tag t WHERE t.id=b.id) AS tags
FROM blob b
WHERE (:q = '' OR b.name LIKE '%'||:q||'%'
OR EXISTS (SELECT 1 FROM ref r WHERE r.id=b.id AND (r.group_id LIKE '%'||:q||'%'
OR r.user_id LIKE '%'||:q||'%'
OR r.message_id LIKE '%'||:q||'%')))
AND (:tag = '' OR EXISTS (SELECT 1 FROM tag t WHERE t.id=b.id AND t.tag = :tag))
AND (:group = '' OR EXISTS (SELECT 1 FROM ref r WHERE r.id=b.id AND r.group_id = :group))
AND (:origin= '' OR b.origin = :origin)
ORDER BY CASE :sort WHEN 'access' THEN b.last_access_at ELSE b.created_at END DESC
LIMIT :limit OFFSET :offset;
-- 某图的所有来源(详情页/搜索返回用)
SELECT platform, group_id, user_id, message_id, session_id, at, raw_url
FROM ref WHERE id=? ORDER BY at DESC;
16.5 安全抓取(fetch.py)——§5.10 的具体写法¶
MAX_REDIRECTS = 3
BLOCKED = lambda ip: (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified)
def safe_fetch(url, max_bytes, timeout, allow_hosts, tmp_dir):
"""返回 (tmp_path, sha256_hex, size, first8);抛 BadUrl / TooLarge / FetchFailed"""
for hop in range(MAX_REDIRECTS + 1):
u = urlparse(url)
if u.scheme not in ("http", "https"): raise BadUrl("scheme")
if not u.hostname: raise BadUrl("no_host")
if allow_hosts and not host_allowed(u.hostname, allow_hosts):
raise BadUrl("host_not_allowed")
try:
ips = [ip_address(s[4][0]) for s in socket.getaddrinfo(u.hostname, u.port or 80)]
except Exception: raise BadUrl("dns")
if any(BLOCKED(ip) for ip in ips): raise BadUrl("private_ip")
r = requests.get(url, stream=True, timeout=timeout, allow_redirects=False)
if r.is_redirect: # 手动跳转:每跳都重做上面全部校验
url = urljoin(url, r.headers["Location"]); continue
if r.status_code >= 400:
raise FetchFailed(r.status_code)
h, n, tmp = hashlib.sha256(), 0, tempfile.mktemp(dir=tmp_dir)
with open(tmp, "wb") as f:
for chunk in r.iter_content(64 * 1024):
n += len(chunk)
if n > max_bytes:
f.close(); os.remove(tmp); raise TooLarge(n)
h.update(chunk); f.write(chunk)
first8 = open(tmp, "rb").read(8)
return tmp, h.hexdigest(), n, first8
raise BadUrl("too_many_redirects")
要点:
- 必须
allow_redirects=False手动循环:requests的自动跳转不会给你逐跳校验的机会,302 → 127.0.0.1会直接绕过 IP 拦截。这是 §5.10 里最容易写错的一条。 socket.getaddrinfo取全部 A/AAAA(getaddrinfo不指定 family 就返回两者),任一命中黑名单即拒绝。- 残留的 TOCTOU(校验用 IP 与连接用 IP 可能不同,DNS 重绑定)在"私人部署 + token"下可接受;要更严可把 URL 换成已校验 IP +
Host头连接,列为 P7。 TooLarge必须删掉半截临时文件;所有异常路径都要清tmp/*.part。
magic bytes 判定(不引入 Pillow 依赖,纯字节):
SIGNATURES = [
(b"\x89PNG\r\n\x1a\n", "png", "image/png"),
(b"\xff\xd8\xff", "jpg", "image/jpeg"),
(b"GIF87a", "gif", "image/gif"),
(b"GIF89a", "gif", "image/gif"),
(b"BM", "bmp", "image/bmp"),
]
# WEBP:RIFF....WEBP(偏移 8 处判 WEBP)
16.6 落库、去重与容量(store.py)¶
def ensure_capacity(con, need: int, cap: int, watermark=0.9):
"""写入前腾地方;返回淘汰条数。manual 永不自动删。"""
dropped = 0
while used_bytes(con) + need > cap * watermark:
victim = pick_victim_tier1(con) or pick_victim_tier2(con)
if victim is None:
raise DiskFull(used=used_bytes(con), manual=manual_bytes(con))
delete_blob(con, victim) # 连带 ref/tag(CASCADE)+ 删文件 + 删缩略图
dropped += 1
return dropped
def put_blob(con, tmp, sha, fmt, mime, size, origin, ref, source):
row = con.execute("SELECT id FROM blob WHERE id=?", (sha,)).fetchone()
dedup = row is not None
if dedup:
os.remove(tmp) # 哈希命中:不写第二份字节
con.execute("UPDATE blob SET last_access_at=? WHERE id=?", (now_ms(), sha))
else:
ensure_capacity(con, size, CAP) # 锁内
os.replace(tmp, blob_path(sha, fmt)) # 原子落位
thumb = make_thumb(blob_path(sha, fmt), THUMB_MAX) # 失败 → None,不阻断
con.execute("INSERT INTO blob(id,format,mime,size,created_at,last_access_at,origin,thumb_path)"
" VALUES(?,?,?,?,?,?,?,?)", (sha, fmt, mime, size, now_ms(), now_ms(), origin, thumb))
con.execute("INSERT OR REPLACE INTO ref(id,ref,platform,group_id,user_id,message_id,session_id,at,raw_url)"
" VALUES(?,?,?,?,?,?,?,?,?)",
(sha, ref, source.platform, source.group_id, source.user_id,
source.message_id, source.session_id, source.at, source.raw_url))
return sha, dedup
INSERT OR REPLACEonref:同一个ref重复上传是幂等的(插件重试不会产生两条引用)。origin:JSON 抓取路径恒为plugin;multipart(WebUI)恒为manual,不接受客户端指定。
释放引用:
def release_refs(con, refs) -> tuple[int, int]:
released = freed = 0
for ref in refs:
rows = con.execute("SELECT id FROM ref WHERE ref=?", (ref,)).fetchall()
for (bid,) in rows:
con.execute("DELETE FROM ref WHERE id=? AND ref=?", (bid, ref))
released += 1
if con.execute("SELECT COUNT(*) FROM ref WHERE id=?", (bid,)).fetchone()[0] == 0:
con.execute("UPDATE blob SET orphan_at=? WHERE id=? AND orphan_at IS NULL",
(now_ms(), bid))
freed += 1
return released, freed
必须按
ref删,不能按id删:哈希去重下同一 blob 可能有多条 ref,按 id 删会连带抹掉别人的引用(§5.9)。
16.7 缩略图(Pillow)¶
def make_thumb(path, max_edge) -> str | None:
try:
im = Image.open(path)
im.seek(0) # 动画 GIF 只取首帧
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGBA")
im.thumbnail((max_edge, max_edge), Image.LANCZOS)
out = thumb_path_for(path)
im.save(out, "WEBP", quality=80, method=4)
return out
except Exception as e:
log.warning(f"缩略图生成失败 {path}: {e}")
return None # 不阻断主流程;画廊回退用原图
- 原图不转码:PNG 存 PNG、GIF 存 GIF(动画不丢帧)。
- 缩略图失败时
thumb_path=NULL,/t/<id>回退到/i/<id>(前端换 src 即可)。
16.8 并发模型¶
| 资源 | 处理 |
|---|---|
| Flask | app.run(host=..., port=..., threaded=True) |
| SQLite | 每请求一个连接;WAL;写操作用全局 threading.Lock(容量检查+淘汰+插入必须原子) |
| 抓取 | threading.Semaphore(FETCH_CONCURRENCY) 包住 safe_fetch |
| 缩略图 | 同一把抓取信号量(CPU 密集,别和抓取抢) |
| 淘汰 | 与写操作同一把锁;不允许并发淘汰(否则可能删多) |
16.9 错误码总表(插件侧分类的依据)¶
| HTTP | error |
触发 | 插件侧处理 |
|---|---|---|---|
| 400 | bad_url |
scheme 非法 / 私网 / DNS 失败 / 跳转过多 / 域名不在白名单 | 永久,标 urlBroken |
| 400 | unsupported_format |
magic bytes 不在白名单 | 永久 |
| 401 | unauthorized |
token 缺失或错误;或 token 未配置 | 停用图床(视为未启用)+ 日志 |
| 404 | not_found |
id / ref 不存在 | 视为已删 → urlBroken |
| 413 | too_large |
超单文件上限(流式中断) | 永久 |
| 502 | fetch_failed |
上游 4xx/5xx,带 upstream_status |
4xx(≠429) → 永久;429/5xx → 可重试 |
| 507 | disk_full |
淘汰后仍放不下(手动图占满) | 可重试(冷却) |
| 500 | internal |
其它 | 可重试 |
响应体一律 {"error": "...", ...};502 额外带 upstream_status。
16.10 WebUI 细化(webui.html)¶
登录
GET / → 未设 PASSWORD: 404;未登录: 登录表单;已登录: 画廊
POST /login {password} → 校验 → Set-Cookie: ih=<hmac>; HttpOnly; SameSite=Lax; Path=/
POST /logout → 清 cookie
页面结构(单文件,内联 CSS/JS,无框架、无构建)
┌ 顶栏:搜索框 | 标签筛选 | origin 筛选 | 上传按钮 | 登出
├ 主体:CSS Grid 瀑布流,每卡:
│ <img src="/t/<id>"> (无缩略图时回退 /i/<id>)
│ 名称(可点击就地编辑)
│ 标签 chips(可点 × 删除,+ 添加)
│ 来源摘要:平台 · 群 · 用户 · 时间(多条折叠展开)
│ origin 徽章:插件抓的 / 手动上传
│ 体积 · 格式 · 创建时间
│ 操作:重命名 | 标签 | 收进图库 | 删除
└ 底栏:加载更多(offset 分页)
交互实现
| 动作 | 调用 |
|---|---|
| 列表 / 搜索 / 筛选 | GET /api/list?... |
| 上传 | FormData(file) → POST /api/upload(multipart) |
| 重命名 | PATCH /api/image/<id> {name} |
| 加/删标签 | PATCH /api/image/<id> {add_tags|remove_tags} |
| 收进图库 | PATCH /api/image/<id> {origin:"manual"} |
| 删除 | DELETE /api/image/<id>(批量 = 循环 + 进度) |
安全:所有用户可控文本用 textContent 渲染,不用 innerHTML(名称/标签是用户输入,XSS 必修)。
16.11 MCP 细化(mcp.py)¶
传输:Streamable HTTP,POST /mcp,Accept: application/json;实现 initialize / tools/list / tools/call 三个方法(与 mcp-files-exec 同款,插件侧按标准 MCP 接入)。
// tools/list 返回(3 个,不增不减)
{
"tools": [
{
"name": "image_search",
"description": "搜索私人图库中的图片(按名称、标签、来源元数据)。返回 id/名称/标签/来源/时间/URL。",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "名称/标签/来源模糊匹配" },
"tags": { "type": "array", "items": { "type": "string" } },
"platform": { "type": "string" },
"group_id": { "type": "string" },
"since": { "type": "string", "description": "ISO 时间,只返回此后的" },
"limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 20 },
"offset": { "type": "integer", "minimum": 0, "default": 0 }
},
"additionalProperties": false
}
},
{
"name": "image_rename",
"description": "给图库中的图片重命名",
"inputSchema": {
"type": "object",
"properties": { "id": { "type": "string" }, "name": { "type": "string" } },
"required": ["id", "name"],
"additionalProperties": false
}
},
{
"name": "image_tag",
"description": "给图片增删标签",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" },
"add": { "type": "array", "items": { "type": "string" } },
"remove": { "type": "array", "items": { "type": "string" } }
},
"required": ["id"],
"additionalProperties": false
}
}
]
}
tools/call 返回文本块(不返回大段 base64):
- 与 REST 共用
store.py,不重复实现查询。 - 不做
image_delete(§5.11)。
16.12 后端启动顺序¶
读 env → 建目录 → 打开 SQLite + migrate → 启动时扫描 shards/ 校正 stats(可选,见 §16.13)
→ 打印监听地址与能力(image_host / webui 是否启用)
→ app.run(threaded=True)
16.13 启动自检(建议)¶
| 检查 | 动作 |
|---|---|
| 目录不存在 | 创建 shards/ thumbs/ tmp/ |
tmp/*.part 残留 |
启动时清空(上次崩溃的残骸) |
blob 行指向的文件缺失 |
记 warning + 删该行(连带 ref/tag)——不自动重建,让插件对账发现 |
磁盘上的文件没有对应 blob 行 |
记 warning,保留(可能是手工放的),不自动删 |
PASSWORD 未设 |
日志明示 "WebUI 已关闭" |
| token 未设 | 日志明示 "写操作将全部拒绝(401)" |
| 容量已超 cap | 启动时跑一次 ensure_capacity(con, 0) |
17. 插件实现细化¶
17.1 文件与职责¶
| 文件 | 类型 | 估计行数 | 职责 |
|---|---|---|---|
src/resource/image_host.ts |
新增 | ~180 | 图床客户端:探测 / 上传 / 释放 / 对账 / 取 b64;错误分类 |
src/resource/image_gc.ts |
新增 | ~220 | 分片索引、trim、touch 节流、pendingRelease、对账调度 |
src/resource/image.ts |
改 | +~120 | 新字段、src/CQCode 优先级、ensureBytes、收到即抓、markUrlBrokenByUrls |
src/utils/string.ts |
改 | +~5 | transformArrayToContent 透传 messageId |
src/session/session.ts |
改 | +~5 | handleReceipt / deferReceipt 传入 messageId |
src/utils/message.ts |
改 | +~10 | 多模态直接用 hostedUrl |
src/agent/agent.ts |
改 | +~25 | 下载失败分支区分 404 / 够不到 |
src/config/configs/backend.ts |
改 | +1 | 新增「图床」 |
17.2 图床客户端签名(image_host.ts)¶
export interface HostProbe { enabled: boolean; version: string; }
export interface ImageSource {
platform?: string; groupId?: string; userId?: string;
messageId?: string; sessionId?: string; at?: number; rawUrl?: string;
}
export interface UploadResult {
id: string; url: string; b64Url: string;
format: string; size: number; dedup: boolean;
}
export type HostErrorKind = 'permanent' | 'retryable' | 'unauthorized' | 'disabled';
export function hostBase(): string; // Config.backend.IMAGE_HOST,空 = 未配置
export function isHostEnabled(): boolean; // 已配置 且 探测通过
export async function probeHost(force?: boolean): Promise<HostProbe>;
export async function uploadByUrl(url: string, ref: string, src: ImageSource): Promise<UploadResult>;
export async function releaseRefs(refs: string[]): Promise<{ released: number; freed: number }>;
export async function findMissingRefs(refs: string[]): Promise<string[]>;
export async function fetchBase64(id: string): Promise<string>;
export function classifyHostError(status: number, body: unknown): HostErrorKind;
export function idFromHostedUrl(url: string): string; // 从 /i/<sha256> 里取 id
classifyHostError 规则(与 §16.9 一一对应):
function classifyHostError(status, body): HostErrorKind {
const e = (body as any)?.error;
if (status === 401) return 'unauthorized';
if (status === 404) return 'permanent';
if (status === 400) return 'permanent'; // bad_url / unsupported_format
if (status === 413) return 'permanent';
if (status === 502) {
const up = (body as any)?.upstream_status ?? 0;
return (up >= 400 && up < 500 && up !== 429) ? 'permanent' : 'retryable';
}
return 'retryable'; // 507 / 5xx / 网络错误
}
17.3 索引结构与分片¶
interface ImageIdxEntry { i: string; c: number; u: number; s: string; b: 0|1; x: 0|1; h: 0|1 }
// i=imageId c=createdAt u=lastUsedAt s=sourceSessionId
// b=有 base64 x=urlBroken h=有 hostedUrl
分片哈希不能用 sha1——插件运行在 goja,没有 crypto,全仓库也没有哈希工具(已确认)。用纯 JS 的 FNV-1a:
const SHARDS = 64;
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }
return h >>> 0;
}
const bucketOf = (imageId: string) => fnv1a(imageId) % SHARDS;
const keyOf = (b: number) => `image_idx:${b.toString(16).padStart(2, '0')}`;
读写:append 只重写 keyOf(bucketOf(id)) 那一键;trim 读全部 64 键。
17.4 trim 算法(伪码)¶
trim(now):
if (now - lastTrimAt < TRIM_INTERVAL_MS && newSinceLastTrim < TRIM_EVERY_N) return
entries = 读 64 个分片并合并
victims = entries
.filter(e => now - e.u > MAX_AGE_MS) // 期限超期
.sort((a,b) => a.u - b.u) // 最久未用优先
overflow = entries.length - MAX_ENTRIES
if (overflow > 0) victims = victims.concat(
entries.filter(e => !victims.includes(e)).sort((a,b)=>a.u-b.u).slice(0, overflow))
for v of victims:
ext.storageSet(`image_${v.i}`, '') // 空串 = 删除(§6.7)
delete Image.imageMap[v.i]
pendingRelease.push(v.i) // 稍后统一释放引用
writeBackChangedShards(entries - victims)
lastTrimAt = now; newSinceLastTrim = 0
flushPendingRelease():
if (pendingRelease.empty) return
try { await releaseRefs(pendingRelease); pendingRelease.clear() }
catch { /* 保留,等下次对账补发 */ }
pendingRelease 持久化在 image_gc_state.pendingRelease(必须落盘,否则重载后引用泄漏)。
17.5 touch 与节流¶
// 节流时间戳只放内存,不写进 Image(否则会被 Image.save 序列化进存储)
const lastFlushed = new Map<string, number>();
const TOUCH_THROTTLE_MS = 6 * 3600 * 1000;
export function touch(img: Image) {
const now = Date.now();
img.lastUsedAt = now;
if (now - (lastFlushed.get(img.imageId) || 0) < TOUCH_THROTTLE_MS) return;
lastFlushed.set(img.imageId, now);
Image.save(img);
updateIndexEntry(img); // 同步分片索引里的 u
}
为什么不把
lastFlushedAt放进Image:Image.save是JSON.stringify(img),会把它一起写进存储;而validKeysMap又不含它,读回来会被丢弃——白写一遍。放模块级 Map 更干净。
17.6 收到即抓(调用点与并发)¶
handleImageMessageSegment(ctx, seg, messageId?)(src/resource/image.ts:404)内:
const image = this.createUrlImage(getSessionId(ctx), file);
// ... 现有 raw / 识图逻辑不变 ...
if (isHostEnabled() && Image.isTemporaryUrl(file)) {
void captureInBackground(image, file, {
platform: getPlatform(getSessionId(ctx)),
groupId: /* ctx 群 ID */, userId: ctx.player?.userId,
messageId, sessionId: getSessionId(ctx), at: Date.now(), rawUrl: file,
});
}
captureInBackground(image_host.ts 或 image.ts):
async function captureInBackground(img: Image, url: string, src: ImageSource) {
await acquireCaptureSlot(); // 信号量,上限 4
try {
const r = await withTimeout(() => uploadByUrl(url, img.imageId, src), 15000);
img.hostedUrl = r.url; img.hostedB64Url = r.b64Url;
img.base64 = ''; // 不落 base64,模型要图时取 b64 出口
Image.save(img);
} catch (e) {
const kind = /* classifyHostError 或网络错误 */;
if (kind === 'permanent') markUrlBroken(img); // 复用现有 urlBroken 语义
// retryable / unauthorized / disabled:不改字段,退回现状链路
} finally { releaseCaptureSlot(); }
}
失败一律不改
hostedUrl:宁可这张图走现状(懒转换),也不要留一个半残状态。
17.7 ensureBytes(唯一取字节入口)¶
async ensureBytes(): Promise<string> {
if (this.base64) return this.base64;
if (this.hostedB64Url) { // 配了图床
try { return await fetchBase64(idFromHostedUrl(this.hostedUrl)); }
catch (e) { log.warning(...); } // 落到下一级
}
if (this.type === 'url') await this.urlToBase64(); // 现状(i2b64)
return this.base64;
}
替换点:tool_forum.ts:108-121、tool_image.ts:30、tool_meme.ts 相关分支。
17.8 组包路径(src/utils/message.ts:522)¶
if (image.hostedUrl) {
parts.push({ type: 'image_url', image_url: { url: hostBase() + image.hostedUrl } }); // 拼后端地址
touch(image);
continue;
}
// 以下现状不变:urlToBase64 + isUrlUnusable 判定
imageToText(src/resource/image.ts:272)加同样的提前返回。
17.9 下载失败分支(src/agent/agent.ts:323-360)¶
const urls = collectImageUrls(messages);
const hosted = urls.filter(u => isHostedUrl(u)); // 图床 URL
const others = urls.filter(u => !isHostedUrl(u));
// 1) hosted 的 URL 只在本机解析:探一下确认文件还在不在
for (const u of hosted) {
if (!(await headOk(u))) markUrlBrokenByUrls([u]); // 真没了
}
// 2) 非图床 URL 走现有逻辑
if (others.length) markUrlBrokenByUrls(others);
// 3) 降级重发(现有机制)
markUrlBrokenByUrls 必须同时匹配 img.url 与 img.hostedUrl(src/resource/image.ts:176-178)。
17.10 对账与清理调度(image_gc.ts,已实现)¶
runOnce():
1. syncChanges() // /api/changes 增量:delete_ref / delete_blob / purge_blob → 标失效
2. reconcile() // /api/exists 批量核对(500/批):图床已不存在的 ref → 标失效
// (软删除因 ref 仍在,不会误伤;网络失败本轮跳过)
3. trim() // 超过「图片清理天数」未使用的记录 → 删记录 + release
4. flushPending() // 补发之前失败的 release
触发:启动后延迟 60s、之后每 6h
启动时额外探一次 /api/health(只记日志,不改变启用状态;周期重探不做,避免抖动)。
17.11 配置与探测(已实现部分)¶
- 「图床」为空 → 不启用,全链路现状,不做任何探测。
- 非空 → 启动后 5s 探一次
/api/health,失败只打一条 warning(不因此禁用图床:单次探测失败不代表不可用,长期失败会在每轮的 capture/release 日志里体现)。 - 未做:30 分钟周期重探 / 401 退避——当前没有"按探测结果禁用"的逻辑,加周期探测只增加日志噪音。
18. 端到端时序¶
18.1 一次带图消息(图床已配、可达)¶
协议端 ──图片段(含 rkey 直链)──► 插件
插件: createUrlImage + Image.save(现状) [同步]
插件: captureInBackground(ref=imageId) [异步,不阻塞]
├─ POST {图床}/api/upload {url, ref, source}
│ 图床: 校验→抓取→sha256→落库→缩略图→写 blob+ref
│ 图床: 200 {id, url: "/i/<id>", b64_url, dedup}
└─ 插件: 写 hostedUrl/hostedB64Url, 不写 base64
插件: 组包 → [img:xxx] → 后端地址 + hostedUrl(给协议端)
模型: 需要字节时插件取 /i/<id>/b64 内联
18.2 同一张图(图床对外地址可用)¶
插件: 组包 → 直接用「image-host.publicUrl + /i/<id>」交给模型(不再取字节)
publicUrl 空且 url 是回环: GET /i/<id>/b64 → base64 → 内联给模型
失败 → 降级为 [img:ID] 文本标签
18.3 插件淘汰与交叉清理¶
插件 trim: 选中 victim → storageSet('image_x','') → pendingRelease.push('x')
插件 flushPendingRelease: POST {图床}/api/release {refs:['x']}
图床: 删 ref('x') → 该 blob 引用数=0 → orphan_at=now
图床 cap 压力: 优先淘汰零引用且 origin=plugin 的(按 orphan_at 最旧)
插件 对账: POST {图床}/api/exists {refs:[...]} → missing → 标 urlBroken / 清条目
19. 实施任务拆解(PR 级)¶
P1 插件存储治理(无外部依赖,可先合)¶
-
src/resource/image.ts:加 5 个字段 +validKeysMap补齐(先做这条,否则后面全是静默丢字段) -
src/resource/image_gc.ts:FNV-1a 分片、索引读写、trim、touch节流、pendingRelease持久化 - L1 清理:
urlBroken时清base64+raw -
Image.ensureBytes()+ 替换tool_forum/tool_image/tool_meme - 单测:§11 插件单测 1–5、12
P2 图床核心¶
-
fetch.py:safe_fetch(逐跳校验、流式、magic bytes) -
store.py:DDL/迁移、put_blob、ensure_capacity、make_thumb、查询 -
imagehost.py:路由 + 鉴权 + 错误码 -
backend.json/requirements.txt -
backends.json注册(port 46680,files 列全) - 单测:§11 后端 1–3、5–7、10–14、18
P3 插件接图床¶
-
src/config/configs/backend.ts:新增「图床」 -
src/resource/image_host.ts:probe / upload / classifyHostError -
src/resource/image.ts:收到即抓 +src/CQCode优先级 +imageToText提前返回 -
src/utils/string.ts+src/session/session.ts:透传messageId -
src/utils/message.ts:多模态直接用hostedUrl -
src/agent/agent.ts:下载失败分支 - 单测:§11 插件 6–9、11;集成 1–4
P4 交叉清理¶
-
image_host.ts:releaseRefs/findMissingRefs -
image_gc.ts:pendingRelease补发 +reconcile调度 - 单测:§11 插件 10;后端 4;集成 5
P5 WebUI¶
-
webui.html+/login/logoutcookie - 列表 / 上传 / 重命名 / 标签 / 收进图库 / 删除
- 单测:§11 后端 9、17
P6 MCP¶
-
mcp.py:3 工具 +tools/list/tools/call - 插件侧 MCP 配置(不改代码)
- 单测:§11 后端 16;集成:AI 能
image_search命中
P7 可选¶
-
.ai image stats/.ai image adopt - S3 后端、EXIF 剥离、静态 GIF 的 b64
?format=png - 严格 DNS 绑定(把校验过的 IP 用于连接)
独立事项(不阻塞本方案)¶
- i2b64 加固:
timeout=(5,20)、流式大小上限、scheme 校验、私网拦截
20. 实现期需要核对的事实¶
实现前建议先确认(本文已尽量标注,但以下几条依赖运行时行为):
| # | 待核对 | 影响 |
|---|---|---|
| 1 | seal.MsgContext 上群 ID / 用户 ID / 消息时间的准确字段名 |
source 上报(§17.6) |
| 2 | handleReceipt / deferReceipt 里 getRecordMessageId(ctx, msg) 的返回值是否适合作为 message_id |
同上 |
| 3 | goja 是否支持 Math.imul(FNV-1a 用)——若不支持,退化为 h = (h * 31 + c) \| 0 |
分片哈希(§17.3) |
| 4 | launcher 对 files 列表的下载是否按目录递归 |
backends.json 条目(§16.1) |
| 5 | md-html-render 的 MCP 接入写法(照抄配置格式) |
P6(§16.11) |
---¶
第三部分:批量能力与插件同步¶
覆盖批量上传(多选 / 拖拽 / 粘贴 / URL 列表)、批量存图库、批量标签、批量删除(含回收站)等全部批量入口。 重点回答一个问题:图床这边批量动了图,插件那边怎么同步?
21. 范围与结论¶
21.1 批量能力总览¶
| 能力 | 入口 | 动作 | 危险级 | 需插件同步 | 可撤销 |
|---|---|---|---|---|---|
| 多选上传 | 顶栏「上传」→ 文件多选 | POST /api/upload ×N |
低 | 否 | 删除即撤销 |
| 拖拽上传 | 整页拖入文件 | 同上 | 低 | 否 | 同上 |
| 粘贴上传 | Ctrl/Cmd+V |
同上 | 低 | 否 | 同上 |
| URL 列表导入 | 粘贴多行链接 | POST /api/upload {url} ×N |
低 | 否 | 同上 |
| 批量存图库 | 勾选 → 收进图库 | keep:origin → manual |
中 | 否 | 是 |
| 批量移出图库 | 勾选 → 移出图库 | unkeep:origin → plugin |
高 | 否 | 是 |
| 批量加标签 | 勾选 → 加标签 | tag |
低 | 否 | 否(重做即可) |
| 批量删标签 | 勾选 → 删标签 | untag |
低 | 否 | 否(重做即可) |
| 批量删除 | 勾选 → 删除 | delete(进回收站) |
高 | 是 | 是(回收站) |
| 批量恢复 / 彻底删除 | 回收站视图 | restore / purge |
低 / 极高 | 是 / 是 | 是 / 否 |
| 批量复制链接 | 勾选 → 复制 URL | 纯前端(剪贴板) | 无 | 否 | — |
21.2 三条跨侧结论¶
- 只有"删除"需要同步到插件。上传、标签、改名、存/移出图库都不改变"图还在不在",插件不需要知道。
- 同步不能靠 6 小时的
exists全量对账——批量删 200 张会留下 200 个死引用最长 6 小时。改用增量变更日志 + 游标(§27)。 - 所有批量动作都必须幂等、可部分失败:一条坏 id 不能拖垮整批。
22. 批量上传¶
结论:后端几乎不用改,客户端做队列。 一次请求传一个文件,客户端并发 2,逐文件进度与失败重试。
22.1 入口¶
| 入口 | 实现 | 备注 |
|---|---|---|
| 多选 | <input type="file" multiple accept="image/*"> |
一次可选多个 |
| 拖拽 | 画廊整页 dragenter/dragover/drop,遮罩提示「松开上传 N 个文件(2.1 MB)」 |
支持从资源管理器拖 |
| 粘贴 | document.addEventListener('paste') → e.clipboardData.files |
截图直接 Ctrl+V |
| URL 列表 | 顶栏「导入链接」→ 多行文本框 → 逐行入队 | 复用 POST /api/upload {url} |
| 文件夹拖入(P2) | DataTransferItem.webkitGetAsEntry() 递归 |
设文件数上限,防误拖整个盘 |
22.2 客户端队列¶
type UploadItem = {
key: string; // name+size+lastModified,批内去重
file?: File; // 本地文件
url?: string; // URL 导入
status: 'queued' | 'uploading' | 'done' | 'failed';
id?: string; // 成功返回的 blob id
error?: string; // 失败原因(已翻译)
};
- 入队预检(不发请求):扩展名非图片 / 大小 >
MAX_FILE→ 直接failed并标注原因。 - 批内去重:
key相同只留一条。 - 并发 2:
MAX_CONTENT_LENGTH按请求算,多请求不叠加;落库有写锁,并发再高也没收益。 - 逐条状态:成功计数、失败原因、单条「重试」(重新入队,不重新选文件)。
- 全部结束 →
load(false)刷新 + toast「成功 8 / 失败 2」。
22.3 上传面板¶
左下角固定小面板(与右下 toast 分开),仅在有任务时出现,可折叠;关闭不中断上传:
上传队列 3/9 [全部重试] [折叠]
──────────────────────────────
IMG_2031.png ✓ 已上传
IMG_2032.png ⟳ 上传中…
clip.png ! 超过 8MB
https://…/a.png ! 上游 404(链接已失效)
失败文案映射:unsupported_format → 不是支持的图片格式;too_large → 超过 8MB;fetch_failed → 原链接取不到;disk_full → 图床空间已满。
22.4 URL 列表导入¶
- 逐行入队(同队列);空行与重复行忽略。
- 走
POST /api/upload {url, via:"webui"}→origin=manual(见 22.6)。 - 失败原因直接展示上游状态(
upstream_status)。 - 适合把别的图床/相册的图批量搬进来;也顺带验证了图床的抓取链路。
22.5 后端改动(很小)¶
| 改动 | 内容 |
|---|---|
| 文件名 → 默认名称 | multipart 无 name 时,用文件名去扩展名作为 blob.name;dedup 命中时只在原 name 为空才补(COALESCE(name, ?)) |
via="webui" |
允许 WebUI 会话把 JSON url/base64 路径也判为 manual |
| 其余不变 | 仍是单文件接口,多文件由客户端循环 |
# imagehost.py multipart 分支
name = (request.form.get("name") or "").strip()
if not name and fh.filename:
name = os.path.splitext(os.path.basename(fh.filename))[0][:80]
22.6 origin 判定(防篡改)¶
| 入口 | 判定依据 | origin |
|---|---|---|
插件抓取(JSON {url},token) |
token | plugin |
| WebUI 多选/拖拽/粘贴(multipart) | 传输形态 | manual |
WebUI URL 导入(JSON {url} + via:"webui") |
cookie 会话 + via |
manual |
origin由服务端按"传输形态 + 调用身份"决定,不接受裸的客户端指定。只有 cookie 会话 + 显式via="webui"才把 JSON 路径判为manual;token 调用永远plugin。
22.7 边界¶
- 单文件上限
MAX_FILE(默认 8 MB)→413;客户端预检一次避免白发。 - 非白名单格式 →
400 unsupported_format。 - 同内容不同文件名 → 哈希去重(
dedup:true),但仍记一条 ref;第二张图的文件名不会覆盖已有name。 - 一次拖 50 张 = 50 个请求,本地回环 < 2 秒;不做多文件批量接口(避免 multipart 半成功语义)。
23. 批量存图库(keep / unkeep)¶
23.1 语义¶
| action | 效果 | 对图床内部 |
|---|---|---|
keep |
origin: plugin → manual |
该图立刻退出自动淘汰候选;orphan_at 保留但不再生效 |
unkeep |
origin: manual → plugin |
该图重新进入自动淘汰候选;若 orphan_at 非空(零引用)则立刻变成可淘汰 |
- 天然幂等:已是
manual的再keep不计入affected。 - 返回
changed_ids(真正发生变化的),供前端做精确撤销。
23.2 混合选择¶
选中里同时有 plugin 和 manual 时:
- 操作栏显示「其中 40 张已是手动上传,将被跳过」。
keep只作用于plugin的;unkeep只作用于manual的。affected只统计真正翻转的,requested是选中总数——前端用它展示「已更新 97 / 选中 137(40 张本就如此)」。
23.3 与 cap / 引用的交互¶
| 场景 | 行为 |
|---|---|
keep 与 cap 淘汰并发 |
cap 在事务前已删掉某张 → 该 id 返回 not_found,其余照做;绝不因为一张没了整批失败 |
keep 后插件 release 掉全部引用 |
零引用 + manual → 仍然不自动删(决策 9)。这是刻意的:人收进图库的东西机器不能清 |
unkeep 后零引用 |
立刻进入 tier-1 淘汰候选(按 orphan_at 最旧优先) |
大量 keep 导致手动图占满 cap |
新上传按 §5.9 返回 507,而不是删手动图 |
23.4 撤销(仅这一对动作提供)¶
keep/unkeep的响应带changed_ids。- 客户端记一条撤销项:
{action: 'unkeep', ids: changed_ids}(或反向)。 - 操作完成后 30 秒内,toast 上出现「撤销」;点击即对
changed_ids执行反向动作。 - 撤销只影响这次真正改变过的 id,不会误伤本来就如此的图。
标签操作不提供撤销:精确撤销需要 per-item 的"之前有哪些标签"快照,成本高于收益;加错标签再删掉同样快。
24. 批量标签¶
24.1 加 / 删¶
tag:给所有选中图加同一组标签(INSERT OR IGNORE,幂等)。untag:从所有选中图移除同一组标签(不存在无害)。- 单批可带多个标签(
add_tags: ["猫","表情包"])。
24.2 交互¶
- 加标签(批量):弹层 = 「待添加 chips」+「输入框 + 加号」+「已有标签可点」。
- 输入框有内容时加号才亮,点加号(或 Enter)把标签加入待添加集合;
- 待添加集合非空时「确定」才可点(避免"点了确定却提示先输入"的反向体验);
- 输入框下直接列出已有标签(
标签 ×次数,点一下加入,已选的不再出现); - 提示文案不提 Enter(Enter 只是快捷方式)。不用浏览器
datalist/prompt。 - 加标签(卡片上的「+ 标签」):同样「输入框 + 加号 + 下方已有标签」;点建议或加号即刻生效、可连续加多个;Esc 或失焦关闭;已存在的标签点/输会提示「已有该标签」。
- 删标签:弹出面板,列出选中图标签的并集 + 出现次数:
这比让人手打标签名可靠得多,也避免"删了个根本不存在的标签"。
- 两个操作都走 POST /api/batch。
24.3 与筛选的联动¶
- 若用户先按标签
风景筛选再全选全部,删标签风景会让这批图从当前视图消失——操作后强制刷新列表是预期行为。 - 删标签的确认框提示:「移除后这 137 张将不再匹配当前筛选」。
25. 批量删除与回收站¶
25.1 为什么必须配回收站¶
批量删除不可逆,一次手滑 200 张;且插件侧会立刻收到 200 条失效通知——恢复成本很高。软删除把危险操作变成可回退操作。
25.2 数据与接口¶
ALTER TABLE blob ADD COLUMN deleted_at INTEGER; -- 非空 = 在回收站
CREATE INDEX idx_blob_deleted ON blob(deleted_at);
| 接口 | 行为 |
|---|---|
GET /api/list?trash=1 |
回收站视图(默认列表排除 deleted_at IS NOT NULL) |
POST /api/batch {action:"restore"} |
清 deleted_at,写 restore_blob;文件已不在则 410 gone |
POST /api/batch {action:"purge"} |
硬删文件 + 行,写 purge_blob |
| 自动清理 | 超过 TRASH_DAYS(默认 7)每小时 purge 一批 |
25.3 与其它机制的关系¶
| 机制 | 行为 |
|---|---|
| 插件同步 | delete 时就写 delete_ref(模型此刻已取不到图),不等 purge |
| 与 cap | 回收站仍占盘;cap 压力下先清回收站(按 deleted_at 最旧),再淘汰 plugin 图;manual 图仍不自动删 |
| 与恢复 | 恢复前检查文件在不在(cap 可能已清掉);不在 → 410 |
| 与 LRU | 回收站内容不参与 last_access_at 淘汰排序 |
| 与打包/导出 | 回收站内容不导出 |
25.4 建议¶
TRASH_DAYS=0 可关闭回收站(delete 即硬删)。但只要做批量删除,建议默认开启 7 天——它只多一列、两个 action,却决定了这个功能能不能安全上线。
26. 批量 API¶
26.1 端点¶
两种寻址方式(二选一):
// ① ids 模式(普通勾选)
{ "action": "untag", "ids": ["9f2c…", "a031…"], "remove_tags": ["待分类"] }
// ② filter 模式("全选全部":按当前筛选条件操作)
{ "action": "keep", "filter": { "q": "", "tag": "风景", "origin": "plugin" },
"expected_count": 137 }
26.2 dry_run 预演(推荐先做这个)¶
// req
{ "action": "delete", "filter": { "tag": "风景" }, "dry_run": true }
// resp(不写任何数据)
{ "ok": true, "dry_run": true,
"requested": 142, // 命中多少张
"affected": 142, // 真正会被改变的(keep 已是的会少)
"skipped": 3, // 已是目标状态的
"refs_affected": 168, // 会被判死的插件引用数(前端确认框要显示这个)
"sample": ["9f2c…", "a031…"] }
为什么要有预演:确认框里显示的是真实数字,而不是前端估算;用户确认的是"142 张、168 个引用",服务端再按 expected_count=142 校验一次,双重防呆。
26.3 响应与部分失败¶
// 200
{
"ok": true, "action": "keep",
"requested": 137, "affected": 97,
"changed_ids": ["9f2c…", "…"], // keep/unkeep 用于撤销
"failed": [{ "id": "…", "reason": "not_found" }],
"cursor": 1043 // 本次后的变更日志游标
}
// 409 —— filter 模式下目标数量与确认时不一致
{ "error": "count_mismatch", "expected": 137, "actual": 142 }
// 413 —— 超单批上限
{ "error": "too_many", "count": 2400, "max": 2000 }
// 400 —— 请求本身不合法
{ "error": "bad_request", "reason": "need_ids_or_filter" }
- 逐条失败进
failed[],绝不整批 4xx。 affected只统计真正发生变化的行(幂等语义)。cursor让调用方可以立刻接着拉变更日志(一般用不上,插件是独立轮询的)。
26.4 限额与分片¶
| 项 | 值 | 说明 |
|---|---|---|
ids 单批上限 |
500 | 请求体 + 事务时长 |
| filter 命中上限 | 2000 | 超出 413,提示缩小筛选 |
| UI 分片 | 选中 > 500 → 自动拆 500/批 | 进度可见、单批失败不牵连其余 |
expected_count |
filter 模式必填 | 缺失 → 400 |
26.5 服务端实现要点¶
@app.post("/api/batch")
def api_batch():
_require_auth()
body = request.get_json(silent=True) or {}
action = body.get("action")
if action not in ACTIONS: raise ApiError("bad_request", 400, reason="bad_action")
targets = _resolve_targets(body) # ids 模式直取;filter 模式解析 + 校验 expected_count
if len(targets) > MAX_BATCH: raise ApiError("too_many", 413, count=len(targets), max=MAX_BATCH)
if body.get("dry_run"): return jsonify(store.preview(action, targets, body))
return jsonify(store.batch(action, targets, body))
store.batch() 的实现要点:
| 要点 | 做法 |
|---|---|
| 事务 | 整批一个 BEGIN…COMMIT;持写锁 |
| 批量 SQL | executemany,不要逐条 execute |
| 变更日志 | delete/restore/purge 在同一事务内写入,保证"删了必留痕" |
| 文件删除 | 提交后再 unlink(尽力而为,失败只记日志;启动自检清残留) |
affected |
用 cursor.rowcount 累计,幂等动作自然为 0 |
27. 变更日志与插件同步(重点)¶
27.1 为什么 exists 不够¶
| 维度 | POST /api/exists(现状) |
GET /api/changes(本方案) |
|---|---|---|
| 成本 | O(插件持有的全部引用),每轮全报 | O(被删数量),通常为 0 |
| 延迟 | 6 小时 | 可压到 1–5 分钟 |
| 批量删除 | 一次返回 N 个 missing | 一次返回 N 条事件,插件一次标完 |
| cap 自动淘汰 | 下一轮才发现 | 同一条链路覆盖 |
| 断线/重装 | 只能全量对 | 有游标可增量;游标过期走 reset 兜底 |
exists不删,降级为兜底(reset 时 + 24h 体检),日常走 changes。
27.2 表结构¶
CREATE TABLE change_log (
seq INTEGER PRIMARY KEY AUTOINCREMENT, -- 单调游标
at INTEGER NOT NULL,
kind TEXT NOT NULL, -- delete_ref | delete_blob | restore_blob | purge_blob
blob_id TEXT,
ref TEXT, -- delete_ref 带插件侧 imageId
extra TEXT
);
CREATE INDEX idx_change_seq ON change_log(seq);
CREATE INDEX idx_change_at ON change_log(at);
27.3 写入点(漏一处就漏一次同步)¶
| 触发 | 写入 |
|---|---|
DELETE /api/image/<id> |
读全部 ref → 每条 delete_ref → 一条 delete_blob |
POST /api/batch {action:"delete"} |
同上,逐 blob |
cap/LRU 自动淘汰(_delete_blob) |
同上 |
restore |
restore_blob |
purge |
purge_blob |
插件 POST /api/release |
不写(插件自己发起的) |
唯一例外:多插件实例共用同一图床时,A 的 release 会让 B 的引用消失 →
AIPLUGIN4_IMAGE_HOST_MULTI_CLIENT=1时 release 也写delete_ref。
27.4 拉取¶
{ "cursor": 1043, "reset": false,
"events": [
{ "seq": 1041, "kind": "delete_ref", "ref": "a1b2c3", "blob_id": "9f2c…", "at": 1792… },
{ "seq": 1042, "kind": "delete_blob", "ref": null, "blob_id": "9f2c…", "at": 1792… }
] }
// 游标已过期
{ "cursor": 2098, "reset": true, "events": [] }
27.5 插件侧消费¶
状态(image_host_state 新增):lastChangeSeq、lastChangePollAt。
轮询(image_gc.ts):启动后 30s 一次,之后每 5 分钟;events.length == limit 时继续拉直到空;reset=true → 走 exists 全量兜底。
标记:
function markRefGone(ref: string) {
const img = Image.get(ref); // ref == 插件 imageId
if (!img) return; // 已被 GC,跳过
img.urlBroken = true; // 复用现有语义:渲染期只留文本标签
// 保留 hostedUrl —— 万一之后被恢复,还能复活
Image.save(img); touchIndex(img);
}
function markBlobBack(blobId: string) {
for (const img of Object.values(Image.imageMap)) {
if (img.hostedUrl?.includes(blobId) && img.urlBroken) {
img.urlBroken = false; Image.save(img); touchIndex(img);
}
}
}
- 标记后组包路径(§6.5)自动不再产生
image_url,退化成[img:id:描述]——与"模型够不到"共用同一套兜底。 - 之后由 L2 trim 按
lastUsedAt正常回收,无需额外逻辑。
兜底:reset → 现有 exists 分批对账;另保留 24h 一次的低频体检,防"日志写漏"。
27.6 保留期¶
| 项 | 默认 | 说明 |
|---|---|---|
CHANGE_KEEP_DAYS |
30 | 超期删除;插件离线 >30 天 → reset 全量兜底 |
| 清理时机 | 启动 + 每小时 | 远大于 5 分钟轮询间隔,reset 应极少发生 |
27.7 批量删除的完整链路¶
用户勾选 200 张 → 删除 →(dry_run 显示 200 张 / 231 个引用)→ 二次确认
① POST /api/batch {action:"delete", ids:[…200]}
图床:一个事务写 231 条 delete_ref + 200 条 delete_blob,blob 进回收站
② 插件 5 分钟内拉一次 changes(231 条事件,一次拿完)
231 个 Image 标 urlBroken,索引 x=1
③ 下一轮对话组包:这些图自动退化为文本标签,不产生 400
对比现状:不做变更日志时,这 231 张要等 6 小时对账,期间每次用到都是一轮失败重试。
28. WebUI 交互¶
28.0 视图与上传面板¶
三个视图(顶栏 tab 切换,同一页面内):
| 视图 | 数据源 | 说明 |
|---|---|---|
| 图库 | GET /api/list |
默认视图;卡片、批量选择、上传入口 |
| 回收站 | GET /api/list?trash=1 |
卡片标「回收站」;批量栏换成 恢复 / 彻底删除;缩略图与全图预览仅登录态可见(模型侧 404) |
| 标签 | GET /api/tags?q=&limit=&offset= |
标签清单:按使用次数降序的 chip(标签 ×次数),分页(120/页,显示 已加载 X / 共 N 个标签 + 加载更多);顶部有标签过滤框;点标签即切回图库并按该标签筛选 |
- 顶栏的来源筛选用分段控件(
全部 / 插件抓的 / 手动上传),与视图 tab 同一套样式,不用浏览器原生<select>。 - 没有「筛选」按钮:搜索框与标签筛选都是输入即生效(300ms 防抖),Enter 立即生效;来源分段控件点选即生效。
- 搜索框 / 标签筛选 / 标签视图过滤框右侧都有 「×」一键清空(仅在有内容时出现,清空后立即重新加载)。
/以Cache-Control: no-store返回,避免前端改版后用户看到缓存的旧页面。
标签气泡一律有上限,不会把页面撑爆:
| 位置 | 上限 | 超出时 |
|---|---|---|
| 卡片上的标签 | 8 个 | 折叠成 +N,悬停显示全部 |
| 加标签的「已有标签」建议 | 24 个 | 末尾提示「还有 N 个,继续输入可缩小范围」 |
| 删标签的并集面板 | 60 个 | 末尾提示「还有 N 个标签未列出」 |
| 待添加 chips 区 | — | .pop .chips 限高 120px 内部滚动 |
| 标签清单视图 | 120 个/页 | 「加载更多」分页 |
上传面板(左下角,仅在有任务时出现):
- 标题栏:
上传队列 x/y、全部重试、收起/展开、×(关闭只隐藏面板,不取消上传)。 - 收起后只留标题栏;关闭后左下角出现
上传队列 x/y小按钮,点击恢复。 - 实现坑:面板列表
.qlist与拖拽遮罩.dropovl自带display:flex,会覆盖 UA 的[hidden]{display:none}—— 必须补[hidden]{display:none!important},否则"收起"点了没反应(已修)。
28.1 选择模型¶
state.selection = {
ids: Set<string>, // 跨页保持
allMatching: boolean, // true = filter 模式(全选全部)
filter: { q, tag, origin }, // 进入 allMatching 时的筛选快照
total: number, // 快照时的命中数
};
| 控件 | 行为 |
|---|---|
| 全选本页 | 当前页 24 个 id 加入 ids |
| 全选全部(共 N) | allMatching=true,记 filter 快照与 N |
| 取消选择 | 清空 |
| 手动取消某一项 | 从 allMatching 退回 ids 模式(提示「已取消全选,仅保留当前勾选」) |
28.2 布局¶
- 批量模式下顶栏换成工具栏:
已选 137 张 · 全选本页 · 全选全部(137) · 取消 - 底部浮动操作栏(移动端变 bottom sheet):
删除/移出图库 用危险色;其余中性。未选中时整条隐藏。
- 卡片左上角复选框;选中卡片加高亮描边;点击卡片任意处=切换选中(不再打开大图)。
28.3 危险操作确认¶
两段式:
- 先
dry_run,弹框显示真实数字: 「将删除 142 张图片,其中 168 个插件引用会失效(可在回收站保留 7 天)」allMatching时附筛选摘要:「筛选:标签=风景 · 插件抓的」。 requested > 50→ 要求输入删除两字才启用按钮。
28.4 键盘与无障碍¶
| 交互 | 行为 |
|---|---|
Esc |
退出批量模式 / 关闭弹窗 |
Ctrl/Cmd + A |
全选本页(不劫持输入框内的全选) |
Shift + 点击 |
本页范围选择 |
Delete |
触发删除确认(不直达) |
| 复选框 | 真 <input type=checkbox>,aria-checked 同步 |
| 弹窗 | 焦点陷阱 + role=dialog + aria-modal |
| 动效 | 尊重 prefers-reduced-motion |
28.5 移动端¶
- 长按卡片进入选择模式;底部操作栏变 bottom sheet。
- 上传面板折叠成一个小进度气泡(「3/9」)。
28.6 多标签页并发¶
- id 集合天然幂等;
expected_count防筛选漂移。 - 任一操作后强制
load(false)刷新,避免看到过期状态。 - 另一个标签页的上传队列不可见——可接受(不需要跨页同步)。
29. MCP 批量¶
只放开"安全"的批量,删除仍不给 AI:
| 工具 | 变更 |
|---|---|
image_tag |
参数从 id 扩展为 id? / ids?[](二选一);add / remove 不变 |
image_search |
不变 |
image_rename |
保持单张(批量同名无意义) |
image_keep |
新增:ids[] → origin=manual(AI 可以把"这张别删了"表达出来) |
~~image_delete~~ |
不做(§5.11) |
返回文本示例:已更新 2 张图的标签:添加 表情包 / 已把 3 张图收进图库。
30. 新增接口汇总¶
| 接口 | 方法 | 用途 |
|---|---|---|
/api/batch |
POST | 批量执行 / dry_run 预演 |
/api/changes |
GET | 变更日志增量拉取 |
/api/tags |
GET | 标签词表 + 计数(补全、筛选下拉) |
/api/upload |
POST | 扩展:multipart 支持 name、via="webui" |
/api/list |
GET | 扩展:trash=1 回收站视图 |
31. 新增参数¶
| 配置 | 默认 | 位置 | 说明 |
|---|---|---|---|
MAX_BATCH_IDS |
500 | 后端常量 | 单批 ids 上限 |
MAX_FILTER_MATCH |
2000 | 后端常量 | filter 模式命中上限 |
AIPLUGIN4_IMAGE_HOST_CHANGE_KEEP_DAYS |
30 | 后端 env | 变更日志保留 |
AIPLUGIN4_IMAGE_HOST_TRASH_DAYS |
7 | 后端 env | 回收站保留(0=关闭) |
AIPLUGIN4_IMAGE_HOST_MULTI_CLIENT |
0 | 后端 env | 多实例共用时 release 也记日志 |
IMAGE_CHANGE_POLL_MS |
5 分钟 | 插件常量 | 变更轮询间隔 |
IMAGE_CHANGE_POLL_TIMEOUT |
10s | 插件常量 | 单次拉取超时 |
IMAGE_EXISTS_AUDIT_MS |
24h | 插件常量 | 低频全量体检 |
UPLOAD_CONCURRENCY |
2 | 前端常量 | 上传并发 |
UNDO_TTL_MS |
30s | 前端常量 | 存图库撤销窗口 |
32. 任务拆解与验收¶
T1 后端:批量 API¶
-
store.preview()/store.batch():单事务、幂等、affected统计、部分失败 -
POST /api/batch+_resolve_targets(ids / filter +expected_count) - 限额(500 / 2000)、
409 count_mismatch、413 too_many -
GET /api/tags - 验收:批量 untag 重复执行
affected=0;filter 模式计数变化 → 409;超限 → 413
T2 后端:变更日志¶
-
change_log表 + 迁移(user_version 1 → 2) - 5 个写入点全覆盖(手动删 / 批量删 / cap 淘汰 / restore / purge)
-
GET /api/changes(游标、limit、reset) - 保留期清理(启动 + 每小时)
- 验收:批量删 200 条 → 事件数 = blob + ref;游标单调;日志过期触发 reset
T3 后端:回收站¶
-
deleted_at迁移、列表默认排除、trash=1 -
restore/purge、TTL 清理、cap 优先清回收站 - 验收:删除不可见 → 恢复可见 → 过 TTL 不可恢复
T4 插件:增量同步¶
-
image_host.ts:fetchChanges(since, limit) -
image_gc.ts:轮询、lastChangeSeq持久化、reset→exists兜底、24h 体检 -
Image.markRefGone/markBlobBack - 验收:收到
delete_ref→urlBroken且组包不再产生image_url;restore_blob→ 恢复可渲染;reset→ 触发全量
T5 前端:批量上传¶
- 多选、拖拽遮罩、粘贴、URL 列表导入
- 队列(并发 2、批内去重、预检、逐条状态、重试)、上传面板
- 验收:拖 10 张(含 1 超限、1 非图片)→ 8 成功 / 2 失败且可重试;粘贴截图成功
T6 前端:批量管理¶
- 选择模式、跨页保持、
allMatching - 底部操作栏、>500 自动分片、进度
- 存图库/移出图库 + 30s 撤销
- 标签并集面板、自动补全
-
dry_run驱动的两段式确认 + 超 50 输入确认词 - 验收:全选全部 137 → 存图库 → 刷新后徽章全变「手动上传」;删除 137 → 确认 → 回收站可见 → 恢复
T7 MCP 扩展¶
-
image_tag支持ids[];新增image_keep - 验收:AI 调
image_tag(ids=[...])→ 多张同时更新
33. 明确不做¶
| 项 | 原因 |
|---|---|
| 多文件批量上传接口 | 客户端并发更简单,避免 multipart 半成功语义 |
| AI 批量删除 | 误删代价不对称;删除只留给人 |
| 批量重命名 | 多张同名无意义 |
| 标签操作撤销 | 精确撤销需 per-item 快照,成本 > 收益;重做很快 |
| 跨页"反选全部" | 补集会随筛选变化,语义易错;只提供全选本页/全部 |
| 批量导出 zip | 与图床定位无关,P4 再议 |
| 批量操作的审计日志(谁改了什么) | 当前只有共享密码、没有用户身份,"谁"记不准;change_log 只服务删除同步 |
34. 性能预算与实测(v1.0.0 已实现)¶
实测环境:Windows 11 + Windows Defender 实时防护、Python 3.13、SQLite WAL、单机回环。 每项取 10–50 次的中位数;客户端用
requests.Session保持长连接,避免 TCP 建连噪声。
34.1 实测结果¶
| 场景 | 优化前 | 优化后 | 说明 |
|---|---|---|---|
| 上传 · 哈希去重命中 | 14–16 ms | 2.4 ms | 一份字节都不写 |
| 上传 · 新图 800×600(约 9 KB) | 27–52 ms | 3.1 ms | 直写终态,跳过缩略图 |
| 上传 · 1.9 MB 大图(含缩略图) | — | 183 ms | ≈96 ms/MB,主要是缩略图 + 大 body |
| 列表 24 条/页 | 2.9 ms | 2.1 ms | 三查询取全页 |
GET /i/<id> 热 |
2.1 ms | 1.9 ms | 内存 LRU 命中 |
GET /t/<id> 缩略图 |
68 ms(首次) | 1.7–3.0 ms | 首次读新文件会被杀软扫 |
| 批量 tag 50 张 × 3 标签 | 7.2 ms | 2.4 ms | 单事务 + executemany |
| 批量 delete 50 张(软删 + 100 条日志) | 23.2 ms | 2.7 ms | 同上 |
GET /api/changes 500 条事件 |
19.9 ms | 3.1 ms | 自增 seq 主键,O(delta) |
34.2 关键优化(按收益排序)¶
| # | 优化 | 收益 | 原因 |
|---|---|---|---|
| 1 | SQLite 连接池(queue.LifoQueue + teardown_request 归还) |
单次写 conn 6–19 ms → 0.01 ms | Flask 是「每请求一个线程」,thread-local 等于每请求新建连接;常驻多连接时新建连接极慢 |
| 2 | 每连接重设 PRAGMA(synchronous / foreign_keys 是连接级) |
消除多余 fsync | 新建连接会退回默认 FULL |
| 3 | 小图跳过缩略图(≤64 KB 不打开 Pillow) | 新图上传 27 → 3.1 ms | 省一次「刚写完就立刻读」的磁盘操作 |
| 4 | hash-first:base64 先算 sha,命中则零文件写入;新图直写终态 | 去重 15 → 2.4 ms | 省掉 tmp 文件 + rename |
| 5 | 缩略图生成移出写事务 | 并发上传不再互相阻塞 | CPU 不占写锁 |
| 6 | meta 表维护 used_bytes |
每次写入少一次全表 SUM |
容量检查 O(1) |
| 7 | 不可变元数据内存 LRU(4096 条) | GET /i/<id> 少一次 DB 查询 |
内容寻址 ⇒ 条目不可变,只需在删除时失效 |
| 8 | 列表消除 N+1(blob / tags / sources 各一条) | 24 条/页 3 → 49 次查询 | 原来每张图各查一次标签与来源 |
| 9 | 批量操作单事务 + executemany |
删除 50 张 23 → 2.7 ms | 200 次单独 execute → 4 次批量 |
| 10 | 变更日志自增 seq 主键 + 游标查询 |
500 条事件 20 → 3.1 ms | O(delta) 而非 O(全表) |
34.3 环境噪声(别误判为代码问题)¶
- Windows Defender 会扫描刚创建的文件:首次读一个新写入的图片约 7–12 ms,第二次起 1.9 ms。
这也是"冷读 6.9/12.4 ms、热读 1.9 ms"的原因——同理
GET /t/首次 68 ms。 - Linux 部署没有这笔开销;上表"优化后"的数字里,新图/首次读仍含这部分。
- Flask 开发服务器(
app.run(threaded=True))单进程;家庭场景足够,若要跑公网建议换waitress(waitress-serve一行命令,无需改代码)。
34.4 规模推算¶
| 指标 | 估算 |
|---|---|
| 单张平均占用 | 9 KB(示例图)~ 数百 KB(照片) |
CAP=2 GB 可存 |
约 20 万张 9 KB / 约 1 万张 200 KB |
| 列表与取图延迟 | 与总张数无关(主键 / 索引);COUNT(*) 在 10 万级仍是毫秒内 |
| SQLite 文件 | 1 万张约 3–5 MB 元数据 + 字节文件 |
change_log |
每条约 60 B;30 天保留,日常图床量级 < 1 万条 |
结论:不需要引入数据库外的任何缓存层;瓶颈只在磁盘和缩略图 CPU,且都已错峰。
状态:后端已发布(image-host 1.0.15,6 个 MCP 工具);插件侧已全部实现并通过单测(483 个插件单测 + 57 个后端单测;真后端联调可用
IMAGE_HOST_E2E),见 §6.0/§35。
35. 实现状态(截至本次)¶
| 部分 | 状态 |
|---|---|
后端 image-host(抓取 / 哈希去重 / 引用计数 / b64 出口 / 回收站 / 批量 / 变更日志 / 标签清单分页 / ref 元数据 / MCP 6 工具) |
✅ 已发布(1.0.15:ref tool/server/via、upload tags/source、image_search SQL 过滤/total/since/多标签、image_unkeep、tag 元数据边界) |
| WebUI(外观切换 / 画廊 / 来源 / 重命名 / 标签 / 标签清单视图 / 上传队列(收起·关闭·恢复) / 拖拽 / 粘贴 / URL 导入 / 批量选择与操作 / 回收站) | ✅ 已实现并本地跑通 |
| 性能优化(§34) | ✅ 已落地并实测 |
插件侧(图床客户端 / Image.hosted*+lastUsedAt+sourceMeta / 图片索引 / 定时清理 + release 补发 / changes + exists 双通道对账 / 上传并发上限 4 / 收图与生图自动入库 / ensureBytes 走 b64 出口 / 有图床时不依赖 i2b64 / 不再持久化 base64 / 来源字段完整上报 / CIR v1 + 虚拟图 + image_view / 工具图片英文类型标签 / 模型可达性与 urlBroken 自愈) |
✅ 全部实现,483 个插件单测通过(含真后端联调开关) |
| 独立事项:i2b64 加固 | ⏳ 未开始(与图床无关;配了图床后插件不再调用它) |
可选增强:.ai image stats 命令(条目数/图床命中率等) |
未做(P7 可选,不影响主链路) |