[{"data":1,"prerenderedAt":2288},["ShallowReactive",2],{"\u002F2026-04-12":3,"\u002F2026-04-12-rel":815},{"id":4,"title":5,"body":6,"column":798,"date":799,"description":12,"extension":800,"hero_image":801,"meta":802,"navigation":803,"path":804,"seo":805,"series_id":801,"severity":801,"stem":806,"summary":807,"tags":808,"__hash__":814},"posts\u002F2026-04-12-三端联调的顺序问题.md","三端并行开发，谁先接谁？",{"type":7,"value":8,"toc":786},"minimark",[9,13,17,20,23,41,44,71,82,85,90,96,102,108,122,127,137,140,144,149,154,159,170,174,203,208,241,252,374,377,381,386,391,395,403,407,418,422,462,473,483,503,506,510,515,520,524,539,543,554,558,597,600,614,617,620,652,657,660,755,758,761,775,782],[10,11,12],"p",{},"三端（后台、小程序、设备）并行开发时，联调顺序直接决定返工量。这里讲的是 ToyNet 项目的实践：为什么不能先接设备再接小程序，以及各阶段的桩数据策略。",[14,15,16],"h2",{"id":16},"为什么后台必须先行",[10,18,19],{},"后台 API 是唯一的事实源。小程序和设备都依赖后台数据，这种依赖关系不可逆转。",[10,21,22],{},"ToyNet 的架构很清楚：",[24,25,26,30,33],"ul",{},[27,28,29],"li",{},"后台提供 REST API（用户、商品、订单、设备管理）和 MQTT 消息队列（设备指令下发）",[27,31,32],{},"小程序是 API 消费者（调用 REST 接口）",[27,34,35,36,40],{},"设备是 MQTT 消费者（订阅 ",[37,38,39],"code",{},"toynet\u002Fdevice\u002F{deviceSn}\u002Fcommand"," 主题、执行命令、上报状态）",[10,42,43],{},"这意味着小程序开发的第一天就需要后台 API 存在。不是非得完全实现，但至少得有契约——定义好接口名、参数、返回值格式。设备也是一样，需要后台先把 MQTT 消息格式定下来。",[10,45,46,50,51,54,55,58,59,62,63,66,67,70],{},[47,48,49],"strong",{},"反序的代价","。如果先做设备再做小程序，会发生什么？小程序开发期间没有可读的数据。具体例子：小程序要显示\"设备列表\"和\"设备状态\"，调的是 ",[37,52,53],{},"\u002Fapi\u002Fv1\u002Fdevices\u002Flist","。如果后台这个接口还不存在，小程序工程师得猜测设备状态的格式——也许是 ",[37,56,57],{},"{ status: 'online' }","，也许是 ",[37,60,61],{},"{ online: true }","，也许还要加 ",[37,64,65],{},"battery"," 和 ",[37,68,69],{},"lastSeenAt","。用错了格式渲染页面，即使设备硬件工作正常，小程序也没法验证这个流程。几周后后台实现了，返回格式和小程序猜的不一样，小程序要改、测试、再验证设备——这一圈返工没有价值，本可以避免。",[10,72,73,74,77,78,81],{},"类似的问题还会出现在 MQTT 指令格式上。先做设备时，硬件工程师定义了命令格式，比如 ",[37,75,76],{},"{ action: 'play', audioId: 123 }","。后台做到这一步时，根据产品需求改成了 ",[37,79,80],{},"{ cmd: 'play_audio', params: { id: 123 } }","。小程序要跟着改，设备固件也要改。如果顺序对了，后台先定好格式，这些改动就不会出现。",[14,83,84],{"id":84},"可行的分阶段顺序",[86,87,89],"h3",{"id":88},"第1阶段后台自测","第1阶段：后台自测",[10,91,92,95],{},[47,93,94],{},"目标","：后台 6 个核心模块全部能跑，包括鉴权、设备管理、商品、订单、AI、MQTT。",[10,97,98,101],{},[47,99,100],{},"在 ToyNet 里的对应","：Plan 1–6（2026-04-09 到 2026-04-10）。",[10,103,104,107],{},[47,105,106],{},"桩数据策略","：",[24,109,110,113,116,119],{},[27,111,112],{},"用 Jest 单测 + 集成测试验证每个 service",[27,114,115],{},"MongoDB 用 mongodb-memory-server 在内存中启动，每次测试自动清理",[27,117,118],{},"不需要真实设备，MQTT broker 可以离线或用 mock 实现",[27,120,121],{},"test fixtures 里写好标准数据：用户对象、设备对象、订单对象，每个测试复用",[10,123,124,107],{},[47,125,126],{},"具体做法",[128,129,134],"pre",{"className":130,"code":132,"language":133},[131],"language-text","server\u002Ftests\u002Funit\u002Fmodules\u002Fdevices\u002Fdevices.service.test.js\nserver\u002Ftests\u002Fintegration\u002Fdevices.test.js\nnpm test -- --coverage\n","text",[37,135,132],{"__ignoreMap":136},"",[10,138,139],{},"验收标准是覆盖率 ≥80%，所有 API 路由至少走过一遍。这时候后台是独立的黑盒，没有外部依赖。",[86,141,143],{"id":142},"第2阶段后台小程序","第2阶段：后台+小程序",[10,145,146,148],{},[47,147,94],{},"：小程序能从后台读数据、写数据，覆盖游客浏览和登录用户操作。",[10,150,151,153],{},[47,152,100],{},"：Phase 1–3（2026-04-12 到 2026-04-13 期间）。",[10,155,156,107],{},[47,157,158],{},"依赖关系",[24,160,161,164,167],{},[27,162,163],{},"Phase 1（骨架）需要后台 Plan 1（基础：auth、middleware、request client）",[27,165,166],{},"Phase 2（首页+商品）需要后台 Plan 1 + Plan 4（products API）",[27,168,169],{},"Phase 3（下单+支付）需要后台 Plan 1 + Plan 4（orders\u002Fpayments API）",[10,171,172,107],{},[47,173,106],{},[24,175,176,179,182,189,200],{},[27,177,178],{},"后台完全真实：真实 MongoDB，真实 API 服务",[27,180,181],{},"小程序在微信开发者工具中：用假 WeChat openid（例如 test-user-001）写死在开发配置里，调用真实后台 API",[27,183,184,185,188],{},"支付模块：后台在测试环境下返回 mock 支付结果（检测 ",[37,186,187],{},"NODE_ENV=development"," 时跳过真实微信支付 SDK，直接返回 success）",[27,190,191,192,195,196,199],{},"认证用工具函数 ",[37,193,194],{},"requireLogin()","，小程序 service 的 ",[37,197,198],{},"request()"," 自动读 localStorage 里的 fake token，后台鉴权中间件识别这个 test token 就返回授权",[27,201,202],{},"不真实的数据（设备状态、MQTT 消息）用固定 fixture 返回",[10,204,205,107],{},[47,206,207],{},"具体流程",[209,210,211,218,224,231,238],"ol",{},[27,212,213,214,217],{},"启动后台 dev 服务器 ",[37,215,216],{},"npm run dev","，默认连接本地 MongoDB",[27,219,220,221],{},"打开微信开发者工具，小程序项目配置指向 ",[37,222,223],{},"http:\u002F\u002Flocalhost:3000",[27,225,226,227,230],{},"小程序登录页点\"授权\"，调后台 ",[37,228,229],{},"\u002Fauth\u002Flogin","，后台返回 token，小程序存入 localStorage",[27,232,233,234,237],{},"跳转首页，调 ",[37,235,236],{},"\u002Fproducts?limit=10","，后台返回商品列表，小程序渲染轮播图",[27,239,240],{},"找到接口缺失或返回格式不对，后台补齐、更新 Swagger 文档，小程序跟着改",[10,242,243,244,247,248,251],{},"这个阶段没有真实设备，完全是前后端的对接。设备相关的代码（比如 Phase 2 里的 ",[37,245,246],{},"services\u002Fdevices.ts"," 里的 ",[37,249,250],{},"getDeviceList()","）是空实现或返回 mock 数据，例如：",[128,253,257],{"className":254,"code":255,"language":256,"meta":136,"style":136},"language-typescript shiki shiki-themes github-light github-dark","export async function getDeviceList() {\n  if (process.env.NODE_ENV === 'test') {\n    return { list: [{ id: 'mock-dev-1', name: '玩具1', status: 'online' }], total: 1 }\n  }\n  return request({ url: '\u002Fdevices', skipAuth: false })\n}\n","typescript",[37,258,259,282,305,338,344,368],{"__ignoreMap":136},[260,261,264,268,271,274,278],"span",{"class":262,"line":263},"line",1,[260,265,267],{"class":266},"szBVR","export",[260,269,270],{"class":266}," async",[260,272,273],{"class":266}," function",[260,275,277],{"class":276},"sScJk"," getDeviceList",[260,279,281],{"class":280},"sVt8B","() {\n",[260,283,285,288,291,295,298,302],{"class":262,"line":284},2,[260,286,287],{"class":266},"  if",[260,289,290],{"class":280}," (process.env.",[260,292,294],{"class":293},"sj4cs","NODE_ENV",[260,296,297],{"class":266}," ===",[260,299,301],{"class":300},"sZZnC"," 'test'",[260,303,304],{"class":280},") {\n",[260,306,308,311,314,317,320,323,326,329,332,335],{"class":262,"line":307},3,[260,309,310],{"class":266},"    return",[260,312,313],{"class":280}," { list: [{ id: ",[260,315,316],{"class":300},"'mock-dev-1'",[260,318,319],{"class":280},", name: ",[260,321,322],{"class":300},"'玩具1'",[260,324,325],{"class":280},", status: ",[260,327,328],{"class":300},"'online'",[260,330,331],{"class":280}," }], total: ",[260,333,334],{"class":293},"1",[260,336,337],{"class":280}," }\n",[260,339,341],{"class":262,"line":340},4,[260,342,343],{"class":280},"  }\n",[260,345,347,350,353,356,359,362,365],{"class":262,"line":346},5,[260,348,349],{"class":266},"  return",[260,351,352],{"class":276}," request",[260,354,355],{"class":280},"({ url: ",[260,357,358],{"class":300},"'\u002Fdevices'",[260,360,361],{"class":280},", skipAuth: ",[260,363,364],{"class":293},"false",[260,366,367],{"class":280}," })\n",[260,369,371],{"class":262,"line":370},6,[260,372,373],{"class":280},"}\n",[10,375,376],{},"这样既能让页面渲染通过，又不依赖真实设备存在。",[86,378,380],{"id":379},"第3阶段后台设备","第3阶段：后台+设备",[10,382,383,385],{},[47,384,94],{},"：设备能接收后台指令、上报状态。小程序和设备可以不通信，但后台要能驱动设备。",[10,387,388,390],{},[47,389,100],{},"：Phase 4（设备详情、扫码绑定）。",[10,392,393,107],{},[47,394,158],{},[24,396,397,400],{},[27,398,399],{},"Phase 4 需要后台 Plan 3（devices\u002FMQTT 模块）",[27,401,402],{},"Phase 4 不需要 Phase 2\u002F3 完全完成，但需要后台 device 模型已定义",[10,404,405,107],{},[47,406,106],{},[24,408,409,412,415],{},[27,410,411],{},"后台：真实 MQTT broker（EMQX 或阿里云\u002F腾讯云 MQTT 服务），真实设备在线或可控上线",[27,413,414],{},"小程序：可以继续用假数据调试 UI，设备相关的 API 调用和页面暂时 mock 返回",[27,416,417],{},"关键路径是后台 → MQTT broker → 设备的指令下发和状态回复，这一段必须真实",[10,419,420,107],{},[47,421,207],{},[209,423,424,427,433,439],{},[27,425,426],{},"启动后台 MQTT 服务（连接真实 broker）",[27,428,429,430,432],{},"连接真实设备到 broker（设备订阅 ",[37,431,39],{}," 主题）",[27,434,435,436],{},"后台代码调用 ",[37,437,438],{},"publishDeviceCommand(deviceSn, { cmd: 'set_volume', params: { volume: 50 } })",[27,440,441,442],{},"验证：\n",[24,443,444,447,450,453,459],{},[27,445,446],{},"MQTT broker 收到消息",[27,448,449],{},"设备接收到消息",[27,451,452],{},"设备执行（音量调到 50）",[27,454,455,456],{},"设备上报反馈到后台 ",[37,457,458],{},"toynet\u002Fdevice\u002F{deviceSn}\u002Fstatus",[27,460,461],{},"后台记录状态变化",[10,463,464,465,468,469,472],{},"这个阶段会发现的问题通常是：MQTT topic 路径理解偏差（后台发到 ",[37,466,467],{},"device\u002F123\u002Fcmd","，设备监听的是 ",[37,470,471],{},"device\u002F123\u002Fcommand","）、命令参数类型错误（发的是 string 但设备期望 number）、设备离线处理逻辑不对、消息丢失重试机制缺失。这些问题不会影响后台+小程序的对接（那是上一阶段已验证的），只能来自设备硬件的理解差异。",[10,474,475,478,479,482],{},[47,476,477],{},"离线设备的处理","。Plan 3 设计了一个细节：设备离线时，",[37,480,481],{},"POST \u002Fdevices\u002F:id\u002Fcommand"," 仍然接受请求，返回 422 错误码 42240，告诉客户端\"设备离线，指令已入队但不保证送达\"。这样做是为了解耦小程序和设备的强依赖关系——小程序不用关心设备是否在线，后台负责队列和重试。真实测试时：",[209,484,485,488,491,494,497,500],{},[27,486,487],{},"准备一台设备，上线后发一条指令，确认执行成功",[27,489,490],{},"拔掉设备电源，让它离线",[27,492,493],{},"再发一条指令，应该收到 42240 错误，指令被入队",[27,495,496],{},"给设备重新通电，它上线",[27,498,499],{},"确认设备优先处理队列里的待发指令",[27,501,502],{},"验证指令执行顺序和完整性",[10,504,505],{},"这个测试不复杂，但很关键。它验证了后台的异步处理能力，这对小程序的用户体验很重要——用户不会在\"点了按钮设备没反应\"时尴尬，后台会自动重试。",[86,507,509],{"id":508},"第4阶段三端","第4阶段：三端",[10,511,512,514],{},[47,513,94],{},"：小程序操作 → 后台处理 → 设备执行 → 实时反馈给小程序，完整闭环。",[10,516,517,519],{},[47,518,100],{},"：Phase 5（录音上传、MQTT 推送到设备）。",[10,521,522,107],{},[47,523,158],{},[24,525,526,529,532],{},[27,527,528],{},"Phase 5 需要 Phase 4 的设备模块已稳定",[27,530,531],{},"需要后台 Plan 5（AI 对话）+ Plan 3（MQTT 服务）已完成",[27,533,534,535,538],{},"需要设备硬件支持 ",[37,536,537],{},"play_custom_audio"," 命令（可能需要固件升级，这是前期沟通清楚的）",[10,540,541,107],{},[47,542,106],{},[24,544,545,548,551],{},[27,546,547],{},"全真实：真实小程序用户、真实后台、真实 MQTT broker、真实设备",[27,549,550],{},"不用 mock，不用假数据",[27,552,553],{},"唯一的前置条件是：设备硬件能力清单已确认（支持哪些命令、指令超时时间、失败重试策略）",[10,555,556,107],{},[47,557,207],{},[209,559,560,563,569,572,582,585,591,594],{},[27,561,562],{},"小程序用户点\"录制\"按钮，录音 5 秒钟",[27,564,565,566],{},"上传音频文件到后台 ",[37,567,568],{},"POST \u002Fapi\u002Fv1\u002Frecordings\u002Fupload",[27,570,571],{},"后台接收、持久化音频（存 COS 或本地），返回音频 URL",[27,573,574,575,578,579],{},"后台业务逻辑获取该用户绑定的所有设备，对每一台设备发 MQTT 指令：",[37,576,577],{},"device\u002F{sn}\u002Fcommand"," 消息内容 ",[37,580,581],{},"{ cmd: 'play_custom_audio', params: { url: 'https:\u002F\u002F...', duration: 5 } }",[27,583,584],{},"设备收到消息，下载音频，开始播放",[27,586,587,588],{},"设备每秒上报播放状态（进度、音量、是否完成）到后台 MQTT 主题 ",[37,589,590],{},"device\u002F{sn}\u002Fstatus",[27,592,593],{},"后台通过 WebSocket 或 Server-Sent Event 推送状态更新给小程序",[27,595,596],{},"小程序实时显示设备播放进度条、播放完成提示",[10,598,599],{},"这个阶段是三端各司其职的验证。一旦前三个阶段都通过了，这里通常没有架构问题——问题可能来自：",[24,601,602,605,608,611],{},[27,603,604],{},"硬件支持度（设备固件不支持某个命令）",[27,606,607],{},"网络延迟（MQTT 消息延迟超过 1 秒）",[27,609,610],{},"音频格式不兼容（设备不支持某种编码）",[27,612,613],{},"存储容量（音频文件过大）",[10,615,616],{},"但这些都不是联调顺序的问题，都是已知的工程约束。",[14,618,619],{"id":619},"为什么这个顺序正确",[209,621,622,628,634,640,646],{},[27,623,624,627],{},[47,625,626],{},"依赖关系清晰","：每一步都在前一步的基础上加新功能，不会无限反复。后台是基座，小程序依赖后台 API，设备依赖后台 MQTT，没有循环依赖。",[27,629,630,633],{},[47,631,632],{},"快速反馈","：每个阶段都能独立验证，不用等所有功能都做完。第 1 阶段后端工程师就能 100% 确认\"后台自己能跑\"。第 2 阶段小程序工程师就能 100% 确认\"小程序能调后台 API\"。没有\"可能、应该、估计\"。",[27,635,636,639],{},[47,637,638],{},"问题隔离","：如果第 N 阶段失败，问题一定在这一步加的新东西里，前面的层都已经验证过。例如第 3 阶段如果设备收不到指令，不用怀疑后台 API（已在第 2 阶段通过了），直接看 MQTT 通信。这样定位问题的时间从\"无限\"缩短到\"可控\"。",[27,641,642,645],{},[47,643,644],{},"资源利用","：设备成本高（采购、维护、通电、运输、故障修理）。如果先做设备再做小程序，小程序开发期间所有设备都得 24\u002F7 开着，浪费电，加速磨损。如果后台+小程序联调期间设备全关着，开发速度反而快。",[27,647,648,651],{},[47,649,650],{},"并行开发","：三个角色（后端、小程序、硬件）可以真正并行。后端做 Plan 1-2 时，小程序工程师看 Swagger 文档设计页面框架；硬件工程师看 MQTT topic 设计固件消息处理。等后端出了初版 API，小程序才开始集成；硬件到了 Plan 3 才开始真实测试。如果反序，硬件工程师得等小程序做完才能知道\"产品需要什么\"，这是典型的串行瓶颈。",[10,653,654,656],{},[47,655,49],{},"。如果先做设备再做小程序，流程变成：设备完成 → 小程序根据设备现状开发 → 发现产品需求与设备能力不匹配 → 改硬件或改产品需求 → 小程序重做。这一圈返工，设备的每次改动都要测试，成本指数级上升。而且到了后期，后台 API 还没对接上来，小程序用 mock 数据做了一堆假验证，等真接口来了又全得改。",[14,658,659],{"id":659},"每个阶段的验收输出物",[24,661,662,695,717,736],{},[27,663,664,107,667],{},[47,665,666],{},"第1阶段",[24,668,669,672,675,678,684],{},[27,670,671],{},"后台源码（完整目录结构）",[27,673,674],{},"Jest 覆盖率报告（≥80%）",[27,676,677],{},"Swagger API 文档（自动生成，所有路由都有示例）",[27,679,680,683],{},[37,681,682],{},".env.example"," 和数据库初始化脚本",[27,685,686,687,690,691,694],{},"通过标准：",[37,688,689],{},"npm test"," 全部 PASS，",[37,692,693],{},"npm start"," 能启动",[27,696,697,107,700],{},[47,698,699],{},"第2阶段",[24,701,702,705,708,711,714],{},[27,703,704],{},"小程序源码（所有 Phase 1-3 功能代码）",[27,706,707],{},"Jest 测试报告（services 层覆盖率 ≥80%）",[27,709,710],{},"真机测试截图和视频（登录、首页、搜索、下单、支付成功页）",[27,712,713],{},"小程序 Swagger 文档（调用了哪些后台接口）",[27,715,716],{},"通过标准：真机打开小程序能正常浏览商品、能登录、能加购",[27,718,719,107,722],{},[47,720,721],{},"第3阶段",[24,723,724,727,730,733],{},[27,725,726],{},"设备 MQTT 通信日志（broker 收到的所有消息 + 时间戳）",[27,728,729],{},"设备指令执行记录（对于每条指令记录：发出时刻、设备接收时刻、执行结果、完成时刻）",[27,731,732],{},"离线重试测试报告（设备离线时发指令、再上线的执行顺序）",[27,734,735],{},"通过标准：指令下发成功率 100%，设备离线重试有效",[27,737,738,107,741],{},[47,739,740],{},"第4阶段",[24,742,743,746,749,752],{},[27,744,745],{},"端到端完整流程录屏 3 分钟（从小程序用户点录制，到设备播放完成，中间显示所有 MQTT 消息）",[27,747,748],{},"性能指标（从小程序点击到设备反馈的延迟、音频传输成功率）",[27,750,751],{},"边界情况测试报告（设备离线、网络断线、文件过大、格式不支持等）",[27,753,754],{},"通过标准：真机录音并推送到设备，设备成功播放，小程序显示进度",[14,756,757],{"id":757},"何时切换到真实数据",[10,759,760],{},"每个阶段 mock 和真实的分界线很明确，不要超前也不要延后：",[24,762,763,769],{},[27,764,765,768],{},[47,766,767],{},"不要延后","：后台 API 已写好了还继续用 mock，小程序就永远发现不了接口格式不匹配的问题。",[27,770,771,774],{},[47,772,773],{},"不要超前","：设备硬件还没到，不用强行对接真实 MQTT broker，浪费时间调试网络问题。",[10,776,777,778,781],{},"经验是：",[47,779,780],{},"看依赖关系","。小程序需要后台 API 才能测，但小程序的 UI 测试不需要真设备；设备需要后台 MQTT 才能下指令，但这时候小程序可以继续 mock。按这个粒度切分，三个角色都不会被卡住。",[783,784,785],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":136,"searchDepth":284,"depth":284,"links":787},[788,789,795,796,797],{"id":16,"depth":284,"text":16},{"id":84,"depth":284,"text":84,"children":790},[791,792,793,794],{"id":88,"depth":307,"text":89},{"id":142,"depth":307,"text":143},{"id":379,"depth":307,"text":380},{"id":508,"depth":307,"text":509},{"id":619,"depth":284,"text":619},{"id":659,"depth":284,"text":659},{"id":757,"depth":284,"text":757},"工程手记","2026-04-12","md",null,{},true,"\u002F2026-04-12",{"title":5,"description":12},"2026-04-12-三端联调的顺序问题","三端并行开发时顺序错了导致大量返工；可行顺序是后台自测→后台+小程序→后台+设备→三端，理由是后台是唯一事实源。",[809,810,811,812,813],"联调","项目管理","微信小程序","物联网","MQTT","K1xidurgY1T0nHQz_Hdm21bPL984lc8_rPJD3qsgp80",[816,1012,1368],{"id":817,"title":818,"body":819,"column":798,"date":997,"description":998,"extension":800,"hero_image":801,"meta":999,"navigation":803,"path":1000,"seo":1001,"series_id":801,"severity":801,"stem":1002,"summary":1003,"tags":1004,"__hash__":1011},"posts\u002F2026-09-10-Ai项目群的工程化分水岭.md","AI 项目做多以后，真正难的是把边界接起来",{"type":7,"value":820,"toc":986},[821,828,831,834,838,842,852,855,858,874,877,881,899,902,906,915,918,922,925,928,948,951,955,958,961,964,967,970,973,977,980,983],[10,822,823,824,827],{},"这段时间回看 ",[37,825,826],{},"\u002FUsers\u002Fxingye\u002FAi"," 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。",[10,829,830],{},"早期问题集中在“功能能不能跑起来”：语音推理、网页生成、桌面端启动、小程序页面、视频服务。进入平台化阶段以后，问题变成了另一组：任务是否可追踪，失败是否能恢复，费用是否能对账，版本是否能回滚，用户是否知道发生了什么。",[10,832,833],{},"这是一条从功能开发走向系统工程的分水岭。",[14,835,837],{"id":836},"一项目群已经形成几条清晰的产品线","一、项目群已经形成几条清晰的产品线",[86,839,841],{"id":840},"agent-与多租户平台","Agent 与多租户平台",[10,843,844,847,848,851],{},[37,845,846],{},"yun-claw"," 与 ",[37,849,850],{},"yun-claude"," 的记录显示，平台主线已经覆盖用户容器、模型接入、画布工作流、代理渠道、后台管理和运营工具。近期提交涉及画布自绘 Select、资产大图预览、代理激活码申请，以及渠道注册页的差异化展示。",[10,853,854],{},"这些功能看起来分属不同页面，底层却共享一套问题：用户身份、资源权限、异步任务和状态反馈必须保持一致。只改页面，不补状态契约，功能就会在跨页面操作时暴露断点。",[86,856,857],{"id":857},"内容生产流水线",[10,859,860,863,864,863,867,847,870,873],{},[37,861,862],{},"ai-write","、",[37,865,866],{},"instantlink-site",[37,868,869],{},"ai-auto-edit",[37,871,872],{},"ai-digital-human-workflow"," 分别覆盖写作、漫剧、自动剪辑和数字人视频。提交记录里反复出现几个关键词：任务网关、切片下载、共享卷、FFmpeg、提示词优化、失败提示和构建复现。",[10,875,876],{},"这说明内容生产已经不是单次调用模型，而是一条有中间产物的流水线。文本、图片、视频、音频和项目资产都有生命周期，任何一步的状态丢失都会让用户无法判断下一步该做什么。",[86,878,880],{"id":879},"交付网关与桌面端","交付、网关与桌面端",[10,882,883,863,886,863,889,863,892,66,895,898],{},[37,884,885],{},"newCodex",[37,887,888],{},"new-openclaw",[37,890,891],{},"openclaw",[37,893,894],{},"服务器管理",[37,896,897],{},"SSHTS"," 的记录，集中在更新、恢复、远程诊断、单实例生命周期和离线环境上。这里的核心指标不是“新版本发布了没有”，而是安装失败能否定位、更新中断能否恢复、客户机器能否留下可审计证据。",[10,900,901],{},"这也是为什么桌面端工程会逐渐靠近运维工程：安装器、后台服务、日志、配置和回滚必须一起设计。",[86,903,905],{"id":904},"网关计费与数据观察","网关、计费与数据观察",[10,907,908,847,911,914],{},[37,909,910],{},"NewAPI",[37,912,913],{},"new-api"," 的近期记录包括按模型统计 Token 消耗、扣费量、时间粒度聚合、昨日活跃用户和订单搜索。网关不只是转发请求，它还承担了模型选择、消耗记录、后台观察和问题追踪。",[10,916,917],{},"一旦计费数据进入用户体验，接口成功与业务成功就不再是同一个概念。请求返回 200，只能说明一次网络调用完成；扣费、产物落盘和任务终态是否一致，需要单独核对。",[14,919,921],{"id":920},"二git-记录里最有价值的变化是边界被写出来了","二、Git 记录里最有价值的变化，是“边界被写出来了”",[10,923,924],{},"多个项目的文档都在补同一类内容：部署手册、验收清单、测试报告、故障复盘、模型环境快照和用户协议。",[10,926,927],{},"这类文档的价值不在于把代码再讲一遍，而在于把系统边界写清楚：",[24,929,930,933,936,939,942,945],{},[27,931,932],{},"输入是什么，哪些字段必须存在；",[27,934,935],{},"状态有哪些，什么条件才能进入下一状态；",[27,937,938],{},"外部服务超时后，如何判断请求是否已经执行；",[27,940,941],{},"产物写入失败时，是否允许重试；",[27,943,944],{},"更新中断后，哪个版本可以安全恢复；",[27,946,947],{},"用户看到的成功，是否和后台记录的成功一致。",[10,949,950],{},"当这些问题没有答案时，系统只能靠人工经验维持。一旦项目数量增加，经验就会变成不可复制的隐性依赖。",[14,952,954],{"id":953},"三下一阶段的重点不是再加一个入口","三、下一阶段的重点不是再加一个入口",[10,956,957],{},"从现有项目的提交和文档可以看到，下一阶段更值得投入的是四个基础能力。",[10,959,960],{},"第一，统一任务状态。不同项目都在处理异步任务，但状态命名、失败原因和重试策略不应各自发明。",[10,962,963],{},"第二，统一产物账本。一个任务产生了哪些文件、消耗了哪些资源、是否对用户可见，需要能从提交到终态完整追踪。",[10,965,966],{},"第三，统一故障出口。401、超时、部分成功、未知执行和版本回滚，应该有明确的用户反馈与后台证据。",[10,968,969],{},"第四，统一发布验收。构建通过不代表线上可用，应该把健康检查、代理路径、真实端口、关键日志和浏览器操作放到同一份验收清单里。",[10,971,972],{},"项目多以后，工程能力不再体现在“能写多少功能”，而体现在“能否让不同功能遵守同一套边界”。",[14,974,976],{"id":975},"四我会保留的判断","四、我会保留的判断",[10,978,979],{},"AI 项目最容易被低估的部分，不是模型调用，而是模型调用之后的系统。",[10,981,982],{},"生成只是起点。身份、任务、费用、存储、交付、恢复和反馈，决定了一个功能能不能进入真实使用。",[10,984,985],{},"当 Git 提交开始频繁出现“补状态”“补恢复”“补验收”“补审计”时，这不是工程变慢了，而是产品从演示阶段进入了可运营阶段。",{"title":136,"searchDepth":284,"depth":284,"links":987},[988,994,995,996],{"id":836,"depth":284,"text":837,"children":989},[990,991,992,993],{"id":840,"depth":307,"text":841},{"id":857,"depth":307,"text":857},{"id":879,"depth":307,"text":880},{"id":904,"depth":307,"text":905},{"id":920,"depth":284,"text":921},{"id":953,"depth":284,"text":954},{"id":975,"depth":284,"text":976},"2026-09-10","这段时间回看 \u002FUsers\u002Fxingye\u002FAi 下的项目，最明显的变化不是项目数量增加，而是问题的重心变了。",{},"\u002F2026-09-10-ai",{"title":818,"description":998},"2026-09-10-Ai项目群的工程化分水岭","从多个 AI 项目的 Git 记录和设计文档看，工程难点已经从“能不能生成”转向任务、计费、交付、恢复与用户反馈能否形成闭环。",[1005,1006,1007,1008,1009,1010],"AI 平台","Agent","内容生产","网关","桌面端","工程化","DzOpOX92EsanPp8pLNTusbzBmKH3WOTf3qcaZAFmhM8",{"id":1013,"title":1014,"body":1015,"column":798,"date":1355,"description":1019,"extension":800,"hero_image":801,"meta":1356,"navigation":803,"path":1357,"seo":1358,"series_id":801,"severity":801,"stem":1359,"summary":1360,"tags":1361,"__hash__":1367},"posts\u002F2026-08-15-上线前我把整个仓库审了一遍.md","上线前，我把整个仓库审了一遍",{"type":7,"value":1016,"toc":1344},[1017,1020,1023,1027,1030,1050,1053,1056,1060,1063,1066,1069,1072,1076,1079,1082,1148,1151,1158,1162,1168,1171,1174,1181,1185,1188,1199,1202,1206,1209,1215,1218,1231,1234,1237,1241,1244,1251,1257,1260,1264,1267,1272,1289,1294,1308,1311,1314,1318,1321,1341],[10,1018,1019],{},"上线前做了一次全量静态审查。",[10,1021,1022],{},"本文只讲方法和结论分布。审查报告本身不入库，也不在这里复述——那份报告带内网拓扑、密钥存放位置和尚未修复问题的文件行号证据，公开它就等于公开一份攻击者的地图。",[14,1024,1026],{"id":1025},"一审查怎么进行","一、审查怎么进行",[10,1028,1029],{},"三个部分：",[209,1031,1032,1038,1044],{},[27,1033,1034,1037],{},[47,1035,1036],{},"人工列清单。"," 先按「一个系统可能从哪些地方被打穿」列出检查面，而不是按目录扫。",[27,1039,1040,1043],{},[47,1041,1042],{},"四个并行只读探查代理。"," 每个代理负责一组检查面，只读，不允许改任何文件。",[27,1045,1046,1049],{},[47,1047,1048],{},"关键证据人工逐条复核。"," 代理解释现象可以，但「这条算不算风险、算多重」由人定。",[10,1051,1052],{},"第三步是必要的。代理很容易把「写法不理想」报成「高危」，也很容易漏掉需要跨文件才能看出的问题。它适合做的是穷举和取证，不适合定级。",[10,1054,1055],{},"审查方式记为：静态审查（人工 + 4 个并行只读探查代理）+ 关键证据人工逐条复核。",[14,1057,1059],{"id":1058},"二只读约束的作用","二、只读约束的作用",[10,1061,1062],{},"让探查代理只能读，有两个好处。",[10,1064,1065],{},"一是没有副作用。审查过程中仓库保持原样，报告的结论可以对着同一个 commit 复现。",[10,1067,1068],{},"二是省掉了「代理顺手改一下」的干扰。审查和修改混在一起，会出现「报告说有问题的地方已经被改过」的状态，后续复核就无法对着证据走。",[10,1070,1071],{},"修改是审查之后单独一轮的事。",[14,1073,1075],{"id":1074},"三20-条与四个级别","三、20 条与四个级别",[10,1077,1078],{},"产出 20 条风险，编号 R01 到 R20。每一条有：位置、触发条件、影响、修复优先级。",[10,1080,1081],{},"按修复优先级分四档：",[1083,1084,1085,1101],"table",{},[1086,1087,1088],"thead",{},[1089,1090,1091,1095,1098],"tr",{},[1092,1093,1094],"th",{},"优先级",[1092,1096,1097],{},"条数",[1092,1099,1100],{},"排期",[1102,1103,1104,1116,1127,1138],"tbody",{},[1089,1105,1106,1110,1113],{},[1107,1108,1109],"td",{},"P0",[1107,1111,1112],{},"2",[1107,1114,1115],{},"本周内",[1089,1117,1118,1121,1124],{},[1107,1119,1120],{},"P1",[1107,1122,1123],{},"6",[1107,1125,1126],{},"两周内",[1089,1128,1129,1132,1135],{},[1107,1130,1131],{},"P2",[1107,1133,1134],{},"5",[1107,1136,1137],{},"一个月内",[1089,1139,1140,1143,1146],{},[1107,1141,1142],{},"P3",[1107,1144,1145],{},"7",[1107,1147,1137],{},[10,1149,1150],{},"另外有一个独立的「严重级别」列，按严重 \u002F 高 \u002F 中 \u002F 低分：1 \u002F 4 \u002F 7 \u002F 8。两列不对应是有意的——「有多严重」和「该多快修」是两件事。一条严重但需要停机才能修的问题，排期上反而不能放在本周。",[10,1152,1153,1154,1157],{},"整体评级：",[47,1155,1156],{},"高","。",[14,1159,1161],{"id":1160},"四结论里最值得记的一句","四、结论里最值得记的一句",[1163,1164,1165],"blockquote",{},[10,1166,1167],{},"未发现 SQL 注入、越权与 IDOR、存储型 XSS 等直接可利用的应用漏洞。",[10,1169,1170],{},"这一句和「整体评级高」摆在一起看，是这个月里最值得记的一条工程结论。",[10,1172,1173],{},"风险分布和直觉不一致。业务代码那一侧没查出可直接利用的漏洞——那部分每天在改、每天在跑、每天有人看。问题更多集中在业务代码之外：构建、部署、配置、凭据流转、依赖引入、运行时加固。",[10,1175,1176,1177,1180],{},"这些东西的共同点是",[47,1178,1179],{},"平时不跑","。部署脚本一天执行几次，Dockerfile 改一次放很久，集群配置是照着文档抄的。没有日常反馈的东西，问题会一直留在那里，而且不会有人因为「它今天没出事」而去核对它。",[14,1182,1184],{"id":1183},"五修复的排期方式","五、修复的排期方式",[10,1186,1187],{},"路线图按三档排：P0 本周、P1 两周内、P2\u002FP3 一个月内。",[10,1189,1190,1191,1194,1195,1198],{},"排期依据不是严重级别，是两件事：",[47,1192,1193],{},"能不能立即修","，和",[47,1196,1197],{},"修它会不会动到线上","。一条严重但需要停机或改基础设施配置的问题，得等一个发布窗口；一条中等但改动只在一处代码里的问题，可以马上做。P2\u002FP3 是加固项，攒到下一个版本一起走。",[10,1200,1201],{},"这样做的好处是排期可执行。「两个月内修完 20 条」听起来完整，实际会卡在第一条需要停机的问题上。",[14,1203,1205],{"id":1204},"六报告为什么不入库","六、报告为什么不入库",[10,1207,1208],{},"仓库的忽略规则里专门有一条：",[128,1210,1213],{"className":1211,"code":1212,"language":133},[131],"# 安全审查报告：含内网拓扑、密钥存放路径与未修复漏洞的 file:line 证据，绝不入库\n",[37,1214,1212],{"__ignoreMap":136},[10,1216,1217],{},"三条理由，最后一条最关键：",[24,1219,1220,1223,1226],{},[27,1221,1222],{},"内网拓扑",[27,1224,1225],{},"密钥存放位置",[27,1227,1228],{},[47,1229,1230],{},"未修复漏洞的精确定位",[10,1232,1233],{},"前两条是常识。第三条是这类文档的特例：一份「待修复清单」在修完之前，本身就是攻击说明书。而它又必须有精确定位，否则修不动。",[10,1235,1236],{},"所以这份文档的流转方式和代码相反。代码进仓库、留历史；它不进仓库，修完一条删一条，修完的结论改记到提交信息里。",[14,1238,1240],{"id":1239},"七审查之后同一天修的东西","七、审查之后，同一天修的东西",[10,1242,1243],{},"审查和修复放在同一天做，这是这次的一个做法。",[10,1245,1246,1247,1250],{},"当天修的一批，主题集中在一类：",[47,1248,1249],{},"不报错的失败","。队列投递端断线后不重连、依赖返回的包装形状读错导致判定恒为假、入队不带权重插到最前面、参数格式不对被上游全部拒收、重试链被自己的去重压短、管理操作的幂等键缺失、用户可控的文件名直接进对象存储键、唯一漏掉来源校验的 IPC handler、并发删除后返回假成功、过期验证码被复活、临时文件不清理。",[10,1252,1253,1254,1157],{},"这些已经有单独一篇记录，这里只提它们的共性：",[47,1255,1256],{},"都不抛异常",[10,1258,1259],{},"审查报告里那些「不进仓库」的条目和这些「当天修完」的条目，其实是同一批检查过出来的。区别只在于前者需要动基础设施、要等窗口，后者可以立刻改。",[14,1261,1263],{"id":1262},"八静态审查的边界","八、静态审查的边界",[10,1265,1266],{},"这一轮做的是静态审查，能覆盖和不能覆盖的东西都比较明确。",[10,1268,1269],{},[47,1270,1271],{},"能覆盖：",[24,1273,1274,1277,1280,1283,1286],{},[27,1275,1276],{},"配置与依赖：镜像标签、依赖来源、加密参数、权限配置。",[27,1278,1279],{},"错误处理：异常的吞掉与上抛、失败路径有没有留痕。",[27,1281,1282],{},"凭据流转：什么地方读、什么地方写、会不会进构建产物。",[27,1284,1285],{},"部署清单：运行用户、资源限制、网络策略、滚动更新参数。",[27,1287,1288],{},"代码层面的模式：路径拼接、命令拼接、输入校验的缺失。",[10,1290,1291],{},[47,1292,1293],{},"覆盖不到：",[24,1295,1296,1299,1302,1305],{},[27,1297,1298],{},"运行时竞态。两个请求同时到、并发删除、租约过期，这些要在真实并发下才暴露。",[27,1300,1301],{},"真实的流量特征。哪些入口真的被外部打到、参数实际长什么样，静态看只能推测。",[27,1303,1304],{},"业务逻辑漏洞。越权取数据这类问题需要理解「这个人该不该看到这条」，而业务意图不在代码里。",[27,1306,1307],{},"组合链。单看每一步都合规，串起来才是问题——这类要动态测试或者真实对抗。",[10,1309,1310],{},"第三类是最容易被静态审查漏掉的：代码里所有校验都写了，但校验的规则和业务规则不一致。",[10,1312,1313],{},"所以静态审查的产出不是「系统安全了」，而是「在它能看见的那一面里，还剩哪些要做」。",[14,1315,1317],{"id":1316},"九一次审查真正留下的东西","九、一次审查真正留下的东西",[10,1319,1320],{},"审完当天修掉的那批，留下的是代码。报告里那些没修的，留下的是三样东西：",[209,1322,1323,1329,1335],{},[27,1324,1325,1328],{},[47,1326,1327],{},"一份带精确定位和优先级的清单","，不进仓库，逐条消掉。",[27,1330,1331,1334],{},[47,1332,1333],{},"一个判断","：风险集中的地方和日常注意力集中的地方不一样。业务代码天天改，反而干净；构建部署配置平时没人动。",[27,1336,1337,1340],{},[47,1338,1339],{},"一次复核的习惯","：审查的结论要能对着一个 commit 复现，所以探查只读、修改单独一轮。",[10,1342,1343],{},"第 2 条比第 1 条重要。清单会修完，但「平时不跑的东西没人核对」这件事不会自己消失。",{"title":136,"searchDepth":284,"depth":284,"links":1345},[1346,1347,1348,1349,1350,1351,1352,1353,1354],{"id":1025,"depth":284,"text":1026},{"id":1058,"depth":284,"text":1059},{"id":1074,"depth":284,"text":1075},{"id":1160,"depth":284,"text":1161},{"id":1183,"depth":284,"text":1184},{"id":1204,"depth":284,"text":1205},{"id":1239,"depth":284,"text":1240},{"id":1262,"depth":284,"text":1263},{"id":1316,"depth":284,"text":1317},"2026-08-15",{},"\u002F2026-08-15",{"title":1014,"description":1019},"2026-08-15-上线前我把整个仓库审了一遍","一次上线前的静态安全审查：四个并行只读代理加人工逐条复核，产出 20 条分级风险与三档修复路线图。本文记录方法，不复述条目。",[1362,1363,1364,1365,1366],"安全审查","代码审查","风险分级","修复排期","Agent 协作","bnkC_2bMe8OfgB00HIfDUS9bdRwLUZb_WqDBvXtq__M",{"id":1369,"title":1370,"body":1371,"column":798,"date":2274,"description":1375,"extension":800,"hero_image":801,"meta":2275,"navigation":803,"path":2276,"seo":2277,"series_id":801,"severity":801,"stem":2278,"summary":2279,"tags":2280,"__hash__":2287},"posts\u002F2026-08-02-首页改成八屏.md","首页改成八屏",{"type":7,"value":1372,"toc":2261},[1373,1376,1382,1385,1389,1392,1402,1411,1414,1417,1425,1432,1438,1444,1501,1504,1523,1528,1533,1536,1542,1546,1549,1552,1560,1563,1566,1572,1575,1603,1606,1717,1720,1726,1730,1733,1749,1763,1766,1782,1785,1788,1792,1798,1801,1804,1810,1813,1863,1867,1870,1873,1876,1882,1885,1888,1893,1899,1903,1906,1909,1915,1919,1924,1965,1975,1978,2007,2012,2015,2065,2071,2074,2131,2143,2146,2150,2153,2156,2161,2164,2167,2172,2176,2179,2249,2252,2258],[10,1374,1375],{},"这个博客的首页原来是连续滚动。改成了整屏分页，一页一件事，共八屏：",[128,1377,1380],{"className":1378,"code":1379,"language":133},[131],"首屏 \u002F 自我介绍 01 \u002F 自我介绍 02 \u002F 做过的事 \u002F 精选复盘 \u002F 项目 \u002F 专栏 \u002F 联系\n",[37,1381,1379],{"__ignoreMap":136},[10,1383,1384],{},"一屏容纳是逐档实测调出来的，不是估的。",[14,1386,1388],{"id":1387},"一吸附用谁的","一、吸附用谁的",[10,1390,1391],{},"第一个决定是吸附用哪套机制。",[10,1393,1394,1395,1398,1399,107],{},"原生有 ",[37,1396,1397],{},"scroll-snap","，CSS 几行就能写。但这里用不了——页面上的平滑滚动是 Lenis 提供的，它用 JS 驱动 ",[37,1400,1401],{},"scrollTop",[1163,1403,1404],{},[10,1405,1406,1407,1410],{},"吸附用 Lenis 自带的 Snap 而不是 CSS scroll-snap——Lenis 是用 JS 驱动 scrollTop 的，和原生吸附会互相抢控制权、滚起来发抖。Snap 挂在同一个 Lenis 实例上没有这个问题，",[37,1408,1409],{},"onSnapComplete"," 还能驱动指示器。",[10,1412,1413],{},"两个机制都想控制滚动位置，结果就是抖。",[10,1415,1416],{},"挂在同一个实例上之后，吸附完成后还能拿到回调，右侧的页码指示器直接跟着它更新。",[14,1418,1420,1421,1424],{"id":1419},"二mandatory-是错的","二、",[37,1422,1423],{},"mandatory"," 是错的",[10,1426,1427,1428,1431],{},"第一版用 ",[37,1429,1430],{},"type: 'mandatory'","。用户反馈手感是「被抢走了」。",[10,1433,1434,1435,1437],{},"原因是 ",[37,1436,1423],{}," 在用户还在滑的时候就强行把页面拽向最近一页。滑动过程被插手，手感不是吸附，是失控。",[10,1439,1440,1441,107],{},"改成 ",[37,1442,1443],{},"proximity",[128,1445,1449],{"className":1446,"code":1447,"language":1448,"meta":136,"style":136},"language-ts shiki shiki-themes github-light github-dark","type: 'proximity',\ndistanceThreshold: '30%',\nduration: 1.1,\ndebounce: 500,\n","ts",[37,1450,1451,1465,1477,1489],{"__ignoreMap":136},[260,1452,1453,1456,1459,1462],{"class":262,"line":263},[260,1454,1455],{"class":276},"type",[260,1457,1458],{"class":280},": ",[260,1460,1461],{"class":300},"'proximity'",[260,1463,1464],{"class":280},",\n",[260,1466,1467,1470,1472,1475],{"class":262,"line":284},[260,1468,1469],{"class":276},"distanceThreshold",[260,1471,1458],{"class":280},[260,1473,1474],{"class":300},"'30%'",[260,1476,1464],{"class":280},[260,1478,1479,1482,1484,1487],{"class":262,"line":307},[260,1480,1481],{"class":276},"duration",[260,1483,1458],{"class":280},[260,1485,1486],{"class":293},"1.1",[260,1488,1464],{"class":280},[260,1490,1491,1494,1496,1499],{"class":262,"line":340},[260,1492,1493],{"class":276},"debounce",[260,1495,1458],{"class":280},[260,1497,1498],{"class":293},"500",[260,1500,1464],{"class":280},[10,1502,1503],{},"三条参数各管一件事：",[24,1505,1506,1511,1517],{},[27,1507,1508,1510],{},[37,1509,1443],{}," 只在停下来且已经接近边界时才轻推一把。",[27,1512,1513,1516],{},[37,1514,1515],{},"distanceThreshold: '30%'"," 定义「接近」——落点离边界不到 30% 视口高才吸附，停在页面中间就让它停着。",[27,1518,1519,1522],{},[37,1520,1521],{},"debounce: 500"," 等惯性完全停下来再判断，不在滑行中途插手。",[10,1524,1525,1527],{},[37,1526,1493],{}," 从 220 提到 500：",[1163,1529,1530],{},[10,1531,1532],{},"debounce 太小会在惯性还没停时就抢着吸附，手感发涩。",[10,1534,1535],{},"实测结果：",[128,1537,1540],{"className":1538,"code":1539,"language":133},[131],"滑动全程 0 反向帧（原来会被拽回）\n停在离边界 306px（超过 30% 阈值）时保持不动\n停在边界附近才对齐整页\n",[37,1541,1539],{"__ignoreMap":136},[14,1543,1545],{"id":1544},"三一屏装不装得下","三、一屏装不装得下",[10,1547,1548],{},"分页的前提是内容能装进一屏。这一条逐档量过。",[10,1550,1551],{},"首屏原高 921px：",[24,1553,1554,1557],{},[27,1555,1556],{},"1440×900 超出 89px",[27,1558,1559],{},"1366×768 超出 201px",[10,1561,1562],{},"精选复盘 788px，在 1366×768 超出 88px。",[10,1564,1565],{},"两个方向可选：砍内容，或者按档收紧间距。选了后者，分三档：",[128,1567,1570],{"className":1568,"code":1569,"language":133},[131],"min-width: 1024px and max-height: 820px   深压\nmin-width: 1024px and max-height: 900px   只压首屏\nmin-width: 1024px and min-height: 1000px  放大间距吃大屏留白\n",[37,1571,1569],{"__ignoreMap":136},[10,1573,1574],{},"中间那档的调整方式值得记一笔。为了挤出 27px，第一版直接砍了字号：",[128,1576,1580],{"className":1577,"code":1578,"language":1579,"meta":136,"style":136},"language-css shiki shiki-themes github-light github-dark","font-size: clamp(46px, 9.2vw, 132px)  →  clamp(30px, 5.4vw, 62px)\n","css",[37,1581,1582],{"__ignoreMap":136},[260,1583,1584,1588,1591,1594,1597,1600],{"class":262,"line":263},[260,1585,1587],{"class":1586},"s9eBZ","font-size",[260,1589,1590],{"class":280},": clamp(46px, 9",[260,1592,1593],{"class":276},".2vw",[260,1595,1596],{"class":280},", 132px)  →  clamp(30px, 5",[260,1598,1599],{"class":276},".4vw",[260,1601,1602],{"class":280},", 62px)\n",[10,1604,1605],{},"砍掉一半多。首屏标题是整页的主体，砍字号会让整屏塌下来。改成全部从内边距要：",[128,1607,1609],{"className":1577,"code":1608,"language":1579,"meta":136,"style":136},"@media (min-width: 1024px) and (max-height: 900px) {\n  .page-hero :deep(.hero) { padding-top: 20px; padding-bottom: 24px; }\n  .page-hero :deep(.hero-foot) { margin-top: 26px; }\n}\n",[37,1610,1611,1650,1690,1713],{"__ignoreMap":136},[260,1612,1613,1616,1619,1622,1624,1627,1630,1633,1636,1638,1641,1643,1646,1648],{"class":262,"line":263},[260,1614,1615],{"class":266},"@media",[260,1617,1618],{"class":280}," (",[260,1620,1621],{"class":293},"min-width",[260,1623,1458],{"class":280},[260,1625,1626],{"class":293},"1024",[260,1628,1629],{"class":266},"px",[260,1631,1632],{"class":280},") ",[260,1634,1635],{"class":266},"and",[260,1637,1618],{"class":280},[260,1639,1640],{"class":293},"max-height",[260,1642,1458],{"class":280},[260,1644,1645],{"class":293},"900",[260,1647,1629],{"class":266},[260,1649,304],{"class":280},[260,1651,1652,1655,1658,1661,1664,1667,1669,1672,1674,1677,1680,1682,1685,1687],{"class":262,"line":284},[260,1653,1654],{"class":276},"  .page-hero",[260,1656,1657],{"class":280}," :deep(",[260,1659,1660],{"class":276},".hero",[260,1662,1663],{"class":280},") { ",[260,1665,1666],{"class":293},"padding-top",[260,1668,1458],{"class":280},[260,1670,1671],{"class":293},"20",[260,1673,1629],{"class":266},[260,1675,1676],{"class":280},"; ",[260,1678,1679],{"class":293},"padding-bottom",[260,1681,1458],{"class":280},[260,1683,1684],{"class":293},"24",[260,1686,1629],{"class":266},[260,1688,1689],{"class":280},"; }\n",[260,1691,1692,1694,1696,1699,1701,1704,1706,1709,1711],{"class":262,"line":307},[260,1693,1654],{"class":276},[260,1695,1657],{"class":280},[260,1697,1698],{"class":276},".hero-foot",[260,1700,1663],{"class":280},[260,1702,1703],{"class":293},"margin-top",[260,1705,1458],{"class":280},[260,1707,1708],{"class":293},"26",[260,1710,1629],{"class":266},[260,1712,1689],{"class":280},[260,1714,1715],{"class":262,"line":340},[260,1716,373],{"class":280},[10,1718,1719],{},"只有 700px 可用高度那一档才让一档字号。三档实测：",[128,1721,1724],{"className":1722,"code":1723,"language":133},[131],"1440×900    132px\n1920×1080   132px\n1366×768     92.9px\n",[37,1725,1723],{"__ignoreMap":136},[14,1727,1729],{"id":1728},"四检测判据写错过一次","四、检测判据写错过一次",[10,1731,1732],{},"判断页面有没有溢出，第一版用的是：",[128,1734,1736],{"className":1446,"code":1735,"language":1448,"meta":136,"style":136},"scrollHeight > clientHeight\n",[37,1737,1738],{"__ignoreMap":136},[260,1739,1740,1743,1746],{"class":262,"line":263},[260,1741,1742],{"class":280},"scrollHeight ",[260,1744,1745],{"class":266},">",[260,1747,1748],{"class":280}," clientHeight\n",[10,1750,1751,1752,1755,1756,66,1759,1762],{},"这个判据永远为假。页面用的是 ",[37,1753,1754],{},"min-height","，内容超了页面会自己长高——",[37,1757,1758],{},"scrollHeight",[37,1760,1761],{},"clientHeight"," 一起变大，比值不变。",[10,1764,1765],{},"改成直接比可用高度：",[128,1767,1769],{"className":1446,"code":1768,"language":1448,"meta":136,"style":136},"\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[37,1770,1771,1777],{"__ignoreMap":136},[260,1772,1773],{"class":262,"line":263},[260,1774,1776],{"class":1775},"sJ8bj","\u002F\u002F 检测判据从 scrollHeight > clientHeight 改为直接比可用高度——\n",[260,1778,1779],{"class":262,"line":284},[260,1780,1781],{"class":1775},"\u002F\u002F 页面用的是 min-height，内容超了页面会自己长高，前者永远比不出溢出\n",[10,1783,1784],{},"这个错误值得单独说：判据本身写错了，所以「三档零溢出」这个结论在修正之前是不成立的。",[10,1786,1787],{},"修正之后三档均为 8\u002F8 页零溢出。",[14,1789,1791],{"id":1790},"五不分页的三种情况","五、不分页的三种情况",[128,1793,1796],{"className":1794,"code":1795,"language":133},[131],"窄屏（\u003C 1024px）\n矮视口（\u003C 620px）\n减弱动效（prefers-reduced-motion）\n",[37,1797,1795],{"__ignoreMap":136},[10,1799,1800],{},"三种都退回连续滚动。",[10,1802,1803],{},"前两种有实测依据：",[128,1805,1808],{"className":1806,"code":1807,"language":133},[131],"实测 390px 下自我介绍 1448px、精选复盘 1114px，强行一屏只会截断内容\n",[37,1809,1807],{"__ignoreMap":136},[10,1811,1812],{},"第三种是硬要求。整屏吸附会强制改变滚动位置，这和「尊重减弱动效偏好」直接冲突。CSS 里也要同步禁用：",[128,1814,1816],{"className":1577,"code":1815,"language":1579,"meta":136,"style":136},"@media (prefers-reduced-motion: reduce) {\n  .page { min-height: 0; }\n  .pager { display: none; }\n}\n",[37,1817,1818,1825,1842,1859],{"__ignoreMap":136},[260,1819,1820,1822],{"class":262,"line":263},[260,1821,1615],{"class":266},[260,1823,1824],{"class":280}," (prefers-reduced-motion: reduce) {\n",[260,1826,1827,1830,1833,1835,1837,1840],{"class":262,"line":284},[260,1828,1829],{"class":276},"  .page",[260,1831,1832],{"class":280}," { ",[260,1834,1754],{"class":293},[260,1836,1458],{"class":280},[260,1838,1839],{"class":293},"0",[260,1841,1689],{"class":280},[260,1843,1844,1847,1849,1852,1854,1857],{"class":262,"line":307},[260,1845,1846],{"class":276},"  .pager",[260,1848,1832],{"class":280},[260,1850,1851],{"class":293},"display",[260,1853,1458],{"class":280},[260,1855,1856],{"class":293},"none",[260,1858,1689],{"class":280},[260,1860,1861],{"class":262,"line":340},[260,1862,373],{"class":280},[14,1864,1866],{"id":1865},"六分页之后每页只剩半屏内容","六、分页之后，每页只剩半屏内容",[10,1868,1869],{},"八屏分配完之后，出现一个反向问题：每页内容太少。",[10,1871,1872],{},"实测 1440×900 下多数页只填了 47%~63%，「做过的事」那页 63% 是空的。",[10,1874,1875],{},"既然分了页，每页就得撑得住。逐页补实：",[128,1877,1880],{"className":1878,"code":1879,"language":133},[131],"做过的事：加页首概览条（项目数\u002F时间跨度\u002F人手），每项补详细说明、关键指标与技术标签。308px → 548px\n项目：3 张卡补到 6 张，两行三列。362px → 511px\n联系：补引言、about 与 timeline 两个入口、站点信息带。452px → 791px\n专栏：每张卡补该栏最新一篇标题。512px → 597px\n自我介绍 01：四张能力卡各补首组的真实条目。504px → 596px\n自我介绍 02：补技术栈横带。522px → 603px\n",[37,1881,1879],{"__ignoreMap":136},[10,1883,1884],{},"填充率从 47%~63% 提到 61%~100%。",[10,1886,1887],{},"同一轮里把两个区块的入场动画也补了常驻循环，因为：",[1163,1889,1890],{},[10,1891,1892],{},"实测入场动画本来就在跑（轴线 scaleX 0→0.99、圆点带回弹、卡片 clip-path 从 78% 揭到 4%、指标读数往上跳），问题是太隐蔽：一次性、1 秒内结束、触发点又在区块刚露头时，等正眼看过去通常已经播完。",[128,1894,1897],{"className":1895,"code":1896,"language":133},[131],"触发点 top 88% → 95%，轴线 0.9s → 1.25s，圆点与节点错峰 0.11 → 0.16\n",[37,1898,1896],{"__ignoreMap":136},[14,1900,1902],{"id":1901},"七断点从-900-降到-820","七、断点从 900 降到 820",[10,1904,1905],{},"第一版把「矮视口」定在 900px 高。1440×900 的屏幕因此被当成矮视口，白白砍掉了列表摘要——精选复盘从 788px 缩到 389px。",[10,1907,1908],{},"而 1440×900 明明有空间。断点降到 820：",[128,1910,1913],{"className":1911,"code":1912,"language":133},[131],"820 以下   深压\n900 以下   只压首屏\n1000 以上  放大间距吃掉大屏留白\n",[37,1914,1912],{"__ignoreMap":136},[14,1916,1918],{"id":1917},"八两个-css-优先级问题","八、两个 CSS 优先级问题",[10,1920,1921],{},[47,1922,1923],{},"首屏被挤成一个框。",[128,1925,1927],{"className":1577,"code":1926,"language":1579,"meta":136,"style":136},".page { padding: 24px 0; }\n.page-hero { padding: 0; }\n",[37,1928,1929,1950],{"__ignoreMap":136},[260,1930,1931,1934,1936,1939,1941,1943,1945,1948],{"class":262,"line":263},[260,1932,1933],{"class":276},".page",[260,1935,1832],{"class":280},[260,1937,1938],{"class":293},"padding",[260,1940,1458],{"class":280},[260,1942,1684],{"class":293},[260,1944,1629],{"class":266},[260,1946,1947],{"class":293}," 0",[260,1949,1689],{"class":280},[260,1951,1952,1955,1957,1959,1961,1963],{"class":262,"line":284},[260,1953,1954],{"class":276},".page-hero",[260,1956,1832],{"class":280},[260,1958,1938],{"class":293},[260,1960,1458],{"class":280},[260,1962,1839],{"class":293},[260,1964,1689],{"class":280},[10,1966,1967,1970,1971,1974],{},[37,1968,1969],{},".page-hero { padding: 0 }"," 写在媒体查询之前，被后面同优先级的 ",[37,1972,1973],{},".page { padding: ... }"," 覆盖了。首屏于是四边都留出内边距，看起来像一个框。",[10,1976,1977],{},"修法是在每一档里重新声明一次：",[128,1979,1981],{"className":1577,"code":1980,"language":1579,"meta":136,"style":136},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n.page-hero { padding: 0; }\n",[37,1982,1983,1988,1993],{"__ignoreMap":136},[260,1984,1985],{"class":262,"line":263},[260,1986,1987],{"class":1775},"\u002F* 首屏必须满幅出血。这行不能省——上面那条 .page 同优先级且更靠后，\n",[260,1989,1990],{"class":262,"line":284},[260,1991,1992],{"class":1775},"   会把顶部声明的 .page-hero { padding: 0 } 覆盖掉。 *\u002F\n",[260,1994,1995,1997,1999,2001,2003,2005],{"class":262,"line":307},[260,1996,1954],{"class":276},[260,1998,1832],{"class":280},[260,2000,1938],{"class":293},[260,2002,1458],{"class":280},[260,2004,1839],{"class":293},[260,2006,1689],{"class":280},[10,2008,2009],{},[47,2010,2011],{},"标题被大屏规则压小。",[10,2013,2014],{},"大屏档里有一条：",[128,2016,2018],{"className":1577,"code":2017,"language":1579,"meta":136,"style":136},".page :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[37,2019,2020],{"__ignoreMap":136},[260,2021,2022,2024,2026,2029,2031,2033,2035,2038,2041,2044,2046,2049,2052,2055,2057,2060,2062],{"class":262,"line":263},[260,2023,1933],{"class":276},[260,2025,1657],{"class":280},[260,2027,2028],{"class":276},".big",[260,2030,1663],{"class":280},[260,2032,1587],{"class":293},[260,2034,1458],{"class":280},[260,2036,2037],{"class":293},"clamp",[260,2039,2040],{"class":280},"(",[260,2042,2043],{"class":293},"34",[260,2045,1629],{"class":266},[260,2047,2048],{"class":280},", ",[260,2050,2051],{"class":293},"3.4",[260,2053,2054],{"class":266},"vw",[260,2056,2048],{"class":280},[260,2058,2059],{"class":293},"56",[260,2061,1629],{"class":266},[260,2063,2064],{"class":280},"); }\n",[10,2066,2067,2068,2070],{},"首屏主标题的类名也是 ",[37,2069,2028],{},"。1920×1080 下它从 132px 被压到 56px，首屏整个塌掉。",[10,2072,2073],{},"改成排除首屏：",[128,2075,2077],{"className":1577,"code":2076,"language":1579,"meta":136,"style":136},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n.page:not(.page-hero) :deep(.big) { font-size: clamp(34px, 3.4vw, 56px); }\n",[37,2078,2079,2084,2089],{"__ignoreMap":136},[260,2080,2081],{"class":262,"line":263},[260,2082,2083],{"class":1775},"\u002F* 必须排除首屏——hero 里的 .big 是那个巨大的主标题，\n",[260,2085,2086],{"class":262,"line":284},[260,2087,2088],{"class":1775},"   被这条命中会从 132px 压到 56px，整个首屏塌掉 *\u002F\n",[260,2090,2091,2094,2096,2098,2101,2103,2105,2107,2109,2111,2113,2115,2117,2119,2121,2123,2125,2127,2129],{"class":262,"line":307},[260,2092,2093],{"class":276},".page:not",[260,2095,2040],{"class":280},[260,2097,1954],{"class":276},[260,2099,2100],{"class":280},") :deep(",[260,2102,2028],{"class":276},[260,2104,1663],{"class":280},[260,2106,1587],{"class":293},[260,2108,1458],{"class":280},[260,2110,2037],{"class":293},[260,2112,2040],{"class":280},[260,2114,2043],{"class":293},[260,2116,1629],{"class":266},[260,2118,2048],{"class":280},[260,2120,2051],{"class":293},[260,2122,2054],{"class":266},[260,2124,2048],{"class":280},[260,2126,2059],{"class":293},[260,2128,1629],{"class":266},[260,2130,2064],{"class":280},[10,2132,2133,2134,1157,2137,2139,2140,2142],{},"两个问题的成因相同：",[47,2135,2136],{},"同一个类名在两种语义下被复用",[37,2138,1933],{}," 既是容器也是列表页的内边距来源，",[37,2141,2028],{}," 既是区块小标题也是首屏主标题。分开之后就没这个问题。",[10,2144,2145],{},"修完实测：hero 左 0、底 1、右 10（右侧那 10px 是站点滚动条本身的宽度），标题 132px 满值，8\u002F8 页仍零溢出。",[14,2147,2149],{"id":2148},"九加载页期间的一个时序漏洞","九、加载页期间的一个时序漏洞",[10,2151,2152],{},"这个站有一个启动加载页，首次访问时盖几秒。加载页盖着的时候，入场动画被暂停了——否则动画会在遮挡期间自己跑完。",[10,2154,2155],{},"副作用是页面切换的幕布动画也被冻住：",[1163,2157,2158],{},[10,2159,2160],{},"实测（加载页 3s 时点击）：t=745 路由已切到 \u002Fcolumns 而幕布 display:none，t=1406 幕布才出现并扫过。",[10,2162,2163],{},"路由在没有幕布的情况下切换了，等加载页结束、时间线恢复之后，被冻住的补间才补播。",[10,2165,2166],{},"结论是补一行守卫：加载页盖着时直接放行，不放幕布。理由是：",[1163,2168,2169],{},[10,2170,2171],{},"真实用户点不到——加载页是全屏遮罩。但浏览器前进\u002F后退、程序化跳转仍可能在窗口内触发导航。",[14,2173,2175],{"id":2174},"十整屏分页的账","十、整屏分页的账",[10,2177,2178],{},"这套改动最后收敛成一张数字表：",[1083,2180,2181,2191],{},[1086,2182,2183],{},[1089,2184,2185,2188],{},[1092,2186,2187],{},"项目",[1092,2189,2190],{},"数值",[1102,2192,2193,2201,2209,2217,2225,2233,2241],{},[1089,2194,2195,2198],{},[1107,2196,2197],{},"页数",[1107,2199,2200],{},"8",[1089,2202,2203,2206],{},[1107,2204,2205],{},"吸附类型",[1107,2207,2208],{},"proximity，阈值 30%，防抖 500ms",[1089,2210,2211,2214],{},[1107,2212,2213],{},"吸附时长",[1107,2215,2216],{},"1.1s",[1089,2218,2219,2222],{},[1107,2220,2221],{},"分页生效条件",[1107,2223,2224],{},"宽 ≥ 1024px 且 高 ≥ 620px 且 未开启减弱动效",[1089,2226,2227,2230],{},[1107,2228,2229],{},"溢出",[1107,2231,2232],{},"三档均 0\u002F8",[1089,2234,2235,2238],{},[1107,2236,2237],{},"填充率",[1107,2239,2240],{},"61%~100%（改前 47%~63%）",[1089,2242,2243,2246],{},[1107,2244,2245],{},"首屏标题",[1107,2247,2248],{},"132px（1440×900、1920×1080）、92.9px（1366×768）",[10,2250,2251],{},"每一条都有一次实测在后面。这类改动没别的办法——「一屏装不装得下」这件事只能量，量完才知道该压间距还是该砍内容。",[10,2253,2254,2255,2257],{},"中间还有两处是判断上的错，不是量的问题：一是用 ",[37,2256,1423],{}," 吸附（把用户的滑动抢走了），二是把「矮视口」定在 900（把有空间的屏幕当成没空间）。前者的修法是换模式，后者的修法是挪断点。",[783,2259,2260],{},"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 pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":136,"searchDepth":284,"depth":284,"links":2262},[2263,2264,2266,2267,2268,2269,2270,2271,2272,2273],{"id":1387,"depth":284,"text":1388},{"id":1419,"depth":284,"text":2265},"二、mandatory 是错的",{"id":1544,"depth":284,"text":1545},{"id":1728,"depth":284,"text":1729},{"id":1790,"depth":284,"text":1791},{"id":1865,"depth":284,"text":1866},{"id":1901,"depth":284,"text":1902},{"id":1917,"depth":284,"text":1918},{"id":2148,"depth":284,"text":2149},{"id":2174,"depth":284,"text":2175},"2026-08-02",{},"\u002F2026-08-02",{"title":1370,"description":1375},"2026-08-02-首页改成八屏","把博客首页从连续滚动改成八屏整屏分页。吸附用 Lenis 自带能力而不是 CSS 原生，断点按实测调整，每一档的溢出与填充率都有数字。",[2281,2282,2283,2284,2285,2286],"GSAP","Lenis","滚动吸附","响应式断点","页面结构","prefers-reduced-motion","2o9dKTJYPwCLxF__bLLN46O2x_0PMJoQgyZSxZpO428",1789212573219]