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