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