# 架构 (/zh/docs/architecture)

单实例设计、分层与依赖方向、数据模型、上传与访问的数据流、outbox 围栏、周期任务，以及为什么在进程内集成 rclone。

## 单实例设计 [#单实例设计]

ImgYoung 明确按单实例设计：一个进程、一个数据目录。

- **元数据库是一个 SQLite 文件**（数据目录下的 `imgyoung.db`，WAL 模式）。写操作走单连接排队，读操作走连接池；驱动是纯 Go 的 `modernc.org/sqlite`。
- **任务队列在同一个库里**：异步任务由 River 执行，它的任务表与业务表在同一个 SQLite 文件中，不需要额外的队列服务。
- **其余状态都在本机**：上传并发闸、按用户的上传频控、登录限速、访问活跃度的聚合都在进程内存里，衍生图缓存在本地磁盘上。不使用 Redis。
- **单个静态二进制**：全程 `CGO_ENABLED=0`，管理后台的前端产物与 OpenAPI 文档都编译进二进制。

因此 ImgYoung 不支持多个进程共享同一个数据目录，也不支持水平扩展。需要高可用时，升级路径是把数据库换成 PostgreSQL
（River 本身支持 PostgreSQL，任务队列随之迁过去），进程内的共享状态届时再外置；当前代码只有 SQLite 实现。

## 分层与依赖方向 [#layers]

```text
http ──→ core ──→ infra
  │        └────→ store
  └─────────────→ infra
```

| 层 | 目录 | 职责 |
|---|---|---|
| store | `store/` | 公开的存储引擎：`driver` 接口、`rclone` 实现、`failover` 容灾原语。见 [作为 Go 库使用](./library/index.mdx) |
| infra | `internal/infra/` | 基础设施与出站适配：`config`（引导配置与运营配置）、`db`（schema、查询、迁移、sqlc 生成代码）、`secret`（主密钥与加密）、`id`（短 ID）、`imaging`（图像处理）、`cdn`、`urlfetch`（URL 转存的抓取）、`observability`（指标）、`version` |
| core | `internal/core/` | 业务核心：`channel`（渠道与健康探测）、`replication`（补齐、对账、排空、CDN purge）、`upload`、`variants`（按需处理）、`dedup`、`lifecycle`、`reliability`（备份恢复）、`auth`、`audit`、`referer`、`usage`、`alert` |
| http | `internal/http/` | HTTP 入站：`publicapi`（上传与图片访问）、`s3api`（S3 协议面）、`adminapi`（管理后台）、`middleware`、`httpx`、`router` |
| 装配根 | `cmd/imgyoung/` | `build.go` 按「基础设施 → 业务服务 → HTTP」顺序手工构造对象图，`serve.go` 用 oklog/run 编排启停，`loops.go` 放周期循环 |

`store` 与 `infra` 是两个并列的叶子：`store` 不依赖任何 `internal/` 包，`infra` 不依赖 core、store 与 http；`core` 同时依赖二者。
依赖方向由 golangci-lint 的 depguard 规则强制，CI 不通过即不能合并：

| 规则 | 禁止导入 | 原因 |
|---|---|---|
| `store/**` | `internal/` 下的任何包 | store 是公开的可复用存储引擎，不能依赖应用代码 |
| `internal/infra/**` | `internal/core`、`store`、`internal/http` | infra 是基础设施叶子 |
| `internal/core/**` | `internal/http` | 业务核心不知道传输层，可以脱离 HTTP 测试 |
| `internal/http/**` | `store` | 选路与容灾属于 core，传输层只拿 core 给出的结果 |

另外两条约定：

- **接口由使用方定义**：各包在自己内部声明只含所需方法的小接口（如 `upload.ChannelSelector`、`publicapi.BlobChannels`），由具体类型隐式满足。
  `store/driver.Driver` 是唯一的一等抽象。
- **身份显式传参**：中间件解析出认证主体放进请求上下文，handler 取出后作为参数传给 core；core 不读 context 里的身份，按用户隔离在 core 中强制执行。

HTTP handler 与 River worker 都只是把外部触发翻译成服务调用的薄适配；周期任务是各服务自带的 `Run(ctx, wait)` 循环，由装配根统一编排，没有单独的调度层。

## 数据模型 [#data-model]

数据模型的核心是**物理图与上传引用两层分离**：

```text
images(上传引用)──→ blobs(物理图)──→ blob_channels(各渠道的副本)──→ channels
       N : 1                  1 : N                          N : 1
```

- **`blobs`** 是一份实际存储的字节。每次上传都新建一个 blob；后台查重把字节完全相同的 blob 合并后，一个 blob 可以被多条引用指向。
  它记录净化后字节的 `sha256`、感知哈希 `phash`、渠道内对象键 `storage_key`（形如 `{short_id}_{sha256 前 8 位}`）、MIME、大小、尺寸，
  以及引用计数 `ref_count` 与待删标记。
- **`images`** 是一次上传产生的引用，对外的短 ID（10 位）就是它的主键。归属用户、来源 Token、S3 对象键别名、到期时刻与访问活跃度、
  冷图标签与清理豁免都挂在引用上，不挂在 blob 上。
- **`blob_channels`** 记录某个 blob 在某个渠道上的副本，兼作补齐任务的状态：`pending → uploading → done / failed`，另有尝试次数与最近错误。

两层分离让几件事各得其所：查重合并只是把引用改指到保留的 blob；TTL、冷图与配额按引用计算；副本、补齐与物理删除按 blob 进行。

**引用计数**：`ref_count` 的增减都是原子的 `UPDATE … SET ref_count = ref_count ± 1`。删除引用与递减计数在同一个事务里；
计数归零的 blob 被标记待删，并按运营配置 `delete_grace_minutes` 记下物理删除时刻。到点后清理任务先清掉它的衍生图缓存，
再在一个事务里删除业务行并复查删除条件（期间又被引用就放弃本次删除），最后为每个持有副本的渠道入队对象删除任务。

其余的表：

| 表 | 内容 |
|---|---|
| `channels` | 渠道：rclone 后端名、用主密钥 AES-GCM 加密的参数 JSON、优先级、健康标记与最近探测结果、是否加密、是否允许直链、清理中标记；创建时刻 `created_at`（与渠道 ID 一起参与加密渠道的密钥派生，建后不变）与绑定时刻 `bound_at`（见[对账 GC](#reconcile-gc)）|
| `variants` | 衍生图缓存的索引：一个 blob 一种处理参数一行，字节存在本地磁盘，按最近访问时间做 LRU 淘汰 |
| `settings` | 运营配置，单行宽表；每列的 DEFAULT 就是出厂值。见 [配置](./configuration.mdx) |
| `users`、`tokens`、`sessions` | 用户与配额、上传 Token（只存哈希）、后台会话（只存 session id 的哈希与绑定的 CSRF 令牌）|
| `idempotency_keys` | 上传幂等键，按用户隔离 |
| `dedup_candidates` | 相似重复的候选对，人工裁决的工作队列 |
| `referer_whitelist`、`audit_log`、`alerts` | 防盗链白名单、管理操作审计、告警记录 |
| `master_key_fingerprint` | 单行，由主密钥派生的指纹（不能反推主密钥）；每次启动据此核对主密钥，缺失或不符即拒绝启动，见[主密钥](./configuration.mdx#主密钥) |

表结构以 `internal/infra/db/schema/` 下每表一个文件的声明式 SQL 为准，每张表与每一列都有注释。版本化迁移由 Atlas 据此生成，
随二进制内嵌，进程启动时按顺序自动应用，已应用的版本记在 `schema_revisions` 表里，跨多个版本升级也会补齐中间的迁移。

迁移不支持降级：库里记有本二进制不认识的迁移版本（即库已被更新版本的二进制迁移过）时，进程拒绝启动，错误信息写明库里与本二进制各自的最新迁移版本。
回滚只能用「旧二进制 + 升级前的快照」，见 [升级](./operations/upgrade.mdx)。

## 上传数据流 [#上传数据流]

数据面上传、后台上传、URL 转存与 S3 PutObject 只在「字节从哪来」上不同，拿到字节之后走同一条管道。上传路径不查重：每次上传都新建 blob，
查重在后台进行，上传路径因此是一条没有分支的直线。

<Steps>
<Step>

**入口与防护**：认证（上传 Token、会话或 S3 签名），并通过两道闸：按用户的上传频控（`upload_rate_per_min`）与全局上传并发闸
（`max_concurrent_uploads`，满载直接返回 429）。随后把图片字节完整读进内存，任何大小都不写临时文件。
单图上限 `max_upload_mb` 只约束图片本身，恰好等于上限可以上传，超出返回 413。multipart 上传的请求体整体另以「单图上限 + 64 KiB」为界，
只读 `file`、`exif_policy`、`ttl` 三个段：`exif_policy` 与 `ttl` 只认表单字段（不读 URL 查询参数），每个最多 1 KiB，超出返回 400。

</Step>
<Step>

**幂等**：带 `Idempotency-Key` 且同一用户已用过这个键时，直接返回已有的图。

</Step>
<Step>

**格式与像素预算**：按文件头的 magic bytes 判定真实格式，只接受 JPEG、PNG、GIF、WebP；只读图片头取得尺寸，并与嗅探出的格式交叉核对；
宽 × 高超过 `max_pixels_mp` 直接拒绝，不进入完整解码。解码后的内存由分辨率决定，与文件字节数无关，所以这道闸必须在解码之前。

</Step>
<Step>

**配额预检**：按引用计全额，超出用户配额返回 403，不写渠道。

</Step>
<Step>

**净化**：EXIF 档位为 `strip` 时剥离元数据（JPEG、PNG、WebP；GIF 没有标准元数据段，原样通过）。
JPEG 剥离后补回一个只含方向（Orientation）的最小 Exif 段，靠方向标记摆正的照片不会转倒；GPS、机型、时间等字段照删，方向为默认值时不补。

</Step>
<Step>

**哈希**：完整解码一次计算感知哈希 pHash，再对将要存储的字节计算 SHA-256。净化在哈希之前，所以 `sha256` 永远对应渠道里实际存的字节。

</Step>
<Step>

**写渠道**：对象键为 `{short_id}_{sha256 前 8 位}`，嵌入短 ID 是元数据库全部丢失时从存储端反查的最后手段。
用 `failover.Write` 按优先级向健康渠道写入，凑够 `min_success` 份即止，单次写入超时 10 分钟，失败顺延下一个渠道。

</Step>
<Step>

**落库**：一个事务内写入 blob、引用、每个在役渠道的副本记录（写成功的为 `done`，其余为 `pending`）与幂等键。

</Step>
<Step>

**入队补齐**：为 `pending` 的渠道入队补齐任务后返回。入队失败只记日志，由对账补上。

</Step>
</Steps>

整个上传在内存中完成，不写临时文件；峰值内存约为「并发数 × max（单图上限，像素预算对应的解码内存）」，调低这几项即可适配小内存机器。
渠道写入成功而落库失败时会留下孤儿对象，由对账 GC 回收，不回滚渠道写入。

## 访问数据流 [#访问数据流]

`GET` / `HEAD /i/{id}`（以及 S3 对象键别名 `/o/{user_id}/{key}`；后台预览图 `/api/admin/images/{id}/thumb` 走同一条管线，但不计访问）：

1. 防盗链中间件按 Referer 白名单放行或返回 403（后台预览图不经过这一步）。
2. 查引用，查不到返回 404。到期时刻已过的引用立即按不存在处理，不必等清理任务回收，也不会被访问续期。
3. 校验处理参数：`w` 不在宽度档位、显式 `format` 不在允许格式时返回 400（GIF 原图不处理，忽略这两个参数）。
4. 计访问：只有对外入口的 GET 才在内存中累计活跃度，按 `activity_flush_seconds` 批量落库；HEAD、参数非法的请求与后台预览图都不计访问。
5. 需要缩放或转格式时走按需处理：带 `w`、显式 `format`，或未指定格式而浏览器接受 WebP、档位允许且原图不是 WebP。GIF 一律输出原图。
   命中衍生图缓存直接返回；未命中则在 `process_workers` 并发上限内处理并写入缓存，相同参数的并发请求合并为一次处理；
   处理繁忙或失败时降级输出原图，并带 `Cache-Control: no-store`。HEAD 请求同样取或生成衍生图，只是不返回正文。
6. 否则输出原图：候选渠道为「健康、不在清理中、该图副本为 `done`」的渠道，按优先级排序。访问模式为 `redirect` 且候选中有允许直链、
   未加密并配置了公开地址的渠道时，302 到该渠道，重定向带 `Cache-Control: no-store`（目标随渠道健康与配置变化，不能被缓存）；
   HEAD 请求只写响应头，不读渠道对象；其余用 `failover.Open` 代理读取，第一个渠道读不到立即切下一个。

缓存语义按入口区分：

| 入口 | Cache-Control | 原因 |
|---|---|---|
| `/i/{id}` | `public, max-age=31536000, immutable` | 短 ID 指向的内容不可变，URL 即版本 |
| `/o/{user_id}/{key}` | `public, no-cache` | 同键重传会让别名改指新图；允许缓存，但每次使用前带 ETag 向源站确认，未变即 304 |

两个入口的原图与衍生图都带强 ETag（原图为内容哈希，衍生图为哈希加处理参数），请求的 `If-None-Match` 与之相同时返回 304；
304 带上 200 响应会有的 `ETag`、`Cache-Control` 与 `Vary`，缓存据此续用已存的副本。格式经 `Accept` 协商的响应带 `Vary: Accept`；
降级输出的原图为 `no-store`，不走 304。数据库查询失败时返回纯文本 500，可以重试。

## outbox 围栏：业务表是唯一真相 [#outbox]

异步补齐由 River 执行，但 River 的任务只是**幂等的触发器**，副本状态的唯一真相是业务表 `blob_channels`：

- 补齐任务的含义是「确保 blob X 在渠道 Y 上有副本」。worker 先读业务表：已是 `done` 直接返回；开始复制前置为 `uploading`，
  失败退回 `pending` 交给 River 重试，最后一次尝试仍失败置为 `failed`，成功置为 `done`。
- 入队按（blob，渠道）去重，只对尚未结束的任务去重，同一副本不会在队列里出现两份。
- **对账**在启动时与之后每 10 分钟执行一次：为清理中的渠道补入排空任务；为每个渠道补种缺失的副本行；把健康渠道上 `failed` 的副本复位为 `pending`；
  再按主键游标分页扫描全部 `pending` / `uploading` 副本，重新入队；扫描期间有副本变成 `done` 也不会让后面的页错位漏行。

所以即使 River 的任务表丢失或损坏，队列也能从业务表完整重建；进程被强制杀死时正在执行的任务，超过 16 分钟仍停在运行态会被 River 重新排程。
新建渠道时为存量 blob 播种 `pending` 副本行，同样由这套机制自然收敛，没有额外的回填逻辑。

其他异步任务遵循同一原则：删除渠道上的对象（`delete_object`）、排空清理中的渠道（`drain_channel`：先按剩余的副本行逐个删除副本对象，进度就是剩余的副本行；再删除该渠道上 `_imgyoung/` 下的系统对象（含元数据库快照）与深探测残留，别的程序写的对象不动；最后删除渠道记录）、
清理 CDN 缓存（`cdn_purge`）。它们都是幂等的，失败由 River 退避重试：对象删除最终失败留下的孤儿由对账 GC 回收；
排空任务删对象第一次失败就把原因写进渠道的探测结果并触发 `drain_failed` 告警，之后一直退避重试，尝试次数用尽后由对账重新入队，
不会停止（任务自身超时或停机取消不算失败），出口是在后台对该渠道选择「直接移除」（远端对象保留）；CDN 清理最终失败时，边缘缓存在 CDN 自身的缓存期满后过期。

## 周期任务 [#周期任务]

周期任务都是服务自带的循环，由装配根统一启停。间隔取自运营配置的，在后台修改后立即按新值重新计时，不必等旧的周期走完。

| 任务 | 间隔 | 内容 |
|---|---|---|
| 健康探测 | `health_check_seconds`（默认 60 秒）| 先重试装载此前装载失败的渠道，再对各渠道并行轻探测；不健康的渠道轻探测通过后，再经深探测确认才恢复。见 [多存储容灾](./library/failover.mdx) |
| 告警评估 | 同健康探测 | 渠道不健康、补齐失败、渠道用量超阈值、排空失败、备份失败；可推送到 Webhook |
| 生命周期清理 | `cleanup_sweep_seconds`（默认 60 秒）| 回收到期引用 → 物理删除过了宽限期的 blob → 冷图打标 → 回收过期幂等键 → 回收超期审计日志 |
| 活跃度落库 | `activity_flush_seconds`（默认 60 秒）| 把公开访问时刻与计数批量写库；衍生图的最近访问时间按同一间隔落库 |
| 查重 | `dedup_scan_seconds`（默认 300 秒）| 精确重复（SHA-256 相同）自动合并，再做 pHash 相似扫描，结果进人工裁决队列 |
| 用量上报 | `usage_report_seconds`（默认 300 秒）| 统计各渠道存储用量并更新指标 |
| 元数据库备份 | `backup_interval_hours`（默认 24 小时）| 用 `VACUUM INTO` 做一份一致性快照，写到每个健康且不在清理中的渠道的 `_imgyoung/backup/`，各渠道各自保留 `backup_keep` 份；任一渠道没写成即本轮失败（`backup_failed` 告警），写成的渠道保留这份快照。启动时任一健康渠道上缺少备份间隔内的快照，就先备份一次。见 [备份与恢复](./operations/backup.mdx) |
| 认证维护 | 1 分钟 / 1 小时（固定）| 批量回写 Token 最近使用时刻 / 回收过期会话 |
| 补齐对账 | 10 分钟（固定）| 见上一节 |
| 对账 GC | 24 小时（固定）| 列举每个健康渠道，与库里的副本记录双向比对：回收孤儿对象，把幻影副本退回 `pending` 重新补齐。口径见下 |

### 对账 GC 的口径 [#reconcile-gc]

对账 GC 处理的对象（系统目录 `_imgyoung/` 与轻探测键不参与比对）：

- **孤儿**：只回收同时满足以下条件的对象——键名是本服务的对象键格式 `{short_id}_{sha256 前 8 位}`；库里没有该渠道任何状态的副本记录；
  修改时间不早于该渠道的绑定时刻；已过 48 小时宽限期（覆盖「已写入渠道、尚未落库」的在途上传）。其余库里没有记录的对象，
  例如别的程序写进来的文件、灾备时新实例接入旧桶而元数据还没恢复的对象、从快照恢复之前写进渠道而恢复后的库里没有记录的对象，只计数不回收，对账日志里记为 `foreign`。
- **深探测残留**：深探测删除失败留下的 `.imgyoung-probe-` 临时对象不看写入时刻，过 48 小时宽限期即删，也不计入对账统计的对象数与字节数。
- **幻影**：库里记为 `done`、渠道上实际缺失的副本，退回 `pending` 并重新入队补齐。

**绑定时刻**（`channels.bound_at`）表示这条渠道记录从什么时候起对应当前的存储位置，本服务只会在它之后往这个位置写对象：

- 新建渠道时等于创建时刻。新实例接入旧桶时，桶里原有的对象都早于它，只计数不回收。
- 在后台编辑渠道时，后端名或任何配置参数有改动，即重置为编辑时刻；重新输入同一个密码也算改动（rclone 编码密码时带随机 IV）。
  只改名称、优先级或直链开关不重置，令牌刷新写回也不重置。
- 从快照恢复后，全部渠道重置为恢复后启动的时刻：换入快照之前，在数据目录写下标记文件 `imgyoung.db.restored`；
  迁移之后、加载主密钥之前见到标记即重置，成功后删除标记，日志为
  `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`。这样，快照之后上传、在恢复后的库里没有记录的对象不会被当作孤儿删除。
  标记在换入暂存文件 `<数据目录>/imgyoung.db.restore` 时写下，后台恢复就是先暂存这个文件；手动恢复也要把快照放成这个文件再启动，
  不要直接覆盖 `imgyoung.db`，否则不会写标记、不会重置绑定时刻。

`created_at` 不随绑定时刻变化：它与渠道 ID 一起参与加密渠道的密钥派生。

<Callout type="warn" title="两个实例不能共用同一个渠道路径">
对账只认本库的副本记录：另一个实例写进同一路径的对象，在本实例看来格式正确、却没有记录，过了宽限期就会被当作孤儿删除。
</Callout>

### 停机 [#停机]

停机（收到 SIGINT / SIGTERM，或在后台恢复备份快照后请求重启）按顺序进行：先停收新请求，在途请求最多等 6 秒，超时强制断开；
再取消后台循环与在途的补齐任务，补齐子系统最多等 2 秒，各循环退出前把内存中待落库的数据写完；最后关闭渠道驱动与数据库。
两段合计 8 秒，在 Docker 默认 10 秒的强制终止时限之内，`deploy/` 下的示例配置还另留了余量（compose 的 `stop_grace_period: 15s`、
systemd 的 `TimeoutStopSec=15s`）。被取消的补齐任务状态留在业务表里，下次启动由对账重新入队。

## 技术选型 [#tech-stack]

选型围绕同一个前提：单实例、单个静态二进制，除了存储渠道，部署时不需要任何外部服务。依赖的具体版本以仓库的 `go.mod` 为准。

### 语言与构建 [#语言与构建]

- **Go**：静态编译成单个二进制，Docker 镜像小；多渠道写入、异步补齐、健康探测、任务队列这类 I/O 编排用 goroutine 写起来直接；
  对象存储、HTTP、指标监控的生态成熟，语法简单、编译快，参与贡献的门槛低。决定性的一条是 rclone 本身用 Go 编写，
  只有 Go 能在进程内直接调用它的后端，见[为什么在进程内集成 rclone](#rclone)。
- **全程 `CGO_ENABLED=0`**：不依赖 C 工具链与系统库，才能交叉编译出 Linux、macOS、Windows 各两种架构的发布包，
  并放进只有一个二进制的 distroless 镜像。下面每一项依赖都是纯 Go 或不需要 cgo。构建一律带 `-tags nodynamic`：
  WebP 库默认会在运行期尝试加载系统的 libwebp，关掉这条路径后，Linux 二进制才不依赖动态链接器。

### 依赖 [#依赖]

| 领域 | 选择 | 为什么 |
|---|---|---|
| HTTP 路由 | chi | handler 就是标准库的 `http.Handler`，没有私有概念；上传（流式读请求体）与图片代理（流式写响应）都直接面对 `io.Reader` / `io.Writer`，薄路由最合适 |
| SQLite 驱动 | `modernc.org/sqlite` | 纯 Go 实现，不需要 cgo |
| 数据库访问 | sqlc | 查询写成 SQL，生成类型安全的 Go 代码。不用 ORM：图床的查询都是简单的精确查询，ORM 的抽象没有收益 |
| 迁移 | Atlas | 声明式 schema 是唯一依据，开发期由 Atlas 计算差异生成版本化迁移；运行期用它的纯 Go 执行器在启动时应用，部署方不需要安装迁移工具 |
| 任务队列 | River | 持久化任务队列，重试、退避与并发控制现成；用 SQLite 驱动，任务表与业务表在同一个库里，不多一个服务。任务只是幂等触发器，状态以业务表为准，见[outbox 围栏](#outbox) |
| 周期任务 | 各服务自带的 `Run(ctx, wait)` 循环 | 周期任务只需要「每隔多久执行一次」，间隔取自运营配置、修改后立即重新计时；不引入 cron 库 |
| 依赖装配 | 在 `cmd/imgyoung/build.go` 里手工装配 | 组件集合固定、依赖关系静态，按顺序调用构造函数即可，能搜索、能跳转，没有反射与运行时容器。不用 DI 框架 |
| 生命周期编排 | oklog/run | HTTP、River 与各后台循环各是一个 actor，任何一个退出即触发整组按序停机；比手写 `WaitGroup` 加通道更不容易写错 |
| 架构守卫 | depguard（golangci-lint 内置）| 层间依赖方向写成 lint 规则由 CI 强制，边界不靠自觉，见[分层与依赖方向](#layers) |
| 感知哈希 | goimagehash | 纯 Go，输入是解码后的 `image.Image`，直接接在上传的解码步骤后面 |
| 图像编解码 | 标准库、`golang.org/x/image`、gen2brain/webp | 都不需要 cgo；WebP 编码由 gen2brain/webp 在纯 Go 的 WebAssembly 运行时里执行 libwebp |
| 加密 | 标准库 AES-256-GCM 与 HKDF；`golang.org/x/crypto` 的 argon2id | 分别用于加密渠道配置与凭证、派生渠道密钥与 S3 secret、哈希登录密码。渠道数据本身的加密交给 rclone crypt |
| 限流 | `golang.org/x/time/rate` | 上传频控与登录限速都是进程内的令牌桶 |
| 日志与指标 | `log/slog`、Prometheus 的 client_golang | 各自领域的事实标准 |
| 短 ID | jaevor/go-nanoid | 对外短 ID 用成熟实现；内部主键用 SQLite 自增 rowid，不对外暴露 |

项目以 MIT 许可发布：与 rclone 一致，贡献门槛最低；自部署工具不存在被云厂商拿去托管转售的现实顾虑。

### 不引入的东西 [#不引入的东西]

- **Redis**：单实例下没有它的位置。幂等键、引用计数这类需要原子性的地方交给 SQLite 的约束与原子更新，限流是进程内令牌桶，
  衍生图缓存在本地磁盘。单实例下进程内的实现严格优于 Redis：没有网络往返与序列化，也不多运维一个服务。走向多实例时才需要重新考虑。
- **配置文件与配置库**：环境变量只负责把进程启动起来，其余运营配置以数据库里的 `settings` 表为唯一依据、在后台修改，
  「这个值从哪来」永远只有一个答案，见[配置](./configuration.mdx)。
- **ORM、DI 框架、cron 库**：理由见上表。

### SQLite 与版本化迁移 [#sqlite-与版本化迁移]

SQLite 是单个文件、没有外部依赖，一致性快照也就是一个文件，最贴合自部署，也与单实例设计相配。
版本化迁移从第一天起就有：长期运行的自部署实例会跨很多个版本升级，没有版本化迁移，表结构很快失控。迁移只有升级方向，见[数据模型](#data-model)。

### 两条升级缝 [#两条升级缝]

只有两处预期会被替换，其余选型都是定死的：Go 生态的薄库都贴着标准库的形状，真要替换也是机械劳动，为此预留灵活性没有收益。

- **图像处理引擎**：按需处理的唯一入口是 `internal/infra/imaging` 的 `Processor.Process(src, params)`。当前是纯 Go 实现：标准库解码
  JPEG / PNG / GIF，WebP 编码用 gen2brain/webp，只输出 WebP、JPEG、PNG，GIF 只收发原图不处理。AVIF、HEIF 不在其列：使用率低，
  编码器（如 libaom）又重。需要更多格式或更快的处理时（例如基于 libvips 的实现），替换的是这个处理器。
- **数据库**：当前是 SQLite。表结构以声明式 SQL 为准，迁移由 Atlas 生成，查询代码由 sqlc 生成；换 PostgreSQL 时，
  按目标方言重新生成查询代码与迁移，并换上 River 的 PostgreSQL 驱动。

两处目前都只有一个实现，没有为它们预先定义接口类型。

## 为什么在进程内集成 rclone [#rclone]

存储层的原则是：rclone 只负责「把字节存到某个后端、取回、查询、列举、删除」，主备切换、补齐、引用计数、查重、TTL、健康判断全部由 ImgYoung 自己实现，
两者之间隔着 `store/driver.Driver` 接口。

集成方式是直接 import rclone 的 `fs` 与后端包，在进程内以参数映射构造 `fs.Fs`，而不是调用 rclone 命令行、librclone 的 RPC 接口或 rclone 配置文件：

- **流式、不落盘**：上传用 `fs.Fs.Put` 直接写内存里的字节，读取直接把对象流转给 HTTP 响应。
- **配置在自己的库里**：渠道参数用主密钥加密后存在 `channels` 表，运行时解密成映射交给后端；参数含凭证，不写日志，错误链也不携带参数。
- **令牌写回只有一个刷新者**：rclone 自己刷新 OAuth 令牌，经参数写回钩子交给应用，立即加密落库；应用不另起刷新循环。
- **一个句柄完成全部操作**：读、写、探测、列举、删除走同一个 `fs.Fs`，错误映射只维护一套（对象缺失统一收敛为 `driver.ErrNotFound`）。
- **渠道加密零额外代码**：加密渠道在后端之上叠加 rclone 的 crypt 层，上传与访问路径无需改动；crypt 是公开格式，持密码可以用标准 rclone 独立解密。见 [渠道存储加密](./channels/encryption.mdx)。
- **配置可以原样导出**：后台能为每个渠道导出一份 rclone 配置文件，参数与驱动实际交给后端的完全一致，加密渠道另带 crypt 远端；
  它由 `store/rclone` 的 `ConfigFile` 生成，与构造驱动共用同一套参数整理，标准 rclone 凭它即可脱离 ImgYoung 读写渠道数据。见 [驱动接口与 rclone 实现](./library/driver.mdx#config-file)。
- **测试不需要云账号**：rclone 的 `local` 与 `memory` 后端让整条存储链路可以在本机做集成测试。

注册全部 rclone 后端（`backend/all`）意味着任何 rclone 支持的存储都能直接作为渠道使用，rclone 升级即带来新后端；代价是二进制体积达到百 MB 量级。
这里用的是 rclone 的内部 API，它不承诺稳定，所以 `go.mod` 固定了 rclone 版本，升级由人工进行并跑完整测试。

`Driver` 接口不假设 rclone 存在，这也是 rclone 内部 API 变动时的退路：换一个 `Driver` 实现即可，业务代码不动。
接口与 rclone 实现拆成两个包，也让应用之外的 Go 项目可以单独复用这套存储引擎，见 [作为 Go 库使用](./library/index.mdx)。
