# 踩坑总结(我们真踩过的,不是想象) > 每条格式:**现象 → 根因 → 对策**。带 `实证` 的是当晚真发生过的。 > 如果你正打算做"手机远程操控本机应用 + 消息通知 + 反向召唤",这份清单能省你几个晚上。 --- ## 1. IM 平台"群机器人"只能发、不能收 ⭐最容易被忽略 - **现象**:用企业微信群机器人 webhook 推送成功,但用户在群里给机器人发消息,程序完全收不到。 - **根因**:群机器人的接口设计就是**单向出站**(只提供 `webhook/send`)。它不是"双向机器人"。 - **对策**:收消息必须换通道—— - **企业微信「智能机器人」**(Bot ID + Secret,支持长连接); - 或飞书自建应用 / QQ 机器人(同为长连接模式)。 - **判据**:平台文档里若找不到"接收消息/事件订阅"的能力,它就收不了。 ## 2. 企业微信回调:GET 验证必须回**解密后的明文** - **现象**:在企微后台保存"接收消息"URL 时提示**回填不通过**;但自己看日志,请求确实到了、签名也验过了。 - **根因**:企微回调的 `echostr` 是**AES 加密**的。签名校验用**收到的密文**, 但**应答必须回解密后的明文**。直接回密文原文 → 平台判定失败。 - **对策**:验签用密文,回包用 `decrypt(echostr)`。本地可用同一 AESKey 自造一个加密 echostr 做自测。 - **实证**:我们自测脚本通过后,企微后台立刻保存成功。 ## 3. 快速隧道(quick tunnel)**每次重启换随机域名** ⭐本方案放弃回调模式的根本原因 - **现象**:调试期间地址换了 3 次;用户在平台后台填好的回调地址,电脑一重启就失效。 - **根因**:`cloudflared --url ...` 这类快速隧道,域名是进程启动时随机分配的,**无法指定、无法保持**。 - **对策**: - **正解**:改用**长连接**收消息(客户端主动连出去,压根不需要公网地址)。 - 若必须用回调:需要**固定**公网地址 = 自己的域名 + 命名隧道,或付费/需实名的穿透服务。 - **附加坑**:固定地址的成本选项里, - **花生壳免费版明确不含 HTTP/HTTPS 映射、不含"映射保留"**(域名会被回收)→ 不可用; - **ngrok 免费版有固定开发域名**,但条款写明 **HTTP/S 端点会插一个"拦截页"** → 平台回调的 User-Agent 多为浏览器样式,很可能被挡,风险高。 ## 4. 后台常驻进程被"会话作业对象"连坐杀掉 ⭐Windows 上必踩 - **现象**:用 `wscript`/`VBS`"甩出去"的常驻进程,一开始活着;主程序(终端/IDE/桌面应用)一重启, 它连同保活壳一起消失,日志里**没有崩溃记录**(说明不是自己死的,是被整体杀的)。 - **根因**:Windows 的 Job Object 会级联终止子进程。"detached/不等待"**不等于脱离作业对象**。 - **对策**:常驻件一律交给**计划任务**托管(进程归属任务计划服务 `svchost`,与调用方会话解耦): - 触发器 `AtLogOn`; - 设置 `RestartCount=999`、`RestartInterval=1min`、`ExecutionTimeLimit=0`(不限时)、`Hidden`; - 用 `-WorkingDirectory` 指定工作目录;启动后 `Get-CimInstance Win32_Process` 追父进程**验证血缘**。 - **实证**:改计划任务后,父进程链变为 `node → svchost → services.exe`,桌面程序重启不再影响它。 ## 5. 脚本里写中文 → 编码不对直接解析失败(.vbs 与 .ps1 都中招) ⭐ - **现象 A(.vbs)**:`cscript` 执行报 `The system cannot find the path specified`,但路径肉眼看着没问题。 - **现象 B(.ps1)**:PowerShell 5.1 报 `字符串缺少终止符`,明明引号是配对的。 - **根因**:Windows 上 `.vbs` 按 **ANSI** 读;**PowerShell 5.1 读无 BOM 的 `.ps1` 也按 ANSI**。 我们用 UTF-8 存了中文,字节被按 GBK 解码——某些汉字的字节序列里恰好含有 `"` 或 `'` 的字节, **把字符串"提前结束"了**,于是报语法错。 - **对策**: - `.vbs`:**保持纯 ASCII**(注释也别用中文);路径用 `ScriptFullName` 自行推导。 - `.ps1`:**存成带 UTF-8 BOM**(`EF BB BF`),PowerShell 5.1 就会按 UTF-8 解析; 或者脚本内容只用 ASCII。 - 交付给他人的脚本,优先选"全 ASCII + 参数化路径",最省事。 - **实证**:同一个打包脚本,无 BOM 时报解析错;加上 BOM 后立刻正常执行。 ## 6. 反向代理必须同时改写 `Host` **和** `Origin` - **现象**:HTTP 请求通过代理正常(401/302 都对),但 WebSocket 长连接被拒(403 或直接断)。 - **根因**:目标应用做**信任校验**:`Host` 必须是 loopback / 绑定 IP / 白名单; 且当请求带 `Origin` 时,**`Origin` 必须与 `Host` 同权威**。只改 Host 不改 Origin → 过不了。 - **对策**:转发时同时改写 `host` 与 `origin`(本方案统一改写成上游 `127.0.0.1:<端口>`)。 另外,**WebSocket 升级请求(upgrade)也要走同一套改写**,别只处理普通请求。 ## 7. 被代理应用"自动续钥匙"是把双刃剑 - **现象**:手机上点通知直接进界面,不用输账号——很爽;但**拿到网址的人也不用输**。 - **根因**:为了让"点通知即进",代理在**访客没有登录票据时会自动注入一次性钥匙**。 - **对策**: - 临时门**只开短期**、链接**只发私密渠道**; - 需要更强隔离时,在代理上加一道**门卫口令**(首次访问需带 `?k=...`,换取 HttpOnly cookie)。 - **务必在文档里写明这个权衡**,别让使用者以为是"有密码保护"。 ## 8. DSH 端口每次启动都会变 - **现象**:重启桌面程序后,代理指向的端口失效。 - **根因**:DSH 的 Web 监听端口是启动时确定的(不同实例可能不同)。 - **对策**:不要让代理硬编码端口。插件在激活时把当前上游写成 `upstream.json`,代理**定时读文件**自动跟随。 - **副产品**:这样代理和 DSH 完全解耦,DSH 怎么升级都不用改代理。 ## 9. 客户端插件"装了但界面上看不到" - **现象**:插件提示安装成功、Host 半功能正常,但页面里没有新增面板。 - **根因**:浏览器加载的**客户端模块清单在页面启动那一刻固定**(含 `dsh.client` 声明的插件才进入清单)。 - 先装了一个**没有** `dsh.client` 的版本,之后才补上 → 运行中的 Host 的模块图里仍然没有它。 - **对策**:补上 `dsh.client` 后**重新安装一次插件**,并**刷新页面**(必要时重启宿主程序)。 - **自检**:直接请求页面 HTML,grep 插件包名,确认它已进入引导图。 ## 10. 企微应用"回消息失败"报 `41004 corpsecret missing` - **现象**:能收到用户消息,但程序回消息失败,日志 `gettoken 失败 errcode=41004`。 - **根因**:只配了收消息所需的 `Token/AESKey`,**没配发消息所需的 `Secret`**。 - **对策**:把 `corpId` / `agentId` / `secret` 补齐(或用长连接机器人,凭据只有 `botId`+`secret`,更省事)。 ## 11. 国内网络下"下载安装包"这一步最容易卡住 - **现象**:`winget` 下载长时间无输出;`curl` 直连 GitHub 报 `Connection was reset`(收到 0 字节)。 - **根因**:GitHub Release 域名在国内常被重置;DNS 能解析不代表能下载。 - **对策**: - 换**国内镜像**(如 `ghfast.top` 之类)下载; - **但必须验签**:`Get-AuthenticodeSignature` 检查签名者是否为官方(本方案验的是 Cloudflare 的 DigiCert 代码签名), **签名有效才使用**;记录 SHA256 留痕。 - **原则**:可以借道镜像,**但不能借道信任**。 ## 12. 回调端点必须"只认密信",别顺手把主应用暴露出去 - **现象(风险)**:为收回调开了公网口,图省事把整个主应用也代理出去。 - **根因**:公网口只要存在就会被扫描。 - **对策**:回调端点**独立进程、独立端口**,只做三件事: 1. 验签(签名不符直接 403); 2. AES 解密取消息; 3. 其他任何路径一律 404。 **不代理主应用、不暴露界面**。这样即使地址被扫到,外面看到的也只是个拒绝一切的壳。 - **实测**:无签名 → 403,伪造签名 → 403,乱路径 → 404。 ## 13. "手机能连"这件事,只有真机能证明 - **现象**:电脑侧 curl 全绿(302/200/WebSocket 101),但手机打不开。 - **根因**:可能存在只影响真机的因素(cookie 权威绑定、浏览器 SameSite、系统省电策略等)。 - **对策**:把"电脑侧验证"和"真机验证"分开写进验收清单,**别把电脑侧全绿说成端到端可用**。 - **取证**:在代理日志里找真机请求特征(如 WebSocket `upgrade ok` 的时间串), 真机实测成功时会有**成簇**的记录,而不是零散一两条。 ## 14. 推送通道里别放"长期有效凭据" - **现象(风险)**:通知里带的是**含一次性钥匙的登录链接**。 - **根因**:第三方推送服务会暂存消息;谁看到通知谁就拿到链接。 - **对策**: - 通知里的链接**只对私密频道发送**; - 登录票据有效期尽量短(本方案靠"临时门到点自动关"来收紧); - 公开分享的文档/示例里**绝不出现真实地址、Bot ID、Secret、Webhook key、频道名**。 --- > 下面 4 条是 2026-10-19 一次"公网打不开"排查里挖出来的,都属于**"看起来成了、其实没成"**这一类。 ## 15. "公网地址打印出来了"≠"隧道连上了"(最坑的一条) - **现象**:开门脚本打印出 `https://xxx.trycloudflare.com`,看着像成功; 但该地址点开要么转圈、要么 `530`。日志里随后还有一行 `ERR Initiating shutdown error="Couldn't resolve SRV record ...: no such host"`, cloudflared **自己退出**了。 - **根因**:cloudflared 会**先**把分配到的地址打印出来,**再**去连边缘节点; 连不上时它就打印完地址直接退出。只按"看到地址"判断成功,就会**把一条死链推给用户**。 - **实测数据**:同一台机器、同一条命令,连试 11 次挂了 4 次(约三成), 失败时 100% 会推出死链。 - **对策**(`temp-door.mjs` 已按此改): 1. 必须同时看到地址 **和** `Registered tunnel connection`(边缘连接真的注册上了)才算建立; 2. 建立后再**从公网侧实拨一次**,拿到 HTTP 响应(含 302)才算真能用; 3. 任一条件不满足就**自动重试**(3 次,间隔 3s/8s); 4. 三次都不成 → 只推一条"没开成",**绝不推链接**; 5. 门开着期间隧道掉线,另推一条"门中途断了"。 - **通用教训**:任何"分配了地址就算成功"的第三方服务,都要再加一道**从对端真实可达**的验证。 ## 16. 长连接会"静默假死":进程活着 ≠ 通道通着 - **现象**:机器人进程在、任务状态是 Running、日志每 10 分钟照常打印一行 `isConnected=false`,**连续 9 小时 20 分钟**;期间手机发消息它一条都收不到,没有任何人知道。 - **根因**:TCP 静默断开后 SDK 没收到任何事件(无 `close`、无 `error`、不重连), 成了"半开连接"。只看进程是否存活、只看任务状态,都会判成"健康"。 - **对策**: - 自检**每分钟**一次,连续 2 分钟未连接 → 主动 `connect()` 重连一次; - 重连后仍连续 5 分钟未连接 → **主动退出**,交给保活壳 3 秒内拉一个全新连接; - 在线时每 10 分钟留一条 `isConnected=true` 记录(保活证据要留痕,不能只留故障)。 - **通用教训**:长连接的"判活"必须落在一个**能反映真实连通性的字段**上,且要能在异常时**自动重启**,不能只记录。 ## 17. 通知里放"局域网地址"= 出门必然打不开 - **现象(本次投诉根因)**:任务完成通知里的链接,在家里 WiFi 下一直好使, 一出门用流量就必然打不开。 - **根因**:插件按配置的 `mobileBaseUrl`(家里局域网 IP,如 `192.168.x.x:51130`) 改写链接;**局域网地址在公网上根本不存在**,与门开没开无关。 插件当时也不知道"临时门"的存在,永远不会把公网地址放进通知。 - **对策**(v0.3.0):通知里的链接**一律走公网**——发通知前先复用还够用的临时门, 不够就**自动开一扇 30 分钟的**(`--quiet`,不额外推消息),把公网地址放进通知; 开不成就不带链接。**不再靠用户去分辨内网/公网。** - **通用教训**:凡是"发出去的链接",先问一句**"收链接的人当时在哪张网里"**; 只在某张网里有效的地址,不要放进通知。 ## 18. 到点自动关闭的提醒,被 `process.exit()` 打断了 - **现象**:门到点确实关了(地址变 `530`),但"门已自动关闭"的通知**从来没到过**手机上。 - **根因**:代码是"发通知(fire-and-forget,没 await)→ 紧接着 `process.exit(0)`", 进程在 HTTP 请求发出去之前就没了。 - **对策**:收尾类推送**必须 await 完再退出**(`setTimeout(async () => { ... await push(...) ; process.exit(0) })`)。 - **通用教训**:`void someAsync()` 后面紧跟 `process.exit()`,等于把这条异步动作删掉。 ## 19. 公共状态文件 + 多个进程 = 误杀 - **现象**:新开的门突然消失(公网地址变 `530`),日志里却是旧门那句"到点自动关"; 旧门进程其实早过了自己的时限、只是还没退出。 - **根因**:关门逻辑一律按**公共状态文件**(`door-state.json`)里的进程号去关隧道。 文件里记的永远是"当前那一扇",两扇门并存时,**旧门到点就把新门杀了**。 - **对策**:关自己的门用**自己的进程号**(把自己那条隧道句柄传进去); 只有当状态文件里记的仍是自己时才清除它,别把后来那扇门的记录抹掉。 - **通用教训**:多个进程共享一个"当前值"文件时, **写入方自己持有的事实**(自己起的子进程)要优先于文件里的值。