1382 字
7 分钟
域名改了一个字母,文件上传全挂了

周日下午准备往自己的文件服务传个东西,拖进去,进度条刚跳出来就红了。打开 DevTools 一看,满屏红字:

Access to XMLHttpRequest at 'https://xxx.r2.cloudflarestorage.com/...'
from origin 'https://file.lishuyu.app' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

CORS。老朋友了。

架构背景#

先交代一下这套文件服务的结构。三个独立管理的部分:

  1. 前端 files-web:一个 SPA,部署在 file.lishuyu.app,通过 Caddy 反代
  2. 后端 oss:FastAPI 写的对象存储服务,挂在 api.lishuyu.app/oss,负责元数据管理和生成 presigned URL
  3. 存储 Cloudflare R2:实际存文件的地方,通过 S3 兼容 API 对接

上传流程是这样的:

浏览器 → POST /oss/{bucket}/multipart/init → 后端创建 multipart upload,返回 upload_id
浏览器 → GET /oss/{bucket}/multipart/{id}/part/{n} → 后端返回 presigned URL
浏览器 → PUT presigned URL → 直接传到 R2(不经过后端)
浏览器 → POST /oss/{bucket}/multipart/{id}/complete → 后端合并分片

关键在第三步:浏览器拿着 presigned URL 直接往 R2 发 PUT 请求。这时候 R2 会检查请求的 Origin 头是否在 bucket 的 CORS 白名单里。presigned URL 只解决了鉴权问题,CORS 是另一回事——它是 R2 bucket 层面的策略,和签名完全无关。

排查#

看错误信息,Origin: https://file.lishuyu.app,R2 那边没返回 Access-Control-Allow-Origin

那 CORS 白名单里到底写了什么?翻后端代码:

services/oss/src/oss/r2.py
_DEFAULT_BROWSER_CORS_ORIGINS = (
"https://files.lishuyu.app", # ← 复数
"https://readme.lishuyu.app",
"https://readme.lishuyu.top",
)

files.lishuyu.app。复数。带 s。

但前端现在跑在 file.lishuyu.app。单数。不带 s。

一个字母的差别,CORS 不认。

为什么域名会变#

这要往前倒几周。最初文件服务前端部署在 files.lishuyu.app。后来我觉得 filefiles 更简洁,就把域名改了。改域名的时候,Cloudflare 这边自动处理了 DNS CNAME,旧域名 files.lishuyu.app 设了 301 重定向到 file.lishuyu.app

从用户视角看,一切正常:访问旧域名会自动跳转到新域名,页面加载没问题。

但 CORS 不走 301 重定向。浏览器发 preflight OPTIONS 请求时,用的是当前页面的 origin,也就是 https://file.lishuyu.app。R2 bucket 的 CORS 白名单里只有 https://files.lishuyu.app。origin 不匹配,直接拒绝。

301 重定向解决了”人能不能看到页面”的问题,但没有解决”页面能不能跨域请求 R2”的问题。

为什么没发现#

因为改域名那天,我只测了”页面能不能正常打开”。能打开,完事。

文件列表能看到——那走的是后端 API(api.lishuyu.app/oss),不涉及 CORS。文件下载能下——如果是 public 文件,后端返回 302 重定向到 R2 presigned URL,浏览器跟着跳转就行,也不涉及 CORS。

唯独上传,浏览器要直接往 R2 发 PUT 请求,才会触发 CORS 检查。而我改域名那天没测上传。

更深层的原因是:这三个组件(前端域名、后端 CORS 配置、R2 bucket 策略)分散在不同的代码和配置里。改了域名,脑子里想的是”DNS 和重定向搞定了”,根本没联想到”还有一个 Python 文件里硬编码了旧域名”。

修复#

两行改动。

第一,修正拼写:

services/oss/src/oss/r2.py
_DEFAULT_BROWSER_CORS_ORIGINS = (
"https://file.lishuyu.app", # files → file
"https://readme.lishuyu.app",
"https://readme.lishuyu.top",
)

第二,把 CORS 配置从”按需触发”改成”启动时就应用”。之前 ensure_bucket(里面包含 CORS 配置)只在创建 bucket 或发起 multipart upload 时才被调用。如果只是改了代码重新部署,CORS 规则不会立即更新,要等下一次上传才生效。把它移到 FastAPI 的 lifespan 里:

services/oss/src/oss/main.py
@asynccontextmanager
async def lifespan(app: FastAPI):
# ... db init, r2 client init ...
from oss.r2 import ensure_bucket
ensure_bucket(app.state.r2, _R2_BUCKET) # 启动时就应用 CORS
logger.info("oss started, db=%s", resolved)
yield
conn.close()

ensure_bucket 里的 _ensure_browser_cors 会调用 S3 的 PutBucketCors API,把 CORS 规则写到 R2 bucket 上。这个操作是幂等的,每次启动都跑一遍没有副作用。

推到 main 分支,deployer 通过 GitHub webhook 自动拉代码重新部署,服务重启后 CORS 立即生效。

教训#

这个 bug 的本质不是 CORS 配置写错了,而是配置分散在多个独立管理的地方,改了一处忘了另一处

域名在 Cloudflare DNS 里配。前端部署配置在 service.yaml 里。CORS 白名单硬编码在后端 Python 代码里。R2 bucket 的实际 CORS 规则通过 S3 API 动态写入。四个地方,四个”真相来源”,没有任何机制保证它们一致。

这不是什么罕见的架构问题。微服务、前后端分离、云存储——现代 web 应用基本都是这种结构。每一层都有自己的配置,每一层都可能因为别的层改了什么而悄悄 break。

几个可以做的事情:

测试覆盖完整路径。 改域名不只是”页面能打开”。要测到实际的业务功能——上传、下载、分享链接。特别是涉及跨域请求的场景。

CORS origin 不要硬编码。 这次的修复虽然还是硬编码,但也支持了环境变量覆盖(OSS_BROWSER_CORS_ORIGINS)。更好的做法是从统一配置源读取,或者至少在前端部署配置里声明 origin,后端从同一个 manifest 读。

301 重定向不等于完全迁移。 重定向让人类用户无感,但机器请求(CORS preflight、webhook callback、OAuth redirect URI)不一定跟着走。每次改域名都该列个 checklist:DNS、SSL、重定向、CORS、OAuth callback、webhook URL、硬编码引用。

一个字母的差别,藏了两周才暴露。下次改域名,记得 grep 一下旧域名在代码库里还出现在哪。

域名改了一个字母,文件上传全挂了
https://blog.lishuyu.top/posts/分开管理的项目域名改了cors没改/
作者
猫猫魔女
发布于
2026-05-18
许可协议
CC BY-NC-SA 4.0