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