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