[{"data":1,"prerenderedAt":2484},["ShallowReactive",2],{"\u002F2026-05-30-fa-014":3,"\u002F2026-05-30-fa-014-rel":734},{"id":4,"title":5,"body":6,"column":717,"date":718,"description":12,"extension":719,"hero_image":720,"meta":721,"navigation":507,"path":722,"seo":723,"series_id":724,"severity":720,"stem":725,"summary":726,"tags":727,"__hash__":733},"posts\u002F2026-05-30-FA-014-前端部署五层缓存.md","每一层都对——用户看到的还是旧版",{"type":7,"value":8,"toc":709},"minimark",[9,13,16,72,75,78,133,136,160,163,166,173,176,206,209,217,221,224,232,235,239,242,285,296,302,309,318,322,325,361,364,367,370,379,391,394,401,433,439,442,445,453,456,464,467,681,692,695,702,705],[10,11,12],"p",{},"改了页面样式。构建完成了。原子部署也完成了。反复确认四次。每一次都验证过了。",[10,14,15],{},"第一次：看产物目录。",[17,18,23],"pre",{"className":19,"code":20,"language":21,"meta":22,"style":22},"language-bash shiki shiki-themes github-light github-dark","ls -la dist\u002F\ngrep 'src=\"' dist\u002Findex.html | head -1\n# 输出: src=\"\u002Fassets\u002FMarkdown-a1b2c3d4.js\"（新 hash）\n","bash","",[24,25,26,43,65],"code",{"__ignoreMap":22},[27,28,31,35,39],"span",{"class":29,"line":30},"line",1,[27,32,34],{"class":33},"sScJk","ls",[27,36,38],{"class":37},"sj4cs"," -la",[27,40,42],{"class":41},"sZZnC"," dist\u002F\n",[27,44,46,49,52,55,59,62],{"class":29,"line":45},2,[27,47,48],{"class":33},"grep",[27,50,51],{"class":41}," 'src=\"'",[27,53,54],{"class":41}," dist\u002Findex.html",[27,56,58],{"class":57},"szBVR"," |",[27,60,61],{"class":33}," head",[27,63,64],{"class":37}," -1\n",[27,66,68],{"class":29,"line":67},3,[27,69,71],{"class":70},"sJ8bj","# 输出: src=\"\u002Fassets\u002FMarkdown-a1b2c3d4.js\"（新 hash）\n",[10,73,74],{},"新的。",[10,76,77],{},"第二次：从宿主机 curl，看容器返回的。",[17,79,81],{"className":19,"code":80,"language":21,"meta":22,"style":22},"curl -sk --resolve \u003Cdomain>:443:127.0.0.1 https:\u002F\u002F\u003Cdomain>\u002F | grep 'src='\n",[24,82,83],{"__ignoreMap":22},[27,84,85,88,91,94,97,100,104,107,110,113,116,118,120,122,125,127,130],{"class":29,"line":30},[27,86,87],{"class":33},"curl",[27,89,90],{"class":37}," -sk",[27,92,93],{"class":37}," --resolve",[27,95,96],{"class":57}," \u003C",[27,98,99],{"class":41},"domai",[27,101,103],{"class":102},"sVt8B","n",[27,105,106],{"class":57},">",[27,108,109],{"class":41},":443:127.0.0.1",[27,111,112],{"class":41}," https:\u002F\u002F",[27,114,115],{"class":57},"\u003C",[27,117,99],{"class":41},[27,119,103],{"class":102},[27,121,106],{"class":57},[27,123,124],{"class":41},"\u002F",[27,126,58],{"class":57},[27,128,129],{"class":33}," grep",[27,131,132],{"class":41}," 'src='\n",[10,134,135],{},"还是老 hash。矛盾。进容器确认文件在不在。",[17,137,139],{"className":19,"code":138,"language":21,"meta":22,"style":22},"docker exec cpa-caddy ls -la \u002Fopt\u002Fcpa\u002Frepo\u002Fapps\u002Fweb\u002Fdist\u002F\n",[24,140,141],{"__ignoreMap":22},[27,142,143,146,149,152,155,157],{"class":29,"line":30},[27,144,145],{"class":33},"docker",[27,147,148],{"class":41}," exec",[27,150,151],{"class":41}," cpa-caddy",[27,153,154],{"class":41}," ls",[27,156,38],{"class":37},[27,158,159],{"class":41}," \u002Fopt\u002Fcpa\u002Frepo\u002Fapps\u002Fweb\u002Fdist\u002F\n",[10,161,162],{},"在。文件确实是新的。",[10,164,165],{},"第三次确认：Caddy 配置有没有缓存。",[10,167,168,169,172],{},"检查了。没启用。",[24,170,171],{},"file_server"," 每次都直接 stat。那问题不在 Caddy 的内存。",[10,174,175],{},"第四次：验证浏览器。",[17,177,179],{"className":19,"code":178,"language":21,"meta":22,"style":22},"curl -s https:\u002F\u002F\u003Cdomain>\u002F | grep 'src='\n",[24,180,181],{"__ignoreMap":22},[27,182,183,185,188,190,192,194,196,198,200,202,204],{"class":29,"line":30},[27,184,87],{"class":33},[27,186,187],{"class":37}," -s",[27,189,112],{"class":41},[27,191,115],{"class":57},[27,193,99],{"class":41},[27,195,103],{"class":102},[27,197,106],{"class":57},[27,199,124],{"class":41},[27,201,58],{"class":57},[27,203,129],{"class":33},[27,205,132],{"class":41},[10,207,208],{},"用户端拿到的仍是老 hash 指向。",[10,210,211,212,216],{},"三个观察结果，互相矛盾：文件系统新了，容器内的文件也是新的，Caddy 没有缓存，但用户端还是老样式。不能同时都是对的。这意味着问题不在任何一个环节本身，而在环节之间的",[213,214,215],"strong",{},"链接","。",[218,219,220],"h2",{"id":220},"五层缓存链路",[10,222,223],{},"问题的根源是缓存在多个环节堆积。前端静态资源从产生到用户浏览器要经过五层：",[17,225,230],{"className":226,"code":228,"language":229},[227],"language-text","浏览器磁盘缓存 ← CDN 边缘节点 ← 反向代理（容器内） ← bind mount ← 产物目录\n","text",[24,231,228],{"__ignoreMap":22},[10,233,234],{},"部署过程只触及最内层的产物目录，上面四层完全无感知。",[218,236,238],{"id":237},"docker-bind-mount-的-inode-陷阱","Docker bind mount 的 inode 陷阱",[10,240,241],{},"原子部署的标准做法：",[17,243,245],{"className":19,"code":244,"language":21,"meta":22,"style":22},"mv dist dist.OLD-$(date +%s)        # 原 dist 改名，inode 100\nmv dist.NEW dist                     # 新 dist 改名为 dist，inode 200\n",[24,246,247,273],{"__ignoreMap":22},[27,248,249,252,255,258,261,264,267,270],{"class":29,"line":30},[27,250,251],{"class":33},"mv",[27,253,254],{"class":41}," dist",[27,256,257],{"class":41}," dist.OLD-",[27,259,260],{"class":102},"$(",[27,262,263],{"class":33},"date",[27,265,266],{"class":41}," +%s",[27,268,269],{"class":102},")        ",[27,271,272],{"class":70},"# 原 dist 改名，inode 100\n",[27,274,275,277,280,282],{"class":29,"line":45},[27,276,251],{"class":33},[27,278,279],{"class":41}," dist.NEW",[27,281,254],{"class":41},[27,283,284],{"class":70},"                     # 新 dist 改名为 dist，inode 200\n",[10,286,287,288,291,292,295],{},"改名后，宿主机的 ",[24,289,290],{},"\u002Fopt\u002Fcpa\u002Frepo\u002Fapps\u002Fweb\u002Fdist"," 现在指向 inode 200。但容器内的 bind mount 有个细节：它在启动时绑定了",[213,293,294],{},"inode 编号","，不是路径。",[10,297,298,299,301],{},"容器启动时，宿主机的 dist 指向 inode 100。bind mount 说：\"我要挂载 inode 100\"。容器内的 ",[24,300,290],{}," 永远指向那个 inode。",[10,303,304,305,308],{},"部署后，宿主机的路径改了，指向 inode 200。但容器内仍然看着 inode 100。这就是为什么 ",[24,306,307],{},"docker exec"," 看到的文件是新的（内核在路径后面找文件），但容器运行的 Caddy 进程看不到（它的 bind mount 被钉死在了旧 inode）。",[10,310,311,312,314,315,317],{},"那 ",[24,313,307],{}," 为什么能看到新文件呢？因为 ",[24,316,307],{}," 实际上是在宿主机侧做的文件系统查询，走的是宿主机最新的路径指向。容器内的 Caddy 进程走的是它挂载时定下来的 inode。",[218,319,321],{"id":320},"根因三个矛盾现象的同一原因","根因：三个矛盾现象的同一原因",[10,323,324],{},"这个故障的隐蔽之处在于，三个表面上没有关联的观察结果其实由同一个原因产生：",[326,327,328,335,344],"ol",{},[329,330,331,334],"li",{},[213,332,333],{},"文件系统看得是新的","：宿主机的产物目录已经更新，inode 200 的新文件确实存在。",[329,336,337,340,341,343],{},[213,338,339],{},"容器内看到的是旧的","：因为 bind mount 在启动时绑定了 inode 编号，容器内 ",[24,342,290],{}," 永远指向 inode 100，即便宿主机的路径已经指向 inode 200。",[329,345,346,349,350,353,354,357,358,360],{},[213,347,348],{},"入口 HTML 缓存了旧 hash","：这是最隐蔽的一层。假设容器确实能看到新文件，问题仍然可能出现——如果浏览器或上游 CDN 缓存了旧的 ",[24,351,352],{},"index.html","，拿到的 HTML 里仍然指向旧的 ",[24,355,356],{},"app-old-hash.js","，那么即便所有新资源都已上线，浏览器也会去请求旧文件。而 Vite 生成的文件名带内容 hash，理论上 hash 变了就是新 URL，应该不会命中旧缓存。但前提是获取 ",[24,359,352],{}," 的请求本身不被缓存。",[10,362,363],{},"实际排查中后两个问题叠加了。宿主机部署了新文件，但容器看不到（inode 问题）；即便容器能看到，用户端也可能先从 CDN 拿到缓存的旧 HTML，指向旧的资源 hash，然后再次请求这些旧资源。",[218,365,366],{"id":366},"修复方案与选择",[10,368,369],{},"Docker bind mount 的 inode 绑定问题有两个解决方向：",[10,371,372,375,378],{},[213,373,374],{},"方案 A：重启容器（推荐）",[376,377],"br",{},"\n重新启动 Caddy 容器会重新挂载卷，绑定最新的 inode。单次重启耗时少于 2 秒，操作简单，无需改动部署脚本。重启后验证容器返回的 hash 是否已更新为最新值。",[10,380,381,384,386,387,390],{},[213,382,383],{},"方案 B：改用覆盖式同步",[376,385],{},"\n用 ",[24,388,389],{},"rsync --delete dist.NEW\u002F dist\u002F"," 直接覆盖文件，而不是整个目录改名。这样 dist 目录本身的 inode 保持不变，容器内的 bind mount 始终指向同一个 inode，但目录内的文件被替换。",[10,392,393],{},"方案 A 更简洁，决定采纳。但这只解决了第三层（bind mount）的问题。",[10,395,396,397,400],{},"还需处理第二层和第一层的缓存。CDN 边缘节点不一定完全遵守 origin 返回的 ",[24,398,399],{},"Cache-Control: must-revalidate, no-cache"," 头，不同地区节点的失效时间不同。必须主动向 CDN 服务商提交清缓存请求。",[17,402,404],{"className":19,"code":403,"language":21,"meta":22,"style":22},"# 部署完成后，手动提交 purge 请求，涉及四个 URL\n# https:\u002F\u002F\u003Cdomain>\u002F\n# https:\u002F\u002F\u003Cdomain>\u002Findex.html\n# https:\u002F\u002F\u003Cadmin-domain>\u002F\n# https:\u002F\u002F\u003Cadmin-domain>\u002Findex.html\n",[24,405,406,411,416,421,427],{"__ignoreMap":22},[27,407,408],{"class":29,"line":30},[27,409,410],{"class":70},"# 部署完成后，手动提交 purge 请求，涉及四个 URL\n",[27,412,413],{"class":29,"line":45},[27,414,415],{"class":70},"# https:\u002F\u002F\u003Cdomain>\u002F\n",[27,417,418],{"class":29,"line":67},[27,419,420],{"class":70},"# https:\u002F\u002F\u003Cdomain>\u002Findex.html\n",[27,422,424],{"class":29,"line":423},4,[27,425,426],{"class":70},"# https:\u002F\u002F\u003Cadmin-domain>\u002F\n",[27,428,430],{"class":29,"line":429},5,[27,431,432],{"class":70},"# https:\u002F\u002F\u003Cadmin-domain>\u002Findex.html\n",[10,434,435,436,438],{},"CDN 侧清缓存一般 5-10 分钟生效全网。对于浏览器磁盘缓存，用户拿到新的 ",[24,437,352],{}," 后，浏览器会发现资源 URL（带新 hash）和本地缓存不符，自动重新请求，所以无需单独处理——只要确保上游返回的是新 HTML。",[218,440,441],{"id":441},"防回归",[10,443,444],{},"这个故障暴露了两个流程漏洞：",[10,446,447,450,452],{},[213,448,449],{},"1. 部署验证不完整",[376,451],{},"\n部署脚本完成后没有自动验证生效。从现象看是\"改样式不生效\"，实际排查才发现是系统问题，前面四次盲改毫无意义。",[10,454,455],{},"新增验证步骤：部署后必须从外网 curl 验证 hash，而不是从宿主机或容器内部检查。",[10,457,458,461,463],{},[213,459,460],{},"2. 缓存链路的文档缺失",[376,462],{},"\n五层缓存各有各的失效条件和刷新手段，没有集中文档时，新同学很容易漏掉某一层。",[10,465,466],{},"后续在部署 SOP 中增加这份 checklist：",[17,468,470],{"className":19,"code":469,"language":21,"meta":22,"style":22},"# 1. 执行部署（rsync + atomic mv）\n... rsync apps\u002Fweb\u002Fdist ... && mv dist.NEW dist ...\n\n# 2. 重启容器（修复 inode 绑定）\nsudo docker restart cpa-caddy\nsleep 3\n\n# 3. 验证容器返回新 hash\ncurl -sk --resolve \u003Cdomain>:443:127.0.0.1 https:\u002F\u002F\u003Cdomain>\u002F | grep 'src=' | head -1\n# 应匹配 dist\u002Findex.html 里的 hash\n\n# 4. 清 CDN 缓存（手动或 API）\n# 进入 CDN 控制台，提交 purge 请求：\n# \u003Cdomain>\u002F\n# \u003Cdomain>\u002Findex.html\n# \u003Cadmin-domain>\u002F\n# \u003Cadmin-domain>\u002Findex.html\n\n# 5. 等待 5-10 分钟后，从外网再次验证\ncurl -s https:\u002F\u002F\u003Cdomain>\u002F | grep 'src='\n# 应该是最新 hash\n",[24,471,472,477,503,509,514,528,537,542,548,592,598,603,609,615,621,627,633,639,644,650,675],{"__ignoreMap":22},[27,473,474],{"class":29,"line":30},[27,475,476],{"class":70},"# 1. 执行部署（rsync + atomic mv）\n",[27,478,479,482,485,488,491,494,496,498,500],{"class":29,"line":45},[27,480,481],{"class":37},"...",[27,483,484],{"class":41}," rsync",[27,486,487],{"class":41}," apps\u002Fweb\u002Fdist",[27,489,490],{"class":41}," ...",[27,492,493],{"class":102}," && ",[27,495,251],{"class":33},[27,497,279],{"class":41},[27,499,254],{"class":41},[27,501,502],{"class":41}," ...\n",[27,504,505],{"class":29,"line":67},[27,506,508],{"emptyLinePlaceholder":507},true,"\n",[27,510,511],{"class":29,"line":423},[27,512,513],{"class":70},"# 2. 重启容器（修复 inode 绑定）\n",[27,515,516,519,522,525],{"class":29,"line":429},[27,517,518],{"class":33},"sudo",[27,520,521],{"class":41}," docker",[27,523,524],{"class":41}," restart",[27,526,527],{"class":41}," cpa-caddy\n",[27,529,531,534],{"class":29,"line":530},6,[27,532,533],{"class":33},"sleep",[27,535,536],{"class":37}," 3\n",[27,538,540],{"class":29,"line":539},7,[27,541,508],{"emptyLinePlaceholder":507},[27,543,545],{"class":29,"line":544},8,[27,546,547],{"class":70},"# 3. 验证容器返回新 hash\n",[27,549,551,553,555,557,559,561,563,565,567,569,571,573,575,577,579,581,583,586,588,590],{"class":29,"line":550},9,[27,552,87],{"class":33},[27,554,90],{"class":37},[27,556,93],{"class":37},[27,558,96],{"class":57},[27,560,99],{"class":41},[27,562,103],{"class":102},[27,564,106],{"class":57},[27,566,109],{"class":41},[27,568,112],{"class":41},[27,570,115],{"class":57},[27,572,99],{"class":41},[27,574,103],{"class":102},[27,576,106],{"class":57},[27,578,124],{"class":41},[27,580,58],{"class":57},[27,582,129],{"class":33},[27,584,585],{"class":41}," 'src='",[27,587,58],{"class":57},[27,589,61],{"class":33},[27,591,64],{"class":37},[27,593,595],{"class":29,"line":594},10,[27,596,597],{"class":70},"# 应匹配 dist\u002Findex.html 里的 hash\n",[27,599,601],{"class":29,"line":600},11,[27,602,508],{"emptyLinePlaceholder":507},[27,604,606],{"class":29,"line":605},12,[27,607,608],{"class":70},"# 4. 清 CDN 缓存（手动或 API）\n",[27,610,612],{"class":29,"line":611},13,[27,613,614],{"class":70},"# 进入 CDN 控制台，提交 purge 请求：\n",[27,616,618],{"class":29,"line":617},14,[27,619,620],{"class":70},"# \u003Cdomain>\u002F\n",[27,622,624],{"class":29,"line":623},15,[27,625,626],{"class":70},"# \u003Cdomain>\u002Findex.html\n",[27,628,630],{"class":29,"line":629},16,[27,631,632],{"class":70},"# \u003Cadmin-domain>\u002F\n",[27,634,636],{"class":29,"line":635},17,[27,637,638],{"class":70},"# \u003Cadmin-domain>\u002Findex.html\n",[27,640,642],{"class":29,"line":641},18,[27,643,508],{"emptyLinePlaceholder":507},[27,645,647],{"class":29,"line":646},19,[27,648,649],{"class":70},"# 5. 等待 5-10 分钟后，从外网再次验证\n",[27,651,653,655,657,659,661,663,665,667,669,671,673],{"class":29,"line":652},20,[27,654,87],{"class":33},[27,656,187],{"class":37},[27,658,112],{"class":41},[27,660,115],{"class":57},[27,662,99],{"class":41},[27,664,103],{"class":102},[27,666,106],{"class":57},[27,668,124],{"class":41},[27,670,58],{"class":57},[27,672,129],{"class":33},[27,674,132],{"class":41},[27,676,678],{"class":29,"line":677},21,[27,679,680],{"class":70},"# 应该是最新 hash\n",[10,682,683,684,687,688,691],{},"目前 ",[24,685,686],{},"release-connector.sh"," 只处理后端服务和链配置，前端改动不频繁，单独拉出一个 ",[24,689,690],{},"release-web.sh"," 脚本会更清晰，包含上述步骤。",[218,693,694],{"id":694},"隐蔽点总结",[10,696,697,698,701],{},"五层缓存中，最容易被忽视的是：",[213,699,700],{},"即便下层的资源文件都更新了，上层缓存的入口 HTML 如果不更新，用户浏览器拿到的仍是指向旧资源 hash 的 HTML","。因为 HTML 本身通常没有被标记为 immutable，且如果被 CDN 或浏览器缓存，就会导致\"文件都对，但浏览器看不到新版本\"的假象。只有 HTML 这一个引入点的 hash 改变了，整个依赖树才会指向新的资源。",[10,703,704],{},"这也解释了为什么\"产物目录看着没问题，容器内也看着没问题，但用户端就是看不到\"——问题不在文件是否存在，而在从启动到用户的整条链路中，任何一层缓存都可能吞掉更新信号。Docker bind mount 的 inode 绑定恰好是最隐蔽的那一层，因为容器内的 stat 调用看起来成功了，但返回的是旧 inode 的元数据。",[706,707,708],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":22,"searchDepth":45,"depth":45,"links":710},[711,712,713,714,715,716],{"id":220,"depth":45,"text":220},{"id":237,"depth":45,"text":238},{"id":320,"depth":45,"text":321},{"id":366,"depth":45,"text":366},{"id":441,"depth":45,"text":441},{"id":694,"depth":45,"text":694},"故障档案","2026-05-30","md",null,{},"\u002F2026-05-30-fa-014",{"title":5,"description":12},"FA-014","2026-05-30-FA-014-前端部署五层缓存","改样式后部署完成但用户端看不到，排查发现五层缓存全链失效，最隐蔽的陷阱是 Docker bind mount 的 inode 绑定。",[728,729,730,731,732],"Docker","CDN","缓存","前端部署","原子部署","CGuiaZICRCpHOOXKdoMigh6WUy7sjGN-7iXKVf1fxmQ",[735,889,1794],{"id":736,"title":737,"body":738,"column":717,"date":875,"description":742,"extension":719,"hero_image":720,"meta":876,"navigation":507,"path":877,"seo":878,"series_id":720,"severity":720,"stem":879,"summary":880,"tags":881,"__hash__":888},"posts\u002F2026-09-10-登录失效不能只显示空白页.md","登录失效不能只显示空白页",{"type":7,"value":739,"toc":868},[740,743,746,749,753,756,759,762,774,778,785,801,804,807,811,814,817,820,823,827,830,841,844,848,851,854,865],[10,741,742],{},"用户看到的是空白、卡住或一直加载，系统里却可能已经有一个明确的信号：接口返回了 401。",[10,744,745],{},"最近一次修复针对的就是这种错位。登录令牌过期后，前端仍只检查本地存储里有没有令牌字符串，不检查令牌是否还有效。页面因此保持“已登录”的外观，但余额停在同步中，会话列表没有内容，用户也收不到提示。",[10,747,748],{},"这不是一个单独页面的 Bug，而是登录状态判断与接口真实状态脱节。",[218,750,752],{"id":751},"现象页面还在业务已经失效","现象：页面还在，业务已经失效",[10,754,755],{},"问题出现时，用户仍然可以看到应用外壳。真正需要数据的请求陆续返回 401，页面组件各自处理失败结果，有的停在 loading，有的显示空列表，有的没有任何反馈。",[10,757,758],{},"这种状态比直接跳转登录页更难排查，因为页面没有崩溃，浏览器也没有明显报错。用户的描述通常是“突然不能用了”。",[10,760,761],{},"根因有两个：",[326,763,764,771],{},[329,765,766,767,770],{},"本地 ",[24,768,769],{},"yc_token"," 只作为“字符串是否存在”的标记；",[329,772,773],{},"应用内的请求分散在几十个文件，没有统一的 401 出口。",[218,775,777],{"id":776},"修复在请求边界识别失效","修复：在请求边界识别失效",[10,779,780,781,784],{},"这次修改没有要求逐个页面补判断，而是在应用入口包一层 ",[24,782,783],{},"window.fetch","，把判定条件收紧到三点：",[786,787,788,795,798],"ul",{},[329,789,790,791,794],{},"请求是同源 ",[24,792,793],{},"\u002Fapi\u002F"," 路径；",[329,796,797],{},"请求不在登录、注册和票据登录等合法返回 401 的白名单里；",[329,799,800],{},"本地确实存在登录令牌。",[10,802,803],{},"满足条件后，才把 401 解释为登录失效，并触发统一提示。外部签名 URL 不会被误判，流式响应也不读取 body，只观察 HTTP status，避免影响聊天和视频编导流程。",[10,805,806],{},"这个边界很重要。全局拦截器如果只看状态码，容易把登录接口本身的“凭据错误”当成“会话过期”；如果不区分同源请求，也可能误伤对象存储或其他外部服务。",[218,808,810],{"id":809},"交互先保住用户正在做的事","交互：先保住用户正在做的事",[10,812,813],{},"修复没有直接清空登录态，而是分两层提示。",[10,815,816],{},"第一层是弹窗，告诉用户会话已失效，并提供重新登录入口。用户可以选择稍后处理，先复制还没有保存的内容。",[10,818,819],{},"第二层是顶部常驻提示条。用户关掉弹窗后，页面仍保留可见的恢复入口，避免回到“没有提示的死状态”。",[10,821,822],{},"多标签页也是一个边界：如果另一个标签页已经完成续期，当前页面重新检查后应接管新的有效令牌，而不是把它删掉。否则用户在一个标签页登录，另一个标签页却把新令牌清除，问题会从单页失效变成跨标签页互相干扰。",[218,824,826],{"id":825},"测试验证状态转换而不是只测组件渲染","测试：验证状态转换，而不是只测组件渲染",[10,828,829],{},"这次变更补了三类测试：",[786,831,832,835,838],{},[329,833,834],{},"失效令牌触发弹窗和常驻提示；",[329,836,837],{},"登录、注册等白名单接口的 401 不触发全局失效；",[329,839,840],{},"多标签页已有新令牌时，当前页面能复用有效状态。",[10,842,843],{},"测试重点不是“按钮是否出现”，而是确认请求状态、登录状态和用户反馈之间的转换。只有把这三者放在一起验证，才能避免页面看似正常、用户却无法继续工作的情况。",[218,845,847],{"id":846},"结论鉴权错误也是产品状态","结论：鉴权错误也是产品状态",[10,849,850],{},"401 不是只留给开发者看的网络状态。对用户来说，它意味着“当前工作无法继续”，必须有明确的恢复路径。",[10,852,853],{},"前端鉴权设计至少要回答三个问题：",[326,855,856,859,862],{},[329,857,858],{},"哪些 401 表示凭据错误，哪些表示会话过期；",[329,860,861],{},"失效时用户正在编辑的内容如何保留；",[329,863,864],{},"多标签页和重试发生时，哪个令牌拥有更高的有效性。",[10,866,867],{},"把这三个问题写进代码和测试，登录失效就不再是一张空白页，而是一个可理解、可恢复的产品状态。",{"title":22,"searchDepth":45,"depth":45,"links":869},[870,871,872,873,874],{"id":751,"depth":45,"text":752},{"id":776,"depth":45,"text":777},{"id":809,"depth":45,"text":810},{"id":825,"depth":45,"text":826},{"id":846,"depth":45,"text":847},"2026-09-10",{},"\u002F2026-09-10",{"title":737,"description":742},"2026-09-10-登录失效不能只显示空白页","一次登录令牌过期问题的修复：从分散的接口错误，收口到同源 API 的统一失效提示，同时保留未保存内容和多标签页续期场景。",[882,883,884,885,886,887],"前端","鉴权",401,"fetch","用户体验","测试","mDBl0HrrKMlN2mL_SZGdBwS6YpvL96QS8WWVfThP2c4",{"id":890,"title":891,"body":892,"column":717,"date":1779,"description":896,"extension":719,"hero_image":720,"meta":1780,"navigation":507,"path":1781,"seo":1782,"series_id":1783,"severity":720,"stem":1784,"summary":1785,"tags":1786,"__hash__":1793},"posts\u002F2026-08-31-FA-024-同一批活儿扣了两遍钱.md","同一批活儿，扣了两遍钱",{"type":7,"value":893,"toc":1771},[894,897,908,911,915,918,991,998,1005,1008,1011,1068,1075,1078,1172,1179,1183,1186,1193,1271,1285,1292,1295,1298,1318,1321,1325,1328,1331,1334,1337,1352,1359,1363,1366,1369,1430,1441,1444,1518,1521,1586,1589,1596,1599,1627,1630,1634,1637,1640,1678,1689,1692,1703,1706,1712,1716,1719,1734,1737,1740,1747,1761,1768],[10,895,896],{},"用户点「批量生视频」，10 张已经出好的分镜图被整套重跑，再扣一遍钱。",[10,898,899,900,903,904,907],{},"生产数据核实过：两次运行的 ",[24,901,902],{},"image.generate"," 都是 ",[24,905,906],{},"charged","。不是显示问题，是真的扣了两次。",[10,909,910],{},"修的过程里翻出两处独立成因，以及一条让它们同时隐身的原因。",[218,912,914],{"id":913},"一祖先闭包不该当目标传","一、祖先闭包不该当目标传",[10,916,917],{},"画布上有一个「运行整组」的入口。前端传目标节点给后端：",[17,919,923],{"className":920,"code":921,"language":922,"meta":22,"style":22},"language-tsx shiki shiki-themes github-light github-dark","onRunGroup={\n  onRunTargets && runState && !['estimating', 'submitted', 'running'].includes(runState)\n    ? (groupId) => {\n        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n        onRunTargets(targetNodes);\n      }\n    : undefined\n}\n","tsx",[24,924,925,936,958,963,968,973,978,986],{"__ignoreMap":22},[27,926,927,930,933],{"class":29,"line":30},[27,928,929],{"class":102},"onRunGroup",[27,931,932],{"class":57},"=",[27,934,935],{"class":102},"{\n",[27,937,938,941,944,947,950,952,955],{"class":29,"line":45},[27,939,940],{"class":102},"  onRunTargets && runState && ![",[27,942,943],{"class":41},"'estimating'",[27,945,946],{"class":102},", ",[27,948,949],{"class":41},"'submitted'",[27,951,946],{"class":102},[27,953,954],{"class":41},"'running'",[27,956,957],{"class":102},"].includes(runState)\n",[27,959,960],{"class":29,"line":67},[27,961,962],{"class":102},"    ? (groupId) => {\n",[27,964,965],{"class":29,"line":423},[27,966,967],{"class":102},"        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n",[27,969,970],{"class":29,"line":429},[27,971,972],{"class":102},"        onRunTargets(targetNodes);\n",[27,974,975],{"class":29,"line":530},[27,976,977],{"class":102},"      }\n",[27,979,980,983],{"class":29,"line":539},[27,981,982],{"class":102},"    : ",[27,984,985],{"class":37},"undefined\n",[27,987,988],{"class":29,"line":544},[27,989,990],{"class":102},"}\n",[10,992,993,994,997],{},"它把组内节点连同",[213,995,996],{},"祖先闭包","一起传了过去。",[10,999,1000,1001,1004],{},"后端的复用逻辑是这样的：",[24,1002,1003],{},"reuseCandidates = runSet − targetSet","。已经在目标集合里的节点不会被复用，要重新执行。祖先一旦也算目标，复用候选就成了空集——本来该被复用的上游，全部重跑。",[10,1006,1007],{},"而那 10 张分镜图正是祖先。",[10,1009,1010],{},"修法是把这一步交回给后端：",[17,1012,1014],{"className":920,"code":1013,"language":922,"meta":22,"style":22},"? () => {\n    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n    onRunTargets(group.nodeIds);\n  }\n",[24,1015,1016,1030,1035,1040,1045,1050,1055,1063],{"__ignoreMap":22},[27,1017,1018,1021,1024,1027],{"class":29,"line":30},[27,1019,1020],{"class":57},"?",[27,1022,1023],{"class":102}," () ",[27,1025,1026],{"class":57},"=>",[27,1028,1029],{"class":102}," {\n",[27,1031,1032],{"class":29,"line":45},[27,1033,1034],{"class":70},"    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n",[27,1036,1037],{"class":29,"line":67},[27,1038,1039],{"class":70},"    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n",[27,1041,1042],{"class":29,"line":423},[27,1043,1044],{"class":70},"    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n",[27,1046,1047],{"class":29,"line":429},[27,1048,1049],{"class":70},"    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n",[27,1051,1052],{"class":29,"line":530},[27,1053,1054],{"class":70},"    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n",[27,1056,1057,1060],{"class":29,"line":539},[27,1058,1059],{"class":33},"    onRunTargets",[27,1061,1062],{"class":102},"(group.nodeIds);\n",[27,1064,1065],{"class":29,"line":544},[27,1066,1067],{"class":102},"  }\n",[10,1069,1070,1071,1074],{},"祖先闭包本来就由后端的 ",[24,1072,1073],{},"pruneFlowForTargets"," 算。前端多算一遍，算的不只是重复劳动，而是一个语义不同的集合：后端要的是「用户想跑的」，前端给的是「跑这个需要的」。",[10,1076,1077],{},"补的守卫测试很直白——组件层的回调只能接收一个组 ID：",[17,1079,1081],{"className":920,"code":1080,"language":922,"meta":22,"style":22},"it(\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\", () => {\n  fireEvent.click(screen.getByRole(\"button\", { name: \"整组执行\" }));\n  expect(onRunGroup).toHaveBeenCalledWith(mockGroup.id);\n  expect(onRunGroup.mock.calls[0]).toHaveLength(1);\n});\n",[24,1082,1083,1101,1129,1143,1167],{"__ignoreMap":22},[27,1084,1085,1088,1091,1094,1097,1099],{"class":29,"line":30},[27,1086,1087],{"class":33},"it",[27,1089,1090],{"class":102},"(",[27,1092,1093],{"class":41},"\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\"",[27,1095,1096],{"class":102},", () ",[27,1098,1026],{"class":57},[27,1100,1029],{"class":102},[27,1102,1103,1106,1109,1112,1115,1117,1120,1123,1126],{"class":29,"line":45},[27,1104,1105],{"class":102},"  fireEvent.",[27,1107,1108],{"class":33},"click",[27,1110,1111],{"class":102},"(screen.",[27,1113,1114],{"class":33},"getByRole",[27,1116,1090],{"class":102},[27,1118,1119],{"class":41},"\"button\"",[27,1121,1122],{"class":102},", { name: ",[27,1124,1125],{"class":41},"\"整组执行\"",[27,1127,1128],{"class":102}," }));\n",[27,1130,1131,1134,1137,1140],{"class":29,"line":67},[27,1132,1133],{"class":33},"  expect",[27,1135,1136],{"class":102},"(onRunGroup).",[27,1138,1139],{"class":33},"toHaveBeenCalledWith",[27,1141,1142],{"class":102},"(mockGroup.id);\n",[27,1144,1145,1147,1150,1153,1156,1159,1161,1164],{"class":29,"line":423},[27,1146,1133],{"class":33},[27,1148,1149],{"class":102},"(onRunGroup.mock.calls[",[27,1151,1152],{"class":37},"0",[27,1154,1155],{"class":102},"]).",[27,1157,1158],{"class":33},"toHaveLength",[27,1160,1090],{"class":102},[27,1162,1163],{"class":37},"1",[27,1165,1166],{"class":102},");\n",[27,1168,1169],{"class":29,"line":429},[27,1170,1171],{"class":102},"});\n",[10,1173,1174,1175,1178],{},"同一处 ",[24,1176,1177],{},"ancestorClosure"," 在运行面板里保留着，那里是用它显示「运行 N 个（复用 M 个）」的，不参与提交。",[218,1180,1182],{"id":1181},"二落库的行对不上","二、落库的行对不上",[10,1184,1185],{},"第二处成因更早，也更深。",[10,1187,1188,1189,1192],{},"画布运行会把每个节点展开成若干行写入 ",[24,1190,1191],{},"CanvasFlowNodeRun","。批量框要求一个节点跑 N 份，所以展开后的行需要一个复合标识：",[17,1194,1198],{"className":1195,"code":1196,"language":1197,"meta":22,"style":22},"language-ts shiki shiki-themes github-light github-dark","const nodeRuns = expandedNodes.map((node) => ({\n  id: generateNodeRunId(),\n  runId,\n  nodeId: node.nodeId,   \u002F\u002F 展开后的复合 id\n  batchIndex: node.batchIndex,\n  \u002F\u002F ...\n}))\n","ts",[24,1199,1200,1232,1243,1248,1256,1261,1266],{"__ignoreMap":22},[27,1201,1202,1205,1208,1211,1214,1217,1220,1224,1227,1229],{"class":29,"line":30},[27,1203,1204],{"class":57},"const",[27,1206,1207],{"class":37}," nodeRuns",[27,1209,1210],{"class":57}," =",[27,1212,1213],{"class":102}," expandedNodes.",[27,1215,1216],{"class":33},"map",[27,1218,1219],{"class":102},"((",[27,1221,1223],{"class":1222},"s4XuR","node",[27,1225,1226],{"class":102},") ",[27,1228,1026],{"class":57},[27,1230,1231],{"class":102}," ({\n",[27,1233,1234,1237,1240],{"class":29,"line":45},[27,1235,1236],{"class":102},"  id: ",[27,1238,1239],{"class":33},"generateNodeRunId",[27,1241,1242],{"class":102},"(),\n",[27,1244,1245],{"class":29,"line":67},[27,1246,1247],{"class":102},"  runId,\n",[27,1249,1250,1253],{"class":29,"line":423},[27,1251,1252],{"class":102},"  nodeId: node.nodeId,   ",[27,1254,1255],{"class":70},"\u002F\u002F 展开后的复合 id\n",[27,1257,1258],{"class":29,"line":429},[27,1259,1260],{"class":102},"  batchIndex: node.batchIndex,\n",[27,1262,1263],{"class":29,"line":530},[27,1264,1265],{"class":70},"  \u002F\u002F ...\n",[27,1267,1268],{"class":29,"line":539},[27,1269,1270],{"class":102},"}))\n",[10,1272,1273,1274,1277,1278,1281,1282,216],{},"问题在这张表的唯一约束是 ",[24,1275,1276],{},"@@unique([runId, nodeId, batchIndex])","，而 ",[24,1279,1280],{},"nodeId"," 落的是",[213,1283,1284],{},"展开后的复合 id",[10,1286,1287,1288,1291],{},"于是 executor 那一侧全线对不上。它按 ",[24,1289,1290],{},"nodeId + batchIndex"," 反查行、统计每份的项数、构建调度状态——落库的键和它查的键不是一套。",[10,1293,1294],{},"调度器找不到已派发的记录，于是重复派发同一个批量单元。周期的量级是 100 毫秒。",[10,1296,1297],{},"修法是把复合 id 留在内存里，落库只落原始 ID：",[17,1299,1301],{"className":1195,"code":1300,"language":1197,"meta":22,"style":22},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[24,1302,1303,1308,1313],{"__ignoreMap":22},[27,1304,1305],{"class":29,"line":30},[27,1306,1307],{"class":70},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n",[27,1309,1310],{"class":29,"line":45},[27,1311,1312],{"class":70},"\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n",[27,1314,1315],{"class":29,"line":67},[27,1316,1317],{"class":70},"\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[10,1319,1320],{},"配套改了 executor 三处状态写库的定位方式，并在 ready 循环里加了一层 in-flight 防重派兜底，防止同类问题再犯。",[218,1322,1324],{"id":1323},"三为什么单测全绿","三、为什么单测全绿",[10,1326,1327],{},"这一处的成因能藏住，是因为测试的写法。",[10,1329,1330],{},"批量链路的每个环节都有自己的单测：创建函数有自己的用例，executor 也有。两组用例各自手写 fixture——创建函数的用例断言它写出了正确的行，executor 的用例喂给它一组正确的行，断言它正确调度。两组都绿。",[10,1332,1333],{},"错的正是「创建函数写出的行」和「executor 期望的行」之间的那个接口。",[10,1335,1336],{},"修的时候补了一个贯通测试环境，理由写在文件头：",[17,1338,1340],{"className":1195,"code":1339,"language":1197,"meta":22,"style":22},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[24,1341,1342,1347],{"__ignoreMap":22},[27,1343,1344],{"class":29,"line":30},[27,1345,1346],{"class":102},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n",[27,1348,1349],{"class":29,"line":45},[27,1350,1351],{"class":102},"中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[10,1353,1354,1355,1358],{},"新的契约测试从创建一路跑到执行，中间不插桩。断言的是端到端的账：",[24,1356,1357],{},"itemCount=3"," 时，成员 3 行、每份恰好执行一次。",[218,1360,1362],{"id":1361},"四项数从哪来","四、项数从哪来",[10,1364,1365],{},"修完上面两处，还有一个数对不上：预估和实扣。",[10,1367,1368],{},"批量框跑几份，原先是从上游推断的：",[17,1370,1372],{"className":1195,"code":1371,"language":1197,"meta":22,"style":22},"if (upstreamNode.nodeDefId === 'material.input') {\n  const count = (upstreamNode.params as any)?.count ?? 1;\n  \u002F\u002F ...\n}\n",[24,1373,1374,1391,1422,1426],{"__ignoreMap":22},[27,1375,1376,1379,1382,1385,1388],{"class":29,"line":30},[27,1377,1378],{"class":57},"if",[27,1380,1381],{"class":102}," (upstreamNode.nodeDefId ",[27,1383,1384],{"class":57},"===",[27,1386,1387],{"class":41}," 'material.input'",[27,1389,1390],{"class":102},") {\n",[27,1392,1393,1396,1399,1401,1404,1407,1410,1413,1416,1419],{"class":29,"line":45},[27,1394,1395],{"class":57},"  const",[27,1397,1398],{"class":37}," count",[27,1400,1210],{"class":57},[27,1402,1403],{"class":102}," (upstreamNode.params ",[27,1405,1406],{"class":57},"as",[27,1408,1409],{"class":37}," any",[27,1411,1412],{"class":102},")?.count ",[27,1414,1415],{"class":57},"??",[27,1417,1418],{"class":37}," 1",[27,1420,1421],{"class":102},";\n",[27,1423,1424],{"class":29,"line":67},[27,1425,1265],{"class":70},[27,1427,1428],{"class":29,"line":423},[27,1429,990],{"class":102},[10,1431,1432,1433,1436,1437,1440],{},"注册表里从来没有 ",[24,1434,1435],{},"material.input"," 这个节点——真名是 ",[24,1438,1439],{},"asset.input","。这个分支从未命中过，项数永远回落 1。",[10,1442,1443],{},"改成框上显式配置：",[17,1445,1447],{"className":1195,"code":1446,"language":1197,"meta":22,"style":22},"\u002F**\n * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n * 手动份数是当前唯一的项数来源。\n *\u002F\nitemCount: z.number().int().min(1).max(50).optional(),\n",[24,1448,1449,1454,1459,1464,1469,1474],{"__ignoreMap":22},[27,1450,1451],{"class":29,"line":30},[27,1452,1453],{"class":70},"\u002F**\n",[27,1455,1456],{"class":29,"line":45},[27,1457,1458],{"class":70}," * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n",[27,1460,1461],{"class":29,"line":67},[27,1462,1463],{"class":70}," * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n",[27,1465,1466],{"class":29,"line":423},[27,1467,1468],{"class":70}," * 手动份数是当前唯一的项数来源。\n",[27,1470,1471],{"class":29,"line":429},[27,1472,1473],{"class":70}," *\u002F\n",[27,1475,1476,1479,1482,1485,1488,1491,1493,1496,1498,1500,1503,1506,1508,1511,1513,1516],{"class":29,"line":530},[27,1477,1478],{"class":33},"itemCount",[27,1480,1481],{"class":102},": z.",[27,1483,1484],{"class":33},"number",[27,1486,1487],{"class":102},"().",[27,1489,1490],{"class":33},"int",[27,1492,1487],{"class":102},[27,1494,1495],{"class":33},"min",[27,1497,1090],{"class":102},[27,1499,1163],{"class":37},[27,1501,1502],{"class":102},").",[27,1504,1505],{"class":33},"max",[27,1507,1090],{"class":102},[27,1509,1510],{"class":37},"50",[27,1512,1502],{"class":102},[27,1514,1515],{"class":33},"optional",[27,1517,1242],{"class":102},[10,1519,1520],{},"同时把预估也切到同一个来源：",[17,1522,1524],{"className":1195,"code":1523,"language":1197,"meta":22,"style":22},"\u002F**\n * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n *\u002F\nexport function batchMultipliersFromFlow(flow: CanvasFlow): Record\u003Cstring, number> {\n",[24,1525,1526,1530,1535,1540,1544],{"__ignoreMap":22},[27,1527,1528],{"class":29,"line":30},[27,1529,1453],{"class":70},[27,1531,1532],{"class":29,"line":45},[27,1533,1534],{"class":70}," * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n",[27,1536,1537],{"class":29,"line":67},[27,1538,1539],{"class":70}," * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n",[27,1541,1542],{"class":29,"line":423},[27,1543,1473],{"class":70},[27,1545,1546,1549,1552,1555,1557,1560,1563,1566,1569,1571,1574,1576,1579,1581,1583],{"class":29,"line":429},[27,1547,1548],{"class":57},"export",[27,1550,1551],{"class":57}," function",[27,1553,1554],{"class":33}," batchMultipliersFromFlow",[27,1556,1090],{"class":102},[27,1558,1559],{"class":1222},"flow",[27,1561,1562],{"class":57},":",[27,1564,1565],{"class":33}," CanvasFlow",[27,1567,1568],{"class":102},")",[27,1570,1562],{"class":57},[27,1572,1573],{"class":33}," Record",[27,1575,115],{"class":102},[27,1577,1578],{"class":37},"string",[27,1580,946],{"class":102},[27,1582,1484],{"class":37},[27,1584,1585],{"class":102},"> {\n",[10,1587,1588],{},"创建、预估、重试三个路由统一用这个函数。",[10,1590,1591,1592,1595],{},"同一轮里还修了预估的一个老问题：视频节点的预估用的是后台的一口价资源键 ",[24,1593,1594],{},"canvas_video_generate","，而执行侧用的是「模型 + 分辨率 + 是否带上游视频」算出的键，按秒计价。两套键，两套价——预估和实扣自然对不上。预估改用执行同款键。",[10,1597,1598],{},"以及一处显眼的占位：",[17,1600,1602],{"className":1195,"code":1601,"language":1197,"meta":22,"style":22},"\u002F\u002F 改前\nconst totalEstimatedCost = 0; \u002F\u002F TODO: Pass in actual estimate\n",[24,1603,1604,1609],{"__ignoreMap":22},[27,1605,1606],{"class":29,"line":30},[27,1607,1608],{"class":70},"\u002F\u002F 改前\n",[27,1610,1611,1613,1616,1618,1621,1624],{"class":29,"line":45},[27,1612,1204],{"class":57},[27,1614,1615],{"class":37}," totalEstimatedCost",[27,1617,1210],{"class":57},[27,1619,1620],{"class":37}," 0",[27,1622,1623],{"class":102},"; ",[27,1625,1626],{"class":70},"\u002F\u002F TODO: Pass in actual estimate\n",[10,1628,1629],{},"运行记录里的预估成本一直是 0。",[218,1631,1633],{"id":1632},"五续跑为什么不重复扣费","五、续跑为什么不重复扣费",[10,1635,1636],{},"同一轮加了单节点重试。既然重复扣费是这个月的主线，这条功能的实现方式值得记下来。",[10,1638,1639],{},"失败运行的续跑不是「重新跑一遍」：",[17,1641,1643],{"className":1195,"code":1642,"language":1197,"meta":22,"style":22},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[24,1644,1645,1649,1654,1659,1664,1669,1674],{"__ignoreMap":22},[27,1646,1647],{"class":29,"line":30},[27,1648,1453],{"class":70},[27,1650,1651],{"class":29,"line":45},[27,1652,1653],{"class":70}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[27,1655,1656],{"class":29,"line":67},[27,1657,1658],{"class":70}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[27,1660,1661],{"class":29,"line":423},[27,1662,1663],{"class":70}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[27,1665,1666],{"class":29,"line":429},[27,1667,1668],{"class":70}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[27,1670,1671],{"class":29,"line":530},[27,1672,1673],{"class":70}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[27,1675,1676],{"class":29,"line":539},[27,1677,1473],{"class":70},[10,1679,1680,1681,1684,1685,1688],{},"关键在于复用判定落在",[213,1682,1683],{},"行状态","上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带上原来的 ",[24,1686,1687],{},"billingRef","，调度器看到它就不再执行。",[10,1690,1691],{},"预估也跟着只算子集，余额检查同理。",[10,1693,1694,1695,1698,1699,1702],{},"同一次改动里还补了一个幂等入口——按 ",[24,1696,1697],{},"userId + clientRequestId"," 查重，重复提交返回已有的运行 ID。归属校验用带 ",[24,1700,1701],{},"userId"," 的查询。",[10,1704,1705],{},"界面文案也改了，从「重试」改成「重试失败节点」，带一句说明：",[1707,1708,1709],"blockquote",{},[10,1710,1711],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[218,1713,1715],{"id":1714},"六重复扣费这道题","六、重复扣费这道题",[10,1717,1718],{},"两处成因的形式完全不同：",[786,1720,1721,1728],{},[329,1722,1723,1724,1727],{},"一处是",[213,1725,1726],{},"前端算了一个它不该算的集合","。祖先闭包的计算本身没错，错在把它当成了「目标」。",[329,1729,1723,1730,1733],{},[213,1731,1732],{},"落库的键和查库的键不是一套","。两边各自都有定义，各自都有测试，只是没人对着比过一次。",[10,1735,1736],{},"两条链路的共同点是：扣费发生的时刻离「用户点了什么」很远。用户在界面上点的是一个按钮，扣费发生在调度器认为某个节点未被执行的那一刻。中间隔着目标集合的计算、行的展开、行的落库、调度状态的重建。",[10,1738,1739],{},"链路上每一段都「合理」，叠起来就是收两次钱。",[10,1741,1742,1743,1746],{},"防守这类问题的办法不是加校验，是",[213,1744,1745],{},"把口径收成一份","：",[786,1748,1749,1752,1755,1758],{},[329,1750,1751],{},"目标集合由后端算，前端只传用户选了什么。",[329,1753,1754],{},"批量份数由框上配置，预估与执行读同一个函数。",[329,1756,1757],{},"复用与否由行状态决定，续跑沿用这套判定，不加例外。",[329,1759,1760],{},"贯通测试不允许手写 fixture——两侧各自造数据，就只能测出两侧各自的正确性。",[10,1762,1763,1764,1767],{},"最后那条是这次最实在的收获。一组测试全绿但系统是错的，通常不是因为用例写得不好，而是因为",[213,1765,1766],{},"用例的输入各自构造","。第三方造的数据一定和另一方一致，因为它们都来自同一个人的同一份理解；真实的接口不一致，恰恰是因为两边由不同的人、在不同的时间实现。",[706,1769,1770],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":22,"searchDepth":45,"depth":45,"links":1772},[1773,1774,1775,1776,1777,1778],{"id":913,"depth":45,"text":914},{"id":1181,"depth":45,"text":1182},{"id":1323,"depth":45,"text":1324},{"id":1361,"depth":45,"text":1362},{"id":1632,"depth":45,"text":1633},{"id":1714,"depth":45,"text":1715},"2026-08-31",{},"\u002F2026-08-31-fa-024",{"title":891,"description":896},"FA-024","2026-08-31-FA-024-同一批活儿扣了两遍钱","点「批量生视频」把 10 张已经出好的分镜图整套重跑并再次扣费。两处独立成因：前端把祖先闭包当目标传给后端，后端落库的行 ID 与唯一约束不一致导致重复派发。",[1787,1788,1789,1790,1791,1792],"画布","批量执行","幂等","重复扣费","契约测试","计费","ajZtLrM5ge42AeND6nbuv6CH5k69juZtT88VhFe_hNc",{"id":1795,"title":1796,"body":1797,"column":717,"date":2471,"description":1801,"extension":719,"hero_image":720,"meta":2472,"navigation":507,"path":2473,"seo":2474,"series_id":2475,"severity":720,"stem":2476,"summary":2477,"tags":2478,"__hash__":2483},"posts\u002F2026-08-28-FA-023-图是好的链接死了.md","图是好的，链接死了",{"type":7,"value":1798,"toc":2464},[1799,1802,1805,1808,1812,1815,1821,1824,1827,1833,1836,1843,1846,1897,1900,1903,1907,1910,1913,1981,1984,1990,2037,2040,2044,2047,2050,2056,2059,2159,2166,2169,2248,2259,2265,2269,2272,2278,2281,2286,2289,2362,2368,2375,2378,2382,2388,2399,2402,2458,2461],[10,1800,1801],{},"画布开着十几分钟之后，上面的素材图集体变成「图片加载失败」。",[10,1803,1804],{},"刷新能好，但好十几分钟，然后再裂。用户以为是网络问题，我们一开始也这么以为——直到发现刷新只是换来另一条同样会死的链接。",[10,1806,1807],{},"三处独立的问题叠在一起。",[218,1809,1811],{"id":1810},"一每请求重建一次签名客户端","一、每请求重建一次签名客户端",[10,1813,1814],{},"起因是压测。素材库场景下测图片端点的吞吐：",[17,1816,1819],{"className":1817,"code":1818,"language":229},[227],"素材库场景压测（100 用户 × 24 张图）实测 \u002Fapi\u002Fmedia 卡在约 1400 req\u002Fs，\n同等并发下 \u002Fhealth 有 4100 req\u002Fs。\n",[24,1820,1818],{"__ignoreMap":22},[10,1822,1823],{},"差了三倍。定位到签名逻辑：每次请求都新建一个 S3 客户端。",[10,1825,1826],{},"对比两种写法：",[17,1828,1831],{"className":1829,"code":1830,"language":229},[227],"每请求新建 client + 签名  1.223ms\u002F次 → 818 次\u002F秒\u002F核\n复用 client 只签名        0.385ms\u002F次 → 2598 次\u002F秒\u002F核\n",[24,1832,1830],{"__ignoreMap":22},[10,1834,1835],{},"818 × 2 个 api 副本约等于 1636，与实测的 1400 吻合。",[10,1837,1838,1839,1842],{},"差的那部分不在构造函数上。构造函数本身只要 0.069ms，剩下的一毫秒多花在",[213,1840,1841],{},"首次签名","：新客户端要重新解析中间件栈、区域、凭证链，复用之后这些被记住。",[10,1844,1845],{},"改法是缓存，但缓存键不是「无脑单例」：",[17,1847,1849],{"className":1195,"code":1848,"language":1197,"meta":22,"style":22},"签名客户端按配置缓存。素材库一屏 24 张图，每张都重建 client 时实测 1.223ms\u002F次，\n复用后 0.385ms\u002F次——快 3.2 倍。\n\n按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n生产改配置不生效，测试之间也会互相污染。\n",[24,1850,1851,1867,1883,1887,1892],{"__ignoreMap":22},[27,1852,1853,1856,1859,1862,1864],{"class":29,"line":30},[27,1854,1855],{"class":102},"签名客户端按配置缓存。素材库一屏 ",[27,1857,1858],{"class":37},"24",[27,1860,1861],{"class":102}," 张图，每张都重建 client 时实测 1.223ms",[27,1863,124],{"class":57},[27,1865,1866],{"class":102},"次，\n",[27,1868,1869,1872,1874,1877,1880],{"class":29,"line":45},[27,1870,1871],{"class":102},"复用后 0.385ms",[27,1873,124],{"class":57},[27,1875,1876],{"class":102},"次——快 ",[27,1878,1879],{"class":37},"3.2",[27,1881,1882],{"class":102}," 倍。\n",[27,1884,1885],{"class":29,"line":67},[27,1886,508],{"emptyLinePlaceholder":507},[27,1888,1889],{"class":29,"line":423},[27,1890,1891],{"class":102},"按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n",[27,1893,1894],{"class":29,"line":429},[27,1895,1896],{"class":102},"生产改配置不生效，测试之间也会互相污染。\n",[10,1898,1899],{},"键包含公开域名、端点、区域、桶名、路径风格、访问凭证。反向验证过：注入一个裸单例，三条测试转红。",[10,1901,1902],{},"这一处只解决吞吐，不解决裂图。",[218,1904,1906],{"id":1905},"二前端把签名-url-当永久地址存","二、前端把签名 URL 当永久地址存",[10,1908,1909],{},"裂图的直接原因在前端。",[10,1911,1912],{},"签名 URL 的有效期是 15 分钟（900 秒）。后端每次都现签，这个行为是对的。错的是前端把签好的 URL 存进了缓存，且没有有效期：",[17,1914,1916],{"className":1195,"code":1915,"language":1197,"meta":22,"style":22},"\u002F**\n * 签名 URL 的缓存寿命。\n *\n * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n * 真正加载完」这段时间的。\n * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n *\u002F\nconst URL_CACHE_TTL_MS = 8 * 60 * 1000;\n",[24,1917,1918,1922,1927,1932,1937,1942,1947,1952,1956],{"__ignoreMap":22},[27,1919,1920],{"class":29,"line":30},[27,1921,1453],{"class":70},[27,1923,1924],{"class":29,"line":45},[27,1925,1926],{"class":70}," * 签名 URL 的缓存寿命。\n",[27,1928,1929],{"class":29,"line":67},[27,1930,1931],{"class":70}," *\n",[27,1933,1934],{"class":29,"line":423},[27,1935,1936],{"class":70}," * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n",[27,1938,1939],{"class":29,"line":429},[27,1940,1941],{"class":70}," * 真正加载完」这段时间的。\n",[27,1943,1944],{"class":29,"line":530},[27,1945,1946],{"class":70}," * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n",[27,1948,1949],{"class":29,"line":539},[27,1950,1951],{"class":70}," * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n",[27,1953,1954],{"class":29,"line":544},[27,1955,1473],{"class":70},[27,1957,1958,1960,1963,1965,1968,1971,1974,1976,1979],{"class":29,"line":550},[27,1959,1204],{"class":57},[27,1961,1962],{"class":37}," URL_CACHE_TTL_MS",[27,1964,1210],{"class":57},[27,1966,1967],{"class":37}," 8",[27,1969,1970],{"class":57}," *",[27,1972,1973],{"class":37}," 60",[27,1975,1970],{"class":57},[27,1977,1978],{"class":37}," 1000",[27,1980,1421],{"class":102},[10,1982,1983],{},"8 分钟：服务端签 15 分钟，前端只存一半多，剩下的留给加载。",[10,1985,1986,1987,1746],{},"光靠 TTL 还不够。页面可能在后台挂着很久，计时器不准；而且用户的操作序列无法预判。所以补第二个机制——",[213,1988,1989],{},"把裂图当作过期信号",[17,1991,1993],{"className":1195,"code":1992,"language":1197,"meta":22,"style":22},"图片加载失败时调用：作废该 URL 的缓存并重新现签一次。\n签名 URL 会过期，光靠 TTL 猜不准（页面可能在后台挂很久）。裂图本身\n就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n同一个 URL 只重试一次，避免真·坏图把请求打成死循环。\n",[24,1994,1995,2006,2022,2027],{"__ignoreMap":22},[27,1996,1997,2000,2003],{"class":29,"line":30},[27,1998,1999],{"class":102},"图片加载失败时调用：作废该 ",[27,2001,2002],{"class":37},"URL",[27,2004,2005],{"class":102}," 的缓存并重新现签一次。\n",[27,2007,2008,2011,2013,2016,2019],{"class":29,"line":45},[27,2009,2010],{"class":102},"签名 ",[27,2012,2002],{"class":37},[27,2014,2015],{"class":102}," 会过期，光靠 ",[27,2017,2018],{"class":37},"TTL",[27,2020,2021],{"class":102}," 猜不准（页面可能在后台挂很久）。裂图本身\n",[27,2023,2024],{"class":29,"line":67},[27,2025,2026],{"class":102},"就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n",[27,2028,2029,2032,2034],{"class":29,"line":423},[27,2030,2031],{"class":102},"同一个 ",[27,2033,2002],{"class":37},[27,2035,2036],{"class":102}," 只重试一次，避免真·坏图把请求打成死循环。\n",[10,2038,2039],{},"一个 URL 只重试一次。真的坏图不能把请求打成死循环——测试里连续上报 5 次加载失败，断言请求数不超过 2。",[218,2041,2043],{"id":2042},"三面向浏览器的链路签了-15-分钟的直链","三、面向浏览器的链路，签了 15 分钟的直链",[10,2045,2046],{},"做到这里，症状没了。但同一个坑之前已经踩过一次，这一次是第三次。",[10,2048,2049],{},"前两次都在画布上：",[17,2051,2054],{"className":2052,"code":2053,"language":229},[227],"线上报障：画布上素材图集体「图片加载失败」。根因是这个端点返回了\nresolveAssetUrl 签的 15 分钟 S3 直链，而不是 \u002Fapi\u002Fmedia 稳定路径。\n只断言「有 url 字段」的用例抓不到这种错，必须钉住 URL 的形态。\n",[24,2055,2053],{"__ignoreMap":22},[10,2057,2058],{},"另一次在画布运行产物：",[17,2060,2062],{"className":1195,"code":2061,"language":1197,"meta":22,"style":22},"-    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n-    signObjectUrl: (objectKey: string) => createPrivateObjectReadUrl(objectKey),\n+    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n+    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n+    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n+    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n+    signObjectUrl: (objectKey: string) => stableAssetUrl(objectKey, ''),\n",[24,2063,2064,2072,2100,2108,2115,2122,2129],{"__ignoreMap":22},[27,2065,2066,2069],{"class":29,"line":30},[27,2067,2068],{"class":57},"-",[27,2070,2071],{"class":70},"    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n",[27,2073,2074,2076,2079,2082,2085,2087,2090,2092,2094,2097],{"class":29,"line":45},[27,2075,2068],{"class":57},[27,2077,2078],{"class":33},"    signObjectUrl",[27,2080,2081],{"class":102},": (",[27,2083,2084],{"class":1222},"objectKey",[27,2086,1562],{"class":57},[27,2088,2089],{"class":37}," string",[27,2091,1226],{"class":102},[27,2093,1026],{"class":57},[27,2095,2096],{"class":33}," createPrivateObjectReadUrl",[27,2098,2099],{"class":102},"(objectKey),\n",[27,2101,2102,2105],{"class":29,"line":67},[27,2103,2104],{"class":57},"+",[27,2106,2107],{"class":70},"    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n",[27,2109,2110,2112],{"class":29,"line":423},[27,2111,2104],{"class":57},[27,2113,2114],{"class":70},"    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n",[27,2116,2117,2119],{"class":29,"line":429},[27,2118,2104],{"class":57},[27,2120,2121],{"class":70},"    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n",[27,2123,2124,2126],{"class":29,"line":530},[27,2125,2104],{"class":57},[27,2127,2128],{"class":70},"    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n",[27,2130,2131,2133,2135,2137,2139,2141,2143,2145,2147,2150,2153,2156],{"class":29,"line":539},[27,2132,2104],{"class":57},[27,2134,2078],{"class":33},[27,2136,2081],{"class":102},[27,2138,2084],{"class":1222},[27,2140,1562],{"class":57},[27,2142,2089],{"class":37},[27,2144,1226],{"class":102},[27,2146,1026],{"class":57},[27,2148,2149],{"class":33}," stableAssetUrl",[27,2151,2152],{"class":102},"(objectKey, ",[27,2154,2155],{"class":41},"''",[27,2157,2158],{"class":102},"),\n",[10,2160,2161,2162,2165],{},"最后那句是这次的实质收获。「读时现签」听起来已经解决了过期问题——每次读都是新的。但签出来的东西只活 15 分钟，而浏览器渲染 ",[24,2163,2164],{},"\u003Cimg>"," 的 URL 会被页面持有到下一次刷新。两个时间尺度不匹配。",[10,2167,2168],{},"真正的修法是给「吐给浏览器」这条路单独一档地址，不再用签名直链，改走后端代理的稳定路径：",[17,2170,2172],{"className":1195,"code":2171,"language":1197,"meta":22,"style":22},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n\u002F\u002F\n\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\nexport const MEDIA_URL_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n",[24,2173,2174,2179,2184,2189,2194,2199,2204,2209,2214],{"__ignoreMap":22},[27,2175,2176],{"class":29,"line":30},[27,2177,2178],{"class":70},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n",[27,2180,2181],{"class":29,"line":45},[27,2182,2183],{"class":70},"\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n",[27,2185,2186],{"class":29,"line":67},[27,2187,2188],{"class":70},"\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n",[27,2190,2191],{"class":29,"line":423},[27,2192,2193],{"class":70},"\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n",[27,2195,2196],{"class":29,"line":429},[27,2197,2198],{"class":70},"\u002F\u002F\n",[27,2200,2201],{"class":29,"line":530},[27,2202,2203],{"class":70},"\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n",[27,2205,2206],{"class":29,"line":539},[27,2207,2208],{"class":70},"\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n",[27,2210,2211],{"class":29,"line":544},[27,2212,2213],{"class":70},"\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\n",[27,2215,2216,2218,2221,2224,2226,2229,2231,2234,2236,2238,2240,2242,2244,2246],{"class":29,"line":550},[27,2217,1548],{"class":57},[27,2219,2220],{"class":57}," const",[27,2222,2223],{"class":37}," MEDIA_URL_TTL_MS",[27,2225,1210],{"class":57},[27,2227,2228],{"class":37}," 7",[27,2230,1970],{"class":57},[27,2232,2233],{"class":37}," 24",[27,2235,1970],{"class":57},[27,2237,1973],{"class":37},[27,2239,1970],{"class":57},[27,2241,1973],{"class":37},[27,2243,1970],{"class":57},[27,2245,1978],{"class":37},[27,2247,1421],{"class":102},[10,2249,2250,2251,2254,2255,2258],{},"路径形态是 ",[24,2252,2253],{},"\u002Fapi\u002Fmedia?key=…&exp=…&sig=…","。签名用 HMAC，不依赖登录态——",[24,2256,2257],{},"img"," 标签发不出 Authorization 头。",[10,2260,2261,2264],{},[24,2262,2263],{},"exp"," 对齐到天窗口这一条值得单独说：同一对象同一天内产出的 URL 字节级相同，浏览器的缓存能命中；而因为窗口按天滚动，任何时刻拿到的 URL 剩余有效期至少 6 天。",[218,2266,2268],{"id":2267},"四把规矩变成测试","四、把规矩变成测试",[10,2270,2271],{},"三档地址的分工写进了代码注释，放在存储工具的末尾：",[17,2273,2276],{"className":2274,"code":2275,"language":229},[227],"吐给浏览器（渲染 \u003Cimg>\u002F\u003Cvideo>）→ stableAssetUrl：返回 \u002Fapi\u002Fmedia 稳定路径，\n7 天有效，根治「页面长驻后签名过期裂图」\n交给上游拉取 → resolveUpstreamAssetUrl：6 小时\n本进程内立刻用 → resolveAssetUrl：15 分钟\n",[24,2277,2275],{"__ignoreMap":22},[10,2279,2280],{},"注释拦不住。这事已经栽过两次，两次的症状一模一样：",[1707,2282,2283],{},[10,2284,2285],{},"两次的症状一模一样：图刚打开好好的，画布放十几分钟就集体「图片加载失败」，刷新只是换来另一条同样 15 分钟后会死的链接，排查时极容易误判成前端缓存问题。",[10,2287,2288],{},"所以加了一条源码级断言。它不是接口测试——它读源码字符串，剥掉注释之后断言不该出现的函数名：",[17,2290,2292],{"className":1195,"code":2291,"language":1197,"meta":22,"style":22},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\nconst SHORT_LIVED_SIGNERS = [\"createPrivateObjectReadUrl\", \"resolveAssetUrl\"];\n\n\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\nconst BROWSER_FACING_FILES = [\n  \"canvas-flow\u002Frun-routes.ts\",\n  \"canvas-adapter\u002Fmaterial-url-route.ts\",\n];\n",[24,2293,2294,2299,2322,2326,2331,2343,2351,2358],{"__ignoreMap":22},[27,2295,2296],{"class":29,"line":30},[27,2297,2298],{"class":70},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\n",[27,2300,2301,2303,2306,2308,2311,2314,2316,2319],{"class":29,"line":45},[27,2302,1204],{"class":57},[27,2304,2305],{"class":37}," SHORT_LIVED_SIGNERS",[27,2307,1210],{"class":57},[27,2309,2310],{"class":102}," [",[27,2312,2313],{"class":41},"\"createPrivateObjectReadUrl\"",[27,2315,946],{"class":102},[27,2317,2318],{"class":41},"\"resolveAssetUrl\"",[27,2320,2321],{"class":102},"];\n",[27,2323,2324],{"class":29,"line":67},[27,2325,508],{"emptyLinePlaceholder":507},[27,2327,2328],{"class":29,"line":423},[27,2329,2330],{"class":70},"\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\n",[27,2332,2333,2335,2338,2340],{"class":29,"line":429},[27,2334,1204],{"class":57},[27,2336,2337],{"class":37}," BROWSER_FACING_FILES",[27,2339,1210],{"class":57},[27,2341,2342],{"class":102}," [\n",[27,2344,2345,2348],{"class":29,"line":530},[27,2346,2347],{"class":41},"  \"canvas-flow\u002Frun-routes.ts\"",[27,2349,2350],{"class":102},",\n",[27,2352,2353,2356],{"class":29,"line":539},[27,2354,2355],{"class":41},"  \"canvas-adapter\u002Fmaterial-url-route.ts\"",[27,2357,2350],{"class":102},[27,2359,2360],{"class":29,"line":544},[27,2361,2321],{"class":102},[10,2363,2364,2365,216],{},"再加一条断言钉住 URL 的形态：产出的地址里不能出现 ",[24,2366,2367],{},"X-Amz-Signature",[10,2369,2370,2371,2374],{},"这是这次修法里唯一带点非常规的做法：",[213,2372,2373],{},"用测试来守一条架构约定","。普通的测试守行为，这条守的是「哪个函数可以在哪里被调用」。",[10,2376,2377],{},"之所以需要它，是因为这类错误的三个特征叠在一起：编译期查不出（函数签名都一样）、单测查不出（旧用例只断言「有 url 字段」）、运行时查不出（图在头十几分钟是好的）。三个环节都不拦，只能靠一条读源码的断言。",[218,2379,2381],{"id":2380},"五时间尺度","五、时间尺度",[10,2383,2384,2385,216],{},"回头看，这三处其实是同一个问题在三个层面的表现：",[213,2386,2387],{},"链接的有效期和它的使用方式不匹配",[786,2389,2390,2393,2396],{},[329,2391,2392],{},"签名客户端：复用的时间尺度是「进程生命周期」，实现给的是「单次请求」。",[329,2394,2395],{},"前端缓存：URL 被持有的时间尺度是「页面停留时长」，缓存给的是「无限」。",[329,2397,2398],{},"直链：浏览器的取用方式是「随时重新加载」，签名给的是 15 分钟。",[10,2400,2401],{},"修完之后的取值是有依据的，不是拍的：",[2403,2404,2405,2421],"table",{},[2406,2407,2408],"thead",{},[2409,2410,2411,2415,2418],"tr",{},[2412,2413,2414],"th",{},"用途",[2412,2416,2417],{},"有效期",[2412,2419,2420],{},"依据",[2422,2423,2424,2436,2447],"tbody",{},[2409,2425,2426,2430,2433],{},[2427,2428,2429],"td",{},"本进程立刻取用",[2427,2431,2432],{},"15 分钟",[2427,2434,2435],{},"签完就 fetch，够用且泄露窗口小",[2409,2437,2438,2441,2444],{},[2427,2439,2440],{},"交给上游排队拉取",[2427,2442,2443],{},"6 小时",[2427,2445,2446],{},"实测排队 10~25 分钟才轮到上游下载",[2409,2448,2449,2452,2455],{},[2427,2450,2451],{},"浏览器渲染",[2427,2453,2454],{},"7 天（按天对齐）",[2427,2456,2457],{},"覆盖任意页面停留，且同一天 URL 相同可命中缓存",[10,2459,2460],{},"最后一条还有一层：它不走签名直链，走后端代理。这换来两个好处——不受对象存储域名可达性影响，也不受签名有效期约束。代价是流量过后端，但素材图不走这条路的代价更大。",[706,2462,2463],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":22,"searchDepth":45,"depth":45,"links":2465},[2466,2467,2468,2469,2470],{"id":1810,"depth":45,"text":1811},{"id":1905,"depth":45,"text":1906},{"id":2042,"depth":45,"text":2043},{"id":2267,"depth":45,"text":2268},{"id":2380,"depth":45,"text":2381},"2026-08-28",{},"\u002F2026-08-28-fa-023",{"title":1796,"description":1801},"FA-023","2026-08-28-FA-023-图是好的链接死了","画布上的素材图集体裂开，刷新只换来另一条同样会死的链接。三处独立问题：签名客户端每请求重建、前端把签名 URL 当永久地址存、面向浏览器的链路签了 15 分钟的直链。",[2479,2480,730,2481,1791,2482],"S3","签名URL","性能","对象存储","LNHg4QB5lztHWhSI-OvrOM4XrnhLpHHPMlNgjTOxMA4",1789212572909]