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