给桌面客户端做下载加速:从「桶直接公开」到「边缘优先、源站兜底」的三代演进
Scorpius 是个桌面客户端,官网只做两件事:卖激活码、给下载页。下载页看起来是小事——点一下,浏览器开始下 .dmg——但它背后要同时满足四个互相拉扯的要求:
- 文件得能下完。5 MB 的 DMG 从源站直出,用户在广西、在新加坡、在德国,体验不能差太远。
- 链接不能被转发。一个用户把下载链接贴到群里,群里 500 个人一起来下,源站带宽和费用都是自己的。
- 桶不能公开。桶里除了安装包,还有
latest.json(发布清单)和changelog.json(更新日志),后者决定客户端弹不弹更新提示。 - CDN 挂了要能退。加速是加分项,不是前提项。
这四条凑在一起,演进了三代。这篇按真实过程写:每一代的设计、代码、操作步骤、验证方法,以及最后怎么用 EdgeOne Pages 把字节挪出源站、又怎么保证它挂了不影响下载。
文中代码来自
lens077/Scorpius,域名与凭据已替换;截图是真实运行时的输出。
三代演进总览
每一代都在前一代的漏洞上补一层,而不是推翻重来。第三代尤其克制——第二代的安全模型一字未改,只在「302 到哪儿」那一步加了分流。
第一代:能用,但不安全
最初的版本简单到不像设计:
<a href="https://silo-api.apikv.com/scorpius/releases/1.0.1/Scorpius_1.0.1_macos_arm64.dmg"> 立即下载</a>桶直接公开,文件 URL 写死在网页里。这版能跑通,但有三个洞:
| 问题 | 后果 |
|---|---|
| 桶地址与对象键公开 | 任何人 curl 一下就能列出全部文件,甚至整个桶 |
| 没有任何节流 | 无人机脚本刷流量,源站出站带宽和钱都是自己的 |
| 没有「这一次下载」的概念 | 链接可以被无限转发;转发的死链不会死 |
第三条最要命。一个付费软件的安装包被无限镜像,等于把这层门白装了。
但也别急着说第一代错。 它是绝大多数项目的起点,而且它明确了后面两代要解决什么:把「链接」从「地址」变成「能力」。
第二代:桶私有 + 一次性令牌 + 三层限流
核心思路只有一句话:不让用户拿到桶里的地址,只给他一个只能用一次、很快就失效的短链。
两步握手
浏览器 ──POST /v1/update/download?platform=darwin-aarch64──▶ 授权 API ◀──{url: "https://…/dl/<token>", expiresIn: 60, singleUse: true}
浏览器 ──GET /dl/<token>────────────────────────────────▶ 授权 API ◀──302 Location: https://silo-api…/x.dmg?X-Amz-Signature=…&X-Amz-Expires=20
浏览器 ──GET(带签名)──────────────────────────────────▶ 私有桶 ◀──文件字节三段各司其职:
- 令牌(
/dl/<token>)是一次性能力,60 秒过期; - 预签名 URL 是桶的临时授权,20 秒过期,桶本身始终私有;
- 文件字节从不经过 Go 服务,直接从存储流向浏览器。
第一步的返回体长这样——注意它不含任何桶地址,只有一个不透明的令牌:
令牌怎么发
24 字节密码学随机数,Base64URL 编码成 32 字符;存在内存 map 里,60 秒后过期;兑换时立刻标记为已用:
const ( dlTokenTTL = 60 * time.Second redeemTTL = 20 * time.Second dlTokenSize = 24 // 字节熵 → 32 个 base64url 字符)
type dlToken struct { key string // 桶里的对象键 exp time.Time ip string used bool created time.Time}
func (s *Server) issueDownloadToken(key, ip string) string { raw := make([]byte, dlTokenSize) if _, err := rand.Read(raw); err != nil { panic(err) // crypto/rand 失败不是请求级故障 } tok := base64.RawURLEncoding.EncodeToString(raw) now := s.now() s.dlMu.Lock() defer s.dlMu.Unlock() if s.dlTokens == nil { s.dlTokens = map[string]*dlToken{} } // 顺手 GC:过期条目删掉,map 不会无限涨 for k, t := range s.dlTokens { if now.After(t.exp) { delete(s.dlTokens, k) } } s.dlTokens[tok] = &dlToken{key: key, exp: now.Add(dlTokenTTL), ip: ip, created: now} return tok}
func (s *Server) redeemDownloadToken(tok string) (key string, ok bool) { s.dlMu.Lock() defer s.dlMu.Unlock() t, found := s.dlTokens[tok] if !found || t.used || s.now().After(t.exp) { return "", false } t.used = true return t.key, true}几个细节值得说:
- 未知、过期、已用过,三种情况返回完全一样(都是
410)。区分它们等于给攻击者一个探测器:能区分就说明这个令牌曾经存在过。 - 令牌存内存,不落库。服务重启后未兑换的令牌全部作废——这是特性不是缺陷,重启本来就该让在途下载失败。
ip字段存了但只用于审计,不参与判定。
三层限流,以及一个踩过的坑
// 签发:严。滥用值得在这里被挡住。srv.download = ratelimit.New(ratelimit.Config{PerMinute: 6, Burst: 3, MaxFails: 0, BreakFails: 0})// 兑换:宽。滥用不值得在这里被挡住——拒绝发生在用户已经开始的跳转途中。srv.downloadRedeem = ratelimit.New(ratelimit.Config{PerMinute: 30, Burst: 15, MaxFails: 0, BreakFails: 0})// 全局:所有 IP 共享一个桶,兜住出站流量上限。srv.downloadGlobal = ratelimit.New(ratelimit.Config{PerMinute: cfg.DownloadsPerMinute, Burst: cfg.DownloadsPerMinute})为什么签发和兑换是两个桶? 最初我图省事让两步共用一个限流器,结果用户连着点两次下载,第二次在浏览器跳转途中被拒了——用户看到的是一页限流报错,而不是安装包。更糟的是,第一步已经消耗了一个令牌,用户看到报错后想重试,令牌已经没了。
现在两步各花各的令牌额度:签发严(滥用发生在这里),兑换宽(令牌本身已经是一次性、60 秒短命的,滥用价值不高,而拒绝的代价是打断用户)。
第三层是全局上限,默认 120 次/分钟,所有 IP 共享。它不是防某个 IP,是兜住出站流量总量——这是整个下载系统唯一真正花钱的地方。
第二代做对了什么
- 桶私有,
latest.json和changelog.json拿不到; - 桶地址永不外泄,链接泄露也只有 20 秒寿命;
- 链接一次性,转发出去的是死链;
- 三层限流分别管「单 IP 滥用」「跳转打断」「总量兜底」。
第二代上线时,官网下载页长这样——点开就在下载,用户不需要做任何事:
第三代:边缘承载字节,源站退成兜底
第二代解决了安全,但没解决速度和成本。文件字节仍然从源站直出:
# 兑换令牌,看 302 去哪curl -sI "https://scorpius-api.apikv.com/dl/<token>" | grep -iE "^(HTTP|location)"# location: https://silo-api.apikv.com/scorpius/releases/1.0.1/Scorpius_1.0.1_macos_arm64.dmg?X-Amz-Algorithm=…&X-Amz-Expires=20&X-Amz-Signature=…源站在国内,海外用户拉 5 MB 要绕一圈;国内用户虽然近,但源站的出站带宽要真金白银。
为什么选 EdgeOne Pages
EdgeOne 有两套产品,容易混:
| 站点 | 套餐 | 干什么 | |
|---|---|---|---|
| EdgeOne Pages(Makers) | zone-3w6o7l5ze4sc(Type: pages) | edgeone-3w6o7j6obi5k(plan-free) | 静态托管 + 全球边缘分发 |
| EdgeOne EO(边缘安全加速) | zone-3utthqbl9gyd | edgeone-3uttftrtl85h(plan-personal,付费) | 回源加速、WAF、安全防护 |
这里选 Pages,理由是它免费、而且是静态托管——安装包本来就是静态文件,正好是它的本职。EO 那套我原本用来自加速官网,2026-10-14 因为 apikv.com 没 ICP 备案(只能建境外加速区,大陆用户反而绕远)整体下线了,2026-10-26 把付费套餐也销毁了。
Pages 的三个硬限制决定了架构:
| 限制 | 数值 | 影响 |
|---|---|---|
| 单文件大小 | 25 MB | Linux AppImage 超限 4 倍 |
| 站点总容量 | 5 GB | 只能保留最近几个版本 |
| 项目数 | 40 | 够用 |
实测:哪些文件能上边缘
用 Range 请求读 Content-Range 的总长度,这是线上真实值:
curl -s -r 0-0 -D - -o /dev/null "$PRESIGNED_URL" | grep -i content-range# content-range: bytes 0-0/101829112 ← Linux AppImage:97.1 MB| 平台 | 体积 | 能否上边缘 |
|---|---|---|
macOS arm64 / x64 .dmg | 5.5 / 5.9 MB | ✅ |
Windows x64 / arm64 _setup.exe | 2.7 / 2.2 MB | ✅ |
Linux .AppImage | 97.1 MB | ❌ 超 25 MB 上限 4 倍 |
Linux 平台天然走对象存储,不需要任何特殊处理——复制脚本按大小跳过并如实报告,不静默截断。
关键设计:为什么是「探测」而不是「清单」
直觉做法是在 latest.json 里加一个字段,声明哪些平台上了 CDN:
{ "cdn": { "darwin-aarch64": "releases/1.0.1/Scorpius_1.0.1_macos_arm64.dmg" } }我先这么写了,然后推翻了。 原因是两条流水线会短暂不同步:
站点部署(make pages-deploy-eo)和发布上传(make comm-push)是两个独立流程。新版本上传到桶了、边缘部署还没落地——这时清单说「这个文件在 CDN 上」,但边缘上其实没有,探测会 404,用户拿到一个坏链接。
反过来边缘先部署、桶还没上传,也有同样的窗口。
问边缘「你现在能不能给这个文件」才是准确的问题。 一次 HEAD 请求,2xx 就走边缘,否则走桶。
实现
// 兑换令牌时选源key, ok := s.redeemDownloadToken(r.PathValue("token"))if !ok { // 未知 / 过期 / 已用过 —— 三者表现一致,不给攻击者探测器 s.dlFail(w, r, dlProblem{Status: http.StatusGone, Code: "link_expired", ...}) return}
// 边缘优先,桶兜底。判断依据是「问边缘」,不是「读清单」。if link, served := s.cdnLink(r.Context(), key); served { w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, link, http.StatusFound) return}
// 边缘没有这个文件 / 边缘当前不可达 → 预签名桶链接link, err := s.presign(r.Context(), key, redeemTTL)if err != nil { s.dlFail(w, r, dlProblem{Status: http.StatusServiceUnavailable, ...}) return}w.Header().Set("Cache-Control", "no-store")http.Redirect(w, r, link, http.StatusFound)选源本身:
const cdnPathPrefix = "/dl"
func (s *Server) cdnLink(ctx context.Context, key string) (string, bool) { base := strings.TrimRight(s.cfg.CDNBaseURL, "/") if base == "" || key == "" { return "", false } // 边缘上的路径与桶里的对象键同构:releases/1.0.1/x.dmg // → <base>/dl/releases/1.0.1/x.dmg url := base + cdnPathPrefix + "/" + strings.TrimLeft(key, "/") if !s.cdnReachable(ctx, key, url) { return "", false } return url, true}探测与缓存:
const ( cdnProbeTTL = 30 * time.Second cdnProbeTimeout = 3 * time.Second)
func (s *Server) cdnReachable(ctx context.Context, key, url string) bool { now := s.now() if v, ok := s.cdn.verdict(key, now); ok { return v.ok }
probeCtx, cancel := context.WithTimeout(ctx, cdnProbeTimeout) defer cancel() req, err := http.NewRequestWithContext(probeCtx, http.MethodHead, url, nil) if err != nil { s.cdn.put(key, cdnVerdict{ok: false, at: now, err: err.Error()}) return false } resp, err := s.cdnClient().Do(req) if err != nil { s.log.Warn("cdn probe failed", "key", key, "err", err) s.cdn.put(key, cdnVerdict{ok: false, at: now, err: err.Error()}) return false } defer resp.Body.Close()
ok := resp.StatusCode >= 200 && resp.StatusCode < 300 s.cdn.put(key, cdnVerdict{ok: ok, at: now}) return ok}三个设计点:
- 探测失败按不可用处理。宁可慢一点回落到桶,也不要给用户一个坏链接。
- 超时 3 秒。边缘慢等于不可用——用户不在乎是边缘给的还是桶给的,只在乎能下完。
- 结果缓存 30 秒。一批下载只探一次;边缘恢复后一个 TTL 窗口内自动切回,不需要发版。
三条不变量
- 桶始终私有,链接始终一次性——CDN 改变的是「文件从哪来」,不是「链接怎么发」。
- 探测失败按不可用处理。
- 决策在服务端——前端永远只拿到一个
url,源码零改动,网站不需要知道有 CDN 这回事。
操作步骤
1. 服务端:加一个配置项
type Config struct { // CDNBaseURL:承载安装包的边缘基址,如 https://scorpius-pages.apikv.com。 // 留空表示「没有 CDN」,所有下载都走对象存储。 CDNBaseURL string // …}
// server/cmd/licensed/main.goCDNBaseURL: os.Getenv("CDN_BASE_URL"),Helm values(deploy/chart/scorpius/values.yaml):
api: env: # 承载安装包的 CDN 基址。服务端会先问边缘有没有这个文件,有就从边缘跳, # 没有就回落到对象存储的预签名链接(Linux AppImage 超过 CDN 单文件上限, # 一直走对象存储)。留空则全部走对象存储。 CDN_BASE_URL: https://scorpius-pages.apikv.com2. 发布流水线:把安装包复制进站点产物
scripts/cdn-release-assets.mjs 做两件事:按 25 MB 上限过滤,只保留最近 N 个版本。
// Pages 的单文件上限与站点总容量。写在这里而不是散落在别处,因为它们是// 平台的硬限制,改错了会直接导致部署被拒。const PAGES_MAX_FILE_BYTES = 25 * 1024 * 1024;
// 会被误当成安装包的文件。const NOT_AN_INSTALLER = (name) => name.startsWith(".") || name.endsWith(".sig") || name.endsWith(".app.tar.gz") || name.endsWith("_windows_x64.exe") || name.endsWith("_windows_arm64.exe");版本排序不能用字符串——"1.0.10" 要排在 "1.0.9" 之后:
function compareVersions(a, b) { const pa = a.split(".").map((n) => parseInt(n, 10) || 0); const pb = b.split(".").map((n) => parseInt(n, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d !== 0) return d; } return 0;}超过上限的如实报告,不静默截断:
if (st.size > MAX_FILE_BYTES) { // 超过 Pages 单文件上限。留给对象存储,由服务端兜底。 skipped.push({ version, file: f.name, bytes: st.size }); continue;}Makefile 里接上(EO_CDN_VERSIONS 控制保留几个版本,Pages 站点总容量 5 GB):
EO_CDN_VERSIONS ?= 2pages-deploy-eo: cd web && pnpm install --frozen-lockfile && pnpm build node scripts/cdn-release-assets.mjs --release-dir release --out web/dist/dl \ --versions $(EO_CDN_VERSIONS) --manifest web/dist/dl/manifest.json cd web && PAGES_SOURCE=skills edgeone makers deploy dist -n $(EO_PROJECT) -e production3. 缓存策略
web/public/edgeone.json(Vite 会原样复制进 dist/)。安装包不可变,可以用长 TTL + immutable:
{ "caches": [{ "source": "/dl/releases/*", "cacheTtl": 604800 }], "headers": [ { "source": "/dl/releases/*", "headers": [ { "key": "Cache-Control", "value": "public, max-age=604800, immutable" }, { "key": "Pages-Cache-Control", "value": "s-maxage=604800" } ] } ]}Cache-Control 管浏览器,Pages-Cache-Control 管边缘节点。两个都要——只设前者,边缘还是会每次回源。
验证
边缘命中
curl -sI "https://scorpius-pages.apikv.com/dl/releases/1.0.1/Scorpius_1.0.1_macos_arm64.dmg" \ | grep -iE "^(HTTP|content-type|content-length|cache-control|pages-cache-control|age|eo-cache-status)"要点:EO-Cache-Status: Cache Hit、Content-Type: application/x-apple-diskimage(浏览器才会下载而不是渲染)、Cache-Control: public, max-age=604800, immutable。
实际下载
curl -s -r 0-1048575 -o /tmp/sample.bin \ -w "status=%{http_code} 下载=%{size_download} 字节\n" \ "https://scorpius-pages.apikv.com/dl/releases/1.0.1/Scorpius_1.0.1_macos_arm64.dmg"# status=206 下载=1048576 字节206 + 字节数正确,说明 Range 与字节流都对。文件头应是 78 da(zlib),DMG 内部结构正确。
走边缘 vs 回落桶
# ① 签发令牌curl -s -X POST "https://scorpius-api.apikv.com/v1/update/download?platform=darwin-aarch64"# {"expiresIn":60,"singleUse":true,"url":"https://scorpius-api.apikv.com/dl/xNpq…","version":"1.0.1"}
# ② 兑换 → 302 去边缘curl -sI "https://scorpius-api.apikv.com/dl/xNpq…" | grep -iE "^(HTTP|location)"# location: https://scorpius-pages.apikv.com/dl/releases/1.0.1/Scorpius_1.0.1_macos_arm64.dmg换一个边缘上没有的平台(Linux AppImage 超限):
curl -sI "https://scorpius-api.apikv.com/dl/FGAj…" | grep -iE "^(HTTP|location)"# location: https://silo-api.apikv.com/scorpius/releases/1.0.0/Scorpius_1.0.0_linux_x64.AppImage?X-Amz-…&X-Amz-Expires=20&X-Amz-Signature=…同一个接口、同一段代码、同一个平台查询,只是换个平台就落到不同源——这就是双源。
故障注入
单元测试里把边缘模拟服务器关掉,验证回落到桶:
func TestDownloadFallsBackWhenTheCDNIsDown(t *testing.T) { edge := cdnEdge(t, map[string]bool{ "/dl/releases/0.3.0/Scorpius_0.3.0_macos_arm64.dmg": true, }) url := edge.URL edge.Close() // 边缘在下载到达之前就没了
e := withCOS(t, testManifest, nil) e.srv.cfg.CDNBaseURL = url
r := e.do("POST", "/v1/update/download?platform=darwin-aarch64", nil, nil) tok := strings.TrimPrefix(r.Body["url"].(string), "https://api.example.com") red := e.do("GET", tok, nil, nil) loc := red.Header.Get("Location") if !strings.Contains(loc, "q-signature=") { t.Fatalf("a dead edge must not be the redirect target: %s", loc) }}线上也可以注入——把 Pod 的 CDN_BASE_URL 指向一个不可达地址:
kubectl -n scorpius patch deploy/scorpius-api --type=json \ -p='[{"op":"replace","path":"/spec/template/spec/containers/0/env/31/value","value":"https://127.0.0.1:1"}]'不过在 Argo CD 管理的集群里这招不灵——Argo 会立刻把手工 patch 冲回 Git 里的值(我试过,CDN_BASE_URL 几秒内就变回去了)。GitOps 的保护在这里是好事:要临时改配置,得改 values 再提交。
全套回归
cd server && go vet ./... && go test ./... # 服务端node --test tests/*.test.mjs # 根项目helm template scorpius deploy/chart/scorpius -f deploy/chart/scorpius/values-prod.yaml \ | grep -A1 CDN_BASE_URL # 确认变量注入一个差点让功能静默失效的坑
上线前最后一次自查时,我发现路径前缀对不上:
- 复制脚本把安装包放到
dist/dl/releases/…→ 边缘上的实际 URL 是/dl/releases/… - 服务端却拼
base + "/" + key→ 拿到的是/releases/…
差一段 /dl。 探测永远 404,所有下载静默回落到对象存储。
这个 bug 的阴险之处在于它不报错。功能看起来像「CDN 从来没生效过」,而不是像 bug——你只会觉得「EdgeOne 是不是不支持这样用」。如果没在部署前查一遍 302 目标,它会一直藏着,直到有人抱怨海外用户下载慢。
修法是加一个常量,并用测试把三处路径锁在一起:
// cdnPathPrefix is where release files live on the CDN. It must agree with// the directory scripts/cdn-release-assets.mjs writes to and the path// edgeone.json caches. A mismatch here fails silently: the probe finds// nothing and every download falls back to the bucket, which looks like// "the CDN never worked" rather than like a bug.const cdnPathPrefix = "/dl"// TestCDNPathPrefixAgreesWithTheSiteDeploymentfunc TestCDNPathPrefixAgreesWithTheSiteDeployment(t *testing.T) { root := filepath.Join("..", "..", "..") mk, err := os.ReadFile(filepath.Join(root, "Makefile")) if err != nil { t.Fatal(err) } if !strings.Contains(string(mk), "--out web/dist"+cdnPathPrefix) { t.Fatalf("Makefile does not publish the release assets to web/dist%s; the server's cdnPathPrefix=%q no longer matches", cdnPathPrefix, cdnPathPrefix) } cfg, err := os.ReadFile(filepath.Join(root, "web", "public", "edgeone.json")) if err != nil { t.Fatal(err) } if !strings.Contains(string(cfg), cdnPathPrefix+"/releases/*") { t.Fatalf("web/public/edgeone.json does not cache %s/releases/*", cdnPathPrefix) }}这个测试必须真的会红才算数——所以我改坏 Makefile 验证过:
--- FAIL: TestCDNPathPrefixAgreesWithTheSiteDeployment cdn_test.go:182: Makefile does not publish the release assets to web/dist/dl; the server's cdnPathPrefix="/dl" no longer matches教训:跨进程、跨语言的路径约定,人眼看不住。写进 Makefile、Go 常量、JSON 配置三处,就必须有一条测试把它们绑在一起。
最终效果
线上六个平台的实际分流(真实 302 目标):
| 平台 | 落到哪 | 原因 |
|---|---|---|
darwin-aarch64 / darwin-x86_64 | EdgeOne Pages 边缘 | 5.5 / 5.9 MB,在限内 |
windows-x86_64 | EdgeOne Pages 边缘 | 2.7 MB,在限内 |
windows-aarch64 | 对象存储 | 1.0.0 版,不在边缘保留的版本里 |
linux-aarch64 / linux-x86_64 | 对象存储 | 97 MB AppImage,超 25 MB 上限 |
三个端(k8s 主站、EdgeOne 镜像、Cloudflare 海外站)跑同一份产物,bundle 名一致。
几个还没解决的
- Windows arm64 仍在对象存储。它在 1.0.0 版,而边缘只保留最近 2 个版本的副本(
EO_CDN_VERSIONS)。想让它也走边缘,得把 1.0.0 的安装包补回本地release/再部署一次边缘。 - Pages 免费套餐没有公开的流量配额文档。配额表里只有构建次数、函数执行次数、KV/Blob 存储。安装包下载走边缘后出口流量从 MinIO 挪到了 EdgeOne,如果哪天那边有流量上限,这条链路会先撞上。
- 探测是每个平台一次 HEAD,不是「边缘整体健康检查」。边缘整体挂了会被 N 次探测各发现一次,代价是 N 次超时(3 秒)。当前平台数就 6 个,可以接受;平台多起来要改成全局熔断。
收尾清单
# 1. 代码cd server && go vet ./... && go test ./...cd .. && node --test tests/*.test.mjs
# 2. 部署边缘(带上安装包)make pages-deploy-eo
# 3. 发服务端镜像,让 CDN_BASE_URL 生效make k8s-release TAG=1.0.5-$(git rev-parse --short HEAD) PUSH=1
# 4. 验收curl -sI "https://scorpius-pages.apikv.com/dl/releases/<ver>/<file>" | grep -i "eo-cache-status"# → Cache Hit参考
- EdgeOne Pages 缓存配置 —— 「每次创建新部署后,边缘缓存会自动失效」
- EdgeOne 清除缓存 ——
purge_host要裸域名,purge_url才要带协议的 URL - 腾讯云 EdgeOne API 文档
- 仓库:
lens077/Scorpius(server/internal/api/update.go、server/internal/api/cdn.go、scripts/cdn-release-assets.mjs)





