本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月07日 统计字数: 12100字 阅读时间: 25分钟阅读 本文链接: https://soulteary.com/2026/09/07/phorge-modernization-part-9-migrate-file-storage.html ----- # Phorge 现代化改造实战(九):迁移文件存储,写得进去也要读得回来 本文是“Phorge 现代化改造实战”系列第九篇。上一篇检查了搜索服务中必须成对出现的写入、查询、配置和迁移;这一篇转向文件存储,重点处理二进制传输、存量 handle、后端选择、写入下沉和首次启动依赖。 ## 系列导航 1. [从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线](https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html); 2. [改进容器化的七个细节:补齐权限、持久化、依赖和探活](https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html); 3. [接入 Stargate:把 Forward Auth 的信任边界做完整](https://soulteary.com/2026/09/06/phorge-modernization-part-3-stargate-forward-auth-trust-boundary.html); 4. [拆分模块到 Gorge:无侵入改造不等于不碰文件](https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html); 5. [替换 diff 子进程:兼容不等于逐字一致](https://soulteary.com/2026/09/07/phorge-modernization-part-5-replace-diff-subprocess.html); 6. [替换实时通知服务:为什么 HTTP 501 反而表示正常](https://soulteary.com/2026/09/07/phorge-modernization-part-6-replace-realtime-notification-service.html); 7. [迁移邮件服务:先分清哪些失败不该重试](https://soulteary.com/2026/09/07/phorge-modernization-part-7-migrate-mail-service.html); 8. [迁移搜索服务:写进索引不等于搜得到](https://soulteary.com/2026/09/07/phorge-modernization-part-8-migrate-search-service.html); 9. **迁移文件存储:写得进去也要读得回来**; 10. [迁移 Webhook 投递服务:先解决重复投递](https://soulteary.com/2026/09/07/phorge-modernization-part-10-migrate-webhook-delivery.html)。 ## 写在前面 上一篇迁移搜索服务时,我遇到的核心问题是:一份文档能写进索引,不代表查询真的会走到那份数据。文件存储也有一组很像的错觉:服务能启动,不代表它有地方存文件;引擎出现在列表里,不代表新文件会交给它;S3 配置完整,也不代表对象最终会落进 S3。 [Gorge 2026.09.07-r3](https://github.com/soulteary/gorge/releases/tag/2026.09.07-r3) 把原来的 `gorge-file-storage` 迁进单仓,增加独立的 `gorge-file-storage` 二进制、三个存储后端、四条 HTTP 路由、14 份契约固件和一份端到端测试,具体变化可以从 [Gorge r2…r3 的代码差异](https://github.com/soulteary/gorge/compare/2026.09.07-r2...2026.09.07-r3) 中查看;[Phorge 2026.09.07-r3](https://github.com/soulteary/phorge/releases/tag/2026.09.07-r3) 则增加 Gorge 存储引擎、HTTP 客户端和配置检查,并补齐容器编排与启用文档,对应的宿主改动集中在 [Phorge r2…r3 的代码差异](https://github.com/soulteary/phorge/compare/2026.09.07-r2...2026.09.07-r3) 中。 生产代码并不算多:一千多行 Go,加三个 PHP 类。真正花时间的地方,是重新划清 Phorge 与 Gorge 各自负责什么,以及把那些“配置合法、请求成功、结果却不是你以为的那样”的状态找出来。 其中最反直觉的一处,是没有把新引擎声明成“无文件大小限制”。看起来少了一个能力,实际却保住了 Phorge 自己的大文件分块与断点续传。 ### 先划清边界:Phorge 管文件,Gorge 只管字节 Phorge 的文件记录不只有内容。文件名、MIME 类型、大小、所属对象、权限、去重信息,以及存储引擎和 handle,都在 Phorge 自己的数据库里。 `gorge-file-storage` 不认识这些业务概念。它只处理三件事: 1. 收到一段字节,把它交给某个后端,返回后端名和 handle; 2. 根据 `(engine, handle)` 读回字节; 3. 根据同一组信息删除字节。 它不负责文件元数据,不判断访问权限,也不做引用计数、垃圾回收和分块。尤其是分块:大文件到达 Gorge 之前,已经被 `PhabricatorChunkedFileStorageEngine` 切成若干块,每一块再作为一份普通文件写入。 这个边界决定了服务可以独立演进,但也带来一个硬约束:**Gorge 不能根据 handle 猜后端。** 不同后端的 handle 可能重叠,猜错最坏的结果不是报错,而是读到另一个对象。因此每次读取和删除都必须同时给出 `engine` 与 `handle`。 file-storage 也因此独占一个进程和 `:8100` 端口。它要挂持久卷、连接 MySQL 或访问 S3,是 Gorge 单仓里第一个真正持有持久化资源、也是第一个打开数据库连接池的服务。把它并进纯计算的 `gorge-render`,只会让磁盘、数据库或对象存储的故障一起影响代码高亮和 diff。 ## 文件字节不再塞进 JSON 独立服务原来的接口把文件做 base64,再放进 JSON: ```json { "data": "AAECAwQFBgcICQ==" } ``` 这种做法对小配置很方便,对文件却很浪费。一个 N 字节的文件经过 base64 后大约变成 `1.33N`;PHP 要先把完整文件编码成字符串,Go 再把完整字符串解码回来,读取时反方向再做一遍。网络体积、内存峰值和复制次数都被放大。 r3 改成直接传输 `application/octet-stream`: | 方法 | 路径 | 成功响应 | | -------- | ------------------- | ----------------------------------- | | `POST` | `/api/file/blob` | JSON 信封,返回 `handle`、`engine`、`size` | | `GET` | `/api/file/blob` | 原始文件字节 | | `DELETE` | `/api/file/blob` | JSON 信封,返回删除结果 | | `GET` | `/api/file/engines` | JSON 信封,返回后端列表 | 元数据通过 query string 传递。handle 不放在路径段里,因为本地磁盘的 handle 类似 `ab/cd/{28 hex}`,S3 key 类似 `phabricator/ab/cd/{16 hex}`,两者都带斜杠。做成路径段后,每个调用方都要正确转义,漏掉一次就会得到一个很难解释的 404。 上传还要求请求必须带 `Content-Length`。这不是为了形式完整,而是两个实现条件: - S3 流式上传需要预先知道长度,否则 SDK 只能先缓冲请求体; - 各后端必须在读取第一个字节之前判断文件是否超过自己的上限。 真实调用方并不受影响。Phorge 的 `HTTPSFuture` 和 curl 在发送内存或磁盘中的确定内容时都会带上这个头。与其在服务端悄悄缓冲未知长度的请求,不如明确返回 400。 ## 成功是二进制,失败仍然是 JSON 原始字节带来了单仓平台层的第一个例外:`GET /api/file/blob` 成功时不再返回统一的 `{data, error}` 信封,而是直接流出文件内容;失败时仍然返回 JSON 信封。 ```text 200 application/octet-stream 非 200 application/json,{data, error} ``` PHP 客户端必须按**状态码**分支,不能检查 body 是否为空。原因很简单:零字节文件是合法文件,它的正确响应就是 `200` 加空 body。按内容判断会把正常的空文件误认为读取失败,而且这种问题通常只影响少数附件,很难复现。 长度已知时,服务会把 `Content-Length` 原样带回去;未知时则不猜,直接让响应走 chunked。猜错的长度会让客户端无法区分完整文件和被截断的文件,比不提供更危险。 平台层不需要为这个例外改造。handler 成功时直接 `c.Stream()`,出错仍交给全局错误处理器生成信封。于是规则可以保持得很窄:**只有这一条读取接口的成功响应是二进制,其余 API 继续使用统一信封。** ## 最反直觉的决定:保留 8MB 上限 `PhabricatorFileStorageEngine` 基类默认限制单次写入不超过 8MB: ```php public function hasFilesizeLimit() { return true; } public function getFilesizeLimit() { return (1024 * 1024 * 8); } ``` 早期实现曾把 `hasFilesizeLimit()` 覆写成 `false`。直觉上很合理:后端可能是 S3,Go 侧又是流式传输,为什么还要限制 8MB? 问题是,这个上限并不只约束存储引擎。它还参与 Phorge 对分块引擎的选择。 `PhabricatorChunkedFileStorageEngine` 只在没有普通引擎能直接接下文件时介入。一个宣称“无上限”的 Gorge 引擎会接受任何大小,于是 Phorge 不再分块:2GB 文件会变成一次 2GB HTTP 请求,断点续传、请求体上限和两侧有界内存一起失效。 分块引擎寻找块存储后端时还有一条检查: ```php if ($engine->hasFilesizeLimit()) { if ($engine->getFilesizeLimit() < $this->getChunkSize()) { continue; } } ``` 默认块大小是 4MB,基类的 8MB 正好既能触发大文件分块,又能让 Gorge 有资格存放每一个 4MB 块: ```text 文件 <= 8MB -> Gorge 单次写入 文件 > 8MB -> Phorge 切成 4MB 块 -> 每块单独写入 Gorge ``` 因此 r3 什么都没有覆写。Go 服务的传输层 `BodyLimit` 设为 `16M`,给合法的 8MB 请求留出余量;真正的大文件仍然全部存进 Gorge,只是由 Phorge 负责切块。 这次最好的实现恰好是不写代码。前提是先看懂上游默认值为什么存在,而不是只看新后端理论上能接多大的文件。 ## 迁进单仓,主要工作是删掉重复外壳 独立仓自己维护健康路由、token 比较、响应结构和 Echo 启动逻辑。单仓已经有平台层,因此迁移时主要是替换: | 独立仓实现 | 单仓实现 | | ------------------------------------------ | --------------------------------------------- | | `healthPing()` 与手写健康路由 | `health.Register`,同时得到 `/healthz` 与 `/readyz` | | `tokenAuth()` 字符串比较 | `auth.Token()`,使用 constant-time 比较 | | `apiResponse`、`respondOK()`、`respondErr()` | `httpx.OK()`、`httpx.Fail()` | | Echo Logger | `slog`、Request ID 与统一访问日志 | | `e.Start()` | `srv.Run()` 与 SIGTERM 优雅关闭 | 独立仓原来把 `config`、`engine`、`httpapi` 分成多个包。迁入后拍平成 `internal/filestorage` 一个域包,再按文件区分三个 adapter。它和已经迁入的 mailer 本质相同:平台层管进程和 HTTP 外壳,域包管理自己的后端。 数据库连接池暂时也留在 `internal/filestorage/db.go`,没有因为单仓第一次引入数据库驱动就提前造一套通用 DB 平台。当前只有这个域连接数据库,上提只会把 Phorge 的库名规则与表结构泄漏进平台层。如果以后第二个域也需要连接池,再判断哪些能力真正通用。 这次还删掉了一个读取后从未使用的 `STORAGE_ROOT` 配置项,并把 `WriteParams.MimeType` 真正接到 S3 `PutObject` 的 `ContentType` 上。 不过这里有一层容易误读的区别:Go API 已经支持 `mimeType`,Phorge 的 Gorge 引擎却**刻意不发送它**。传进存储引擎的字节已经经过 storage format;启用加密后,它并不是原 MIME 类型对应的内容。把原文件的 `image/png` 写到一段密文字节上,比不写 Content-Type 更错误。这个字段只对明确知道自己传入的是原始内容的其他客户端有效。 ## 三个后端不是可以随便改名的内部实现 服务保留了独立版本的三个后端: | 后端 | identifier | 优先级 | 默认限制 | handle | | ---------- | ------------ | --: | -----------: | ----------------------------------------- | | MySQL blob | `blob` | 1 | 1,000,000 字节 | 自增行 ID | | 本地磁盘 | `local-disk` | 5 | 无 | `ab/cd/{28 hex}` | | S3 | `amazon-s3` | 100 | 无 | `phabricator[/{instance}]/ab/cd/{16 hex}` | 优先级从小到大。默认同时启用 blob 与本地磁盘时,小文件先进入数据库,超过 blob 上限的文件进入磁盘。 这里还有一个常被配置界面掩盖的事实:**配好 S3 不代表新文件会进入 S3。** 只要本地磁盘仍然启用且写入成功,优先级 5 的本地盘会先接下所有 blob 不收的文件,优先级 100 的 S3 根本没有机会。想让大文件进入 S3,需要关掉本地磁盘;想让所有文件都进入 S3,还要同时关掉 blob。 三个 identifier 与三种 handle 形态也不是显示名称,而是存量数据契约。Phorge 会把 Gorge 返回的两个字段拼成一个复合 handle: ```text blob/12345 local-disk/ab/cd/0123456789abcdef0123456789ab amazon-s3/phabricator/ab/cd/0123456789abcdef ``` 读取时必须按**第一个**斜杠拆分。按最后一个斜杠切,会把 `local-disk/ab/cd` 当成引擎名;直接 `explode('/')` 取两段,又会把真正的 handle 截成 `ab`。 更麻烦的是,这两种错误都可能通过“新写一个文件再读回来”的测试,因为写与读共享同一套错误规则。真正坏掉的是数据库里按照旧格式保存的所有文件。 `blob` 也不能顺手改名成看起来更明确的 `mysql`,S3 key 前缀也不能从历史上的 `phabricator` 改成 `gorge`。这些字符串与 Phorge 原生引擎保持一致,使已有目录、表和对象布局能够继续读取;改名之后,新文件仍然自洽,存量文件却会在同一时刻全部不可达。 本地磁盘 handle 的格式校验还兼有安全作用。请求参数来自网络,直接执行 `filepath.Join(root, handle)` 会接受 `../../..`。限制两级十六进制目录和固定长度文件名,是防止读取或删除存储根目录之外文件的边界,不能为了“兼容更多 handle”随意放宽。 ## 写失败可以下沉,但前提是字节还能重放 迁入前的 Router 按优先级找到第一个能接下文件的引擎,写失败就直接返回错误。r3 改为继续尝试下一个后端。 这不是另造一套语义。Phorge 自己的 `buildFromFileData()` 本来就会在首选引擎抛异常后尝试次选引擎。对应到这次迁移,还有一个真实启动窗口:`file_storageblob` 表由 Phorge 的 `bin/storage upgrade` 创建,在它完成之前,blob 后端能连上数据库却无法写表。此时上传应该下沉到本地磁盘,而不是让整个文件上传失败。 但 HTTP 请求体只有一份。第一个引擎读过以后,第二个引擎怎样从头再读? r3 增加了一个有界的 `rewindReader`。它只记录允许回放的那一段字节,预算不是新配置项,而是从所有带大小上限的后端中推导出最大值。默认情况下就是 blob 上限: ```go func (r *Router) rewindBudget() int64 { var budget int64 for _, eng := range r.engines { if eng.HasSizeLimit() && eng.MaxFileSize() > budget { budget = eng.MaxFileSize() } } return budget } ``` blob 本来就会把不超过自身上限的内容读进内存,因此保留这部分字节没有额外改变它的内存级别。若失败的后端消耗超过预算,Router 会停止下沉;继续把剩余内容交给下一个后端,只会写出一个截断文件,并把数据损坏伪装成成功。 这使“失败继续尝试”有了明确边界: - blob 在限额内读完后失败,可以完整回放给本地磁盘; - 零字节文件同样可以回放; - 本地盘或 S3 流式写到一半失败,通常无法再换后端; - 没有带大小限制的后端时,预算为 0,Router 退化成一次尝试。 如果请求显式指定 `?engine=...`,则无论失败原因是什么都不下沉。Phorge 只会在“这个文件原本就记录在某个引擎,现在要重写它”时指定后端。把重写内容悄悄放到另一个引擎,会让数据库里的旧记录指向不存在的数据。 错误码也区分了两个状态:`503 ERR_NO_ENGINE` 表示一次都没有尝试——没有配置后端,或者文件对所有已配置后端都太大;`500 ERR_INTERNAL` 表示确实尝试过,但所有候选都失败。前者去检查配置,后者去看服务日志。 ## 删除幂等,读失败则统一收成 404 删除不存在的对象在 r3 中返回成功。这个行为相对独立服务发生了变化,但更符合 Phorge 的清理流程:Phorge 要先删除字节,再删除指向字节的记录。如果字节已经不在了却返回错误,那条元数据记录会永远留在数据库里,后续每次清理都继续失败。 因此第二次删除同一个 handle 仍然得到: ```json { "data": { "status": "deleted" }, "error": null } ``` 格式非法的 handle 不属于“已经删除”,DELETE 会返回 400;后端真正没有完成删除则返回 500,提醒 Phorge 不要退掉元数据记录。 读取采用了另一套取舍:handle 非法、对象不存在、后端读取失败,目前都统一映射为 404 `ERR_NOT_FOUND`,详细原因只写服务日志。对 PHP 调用方来说,它们都没有第二个后端可以尝试;但对运维来说,这会降低从 HTTP 状态码区分数据缺失和后端故障的能力。排查批量附件 404 时,不能只看 Phorge 页面,必须同时检查 Gorge 日志与后端状态。 ## `/readyz` 不能检查表是否存在 file-storage 的就绪条件看起来应该很严格:至少配置一个后端;如果启用 blob,数据库能连接;最好再确认 `file_storageblob` 表确实存在。 最后这一项恰好会让全新部署死锁。 ```text Phorge app 等待 gorge-file-storage healthy | v gorge-file-storage /readyz 等待 file_storageblob 表存在 | v Phorge bin/storage upgrade 只有 Phorge app 启动后才会执行 ``` 表由 Phorge 的 `storage upgrade` 创建,而 Phorge 容器又通过 `depends_on: service_healthy` 等待文件服务。两边互相等待,谁也无法完成第一次启动。 因此 `/readyz` 只检查两件事:至少注册了一个引擎,以及持有连接的引擎能否连上。当前只有 MySQL blob 实现额外检查,执行一次带 5 秒超时的 `PingContext`。它不查询表。 数据库连接也没有在 `OpenDB()` 阶段主动 ping。`sql.Open()` 保持惰性,让服务先启动并回答 `/healthz`,再由 `/readyz` 表达数据库尚未可用。否则一个启动稍慢的数据库会把服务推入反复重启。 这一版 Phorge 编排让 file-storage 的容器探测 `/readyz`,而 mailer 和 search 只探 `/healthz`。差别是有意的:编排默认已经给 file-storage 挂载本地卷,它开箱就有可写后端;文件又是头像、附件和粘贴图片的同步依赖,完全不可写时让站点等待,比让用户上传后再发现更合适。 代价也很明确:如果主动关掉三个后端,Phorge 会一直等在启动依赖上。这时应当一起移除 file-storage service 和对应依赖,或者临时把探针改回 `/healthz`,不能只清空后端配置。 ## 其他 ### 配置前缀不是为了好看 单仓新配置统一使用 `GORGE_FILE_*`: ```text GORGE_FILE_MYSQL_HOST GORGE_FILE_MYSQL_PORT GORGE_FILE_MYSQL_USER GORGE_FILE_MYSQL_PASS GORGE_FILE_NAMESPACE GORGE_FILE_MYSQL_BLOB_MAX_SIZE GORGE_FILE_LOCAL_DISK_PATH GORGE_FILE_S3_BUCKET GORGE_FILE_S3_REGION GORGE_FILE_S3_ENDPOINT GORGE_FILE_S3_ACCESS_KEY GORGE_FILE_S3_SECRET_KEY ``` 无前缀的 `MYSQL_HOST`、`S3_BUCKET`、`STORAGE_NAMESPACE` 仍作为旧部署兜底,但新编排不再使用。原因不是命名整齐,而是同一份 `.env` 同时服务多个容器:`MYSQL_HOST` 在 Phorge 容器和文件服务里可能表达不同含义,继续复用会让同名变量在两个进程中指向不同拓扑。 `GORGE_FILE_NAMESPACE` 尤其不能依赖服务默认值。Gorge 代码默认是 `phorge`,而当前 Phorge 编排使用 `phabricator`,最终表名是: ```text phabricator_file.file_storageblob ``` 填错 namespace 的表现通常不是上传失败。blob 写入报错后,Router 会把文件下沉到本地磁盘,用户仍然看到上传成功,只有查看文件记录的实际后端才会发现数据库从未接到数据。 S3 则要求 bucket、region、endpoint、access key、secret key 五项全部非空才注册。半套配置若也生成 client,只会增加一个每次请求都失败的候选后端。一个后端都没有配置仍允许进程启动,由 `/readyz` 报 503,把“服务程序坏了”和“配置尚未完成”分开。 核对 r3 时还有一个值得修正文档措辞的小差异:服务代码的 blob 默认上限是 `1,000,000` 字节,Phorge 原生配置默认也是 `1,000,000`;但 `docker-compose.gorge.yml` 显式传入的是 `1,048,576` 字节,也就是 1MiB。两者相差 48,576 字节,不影响 4MB 分块逻辑,却不应该再描述成“逐字节一致”。如果希望两个边界完全相同,应在后续版本统一这个值。 ### 启用需要两步,第一步单独做几乎没有效果 Phorge 通过 `PhutilClassMapQuery` 自动发现文件存储引擎。只要 `gorge.file.uri` 有值,`PhabricatorGorgeFileStorageEngine` 就进入可写引擎列表,priority 是 2。 但是 Phorge 原生 MySQL blob 引擎的 priority 是 1,会先拿到所有自己能接下的小文件。只配置 Gorge 地址时,默认结果是: | 文件大小 | 实际处理者 | | ------------------- | ---------------------------- | | `<= 1,000,000` 字节 | Phorge 原生 `blob` 引擎 | | `1,000,000` 字节到 8MB | Gorge 引擎 | | `> 8MB` | Phorge 先切成 4MB 块,每块重新参与上面的选择 | 服务已经运行,配置页也能看到 Gorge,但文件会按大小散落在两套入口中。这不是错误,所以没有异常可看。 真正切换需要第二步:关闭 Phorge 原生的三个后端,让 Gorge 成为优先级最高的可写引擎。 ```bash docker compose exec phorge \ /opt/phorge/phorge/bin/config set storage.mysql-engine.max-size 0 docker compose exec phorge \ /opt/phorge/phorge/bin/config set storage.local-disk.path null docker compose exec phorge \ /opt/phorge/phorge/bin/config set storage.s3.bucket null ``` 启动脚本刻意不自动执行这一步。它会改变新文件实际落在哪里,应该由操作者显式决定。这样可以先部署服务但不接流量,也可以用一条配置命令回滚。 `PhabricatorGorgeFileStorageSetupCheck` 会把三个状态分开报告:服务不可达、服务可达但没有后端、服务已经就绪但前面仍有更高优先级的原生引擎。最后一个检查专门把“配了但没真正接上”从沉默状态变成 Config 页面上的明确提示。 切换不需要搬迁存量数据。Phorge 按文件保存 `storageEngine` 和 `storageHandle`,读取时仍然使用当初写入它的引擎。回滚也只影响之后的新写入,已经进入 Gorge 的文件继续由 Gorge 读取。 因此“无需迁移”不等于“服务可以随时删除”。只要有一个文件已经记录为 `gorge`,移除服务或删除本地卷就会让它不可达。尤其要避免 `docker compose down -v`:`phorge-files` 卷保存的是文件本体,Phorge 数据库里只有 handle。 ### 两边独立实现之后,测试要守什么 Go 与 PHP 分别实现时,编译器无法检查两边是否说的是同一种协议。r3 合流后需要逐项核对: - 四条路由和 HTTP 方法; - `engine`、`handle`、`name`、`mimeType` 四个 query 参数名; - `X-Service-Token` 请求头; - 成功二进制与失败 JSON 的分支; - `blob`、`local-disk`、`amazon-s3` 三个 identifier; - 复合 handle 的拼接与拆分; - S3 key 前缀和本地磁盘目录布局; - 8MB 单次上限、4MB 分块与 16M 传输上限; - `/healthz` 与 `/readyz` 的不同语义; - Compose 中的端口、namespace 与配置前缀。 Go 侧的 14 份契约固件覆盖路由、鉴权、缺失参数、空文件、指定后端、未知后端、读取、删除和引擎列表;端到端脚本则对一个真实运行实例完成写入、读取、删除、重复删除与零字节文件回环。 测试里还有一条很实用的防线:所有故障路径都会检查日志中是否出现 `PANIC_RECOVERED`。原因是 Recover 中间件会把 panic 转成 500,单看状态码,测试可能以为 handler 正常返回了预期错误。曾经有一次 nil engine 进入读取路径,所有 HTTP 断言都是绿色,真正的证据只有没人看的堆栈。让测试在日志出现 panic 标记时失败,才把“返回了 500”和“代码崩了以后被包装成 500”区分开。 仍然有两条约定主要依赖 PHP 与兼容文档:复合 handle 必须按第一个斜杠切,以及 `/api/file/engines` 返回的字段目前没有 PHP 消费者。后一个端点现在只用于诊断;如果以后 setup check 用它判断 Gorge 内部究竟会选 blob、本地盘还是 S3,就必须先给响应字段补跨语言断言。 ## 最后 文件存储的麻烦从来不在“写进去”这一刻,而在几个月后还能不能按原来的 engine 和 handle 找回来。对这类迁移来说,真正需要保护的是存量数据的可达性,而不是刚写完的那份 smoke 测试。 --EOF