希曼日记

给桌面客户端做下载加速:从「桶直接公开」到「边缘优先、源站兜底」的三代演进

约 8 分钟2,967 字

Scorpius 是个桌面客户端,官网只做两件事:卖激活码、给下载页。下载页看起来是小事——点一下,浏览器开始下 .dmg——但它背后要同时满足四个互相拉扯的要求:

  1. 文件得能下完。5 MB 的 DMG 从源站直出,用户在广西、在新加坡、在德国,体验不能差太远。
  2. 链接不能被转发。一个用户把下载链接贴到群里,群里 500 个人一起来下,源站带宽和费用都是自己的。
  3. 桶不能公开。桶里除了安装包,还有 latest.json(发布清单)和 changelog.json(更新日志),后者决定客户端弹不弹更新提示。
  4. CDN 挂了要能退。加速是加分项,不是前提项。

这四条凑在一起,演进了三代。这篇按真实过程写:每一代的设计、代码、操作步骤、验证方法,以及最后怎么用 EdgeOne Pages 把字节挪出源站、又怎么保证它挂了不影响下载。

文中代码来自 lens077/Scorpius,域名与凭据已替换;截图是真实运行时的输出。

三代演进总览

下载策略的三代演进:第一代桶直接公开,第二代桶私有加一次性令牌加三层限流,第三代边缘优先、桶兜底

每一代都在前一代的漏洞上补一层,而不是推翻重来。第三代尤其克制——第二代的安全模型一字未改,只在「302 到哪儿」那一步加了分流。

第一代:能用,但不安全

最初的版本简单到不像设计:

HTML
<a href="https://silo-api.apikv.com/scorpius/releases/1.0.1/Scorpius_1.0.1_macos_arm64.dmg">
立即下载
</a>

桶直接公开,文件 URL 写死在网页里。这版能跑通,但有三个洞:

问题后果
桶地址与对象键公开任何人 curl 一下就能列出全部文件,甚至整个桶
没有任何节流无人机脚本刷流量,源站出站带宽和钱都是自己的
没有「这一次下载」的概念链接可以被无限转发;转发的死链不会死

第三条最要命。一个付费软件的安装包被无限镜像,等于把这层门白装了。

但也别急着说第一代错。 它是绝大多数项目的起点,而且它明确了后面两代要解决什么:把「链接」从「地址」变成「能力」。

第二代:桶私有 + 一次性令牌 + 三层限流

核心思路只有一句话:不让用户拿到桶里的地址,只给他一个只能用一次、很快就失效的短链。

两步握手

Text
浏览器 ──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 服务,直接从存储流向浏览器。

第一步的返回体长这样——注意它不含任何桶地址,只有一个不透明的令牌:

签发一次性令牌:返回 60 秒有效、singleUse 的 url 与版本号,不含桶地址

令牌怎么发

24 字节密码学随机数,Base64URL 编码成 32 字符;存在内存 map 里,60 秒后过期;兑换时立刻标记为已用:

Go
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 字段存了但只用于审计,不参与判定。

三层限流,以及一个踩过的坑

Go
// 签发:严。滥用值得在这里被挡住。
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 滥用」「跳转打断」「总量兜底」。

第二代上线时,官网下载页长这样——点开就在下载,用户不需要做任何事:

官网下载页:进入即自动开始下载,下方是 macOS 的安装与授权步骤、下载步骤说明,以及「下载没有开始」的排查提示

第三代:边缘承载字节,源站退成兜底

第二代解决了安全,但没解决速度和成本。文件字节仍然从源站直出:

Bash
# 兑换令牌,看 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-3utthqbl9gydedgeone-3uttftrtl85h(plan-personal,付费)回源加速、WAF、安全防护

这里选 Pages,理由是它免费、而且是静态托管——安装包本来就是静态文件,正好是它的本职。EO 那套我原本用来自加速官网,2026-10-14 因为 apikv.com 没 ICP 备案(只能建境外加速区,大陆用户反而绕远)整体下线了,2026-10-26 把付费套餐也销毁了。

Pages 的三个硬限制决定了架构:

限制数值影响
单文件大小25 MBLinux AppImage 超限 4 倍
站点总容量5 GB只能保留最近几个版本
项目数40够用

实测:哪些文件能上边缘

用 Range 请求读 Content-Range 的总长度,这是线上真实值:

Bash
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 .dmg5.5 / 5.9 MB✅
Windows x64 / arm64 _setup.exe2.7 / 2.2 MB✅
Linux .AppImage97.1 MB❌ 超 25 MB 上限 4 倍

安装包体积实测:Linux AppImage 超过 Pages 单文件 25 MB 上限

Linux 平台天然走对象存储,不需要任何特殊处理——复制脚本按大小跳过并如实报告,不静默截断。

关键设计:为什么是「探测」而不是「清单」

直觉做法是在 latest.json 里加一个字段,声明哪些平台上了 CDN:

JSON
{ "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 就走边缘,否则走桶。

实现

Go
// 兑换令牌时选源
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)

选源本身:

Go
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
}

探测与缓存:

Go
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 窗口内自动切回,不需要发版。

最终架构:兑换令牌时用 HEAD 探测决定跳边缘还是跳预签名桶链接,前端零改动

三条不变量

  1. 桶始终私有,链接始终一次性——CDN 改变的是「文件从哪来」,不是「链接怎么发」。
  2. 探测失败按不可用处理。
  3. 决策在服务端——前端永远只拿到一个 url,源码零改动,网站不需要知道有 CDN 这回事。

操作步骤

1. 服务端:加一个配置项

Go · server/internal/api/api.go
type Config struct {
// CDNBaseURL:承载安装包的边缘基址,如 https://scorpius-pages.apikv.com。
// 留空表示「没有 CDN」,所有下载都走对象存储。
CDNBaseURL string
// …
}
// server/cmd/licensed/main.go
CDNBaseURL: os.Getenv("CDN_BASE_URL"),

Helm values(deploy/chart/scorpius/values.yaml):

YAML
api:
env:
# 承载安装包的 CDN 基址。服务端会先问边缘有没有这个文件,有就从边缘跳,
# 没有就回落到对象存储的预签名链接(Linux AppImage 超过 CDN 单文件上限,
# 一直走对象存储)。留空则全部走对象存储。
CDN_BASE_URL: https://scorpius-pages.apikv.com

2. 发布流水线:把安装包复制进站点产物

scripts/cdn-release-assets.mjs 做两件事:按 25 MB 上限过滤,只保留最近 N 个版本。

JavaScript
// 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" 之后:

JavaScript
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;
}

超过上限的如实报告,不静默截断:

JavaScript
if (st.size > MAX_FILE_BYTES) {
// 超过 Pages 单文件上限。留给对象存储,由服务端兜底。
skipped.push({ version, file: f.name, bytes: st.size });
continue;
}

Makefile 里接上(EO_CDN_VERSIONS 控制保留几个版本,Pages 站点总容量 5 GB):

Makefile
EO_CDN_VERSIONS ?= 2
pages-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 production

3. 缓存策略

web/public/edgeone.json(Vite 会原样复制进 dist/)。安装包不可变,可以用长 TTL + immutable:

JSON
{
"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 管边缘节点。两个都要——只设前者,边缘还是会每次回源。

验证

边缘命中

Bash
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)"

边缘响应头:命中边缘缓存,7 天 TTL + immutable,Content-Type 与 Content-Length 正确

要点:EO-Cache-Status: Cache Hit、Content-Type: application/x-apple-diskimage(浏览器才会下载而不是渲染)、Cache-Control: public, max-age=604800, immutable。

实际下载

Bash
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 回落桶

Bash
# ① 签发令牌
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

兑换令牌后 302 指向 EdgeOne Pages 边缘

换一个边缘上没有的平台(Linux AppImage 超限):

Bash
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=…

边缘没有该文件时 302 回落到对象存储的预签名链接

同一个接口、同一段代码、同一个平台查询,只是换个平台就落到不同源——这就是双源。

故障注入

单元测试里把边缘模拟服务器关掉,验证回落到桶:

Go
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 指向一个不可达地址:

Bash
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 再提交。

全套回归

Bash
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 目标,它会一直藏着,直到有人抱怨海外用户下载慢。

修法是加一个常量,并用测试把三处路径锁在一起:

Go
// 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"
Go
// TestCDNPathPrefixAgreesWithTheSiteDeployment
func 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 验证过:

Text
--- 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_64EdgeOne Pages 边缘5.5 / 5.9 MB,在限内
windows-x86_64EdgeOne 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 个,可以接受;平台多起来要改成全局熔断。

收尾清单

Bash
# 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

参考