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