[{"data":1,"prerenderedAt":12188},["ShallowReactive",2],{"seo-locale-availability:\u002Fblog\u002Ftunneldeck-desktop-ssh-tunnel-guide":3,"post:zh-CN:\u002Fblog\u002Ftunneldeck-desktop-ssh-tunnel-guide":7,"surroundings:zh-CN":787},[4,5,6],"zh-CN","en","ja",{"post":8,"contentLocale":4,"isFallback":764},{"id":9,"title":10,"accent":11,"body":12,"description":763,"draft":764,"experience":765,"extension":766,"featured":767,"image":768,"meta":774,"navigation":767,"path":775,"publishedAt":776,"readingTime":777,"seo":778,"stem":779,"tags":780,"updatedAt":785,"video":765,"__hash__":786},"posts_zh\u002Fblog\u002Ftunneldeck-desktop-ssh-tunnel-guide.md","TunnelDeck 桌面版使用指南：把 ssh -L 变成可保存的隧道","mint",{"type":13,"value":14,"toc":736},"minimark",[15,29,32,67,77,80,85,96,118,133,136,139,212,226,234,238,245,250,253,291,294,313,319,338,342,353,359,381,398,402,405,425,440,444,447,498,511,515,518,521,524,530,533,544,551,555,558,565,569,572,589,592,599,603,606,609,612,615,618,637,641,644,648,654,657,660,663,666,669,676,691,695,706,709,729,732],[16,17,18,19,23,24,28],"p",{},"SSH 本地端口转发很好用，但当命令越来越多，记住端口、目标地址和密钥位置就会变得麻烦。",[20,21,22],"strong",{},"TunnelDeck"," 是我做的一款轻量、跨平台的 SSH 隧道桌面工具：它把 ",[25,26,27],"code",{},"ssh -L"," 变成可以保存、编辑、快速启停的连接，同时保留主机指纹校验、系统凭据存储和本地监听等安全边界。",[16,30,31],{},"例如，下面这条命令：",[33,34,39],"pre",{"className":35,"code":36,"language":37,"meta":38,"style":38},"language-bash shiki shiki-themes github-dark github-dark","ssh -L 9108:127.0.0.1:9108 -p 33899 root@ssh.example.com\n","bash","",[25,40,41],{"__ignoreMap":38},[42,43,46,50,54,58,61,64],"span",{"class":44,"line":45},"line",1,[42,47,49],{"class":48},"sFR8T","ssh",[42,51,53],{"class":52},"s8ozJ"," -L",[42,55,57],{"class":56},"s4wv1"," 9108:127.0.0.1:9108",[42,59,60],{"class":52}," -p",[42,62,63],{"class":52}," 33899",[42,65,66],{"class":56}," root@ssh.example.com\n",[16,68,69,70,73,74,76],{},"可以直接导入 TunnelDeck。连接建立后，本机访问 ",[25,71,72],{},"127.0.0.1:9108","，流量会经过 SSH 服务器转发到它所能访问的 ",[25,75,72],{},"。",[16,78,79],{},"本文以桌面版 v0.3.1 为主，完整介绍安装、创建连接、密码与私钥认证、首次指纹确认、网页快捷入口和常见故障排查。",[81,82,84],"h2",{"id":83},"tunneldeck-适合什么场景","TunnelDeck 适合什么场景",[16,86,87,88,95],{},"TunnelDeck 当前专注于 SSH ",[20,89,90,91,94],{},"本地端口转发（",[25,92,93],{},"-L","）","，适合：",[97,98,99,103,106,109,115],"ul",{},[100,101,102],"li",{},"临时访问只监听在服务器本机的管理面板；",[100,104,105],{},"通过跳板机访问数据库、开发服务或内网 Web 应用；",[100,107,108],{},"保存多条常用隧道，按需启动或停止；",[100,110,111,112,114],{},"不想每次重新输入一整条 ",[25,113,27],{}," 命令；",[100,116,117],{},"希望网页隧道连接后，可以再手动用默认浏览器打开。",[16,119,120,121,124,125,128,129,132],{},"它不是 VPN，也不提供远程转发 ",[25,122,123],{},"-R","、SOCKS 动态代理 ",[25,126,127],{},"-D"," 或远程 Shell。导入器还会拒绝 ",[25,130,131],{},"ProxyCommand"," 等可能执行外部命令的选项。",[81,134,135],{"id":135},"先看懂四个关键参数",[16,137,138],{},"以开头的命令为例：",[140,141,142,158],"table",{},[143,144,145],"thead",{},[146,147,148,152,155],"tr",{},[149,150,151],"th",{},"配置项",[149,153,154],{},"示例值",[149,156,157],{},"含义",[159,160,161,175,188,200],"tbody",{},[146,162,163,167,172],{},[164,165,166],"td",{},"SSH 服务器",[164,168,169],{},[25,170,171],{},"ssh.example.com:33899",[164,173,174],{},"TunnelDeck 实际登录的 SSH 主机和端口",[146,176,177,180,185],{},[164,178,179],{},"SSH 用户",[164,181,182],{},[25,183,184],{},"root",[164,186,187],{},"SSH 登录用户",[146,189,190,193,197],{},[164,191,192],{},"本地入口",[164,194,195],{},[25,196,72],{},[164,198,199],{},"你的电脑上供应用访问的地址",[146,201,202,205,209],{},[164,203,204],{},"远程目标",[164,206,207],{},[25,208,72],{},[164,210,211],{},"从 SSH 服务器视角访问的目标",[16,213,214,215,218,219,222,223,76],{},"最容易混淆的是最后一项：远程目标里的 ",[25,216,217],{},"127.0.0.1"," 指 ",[20,220,221],{},"SSH 服务器自身","，不是正在运行 TunnelDeck 的电脑。如果服务位于 SSH 服务器能访问的另一台内网机器，就应填写那台机器的地址，例如 ",[25,224,225],{},"10.0.0.20:8080",[33,227,232],{"className":228,"code":230,"language":231,"meta":38},[229],"language-text","本机应用\n  └─ 127.0.0.1:9108\n       └─ 加密 SSH 连接 → ssh.example.com:33899\n            └─ SSH 服务器发起连接 → 127.0.0.1:9108\n","text",[25,233,230],{"__ignoreMap":38},[81,235,237],{"id":236},"_1-安装桌面版","1. 安装桌面版",[16,239,240,241,244],{},"目前项目还没有 Apple 或 Windows 代码签名身份，因此 v0.3.1 ",[20,242,243],{},"不提供未签名的预编译桌面安装包","。安装脚本会先检查环境，再下载固定版本源码并在本机完成构建。这种方式不会要求你绕过 macOS Gatekeeper 或 Windows SmartScreen 去运行来源不明的二进制文件。",[246,247,249],"h3",{"id":248},"macos-与-linux","macOS 与 Linux",[16,251,252],{},"建议先只检查依赖：",[33,254,256],{"className":35,"code":255,"language":37,"meta":38,"style":38},"curl -fsSL https:\u002F\u002Fraw.githubusercontent.com\u002FNciae-Zyh\u002FTunnelDeck\u002Fv0.3.1\u002Finstall.sh \\\n  | sh -s -- --check\n",[25,257,258,272],{"__ignoreMap":38},[42,259,260,263,266,269],{"class":44,"line":45},[42,261,262],{"class":48},"curl",[42,264,265],{"class":52}," -fsSL",[42,267,268],{"class":56}," https:\u002F\u002Fraw.githubusercontent.com\u002FNciae-Zyh\u002FTunnelDeck\u002Fv0.3.1\u002Finstall.sh",[42,270,271],{"class":52}," \\\n",[42,273,275,279,282,285,288],{"class":44,"line":274},2,[42,276,278],{"class":277},"sOPea","  |",[42,280,281],{"class":48}," sh",[42,283,284],{"class":52}," -s",[42,286,287],{"class":52}," --",[42,289,290],{"class":52}," --check\n",[16,292,293],{},"确认输出后再安装：",[33,295,297],{"className":35,"code":296,"language":37,"meta":38,"style":38},"curl -fsSL https:\u002F\u002Fraw.githubusercontent.com\u002FNciae-Zyh\u002FTunnelDeck\u002Fv0.3.1\u002Finstall.sh | sh\n",[25,298,299],{"__ignoreMap":38},[42,300,301,303,305,307,310],{"class":44,"line":45},[42,302,262],{"class":48},[42,304,265],{"class":52},[42,306,268],{"class":56},[42,308,309],{"class":277}," |",[42,311,312],{"class":48}," sh\n",[16,314,315,316,318],{},"没有 ",[25,317,262],{}," 时也可以使用：",[33,320,322],{"className":35,"code":321,"language":37,"meta":38,"style":38},"wget -qO- https:\u002F\u002Fraw.githubusercontent.com\u002FNciae-Zyh\u002FTunnelDeck\u002Fv0.3.1\u002Finstall.sh | sh\n",[25,323,324],{"__ignoreMap":38},[42,325,326,329,332,334,336],{"class":44,"line":45},[42,327,328],{"class":48},"wget",[42,330,331],{"class":52}," -qO-",[42,333,268],{"class":56},[42,335,309],{"class":277},[42,337,312],{"class":48},[246,339,341],{"id":340},"windows-powershell","Windows PowerShell",[33,343,347],{"className":344,"code":345,"language":346,"meta":38,"style":38},"language-powershell shiki shiki-themes github-dark github-dark","irm https:\u002F\u002Fraw.githubusercontent.com\u002FNciae-Zyh\u002FTunnelDeck\u002Fv0.3.1\u002Finstall.ps1 | iex\n","powershell",[25,348,349],{"__ignoreMap":38},[42,350,351],{"class":44,"line":45},[42,352,345],{},[16,354,355,356,76],{},"安装器会检查 Go 1.25+、Node.js 20+ 和对应平台的构建依赖。缺少 Go 或 Node.js 时，它们会被下载到 TunnelDeck 的私有用户目录，并校验官方 SHA-256，不会修改全局 ",[25,357,358],{},"PATH",[97,360,361,368,374],{},[100,362,363,364,367],{},"macOS 还需要 Xcode Command Line Tools，应用安装到 ",[25,365,366],{},"~\u002FApplications\u002FTunnelDeck.app","；",[100,369,370,371,367],{},"Linux 需要 GTK3 与 WebKitGTK 4.1，程序安装到 ",[25,372,373],{},"~\u002F.local\u002Fbin\u002FTunnelDeck",[100,375,376,377,380],{},"Windows 需要 WebView2，程序安装到 ",[25,378,379],{},"%LOCALAPPDATA%\\Programs\\TunnelDeck\\","，同时创建开始菜单入口。",[16,382,383,384,391,392,397],{},"将远程脚本交给 Shell 前，建议先打开 ",[385,386,390],"a",{"href":387,"rel":388},"https:\u002F\u002Fgithub.com\u002FNciae-Zyh\u002FTunnelDeck\u002Fblob\u002Fv0.3.1\u002Finstall.sh",[389],"nofollow","v0.3.1 安装脚本","阅读内容。完整依赖和手动构建方式也记录在",[385,393,396],{"href":394,"rel":395},"https:\u002F\u002Fgithub.com\u002FNciae-Zyh\u002FTunnelDeck\u002Fblob\u002Fv0.3.1\u002Fdocs\u002FSOURCE_INSTALL.md",[389],"源码安装指南","中。",[81,399,401],{"id":400},"_2-导入已有的-ssh-l-命令","2. 导入已有的 ssh -L 命令",[16,403,404],{},"如果手上已有可以工作的命令，最快的方法是：",[406,407,408,411,416,419,422],"ol",{},[100,409,410],{},"打开 TunnelDeck，点击“导入命令”；",[100,412,413,414,114],{},"粘贴完整的 ",[25,415,27],{},[100,417,418],{},"检查解析出的 SSH 主机、端口、用户、本地入口和远程目标；",[100,420,421],{},"为连接填写一个便于识别的名称；",[100,423,424],{},"选择认证方式，保存后启动。",[16,426,427,428,431,432,434,435,437,438,76],{},"TunnelDeck 解析参数后由 Go SSH 库直接建立连接，",[20,429,430],{},"不会把输入拼接成 Shell 命令执行","。为了把导入边界保持在可审计范围内，目前只接受一条本地转发，不接受远程命令、",[25,433,123],{},"、",[25,436,127],{}," 或 ",[25,439,131],{},[81,441,443],{"id":442},"_3-手动创建一条隧道","3. 手动创建一条隧道",[16,445,446],{},"点击“新建”，依次填写：",[406,448,449,455,468,474,483,489,495],{},[100,450,451,454],{},[20,452,453],{},"名称","：例如“开发环境面板”；",[100,456,457,460,461,464,465,367],{},[20,458,459],{},"SSH 服务器与端口","：例如 ",[25,462,463],{},"ssh.example.com"," 和 ",[25,466,467],{},"33899",[100,469,470,473],{},[20,471,472],{},"SSH 用户名","：使用服务器实际允许登录的用户；",[100,475,476,479,480,482],{},[20,477,478],{},"本地监听地址与端口","：通常保持 ",[25,481,217],{},"，再选一个未占用端口；",[100,484,485,488],{},[20,486,487],{},"远程目标地址与端口","：填写 SSH 服务器可以访问的服务；",[100,490,491,494],{},[20,492,493],{},"认证方式","：选择密码或 SSH 私钥；",[100,496,497],{},"点击“保存并启动”。",[16,499,500,501,503,504,437,507,510],{},"默认绑定 ",[25,502,217],{},"，意味着只有本机程序能访问这条隧道。除非明确需要让局域网其他设备接入，并且已经配置系统防火墙，否则不要改成 ",[25,505,506],{},"0.0.0.0",[25,508,509],{},"::","。TunnelDeck 在这种情况下也会显示暴露风险提示。",[81,512,514],{"id":513},"_4-选择密码或私钥认证","4. 选择密码或私钥认证",[246,516,517],{"id":517},"密码认证",[16,519,520],{},"输入 SSH 登录密码即可。默认不要勾选“记住凭据”，密码只在当前运行期间保留，停止隧道后会从隧道对象中清除。",[246,522,523],{"id":523},"私钥认证",[16,525,526,527,76],{},"选择私钥文件；如果私钥已加密，再输入私钥口令。公钥需要提前写入服务器对应用户的 ",[25,528,529],{},"authorized_keys",[16,531,532],{},"如果主动启用“记住凭据”，TunnelDeck 会调用操作系统提供的安全存储：",[97,534,535,538,541],{},[100,536,537],{},"macOS Keychain；",[100,539,540],{},"Windows Credential Manager；",[100,542,543],{},"Linux Secret Service。",[16,545,546,547,550],{},"普通的 ",[25,548,549],{},"profiles.json"," 不保存密码、私钥内容或私钥口令。即使如此，服务器端仍应使用最小权限账号，限制可登录来源，并定期轮换密钥。",[81,552,554],{"id":553},"_5-首次连接要核对主机指纹","5. 首次连接要核对主机指纹",[16,556,557],{},"第一次连接某台 SSH 服务器时，TunnelDeck 会显示其 SHA-256 主机指纹。不要只因为弹窗出现就直接信任：应通过服务器控制台、运维文档或其他可信渠道核对指纹，再点击“信任并连接”。",[16,559,560,561,564],{},"确认后的记录写入 TunnelDeck 独立的 ",[25,562,563],{},"known_hosts","。如果服务器主机密钥后来发生变化，连接会被阻止，而不是静默接受新密钥。正常重装服务器也可能导致变化，但在确认原因之前，不要绕过这个警告——它也可能意味着主机写错或连接被劫持。",[81,566,568],{"id":567},"_6-连接后访问服务","6. 连接后访问服务",[16,570,571],{},"隧道显示“运行中”后，使用本地入口访问服务：",[97,573,574,580,586],{},[100,575,576,577,367],{},"网页：在浏览器打开 ",[25,578,579],{},"http:\u002F\u002F127.0.0.1:9108",[100,581,582,583,585],{},"数据库：让数据库客户端连接 ",[25,584,217],{}," 和本地端口；",[100,587,588],{},"其他 TCP 服务：同样把客户端目标改为本地入口。",[16,590,591],{},"如果目标是网页，可以在配置中启用“这是网页服务”，并选择 HTTP 或 HTTPS。连接成功后会出现“打开网页”按钮，由用户点击后调用系统默认浏览器。",[16,593,594,595,598],{},"TunnelDeck ",[20,596,597],{},"不会","在启动应用、启动隧道或自动重连时擅自打开网页。因为很多端口并不是 HTTP 服务，把“连接”和“打开网页”分开能避免错误跳转，也不会在后台重连时不断弹出浏览器窗口。",[81,600,602],{"id":601},"_7-日常管理与自动重连","7. 日常管理与自动重连",[16,604,605],{},"列表会显示每条配置的运行状态和活动连接数。你可以按需启动、停止、编辑或删除配置。编辑运行中的隧道时，应先停止旧连接，再让新参数生效，避免误以为界面已改但旧监听仍在运行。",[16,607,608],{},"对于临时网络中断，TunnelDeck 会发送 SSH keepalive，并使用 2–30 秒的指数退避尝试重连。重连只恢复隧道，不会自动打开网页。",[81,610,611],{"id":611},"常见问题排查",[246,613,614],{"id":614},"本地端口无法监听",[16,616,617],{},"通常是端口已被其他程序占用。换一个本地端口，或先确认占用者：",[33,619,621],{"className":35,"code":620,"language":37,"meta":38,"style":38},"lsof -nP -iTCP:9108 -sTCP:LISTEN\n",[25,622,623],{"__ignoreMap":38},[42,624,625,628,631,634],{"class":44,"line":45},[42,626,627],{"class":48},"lsof",[42,629,630],{"class":52}," -nP",[42,632,633],{"class":52}," -iTCP:9108",[42,635,636],{"class":52}," -sTCP:LISTEN\n",[246,638,640],{"id":639},"ssh-服务器连不上","SSH 服务器连不上",[16,642,643],{},"先检查域名、SSH 端口、用户名、防火墙和当前网络是否允许访问。可以在终端使用相同参数做一次基础连接测试，但不要把密码写进命令或截图。",[246,645,647],{"id":646},"隧道已运行但目标服务打不开","隧道已运行，但目标服务打不开",[16,649,650,651,653],{},"从 SSH 服务器的视角检查目标地址。尤其注意：远程目标写 ",[25,652,217],{}," 时，服务必须运行在 SSH 服务器本机。还要确认目标服务确实监听对应端口，并允许 SSH 用户所在主机访问。",[246,655,656],{"id":656},"私钥无法使用",[16,658,659],{},"检查是否选错公钥文件、私钥格式是否受支持，以及加密私钥的口令是否正确。私钥应只对当前系统用户开放读取权限。",[246,661,662],{"id":662},"出现主机密钥变更警告",[16,664,665],{},"先暂停连接并核对服务器是否重装、域名是否解析到了新主机。只有通过可信渠道确认新指纹后，才更新信任记录；不要为了“先连上”而关闭校验。",[81,667,668],{"id":668},"为什么桌面版比较轻",[16,670,671,672,675],{},"TunnelDeck 使用 Go、Wails 和 Vue 3 构建，界面复用操作系统 WebView，不会随应用打包一整套 Chromium。SSH 隧道由 ",[25,673,674],{},"golang.org\u002Fx\u002Fcrypto\u002Fssh"," 直接建立。当前 macOS ARM64 应用包约 8.7 MB。",[16,677,678,679,684,685,690],{},"项目采用 MIT License，源码、安装脚本和发布记录都在 ",[385,680,683],{"href":681,"rel":682},"https:\u002F\u002Fgithub.com\u002FNciae-Zyh\u002FTunnelDeck",[389],"TunnelDeck GitHub 仓库","。当前稳定版本可在 ",[385,686,689],{"href":687,"rel":688},"https:\u002F\u002Fgithub.com\u002FNciae-Zyh\u002FTunnelDeck\u002Freleases\u002Ftag\u002Fv0.3.1",[389],"v0.3.1 Release","查看。",[81,692,694],{"id":693},"chrome-应用商店版本已经上线","Chrome 应用商店版本已经上线",[16,696,697,702,703,76],{},[385,698,701],{"href":699,"rel":700},"https:\u002F\u002Fchromewebstore.google.com\u002Fdetail\u002Ftunneldeck\u002Fjnfkjehpbkmfnidfcilehhkpbjjinmod",[389],"TunnelDeck Chrome 侧边栏扩展 v0.3.1","现已在 Chrome Web Store 公开上线，可以直接从商店安装。完整源码、桌面端安装脚本和问题反馈仍统一维护在 ",[385,704,683],{"href":681,"rel":705},[389],[16,707,708],{},"推荐按下面的顺序完成安装：",[406,710,711,714,717,723,726],{},[100,712,713],{},"先按本文第 1 节或 GitHub README 安装 TunnelDeck 桌面端；",[100,715,716],{},"从 Chrome Web Store 安装扩展；",[100,718,719,720,367],{},"启动桌面端，在底部“Chrome 浏览器集成”中确认正式扩展 ID ",[25,721,722],{},"jnfkjehpbkmfnidfcilehhkpbjjinmod",[100,724,725],{},"如果状态显示尚未注册，点击“注册 Chrome 服务”，然后重新加载扩展；",[100,727,728],{},"点击 Chrome 工具栏中的 TunnelDeck 图标，即可在侧边栏管理连接。",[16,730,731],{},"浏览器扩展通过 Native Messaging 控制本机 TunnelDeck，可以创建、编辑、导入和启停连接，也能在用户点击后打开已标记的网页服务；它不会在浏览器进程中自行建立 SSH。因此，商店扩展负责快捷操作，桌面版仍然是实际运行 SSH 隧道的核心。",[733,734,735],"style",{},"html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--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 .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}",{"title":38,"searchDepth":737,"depth":737,"links":738},3,[739,740,741,745,746,747,751,752,753,754,761,762],{"id":83,"depth":274,"text":84},{"id":135,"depth":274,"text":135},{"id":236,"depth":274,"text":237,"children":742},[743,744],{"id":248,"depth":737,"text":249},{"id":340,"depth":737,"text":341},{"id":400,"depth":274,"text":401},{"id":442,"depth":274,"text":443},{"id":513,"depth":274,"text":514,"children":748},[749,750],{"id":517,"depth":737,"text":517},{"id":523,"depth":737,"text":523},{"id":553,"depth":274,"text":554},{"id":567,"depth":274,"text":568},{"id":601,"depth":274,"text":602},{"id":611,"depth":274,"text":611,"children":755},[756,757,758,759,760],{"id":614,"depth":737,"text":614},{"id":639,"depth":737,"text":640},{"id":646,"depth":737,"text":647},{"id":656,"depth":737,"text":656},{"id":662,"depth":737,"text":662},{"id":668,"depth":274,"text":668},{"id":693,"depth":274,"text":694},"从源码安装 TunnelDeck，创建或导入 SSH 本地端口转发，选择密码或私钥认证，核对主机指纹，并安全地打开本地网页服务。",false,null,"md",true,{"src":769,"alt":770,"caption":771,"width":772,"height":773},"\u002Fmedia\u002Fposts\u002Ftunneldeck-desktop-ssh-tunnel-guide\u002Ftunneldeck-macos-v1.webp","TunnelDeck 桌面版新建隧道界面，展示 SSH 服务器、本地入口和远程目标配置","TunnelDeck v0.3.1 桌面版真实界面；示例地址不包含真实服务器或凭据。点击图片可全屏查看。",2360,1624,{},"\u002Fblog\u002Ftunneldeck-desktop-ssh-tunnel-guide","2026-08-07",13,{"title":10,"description":763},"blog\u002Ftunneldeck-desktop-ssh-tunnel-guide",[22,781,782,783,784],"SSH","端口转发","开源工具","Wails","2026-08-10","z5xVIV6kQWiNR8bXRQcxyNofnXsnBlAbQM0bABikqQk",[788,1284,3423,5046,6632,9015,10718,10975,11424,11825],{"id":9,"title":10,"accent":11,"body":789,"description":763,"draft":764,"experience":765,"extension":766,"featured":767,"image":1280,"meta":1281,"navigation":767,"path":775,"publishedAt":776,"readingTime":777,"seo":1282,"stem":779,"tags":1283,"updatedAt":785,"video":765,"__hash__":786,"contentLocale":4,"isFallback":764},{"type":13,"value":790,"toc":1254},[791,797,799,817,823,825,827,833,847,855,857,859,913,921,926,928,932,934,936,962,964,980,984,1000,1002,1010,1014,1028,1036,1038,1040,1054,1064,1066,1068,1102,1110,1112,1114,1116,1118,1122,1124,1132,1136,1138,1140,1144,1146,1148,1160,1162,1166,1168,1170,1172,1174,1176,1178,1192,1194,1196,1198,1202,1204,1206,1208,1210,1212,1216,1224,1226,1234,1236,1250,1252],[16,792,18,793,23,795,28],{},[20,794,22],{},[25,796,27],{},[16,798,31],{},[33,800,801],{"className":35,"code":36,"language":37,"meta":38,"style":38},[25,802,803],{"__ignoreMap":38},[42,804,805,807,809,811,813,815],{"class":44,"line":45},[42,806,49],{"class":48},[42,808,53],{"class":52},[42,810,57],{"class":56},[42,812,60],{"class":52},[42,814,63],{"class":52},[42,816,66],{"class":56},[16,818,69,819,73,821,76],{},[25,820,72],{},[25,822,72],{},[16,824,79],{},[81,826,84],{"id":83},[16,828,87,829,95],{},[20,830,90,831,94],{},[25,832,93],{},[97,834,835,837,839,841,845],{},[100,836,102],{},[100,838,105],{},[100,840,108],{},[100,842,111,843,114],{},[25,844,27],{},[100,846,117],{},[16,848,120,849,124,851,128,853,132],{},[25,850,123],{},[25,852,127],{},[25,854,131],{},[81,856,135],{"id":135},[16,858,138],{},[140,860,861,871],{},[143,862,863],{},[146,864,865,867,869],{},[149,866,151],{},[149,868,154],{},[149,870,157],{},[159,872,873,883,893,903],{},[146,874,875,877,881],{},[164,876,166],{},[164,878,879],{},[25,880,171],{},[164,882,174],{},[146,884,885,887,891],{},[164,886,179],{},[164,888,889],{},[25,890,184],{},[164,892,187],{},[146,894,895,897,901],{},[164,896,192],{},[164,898,899],{},[25,900,72],{},[164,902,199],{},[146,904,905,907,911],{},[164,906,204],{},[164,908,909],{},[25,910,72],{},[164,912,211],{},[16,914,214,915,218,917,222,919,76],{},[25,916,217],{},[20,918,221],{},[25,920,225],{},[33,922,924],{"className":923,"code":230,"language":231,"meta":38},[229],[25,925,230],{"__ignoreMap":38},[81,927,237],{"id":236},[16,929,240,930,244],{},[20,931,243],{},[246,933,249],{"id":248},[16,935,252],{},[33,937,938],{"className":35,"code":255,"language":37,"meta":38,"style":38},[25,939,940,950],{"__ignoreMap":38},[42,941,942,944,946,948],{"class":44,"line":45},[42,943,262],{"class":48},[42,945,265],{"class":52},[42,947,268],{"class":56},[42,949,271],{"class":52},[42,951,952,954,956,958,960],{"class":44,"line":274},[42,953,278],{"class":277},[42,955,281],{"class":48},[42,957,284],{"class":52},[42,959,287],{"class":52},[42,961,290],{"class":52},[16,963,293],{},[33,965,966],{"className":35,"code":296,"language":37,"meta":38,"style":38},[25,967,968],{"__ignoreMap":38},[42,969,970,972,974,976,978],{"class":44,"line":45},[42,971,262],{"class":48},[42,973,265],{"class":52},[42,975,268],{"class":56},[42,977,309],{"class":277},[42,979,312],{"class":48},[16,981,315,982,318],{},[25,983,262],{},[33,985,986],{"className":35,"code":321,"language":37,"meta":38,"style":38},[25,987,988],{"__ignoreMap":38},[42,989,990,992,994,996,998],{"class":44,"line":45},[42,991,328],{"class":48},[42,993,331],{"class":52},[42,995,268],{"class":56},[42,997,309],{"class":277},[42,999,312],{"class":48},[246,1001,341],{"id":340},[33,1003,1004],{"className":344,"code":345,"language":346,"meta":38,"style":38},[25,1005,1006],{"__ignoreMap":38},[42,1007,1008],{"class":44,"line":45},[42,1009,345],{},[16,1011,355,1012,76],{},[25,1013,358],{},[97,1015,1016,1020,1024],{},[100,1017,363,1018,367],{},[25,1019,366],{},[100,1021,370,1022,367],{},[25,1023,373],{},[100,1025,376,1026,380],{},[25,1027,379],{},[16,1029,383,1030,391,1033,397],{},[385,1031,390],{"href":387,"rel":1032},[389],[385,1034,396],{"href":394,"rel":1035},[389],[81,1037,401],{"id":400},[16,1039,404],{},[406,1041,1042,1044,1048,1050,1052],{},[100,1043,410],{},[100,1045,413,1046,114],{},[25,1047,27],{},[100,1049,418],{},[100,1051,421],{},[100,1053,424],{},[16,1055,427,1056,431,1058,434,1060,437,1062,76],{},[20,1057,430],{},[25,1059,123],{},[25,1061,127],{},[25,1063,131],{},[81,1065,443],{"id":442},[16,1067,446],{},[406,1069,1070,1074,1082,1086,1092,1096,1100],{},[100,1071,1072,454],{},[20,1073,453],{},[100,1075,1076,460,1078,464,1080,367],{},[20,1077,459],{},[25,1079,463],{},[25,1081,467],{},[100,1083,1084,473],{},[20,1085,472],{},[100,1087,1088,479,1090,482],{},[20,1089,478],{},[25,1091,217],{},[100,1093,1094,488],{},[20,1095,487],{},[100,1097,1098,494],{},[20,1099,493],{},[100,1101,497],{},[16,1103,500,1104,503,1106,437,1108,510],{},[25,1105,217],{},[25,1107,506],{},[25,1109,509],{},[81,1111,514],{"id":513},[246,1113,517],{"id":517},[16,1115,520],{},[246,1117,523],{"id":523},[16,1119,526,1120,76],{},[25,1121,529],{},[16,1123,532],{},[97,1125,1126,1128,1130],{},[100,1127,537],{},[100,1129,540],{},[100,1131,543],{},[16,1133,546,1134,550],{},[25,1135,549],{},[81,1137,554],{"id":553},[16,1139,557],{},[16,1141,560,1142,564],{},[25,1143,563],{},[81,1145,568],{"id":567},[16,1147,571],{},[97,1149,1150,1154,1158],{},[100,1151,576,1152,367],{},[25,1153,579],{},[100,1155,582,1156,585],{},[25,1157,217],{},[100,1159,588],{},[16,1161,591],{},[16,1163,594,1164,598],{},[20,1165,597],{},[81,1167,602],{"id":601},[16,1169,605],{},[16,1171,608],{},[81,1173,611],{"id":611},[246,1175,614],{"id":614},[16,1177,617],{},[33,1179,1180],{"className":35,"code":620,"language":37,"meta":38,"style":38},[25,1181,1182],{"__ignoreMap":38},[42,1183,1184,1186,1188,1190],{"class":44,"line":45},[42,1185,627],{"class":48},[42,1187,630],{"class":52},[42,1189,633],{"class":52},[42,1191,636],{"class":52},[246,1193,640],{"id":639},[16,1195,643],{},[246,1197,647],{"id":646},[16,1199,650,1200,653],{},[25,1201,217],{},[246,1203,656],{"id":656},[16,1205,659],{},[246,1207,662],{"id":662},[16,1209,665],{},[81,1211,668],{"id":668},[16,1213,671,1214,675],{},[25,1215,674],{},[16,1217,678,1218,684,1221,690],{},[385,1219,683],{"href":681,"rel":1220},[389],[385,1222,689],{"href":687,"rel":1223},[389],[81,1225,694],{"id":693},[16,1227,1228,702,1231,76],{},[385,1229,701],{"href":699,"rel":1230},[389],[385,1232,683],{"href":681,"rel":1233},[389],[16,1235,708],{},[406,1237,1238,1240,1242,1246,1248],{},[100,1239,713],{},[100,1241,716],{},[100,1243,719,1244,367],{},[25,1245,722],{},[100,1247,725],{},[100,1249,728],{},[16,1251,731],{},[733,1253,735],{},{"title":38,"searchDepth":737,"depth":737,"links":1255},[1256,1257,1258,1262,1263,1264,1268,1269,1270,1271,1278,1279],{"id":83,"depth":274,"text":84},{"id":135,"depth":274,"text":135},{"id":236,"depth":274,"text":237,"children":1259},[1260,1261],{"id":248,"depth":737,"text":249},{"id":340,"depth":737,"text":341},{"id":400,"depth":274,"text":401},{"id":442,"depth":274,"text":443},{"id":513,"depth":274,"text":514,"children":1265},[1266,1267],{"id":517,"depth":737,"text":517},{"id":523,"depth":737,"text":523},{"id":553,"depth":274,"text":554},{"id":567,"depth":274,"text":568},{"id":601,"depth":274,"text":602},{"id":611,"depth":274,"text":611,"children":1272},[1273,1274,1275,1276,1277],{"id":614,"depth":737,"text":614},{"id":639,"depth":737,"text":640},{"id":646,"depth":737,"text":647},{"id":656,"depth":737,"text":656},{"id":662,"depth":737,"text":662},{"id":668,"depth":274,"text":668},{"id":693,"depth":274,"text":694},{"src":769,"alt":770,"caption":771,"width":772,"height":773},{},{"title":10,"description":763},[22,781,782,783,784],{"id":1285,"title":1286,"accent":1287,"body":1288,"description":3394,"draft":764,"experience":765,"extension":766,"featured":764,"image":3395,"meta":3400,"navigation":767,"path":3401,"publishedAt":3402,"readingTime":3403,"seo":3404,"stem":3405,"tags":3406,"updatedAt":3414,"video":3415,"__hash__":3422,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Fstundeck-docker-deployment-guide.md","StunDeck 实战：飞牛 fnOS FPK、Docker 与 Cloudflare 302","cyan",{"type":13,"value":1289,"toc":3367},[1290,1301,1308,1311,1340,1346,1350,1359,1374,1397,1400,1425,1432,1453,1456,1462,1468,1471,1482,1485,1489,1492,1512,1520,1523,1527,1543,1772,1782,1819,1823,1830,1833,1933,1940,1943,2009,2012,2018,2021,2028,2034,2038,2041,2068,2075,2320,2323,2362,2368,2372,2435,2442,2498,2505,2511,2516,2520,2523,2529,2532,2549,2555,2560,2566,2575,2578,2582,2589,2592,2598,2601,2607,2614,2672,2679,2685,2688,2691,2697,2704,2708,2711,2725,2731,2736,2742,2746,2749,2755,2761,2766,2770,2809,2812,2819,2822,2826,2829,2835,2842,2845,2895,2901,2906,2912,2917,2921,2924,2961,2971,2977,2982,2985,3024,3034,3040,3046,3050,3057,3060,3085,3089,3109,3113,3115,3139,3143,3146,3150,3160,3190,3193,3238,3241,3257,3260,3279,3285,3288,3357,3364],[16,1291,1292,1293,1296,1297,1300],{},"这篇教程的重点不是单独把 STUN 跑通，而是让用户始终访问一个",[20,1294,1295],{},"固定域名","：StunDeck 发现公网 IP 或动态端口变化后，自动更新 Cloudflare Single Redirect；Cloudflare 对入口域名返回 ",[20,1298,1299],{},"302","（或 307），客户端再访问新的公网映射。",[16,1302,1303,1304,1307],{},"部署仍从 Docker 开始，但配置顺序必须是：",[20,1305,1306],{},"部署 StunDeck → 创建 Cloudflare Token → 保存 Cloudflare 连接 → 创建 Redirect 服务 → 启动 STUN\u002FUPnP → 验证 302 与第二跳","。Cloudflare 不能等服务建完才补，否则创建服务时没有可选连接，也无法验证自动同步。",[16,1309,1310],{},"最终实测结果：",[97,1312,1313,1316,1319,1325,1328,1331,1337],{},[100,1314,1315],{},"Debian 13.6，amd64；",[100,1317,1318],{},"Docker Engine 29.7.1，Docker Compose 5.3.1；",[100,1320,1321,1322,367],{},"StunDeck 镜像对应公开仓库提交 ",[25,1323,1324],{},"bcb5a1a",[100,1326,1327],{},"Docker 服务环境没有代理，StunDeck 容器内的代理变量全部为空；",[100,1329,1330],{},"NATMap 成功取得映射，UPnP 已把第一跳监听端口放行；",[100,1332,1333,1334,367],{},"独立外网主机直连动态公网地址返回 HTTP 200；固定入口复查返回 HTTP 302 和 ",[25,1335,1336],{},"Location",[100,1338,1339],{},"没有把真实用户名、密码、Token、内网地址、公网 IP 或动态端口写入本文与配图。",[1341,1342,1343],"blockquote",{},[16,1344,1345],{},"STUN 不是中继，也不能把对称型 NAT 或受限 CGNAT 变成公网入口。本文记录的是在当前测试网络中验证成功的部署流程，不代表所有宽带环境都能复现相同结果。",[81,1347,1349],{"id":1348},"飞牛-fnos直接安装正式-fpk","飞牛 fnOS：直接安装正式 FPK",[1341,1351,1352],{},[16,1353,1354,1355,1358],{},"上架状态（2026-08-26）：StunDeck 已提交飞牛应用中心上架审核。审核通过并公开上架前，可以先手动安装这里提供的正式 FPK。这里的飞牛安装包格式是 ",[20,1356,1357],{},"FPK","，不是 Android APK。",[16,1360,1361,1362,1365,1366,1369,1370,1373],{},"当前正式版本为 ",[20,1363,1364],{},"StunDeck v0.1.1","，支持 ",[25,1367,1368],{},"linux\u002Famd64"," 与 ",[25,1371,1372],{},"linux\u002Farm64","。博客保留一份经过校验的正式发布包，同时提供 GitHub Release 作为备用来源：",[97,1375,1376,1384,1391],{},[100,1377,1378,1383],{},[385,1379,1382],{"href":1380,"rel":1381},"https:\u002F\u002Fblog.sparkles-editor.com\u002Fdownloads\u002Fstundeck\u002Fstundeck-fpk-0.1.1.fpk",[389],"从本站下载 stundeck-fpk-0.1.1.fpk","（136,791 字节）；",[100,1385,1386,367],{},[385,1387,1390],{"href":1388,"rel":1389},"https:\u002F\u002Fgithub.com\u002FNciae-Zyh\u002Fstundeck\u002Freleases\u002Ftag\u002Fv0.1.1",[389],"查看 StunDeck v0.1.1 GitHub Release",[100,1392,1393,1394,76],{},"SHA-256：",[25,1395,1396],{},"63d7f0e00b3777400507e62033fb0cce72884d10ad824f51b435a502d3a5dd8f",[16,1398,1399],{},"安装步骤：",[406,1401,1402,1405,1408,1415,1422],{},[100,1403,1404],{},"下载 FPK，并在本机核对 SHA-256；",[100,1406,1407],{},"在飞牛 fnOS 打开「应用中心 → 设置 → 手动安装应用」；",[100,1409,1410,1411,1414],{},"选择 ",[25,1412,1413],{},"stundeck-fpk-0.1.1.fpk","，按向导设置时区；只有通过 HTTPS 反向代理访问时才启用安全 Cookie；",[100,1416,1417,1418,1421],{},"首次安装会从 ",[25,1419,1420],{},"20000–59999"," 选择一个空闲端口并持久化保存，升级和重启不会重新随机；",[100,1423,1424],{},"从飞牛桌面打开 StunDeck，创建管理员，再添加 Cloudflare Token、映射服务与 Webhook。",[16,1426,1427,1428,1431],{},"FPK 固定拉取不可变镜像 ",[25,1429,1430],{},"ghcr.io\u002Fnciae-zyh\u002Fstundeck:v0.1.1","，因此 NAS 首次安装时需要能够访问 GHCR。安装包和向导不会预置或收集 Cloudflare Token、Webhook Secret 等秘密。",[16,1433,1434,1435,1438,1439,1369,1442,1445,1446,1449,1450,1452],{},"fnOS 会管理应用配置与持久化目录，真实数据位于包的 ",[25,1436,1437],{},"${TRIM_PKGVAR}\u002Fdata","。升级、迁移或重装前，必须把 ",[25,1440,1441],{},"stundeck.db",[25,1443,1444],{},"master.key"," ",[20,1447,1448],{},"成对备份","；缺少 ",[25,1451,1444],{}," 时，数据库中加密保存的 Cloudflare Token、Webhook Secret 与 TOTP Secret 无法恢复。卸载向导默认保留数据，只有明确选择“永久删除数据”时才会删除它。",[81,1454,1455],{"id":1455},"先理解重点链路",[33,1457,1460],{"className":1458,"code":1459,"language":231,"meta":38},[229],"用户始终访问固定域名\n  └─ Cloudflare 代理 DNS（橙色云）\n       └─ Single Redirect 返回 302 \u002F 307 + Location\n            └─ 当前公网 IP:动态端口\n                 └─ 主路由 UPnP \u002F NAT-PMP \u002F 手动端口映射\n                      └─ StunDeck + NATMap\n                           └─ 局域网目标服务\n",[25,1461,1459],{"__ignoreMap":38},[16,1463,1464,1465,1467],{},"固定的是入口域名，不是公网端口。Cloudflare 只处理第一跳：它返回重定向响应，不会为第二跳提供 Tunnel、中继、WAF 或 Access。浏览器看到 ",[25,1466,1336],{}," 后会直接连接公网 IP 与动态端口。",[16,1469,1470],{},"这里有两个独立成功条件：",[406,1472,1473,1476],{},[100,1474,1475],{},"固定域名返回正确的 302\u002F307，证明 DNS 与 Single Redirect 已同步；",[100,1477,1478,1479,1481],{},"跟随 ",[25,1480,1336],{}," 后业务返回 200，证明 STUN、网关放行、防火墙与目标服务均可用。",[16,1483,1484],{},"只看到第一个结果，不能把服务标记为“公网可用”。",[81,1486,1488],{"id":1487},"_1-部署前检查","1. 部署前检查",[16,1490,1491],{},"准备一台 Linux 主机，建议满足：",[97,1493,1494,1497,1500,1503,1506,1509],{},[100,1495,1496],{},"Debian 13\u002F12 或 Docker 官方支持的其他发行版；",[100,1498,1499],{},"amd64 或 arm64；",[100,1501,1502],{},"固定或可识别的局域网地址；",[100,1504,1505],{},"能访问局域网目标服务；",[100,1507,1508],{},"主路由允许按需启用 UPnP，或者你能配置手动端口转发；",[100,1510,1511],{},"准备一个无敏感数据、可以随时关闭的测试服务。",[16,1513,1514,1519],{},[385,1515,1518],{"href":1516,"rel":1517},"https:\u002F\u002Flucky666.cn\u002Fdocs\u002Fmodules\u002Fstun",[389],"Lucky 的 STUN 文档","建议在 NAT1 环境使用，并说明局域网主机需要 UPnP、端口转发或 DMZ 等额外条件。不同设备对 NAT 类型的命名并不统一，所以最终应以 STUN 日志与真正的外网回连为准。",[16,1521,1522],{},"不要把 NAS 管理后台、路由器后台或 StunDeck 控制面当作第一条公网测试规则。先使用静态测试页或临时 HTTP 服务。",[81,1524,1526],{"id":1525},"_2-安装-docker-engine-与-compose","2. 安装 Docker Engine 与 Compose",[16,1528,1529,1530,1535,1536,1539,1540,76],{},"下面按 ",[385,1531,1534],{"href":1532,"rel":1533},"https:\u002F\u002Fdocs.docker.com\u002Fengine\u002Finstall\u002Fdebian\u002F",[389],"Docker 官方 Debian 安装文档","配置 apt 仓库。Docker Compose 使用官方 ",[25,1537,1538],{},"docker-compose-plugin","，不要再安装旧版独立 ",[25,1541,1542],{},"docker-compose",[33,1544,1546],{"className":35,"code":1545,"language":37,"meta":38,"style":38},"sudo apt update\nsudo apt install -y ca-certificates curl\n\nsudo install -m 0755 -d \u002Fetc\u002Fapt\u002Fkeyrings\nsudo curl -fsSL https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fdebian\u002Fgpg \\\n  -o \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\nsudo chmod a+r \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\n\nsudo tee \u002Fetc\u002Fapt\u002Fsources.list.d\u002Fdocker.sources >\u002Fdev\u002Fnull \u003C\u003C'EOF'\nTypes: deb\nURIs: https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fdebian\nSuites: trixie\nComponents: stable\nArchitectures: amd64\nSigned-By: \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\nEOF\n\nsudo apt update\nsudo apt install -y \\\n  docker-ce \\\n  docker-ce-cli \\\n  containerd.io \\\n  docker-buildx-plugin \\\n  docker-compose-plugin\n",[25,1547,1548,1559,1577,1582,1601,1616,1625,1638,1643,1666,1672,1678,1684,1689,1695,1701,1707,1712,1721,1734,1742,1750,1758,1766],{"__ignoreMap":38},[42,1549,1550,1553,1556],{"class":44,"line":45},[42,1551,1552],{"class":48},"sudo",[42,1554,1555],{"class":56}," apt",[42,1557,1558],{"class":56}," update\n",[42,1560,1561,1563,1565,1568,1571,1574],{"class":44,"line":274},[42,1562,1552],{"class":48},[42,1564,1555],{"class":56},[42,1566,1567],{"class":56}," install",[42,1569,1570],{"class":52}," -y",[42,1572,1573],{"class":56}," ca-certificates",[42,1575,1576],{"class":56}," curl\n",[42,1578,1579],{"class":44,"line":737},[42,1580,1581],{"emptyLinePlaceholder":767},"\n",[42,1583,1585,1587,1589,1592,1595,1598],{"class":44,"line":1584},4,[42,1586,1552],{"class":48},[42,1588,1567],{"class":56},[42,1590,1591],{"class":52}," -m",[42,1593,1594],{"class":52}," 0755",[42,1596,1597],{"class":52}," -d",[42,1599,1600],{"class":56}," \u002Fetc\u002Fapt\u002Fkeyrings\n",[42,1602,1604,1606,1609,1611,1614],{"class":44,"line":1603},5,[42,1605,1552],{"class":48},[42,1607,1608],{"class":56}," curl",[42,1610,265],{"class":52},[42,1612,1613],{"class":56}," https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fdebian\u002Fgpg",[42,1615,271],{"class":52},[42,1617,1619,1622],{"class":44,"line":1618},6,[42,1620,1621],{"class":52},"  -o",[42,1623,1624],{"class":56}," \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\n",[42,1626,1628,1630,1633,1636],{"class":44,"line":1627},7,[42,1629,1552],{"class":48},[42,1631,1632],{"class":56}," chmod",[42,1634,1635],{"class":56}," a+r",[42,1637,1624],{"class":56},[42,1639,1641],{"class":44,"line":1640},8,[42,1642,1581],{"emptyLinePlaceholder":767},[42,1644,1646,1648,1651,1654,1657,1660,1663],{"class":44,"line":1645},9,[42,1647,1552],{"class":48},[42,1649,1650],{"class":56}," tee",[42,1652,1653],{"class":56}," \u002Fetc\u002Fapt\u002Fsources.list.d\u002Fdocker.sources",[42,1655,1656],{"class":277}," >",[42,1658,1659],{"class":56},"\u002Fdev\u002Fnull",[42,1661,1662],{"class":277}," \u003C\u003C",[42,1664,1665],{"class":56},"'EOF'\n",[42,1667,1669],{"class":44,"line":1668},10,[42,1670,1671],{"class":56},"Types: deb\n",[42,1673,1675],{"class":44,"line":1674},11,[42,1676,1677],{"class":56},"URIs: https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fdebian\n",[42,1679,1681],{"class":44,"line":1680},12,[42,1682,1683],{"class":56},"Suites: trixie\n",[42,1685,1686],{"class":44,"line":777},[42,1687,1688],{"class":56},"Components: stable\n",[42,1690,1692],{"class":44,"line":1691},14,[42,1693,1694],{"class":56},"Architectures: amd64\n",[42,1696,1698],{"class":44,"line":1697},15,[42,1699,1700],{"class":56},"Signed-By: \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\n",[42,1702,1704],{"class":44,"line":1703},16,[42,1705,1706],{"class":56},"EOF\n",[42,1708,1710],{"class":44,"line":1709},17,[42,1711,1581],{"emptyLinePlaceholder":767},[42,1713,1715,1717,1719],{"class":44,"line":1714},18,[42,1716,1552],{"class":48},[42,1718,1555],{"class":56},[42,1720,1558],{"class":56},[42,1722,1724,1726,1728,1730,1732],{"class":44,"line":1723},19,[42,1725,1552],{"class":48},[42,1727,1555],{"class":56},[42,1729,1567],{"class":56},[42,1731,1570],{"class":52},[42,1733,271],{"class":52},[42,1735,1737,1740],{"class":44,"line":1736},20,[42,1738,1739],{"class":56},"  docker-ce",[42,1741,271],{"class":52},[42,1743,1745,1748],{"class":44,"line":1744},21,[42,1746,1747],{"class":56},"  docker-ce-cli",[42,1749,271],{"class":52},[42,1751,1753,1756],{"class":44,"line":1752},22,[42,1754,1755],{"class":56},"  containerd.io",[42,1757,271],{"class":52},[42,1759,1761,1764],{"class":44,"line":1760},23,[42,1762,1763],{"class":56},"  docker-buildx-plugin",[42,1765,271],{"class":52},[42,1767,1769],{"class":44,"line":1768},24,[42,1770,1771],{"class":56},"  docker-compose-plugin\n",[16,1773,1774,1775,464,1778,1781],{},"如果不是 Debian 13 amd64，把 ",[25,1776,1777],{},"Suites",[25,1779,1780],{},"Architectures"," 改成实际值。可以用下面两条命令确认：",[33,1783,1785],{"className":35,"code":1784,"language":37,"meta":38,"style":38},". \u002Fetc\u002Fos-release && echo \"$VERSION_CODENAME\"\ndpkg --print-architecture\n",[25,1786,1787,1811],{"__ignoreMap":38},[42,1788,1789,1792,1795,1799,1802,1805,1808],{"class":44,"line":45},[42,1790,1791],{"class":52},".",[42,1793,1794],{"class":56}," \u002Fetc\u002Fos-release",[42,1796,1798],{"class":1797},"suv1-"," && ",[42,1800,1801],{"class":52},"echo",[42,1803,1804],{"class":56}," \"",[42,1806,1807],{"class":1797},"$VERSION_CODENAME",[42,1809,1810],{"class":56},"\"\n",[42,1812,1813,1816],{"class":44,"line":274},[42,1814,1815],{"class":48},"dpkg",[42,1817,1818],{"class":52}," --print-architecture\n",[246,1820,1822],{"id":1821},"下载需要代理时只让安装阶段使用","下载需要代理时，只让安装阶段使用",[16,1824,1825,1826,1829],{},"测试中，Docker 官方 GPG 地址有一次直连被重置。临时代理只用于下载，不应写进 ",[25,1827,1828],{},"\u002Fetc\u002Fenvironment","，也不应长期留在 Docker systemd 服务中。",[16,1831,1832],{},"一个进程级示例：",[33,1834,1836],{"className":35,"code":1835,"language":37,"meta":38,"style":38},"PROXY_URL=http:\u002F\u002F192.168.50.9:7890\n\nsudo curl --proxy \"$PROXY_URL\" -fsSL \\\n  https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fdebian\u002Fgpg \\\n  -o \u002Fetc\u002Fapt\u002Fkeyrings\u002Fdocker.asc\n\nsudo env http_proxy=\"$PROXY_URL\" https_proxy=\"$PROXY_URL\" \\\n  apt update\n\nunset PROXY_URL\n",[25,1837,1838,1849,1853,1874,1881,1887,1891,1914,1921,1925],{"__ignoreMap":38},[42,1839,1840,1843,1846],{"class":44,"line":45},[42,1841,1842],{"class":1797},"PROXY_URL",[42,1844,1845],{"class":277},"=",[42,1847,1848],{"class":56},"http:\u002F\u002F192.168.50.9:7890\n",[42,1850,1851],{"class":44,"line":274},[42,1852,1581],{"emptyLinePlaceholder":767},[42,1854,1855,1857,1859,1862,1864,1867,1870,1872],{"class":44,"line":737},[42,1856,1552],{"class":48},[42,1858,1608],{"class":56},[42,1860,1861],{"class":52}," --proxy",[42,1863,1804],{"class":56},[42,1865,1866],{"class":1797},"$PROXY_URL",[42,1868,1869],{"class":56},"\"",[42,1871,265],{"class":52},[42,1873,271],{"class":52},[42,1875,1876,1879],{"class":44,"line":1584},[42,1877,1878],{"class":56},"  https:\u002F\u002Fdownload.docker.com\u002Flinux\u002Fdebian\u002Fgpg",[42,1880,271],{"class":52},[42,1882,1883,1885],{"class":44,"line":1603},[42,1884,1621],{"class":52},[42,1886,1624],{"class":56},[42,1888,1889],{"class":44,"line":1618},[42,1890,1581],{"emptyLinePlaceholder":767},[42,1892,1893,1895,1898,1901,1903,1905,1908,1910,1912],{"class":44,"line":1627},[42,1894,1552],{"class":48},[42,1896,1897],{"class":56}," env",[42,1899,1900],{"class":56}," http_proxy=\"",[42,1902,1866],{"class":1797},[42,1904,1869],{"class":56},[42,1906,1907],{"class":56}," https_proxy=\"",[42,1909,1866],{"class":1797},[42,1911,1869],{"class":56},[42,1913,271],{"class":52},[42,1915,1916,1919],{"class":44,"line":1640},[42,1917,1918],{"class":56},"  apt",[42,1920,1558],{"class":56},[42,1922,1923],{"class":44,"line":1645},[42,1924,1581],{"emptyLinePlaceholder":767},[42,1926,1927,1930],{"class":44,"line":1668},[42,1928,1929],{"class":52},"unset",[42,1931,1932],{"class":56}," PROXY_URL\n",[16,1934,1935,1936,1939],{},"如果仓库持续不稳定，可以按 Docker 官方文档的“Install from a package”下载与系统匹配的 ",[25,1937,1938],{},".deb","，先校验文件，再在主机上安装。不要从网盘或不明镜像站获取 Docker 安装包。",[16,1941,1942],{},"安装后验证：",[33,1944,1946],{"className":35,"code":1945,"language":37,"meta":38,"style":38},"sudo docker version\nsudo docker compose version\nsudo systemctl is-enabled docker\nsudo systemctl is-active docker\nsudo systemctl show docker -p Environment\n",[25,1947,1948,1958,1969,1982,1993],{"__ignoreMap":38},[42,1949,1950,1952,1955],{"class":44,"line":45},[42,1951,1552],{"class":48},[42,1953,1954],{"class":56}," docker",[42,1956,1957],{"class":56}," version\n",[42,1959,1960,1962,1964,1967],{"class":44,"line":274},[42,1961,1552],{"class":48},[42,1963,1954],{"class":56},[42,1965,1966],{"class":56}," compose",[42,1968,1957],{"class":56},[42,1970,1971,1973,1976,1979],{"class":44,"line":737},[42,1972,1552],{"class":48},[42,1974,1975],{"class":56}," systemctl",[42,1977,1978],{"class":56}," is-enabled",[42,1980,1981],{"class":56}," docker\n",[42,1983,1984,1986,1988,1991],{"class":44,"line":1584},[42,1985,1552],{"class":48},[42,1987,1975],{"class":56},[42,1989,1990],{"class":56}," is-active",[42,1992,1981],{"class":56},[42,1994,1995,1997,1999,2002,2004,2006],{"class":44,"line":1603},[42,1996,1552],{"class":48},[42,1998,1975],{"class":56},[42,2000,2001],{"class":56}," show",[42,2003,1954],{"class":56},[42,2005,60],{"class":52},[42,2007,2008],{"class":56}," Environment\n",[16,2010,2011],{},"最后一条在本次环境中输出：",[33,2013,2016],{"className":2014,"code":2015,"language":231,"meta":38},[229],"Environment=\n",[25,2017,2015],{"__ignoreMap":38},[16,2019,2020],{},"这能证明 Docker daemon 没有继承常驻代理。它还不能证明容器没有代理，后面会再检查一次。",[16,2022,2023],{},[2024,2025],"img",{"alt":2026,"src":2027},"Docker Engine、Compose 与 systemd 状态的核验结果","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fdocker-install-verified-v1.webp",[16,2029,2030],{},[2031,2032,2033],"em",{},"根据本次真实命令输出整理；主机名、登录身份、局域网地址和临时下载代理均已移除。",[81,2035,2037],{"id":2036},"_3-编写-compose-配置","3. 编写 Compose 配置",[16,2039,2040],{},"创建目录：",[33,2042,2044],{"className":35,"code":2043,"language":37,"meta":38,"style":38},"sudo install -d -m 0755 \u002Fopt\u002Fstundeck\ncd \u002Fopt\u002Fstundeck\n",[25,2045,2046,2061],{"__ignoreMap":38},[42,2047,2048,2050,2052,2054,2056,2058],{"class":44,"line":45},[42,2049,1552],{"class":48},[42,2051,1567],{"class":56},[42,2053,1597],{"class":52},[42,2055,1591],{"class":52},[42,2057,1594],{"class":52},[42,2059,2060],{"class":56}," \u002Fopt\u002Fstundeck\n",[42,2062,2063,2066],{"class":44,"line":274},[42,2064,2065],{"class":52},"cd",[42,2067,2060],{"class":56},[16,2069,2070,2071,2074],{},"保存为 ",[25,2072,2073],{},"\u002Fopt\u002Fstundeck\u002Fcompose.yaml","：",[33,2076,2080],{"className":2077,"code":2078,"language":2079,"meta":38,"style":38},"language-yaml shiki shiki-themes github-dark github-dark","services:\n  stundeck:\n    image: ghcr.io\u002Fnciae-zyh\u002Fstundeck:latest\n    network_mode: host\n    restart: unless-stopped\n    read_only: true\n    cap_drop:\n      - ALL\n    security_opt:\n      - no-new-privileges:true\n    tmpfs:\n      - \u002Ftmp:size=16m,mode=1777\n    volumes:\n      - stundeck-data:\u002Fvar\u002Flib\u002Fstundeck\n    environment:\n      STUNDECK_LISTEN: 0.0.0.0:8080\n      STUNDECK_DATA_DIR: \u002Fvar\u002Flib\u002Fstundeck\n      STUNDECK_SECURE_COOKIES: \"false\"\n      TZ: Asia\u002FShanghai\n      HTTP_PROXY: \"\"\n      HTTPS_PROXY: \"\"\n      ALL_PROXY: \"\"\n      http_proxy: \"\"\n      https_proxy: \"\"\n      all_proxy: \"\"\n\nvolumes:\n  stundeck-data:\n","yaml",[25,2081,2082,2091,2098,2109,2119,2129,2139,2146,2154,2161,2168,2175,2182,2189,2196,2203,2213,2223,2233,2243,2253,2262,2271,2280,2289,2299,2304,2312],{"__ignoreMap":38},[42,2083,2084,2088],{"class":44,"line":45},[42,2085,2087],{"class":2086},"sxg3X","services",[42,2089,2090],{"class":1797},":\n",[42,2092,2093,2096],{"class":44,"line":274},[42,2094,2095],{"class":2086},"  stundeck",[42,2097,2090],{"class":1797},[42,2099,2100,2103,2106],{"class":44,"line":737},[42,2101,2102],{"class":2086},"    image",[42,2104,2105],{"class":1797},": ",[42,2107,2108],{"class":56},"ghcr.io\u002Fnciae-zyh\u002Fstundeck:latest\n",[42,2110,2111,2114,2116],{"class":44,"line":1584},[42,2112,2113],{"class":2086},"    network_mode",[42,2115,2105],{"class":1797},[42,2117,2118],{"class":56},"host\n",[42,2120,2121,2124,2126],{"class":44,"line":1603},[42,2122,2123],{"class":2086},"    restart",[42,2125,2105],{"class":1797},[42,2127,2128],{"class":56},"unless-stopped\n",[42,2130,2131,2134,2136],{"class":44,"line":1618},[42,2132,2133],{"class":2086},"    read_only",[42,2135,2105],{"class":1797},[42,2137,2138],{"class":52},"true\n",[42,2140,2141,2144],{"class":44,"line":1627},[42,2142,2143],{"class":2086},"    cap_drop",[42,2145,2090],{"class":1797},[42,2147,2148,2151],{"class":44,"line":1640},[42,2149,2150],{"class":1797},"      - ",[42,2152,2153],{"class":56},"ALL\n",[42,2155,2156,2159],{"class":44,"line":1645},[42,2157,2158],{"class":2086},"    security_opt",[42,2160,2090],{"class":1797},[42,2162,2163,2165],{"class":44,"line":1668},[42,2164,2150],{"class":1797},[42,2166,2167],{"class":56},"no-new-privileges:true\n",[42,2169,2170,2173],{"class":44,"line":1674},[42,2171,2172],{"class":2086},"    tmpfs",[42,2174,2090],{"class":1797},[42,2176,2177,2179],{"class":44,"line":1680},[42,2178,2150],{"class":1797},[42,2180,2181],{"class":56},"\u002Ftmp:size=16m,mode=1777\n",[42,2183,2184,2187],{"class":44,"line":777},[42,2185,2186],{"class":2086},"    volumes",[42,2188,2090],{"class":1797},[42,2190,2191,2193],{"class":44,"line":1691},[42,2192,2150],{"class":1797},[42,2194,2195],{"class":56},"stundeck-data:\u002Fvar\u002Flib\u002Fstundeck\n",[42,2197,2198,2201],{"class":44,"line":1697},[42,2199,2200],{"class":2086},"    environment",[42,2202,2090],{"class":1797},[42,2204,2205,2208,2210],{"class":44,"line":1703},[42,2206,2207],{"class":2086},"      STUNDECK_LISTEN",[42,2209,2105],{"class":1797},[42,2211,2212],{"class":56},"0.0.0.0:8080\n",[42,2214,2215,2218,2220],{"class":44,"line":1709},[42,2216,2217],{"class":2086},"      STUNDECK_DATA_DIR",[42,2219,2105],{"class":1797},[42,2221,2222],{"class":56},"\u002Fvar\u002Flib\u002Fstundeck\n",[42,2224,2225,2228,2230],{"class":44,"line":1714},[42,2226,2227],{"class":2086},"      STUNDECK_SECURE_COOKIES",[42,2229,2105],{"class":1797},[42,2231,2232],{"class":56},"\"false\"\n",[42,2234,2235,2238,2240],{"class":44,"line":1723},[42,2236,2237],{"class":2086},"      TZ",[42,2239,2105],{"class":1797},[42,2241,2242],{"class":56},"Asia\u002FShanghai\n",[42,2244,2245,2248,2250],{"class":44,"line":1736},[42,2246,2247],{"class":2086},"      HTTP_PROXY",[42,2249,2105],{"class":1797},[42,2251,2252],{"class":56},"\"\"\n",[42,2254,2255,2258,2260],{"class":44,"line":1744},[42,2256,2257],{"class":2086},"      HTTPS_PROXY",[42,2259,2105],{"class":1797},[42,2261,2252],{"class":56},[42,2263,2264,2267,2269],{"class":44,"line":1752},[42,2265,2266],{"class":2086},"      ALL_PROXY",[42,2268,2105],{"class":1797},[42,2270,2252],{"class":56},[42,2272,2273,2276,2278],{"class":44,"line":1760},[42,2274,2275],{"class":2086},"      http_proxy",[42,2277,2105],{"class":1797},[42,2279,2252],{"class":56},[42,2281,2282,2285,2287],{"class":44,"line":1768},[42,2283,2284],{"class":2086},"      https_proxy",[42,2286,2105],{"class":1797},[42,2288,2252],{"class":56},[42,2290,2292,2295,2297],{"class":44,"line":2291},25,[42,2293,2294],{"class":2086},"      all_proxy",[42,2296,2105],{"class":1797},[42,2298,2252],{"class":56},[42,2300,2302],{"class":44,"line":2301},26,[42,2303,1581],{"emptyLinePlaceholder":767},[42,2305,2307,2310],{"class":44,"line":2306},27,[42,2308,2309],{"class":2086},"volumes",[42,2311,2090],{"class":1797},[42,2313,2315,2318],{"class":44,"line":2314},28,[42,2316,2317],{"class":2086},"  stundeck-data",[42,2319,2090],{"class":1797},[16,2321,2322],{},"几个关键点：",[97,2324,2325,2331,2343,2349,2352],{},[100,2326,2327,2330],{},[25,2328,2329],{},"network_mode: host","：NATMap 和 UPnP 需要看到 Linux 主机的真实网络栈；",[100,2332,2333,434,2336,434,2339,2342],{},[25,2334,2335],{},"read_only",[25,2337,2338],{},"cap_drop: ALL",[25,2340,2341],{},"no-new-privileges","：减少容器权限；",[100,2344,2345,2346,367],{},"只持久化 ",[25,2347,2348],{},"\u002Fvar\u002Flib\u002Fstundeck",[100,2350,2351],{},"显式清空大小写代理变量；",[100,2353,2354,2355,2358,2359,76],{},"局域网 HTTP 首次部署使用 ",[25,2356,2357],{},"STUNDECK_SECURE_COOKIES: \"false\"","；如果以后由 HTTPS 反向代理访问，应改为 ",[25,2360,2361],{},"\"true\"",[16,2363,2364,2365,76],{},"不要使用 Docker Desktop 作为正式 STUN 网关，也不要为了省事加入 ",[25,2366,2367],{},"privileged: true",[81,2369,2371],{"id":2370},"_4-拉取并启动","4. 拉取并启动",[33,2373,2375],{"className":35,"code":2374,"language":37,"meta":38,"style":38},"cd \u002Fopt\u002Fstundeck\nsudo docker compose config --quiet\nsudo docker compose pull\nsudo docker compose up -d --no-build\nsudo docker compose ps\n",[25,2376,2377,2383,2397,2408,2424],{"__ignoreMap":38},[42,2378,2379,2381],{"class":44,"line":45},[42,2380,2065],{"class":52},[42,2382,2060],{"class":56},[42,2384,2385,2387,2389,2391,2394],{"class":44,"line":274},[42,2386,1552],{"class":48},[42,2388,1954],{"class":56},[42,2390,1966],{"class":56},[42,2392,2393],{"class":56}," config",[42,2395,2396],{"class":52}," --quiet\n",[42,2398,2399,2401,2403,2405],{"class":44,"line":737},[42,2400,1552],{"class":48},[42,2402,1954],{"class":56},[42,2404,1966],{"class":56},[42,2406,2407],{"class":56}," pull\n",[42,2409,2410,2412,2414,2416,2419,2421],{"class":44,"line":1584},[42,2411,1552],{"class":48},[42,2413,1954],{"class":56},[42,2415,1966],{"class":56},[42,2417,2418],{"class":56}," up",[42,2420,1597],{"class":52},[42,2422,2423],{"class":52}," --no-build\n",[42,2425,2426,2428,2430,2432],{"class":44,"line":1603},[42,2427,1552],{"class":48},[42,2429,1954],{"class":56},[42,2431,1966],{"class":56},[42,2433,2434],{"class":56}," ps\n",[16,2436,2437,2438,2441],{},"本次环境的健康状态在约 10 秒后变为 ",[25,2439,2440],{},"healthy","。继续验证：",[33,2443,2445],{"className":35,"code":2444,"language":37,"meta":38,"style":38},"curl -fsS http:\u002F\u002F127.0.0.1:8080\u002Fapi\u002Fv1\u002Fhealth\n\nsudo docker inspect stundeck-stundeck-1 \\\n  --format '{{range .Config.Env}}{{println .}}{{end}}' \\\n  | grep -i proxy\n",[25,2446,2447,2457,2461,2475,2485],{"__ignoreMap":38},[42,2448,2449,2451,2454],{"class":44,"line":45},[42,2450,262],{"class":48},[42,2452,2453],{"class":52}," -fsS",[42,2455,2456],{"class":56}," http:\u002F\u002F127.0.0.1:8080\u002Fapi\u002Fv1\u002Fhealth\n",[42,2458,2459],{"class":44,"line":274},[42,2460,1581],{"emptyLinePlaceholder":767},[42,2462,2463,2465,2467,2470,2473],{"class":44,"line":737},[42,2464,1552],{"class":48},[42,2466,1954],{"class":56},[42,2468,2469],{"class":56}," inspect",[42,2471,2472],{"class":56}," stundeck-stundeck-1",[42,2474,271],{"class":52},[42,2476,2477,2480,2483],{"class":44,"line":1584},[42,2478,2479],{"class":52},"  --format",[42,2481,2482],{"class":56}," '{{range .Config.Env}}{{println .}}{{end}}'",[42,2484,271],{"class":52},[42,2486,2487,2489,2492,2495],{"class":44,"line":1603},[42,2488,278],{"class":277},[42,2490,2491],{"class":48}," grep",[42,2493,2494],{"class":52}," -i",[42,2496,2497],{"class":56}," proxy\n",[16,2499,2500,2501,2504],{},"健康接口应返回 ",[25,2502,2503],{},"status: ok","。代理检查会看到 6 个变量，但等号右侧都必须为空。镜像拉取时即使临时使用过代理，也要在启动 StunDeck 前撤销 daemon 代理并重启 Docker。",[16,2506,2507],{},[2024,2508],{"alt":2509,"src":2510},"Compose 启动、健康接口与容器代理变量的核验结果","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fcompose-health-verified-v1.webp",[16,2512,2513],{},[2031,2514,2515],{},"本次容器进入 healthy，健康接口返回 ok，容器内的大小写代理变量全部为空。画面未保留主机地址或容器标识。",[81,2517,2519],{"id":2518},"_5-首次初始化与安全边界","5. 首次初始化与安全边界",[16,2521,2522],{},"从局域网打开：",[33,2524,2527],{"className":2525,"code":2526,"language":231,"meta":38},[229],"http:\u002F\u002F服务器局域网地址:8080\n",[25,2528,2526],{"__ignoreMap":38},[16,2530,2531],{},"首次进入需要自行创建管理员：",[406,2533,2534,2537,2540,2543,2546],{},[100,2535,2536],{},"使用唯一用户名和至少 12 位的强密码；",[100,2538,2539],{},"访问模式选择“局域网”；",[100,2541,2542],{},"如果已经确定反向代理域名，可以填入允许访问的 Host；",[100,2544,2545],{},"登录后进入“安全设置”，再配置允许 Host 与验证器 2FA；",[100,2547,2548],{},"不要直接把 8080 控制面映射到公网。",[16,2550,2551],{},[2024,2552],{"alt":2553,"src":2554},"StunDeck 首次进入时的管理员创建页面","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fstundeck-first-run-v1.webp",[16,2556,2557],{},[2031,2558,2559],{},"一次性空数据环境中的真实首次初始化页面；用户名、密码与确认密码都没有填写。",[16,2561,2562],{},[2024,2563],{"alt":2564,"src":2565},"StunDeck 安全设置，显示局域网访问范围、Host 白名单与 2FA 入口","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fstundeck-security-domain-v1.webp",[16,2567,2568],{},[2031,2569,2570,2571,2574],{},"域名 ",[25,2572,2573],{},"stun-lab-20260802.sparkles-editor.com"," 经本人确认可以公开，仅用于演示 Host 白名单；截图环境未保存该值。画面没有 DNS 记录、Zone\u002FRule ID、Token、TOTP 密钥或管理员身份。",[16,2576,2577],{},"如果必须远程管理控制面，优先使用 Cloudflare Tunnel、VPN 或其他带身份认证的反向代理。STUN 动态映射更适合发布可控业务端口，不适合暴露管理后台。",[81,2579,2581],{"id":2580},"_6-先在-cloudflare-创建最小权限-api-token","6. 先在 Cloudflare 创建最小权限 API Token",[16,2583,2584,2585,2588],{},"Single Redirect 只会处理经过 Cloudflare 代理的请求，因此入口域名必须存在于目标 Zone，并使用",[20,2586,2587],{},"代理 DNS（橙色云）","。本文不展示 DNS 记录值、Zone ID、Ruleset ID 或其他域名。",[16,2590,2591],{},"进入 Cloudflare 右上角用户菜单，打开“个人资料 → API 令牌”，点击“创建令牌”。",[16,2593,2594],{},[2024,2595],{"alt":2596,"src":2597},"Cloudflare 用户 API 令牌页面的创建入口","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fcf-token-entry-v2.webp",[16,2599,2600],{},"选择“创建自定义令牌”，不要使用 Global API Key。",[16,2602,2603],{},[2024,2604],{"alt":2605,"src":2606},"Cloudflare 自定义 API 令牌入口","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fcf-custom-token-v2.webp",[16,2608,2609,2610,2613],{},"建议使用可识别的名称，例如 ",[25,2611,2612],{},"StunDeck Redirect · sparkles-editor.com","，然后添加三条 Zone 权限：",[140,2615,2616,2632],{},[143,2617,2618],{},[146,2619,2620,2623,2626,2629],{},[149,2621,2622],{},"资源",[149,2624,2625],{},"权限",[149,2627,2628],{},"级别",[149,2630,2631],{},"用途",[159,2633,2634,2647,2660],{},[146,2635,2636,2639,2641,2644],{},[164,2637,2638],{},"区域",[164,2640,2638],{},[164,2642,2643],{},"读取",[164,2645,2646],{},"查找并校验目标 Zone",[146,2648,2649,2651,2654,2657],{},[164,2650,2638],{},[164,2652,2653],{},"DNS",[164,2655,2656],{},"编辑",[164,2658,2659],{},"自动创建或维护代理 DNS 记录",[146,2661,2662,2664,2667,2669],{},[164,2663,2638],{},[164,2665,2666],{},"单一重定向",[164,2668,2656],{},[164,2670,2671],{},"创建和更新 302\u002F307 Single Redirect",[16,2673,2674,2675,2678],{},"在“区域资源”中选择“包括 → 特定区域 → ",[25,2676,2677],{},"sparkles-editor.com","”。不要授权所有 Zone。若你关闭 StunDeck 的“自动管理 DNS”，可以不授予 DNS 编辑，但必须自行保证入口记录存在且处于代理状态。",[16,2680,2681],{},[2024,2682],{"alt":2683,"src":2684},"Cloudflare 自定义令牌的三项最小权限与特定 Zone 范围","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fcf-token-permissions-v2.webp",[16,2686,2687],{},"客户端 IP 筛选只适合管理端出口固定的环境。家庭宽带出口会变化时，不要误把当前公网 IP 写死，否则 StunDeck 之后可能无法同步。可以按运维周期设置 Token 到期日。",[16,2689,2690],{},"点击“继续以显示摘要”，确认目标 Zone 只有三项权限：",[16,2692,2693],{},[2024,2694],{"alt":2695,"src":2696},"Cloudflare 令牌摘要中的特定 Zone 与最小权限","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fcf-token-summary-v2.webp",[16,2698,2699,2700,2703],{},"最后点击“创建令牌”。",[20,2701,2702],{},"Token 明文只显示一次","：立即放入密码管理器并粘贴到 StunDeck，不要截图、不要写入 Compose、Markdown、Git、日志或聊天记录。本文为了避免创建不必要的长期凭据，截图停在复核页，没有展示或保存任何 Token 值。",[81,2705,2707],{"id":2706},"_7-在-stundeck-保存并检测-cloudflare-连接","7. 在 StunDeck 保存并检测 Cloudflare 连接",[16,2709,2710],{},"先进入 StunDeck 的“Cloudflare”页面，再创建映射服务：",[406,2712,2713,2716,2719,2722],{},[100,2714,2715],{},"连接名称填写易识别的名称；",[100,2717,2718],{},"粘贴刚创建的 API Token；",[100,2720,2721],{},"点击“检测权限”；",[100,2723,2724],{},"只在 Zone、DNS 和 Single Redirect 检测都通过后保存。",[16,2726,2727],{},[2024,2728],{"alt":2729,"src":2730},"StunDeck Cloudflare 连接页，Token 输入框保持为空","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fstundeck-cf-connect-v2.webp",[16,2732,2733],{},[2031,2734,2735],{},"来自重建后的真实管理页面。输入框为空，截图不含 Token、账户名、DNS 值或任何资源 ID。",[16,2737,2738,2739,2741],{},"凭据会由 StunDeck 使用本地主密钥加密保存。备份时必须同时保留 SQLite 数据库与 ",[25,2740,1444],{},"；缺少后者，连接凭据无法恢复。",[81,2743,2745],{"id":2744},"_8-创建以-302-为核心的-redirect-服务","8. 创建以 302 为核心的 Redirect 服务",[16,2747,2748],{},"现在进入“映射服务”，使用一项可以随时关闭的 HTTP 测试服务：",[33,2750,2753],{"className":2751,"code":2752,"language":231,"meta":38},[229],"服务名称：家庭 NAS（示例）\n局域网目标：192.168.1.20（示例）\n目标端口：80（示例）\n协议：TCP\n监听端口：0（自动分配）\n路由器端口放行：UPnP（推荐）\n发布方式：Cloudflare Redirect\nCloudflare 连接：选择上一步保存的连接\n入口域名：stun-lab-20260802.sparkles-editor.com\n目标协议：HTTP\n跳转状态：302 Temporary\n自动管理 DNS：开启\n保留路径：开启\n保留查询参数：开启\n",[25,2754,2752],{"__ignoreMap":38},[16,2756,2757],{},[2024,2758],{"alt":2759,"src":2760},"StunDeck 映射服务中的 Cloudflare Redirect、入口域名与 302 选项","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fstundeck-cf-redirect-service-v2.webp",[16,2762,2763],{},[2031,2764,2765],{},"真实页面中的文档表单；局域网地址和端口均为占位示例，Cloudflare 连接未保存，未提交这条服务。画面上方的实际服务内容已裁掉。",[246,2767,2769],{"id":2768},"_302-还是-307","302 还是 307",[140,2771,2772,2785],{},[143,2773,2774],{},[146,2775,2776,2779,2782],{},[149,2777,2778],{},"状态",[149,2780,2781],{},"客户端行为",[149,2783,2784],{},"推荐场景",[159,2786,2787,2798],{},[146,2788,2789,2792,2795],{},[164,2790,2791],{},"302 Temporary",[164,2793,2794],{},"临时跳转；部分客户端会把 POST 改成 GET",[164,2796,2797],{},"普通网页、下载页、以 GET 为主的服务，本文默认",[146,2799,2800,2803,2806],{},[164,2801,2802],{},"307 Temporary Redirect",[164,2804,2805],{},"临时跳转并保留 HTTP 方法与请求体",[164,2807,2808],{},"API、Webhook、上传或不能改变 POST 语义的服务",[16,2810,2811],{},"两者都不会被当作永久跳转缓存。不要使用 301\u002F308 表示会持续变化的 STUN 端点。",[16,2813,2814,2815,2818],{},"“保留路径”和“保留查询参数”开启后，访问 ",[25,2816,2817],{},"\u002Ffiles?a=1"," 会把路径与查询串拼到当前动态目标。使用 HTTPS 目标时还必须填写“HTTPS 目标域名”，并保证目标证书覆盖该域名；直接跳到公网 IP 通常无法通过证书主机名校验。",[16,2820,2821],{},"自动 DNS 只维护 StunDeck 自己创建并带有管理标记的记录。若同名记录由其他系统管理，StunDeck 会停止同步而不是强行覆盖；应先处理冲突，不能删除整组 DNS 后重建。",[81,2823,2825],{"id":2824},"_9-启动服务并完成-stun-upnp-检测","9. 启动服务并完成 STUN \u002F UPnP 检测",[16,2827,2828],{},"点击“创建服务”，再点击“启动”。正常状态会依次经过：",[33,2830,2833],{"className":2831,"code":2832,"language":231,"meta":38},[229],"已停止 → 探测中 → 路由已放行 → Cloudflare 已同步\n",[25,2834,2832],{"__ignoreMap":38},[16,2836,2837,2838,2841],{},"StunDeck 的 UPnP 逻辑会把",[20,2839,2840],{},"穿透监听端口映射到同一个本地端口","，而不是把最终动态公网端口误当成本地端口。随后它把 NATMap 发现的公网 IP 与端口写入 Single Redirect 的目标。",[16,2843,2844],{},"点击服务卡片上的“STUN 检测”，逐项检查：",[97,2846,2847,2853,2859,2865,2871,2877,2883,2889],{},[100,2848,2849,2852],{},[20,2850,2851],{},"代理环境","：Docker daemon 与容器都必须没有常驻代理；",[100,2854,2855,2858],{},[20,2856,2857],{},"NATMap 程序","：二进制可执行；",[100,2860,2861,2864],{},[20,2862,2863],{},"目标端口","：StunDeck 主机能连接局域网服务；",[100,2866,2867,2870],{},[20,2868,2869],{},"UDP\u002FTCP STUN","：至少与你所选协议对应的 Binding 成功；",[100,2872,2873,2876],{},[20,2874,2875],{},"路由器能力","：发现正确的 UPnP IGD 或 NAT-PMP 网关；",[100,2878,2879,2882],{},[20,2880,2881],{},"路由器端口映射","：映射已下发；",[100,2884,2885,2888],{},[20,2886,2887],{},"公网映射","：已取得地址且更新时间合理；",[100,2890,2891,2894],{},[20,2892,2893],{},"Cloudflare 同步","：DNS 与 Redirect Rule 均已完成或明确报告冲突。",[16,2896,2897],{},[2024,2898],{"alt":2899,"src":2900},"StunDeck STUN 诊断，显示代理为空、NATMap 可执行、目标端口可达及 Binding 成功","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fstundeck-diagnostics-v1.webp",[16,2902,2903],{},[2031,2904,2905],{},"真实诊断结果的安全裁切；公网 IP、动态端口、内网地址与网关地址均不在画面中。",[16,2907,2908],{},[2024,2909],{"alt":2910,"src":2911},"StunDeck 运行概览，显示一个服务、一个已发现映射和运行版本","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fstundeck-overview-v1.webp",[16,2913,2914],{},[2031,2915,2916],{},"真实环境概览；没有展示服务地址、映射地址、凭据或 Cloudflare 配置。",[81,2918,2920],{"id":2919},"_10-验证固定域名的-302-与第二跳","10. 验证固定域名的 302 与第二跳",[16,2922,2923],{},"先禁止 curl 自动跟随跳转，只检查 Cloudflare 第一跳：",[33,2925,2927],{"className":35,"code":2926,"language":37,"meta":38,"style":38},"curl -sS -o \u002Fdev\u002Fnull -D - --max-redirs 0 \\\n  https:\u002F\u002Fstun-lab-20260802.sparkles-editor.com\u002F\n",[25,2928,2929,2956],{"__ignoreMap":38},[42,2930,2931,2933,2936,2939,2942,2945,2948,2951,2954],{"class":44,"line":45},[42,2932,262],{"class":48},[42,2934,2935],{"class":52}," -sS",[42,2937,2938],{"class":52}," -o",[42,2940,2941],{"class":56}," \u002Fdev\u002Fnull",[42,2943,2944],{"class":52}," -D",[42,2946,2947],{"class":56}," -",[42,2949,2950],{"class":52}," --max-redirs",[42,2952,2953],{"class":52}," 0",[42,2955,271],{"class":52},[42,2957,2958],{"class":44,"line":274},[42,2959,2960],{"class":56},"  https:\u002F\u002Fstun-lab-20260802.sparkles-editor.com\u002F\n",[16,2962,2963,2964,2967,2968,2970],{},"应看到 ",[25,2965,2966],{},"HTTP\u002F2 302","（选择 307 时则为 307）以及 ",[25,2969,1336],{},"。Cloudflare 规则列表也应显示同一入口域名、302 状态与活动状态：",[16,2972,2973],{},[2024,2974],{"alt":2975,"src":2976},"Cloudflare 规则列表中的 StunDeck 302 Single Redirect，动态目标已从截图中裁除","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fcf-redirect-rule-v2.webp",[16,2978,2979],{},[2031,2980,2981],{},"真实 Cloudflare 规则行；仅保留经允许公开的入口域名和 302 状态，动态公网目标、账户信息、其他规则与资源 ID 均未进入画面。",[16,2983,2984],{},"再从手机蜂窝网络或独立外网主机验证第二跳：",[33,2986,2988],{"className":35,"code":2987,"language":37,"meta":38,"style":38},"curl -sS -L --max-time 15 -o \u002Fdev\u002Fnull \\\n  -w 'status=%{http_code} redirects=%{num_redirects}\\n' \\\n  https:\u002F\u002Fstun-lab-20260802.sparkles-editor.com\u002F\n",[25,2989,2990,3010,3020],{"__ignoreMap":38},[42,2991,2992,2994,2996,2998,3001,3004,3006,3008],{"class":44,"line":45},[42,2993,262],{"class":48},[42,2995,2935],{"class":52},[42,2997,53],{"class":52},[42,2999,3000],{"class":52}," --max-time",[42,3002,3003],{"class":52}," 15",[42,3005,2938],{"class":52},[42,3007,2941],{"class":56},[42,3009,271],{"class":52},[42,3011,3012,3015,3018],{"class":44,"line":274},[42,3013,3014],{"class":52},"  -w",[42,3016,3017],{"class":56}," 'status=%{http_code} redirects=%{num_redirects}\\n'",[42,3019,271],{"class":52},[42,3021,3022],{"class":44,"line":737},[42,3023,2960],{"class":56},[16,3025,3026,3027,3030,3031,3033],{},"目标是 ",[25,3028,3029],{},"status=200 redirects=1","。本次重建中，独立外网主机直连新的动态映射得到 HTTP 200；固定入口也实测返回 302 和 ",[25,3032,1336],{},"。不过截图所示的旧隔离服务随后已下线，所以它现在只能证明第一跳规则仍生效，不能作为第二跳在线承诺。教程因此明确要求每次映射变化后同时检查这两层。",[16,3035,3036],{},[2024,3037],{"alt":3038,"src":3039},"局域网目标与独立外网第二跳都返回 HTTP 200 的脱敏核验结果","\u002Fmedia\u002Fposts\u002Fstundeck-docker-deployment-guide\u002Fexternal-proof-verified-v1.webp",[16,3041,3042,3043,3045],{},"不要把真实 ",[25,3044,1336],{},"、公网 IP 与端口贴在文章、截图或工单里。它会变化，也会增加不必要的扫描风险。",[81,3047,3049],{"id":3048},"_11-常见故障定位","11. 常见故障定位",[246,3051,3053,3054],{"id":3052},"已取得公网映射但浏览器是-err_empty_response","已取得公网映射，但浏览器是 ",[25,3055,3056],{},"ERR_EMPTY_RESPONSE",[16,3058,3059],{},"按顺序检查：",[406,3061,3062,3065,3070,3073,3076,3079,3082],{},[100,3063,3064],{},"局域网目标是否从 StunDeck 主机可达；",[100,3066,3067,3068,367],{},"目标服务是否监听正确地址，而不是只监听 ",[25,3069,217],{},[100,3071,3072],{},"容器是否使用 host network；",[100,3074,3075],{},"StunDeck 服务是否选择了正确的网关模式；",[100,3077,3078],{},"UPnP\u002FNAT-PMP 是否在真实出网网关下发映射；",[100,3080,3081],{},"多层 NAT 时，上一级光猫或路由器是否仍阻断入站；",[100,3083,3084],{},"是否用真正的外网测试，而不是在不支持 NAT Loopback 的局域网里访问公网地址。",[246,3086,3088],{"id":3087},"stun-时好时坏","STUN 时好时坏",[97,3090,3091,3094,3097,3100,3103,3106],{},[100,3092,3093],{},"确认 Docker daemon 与容器都没有代理；",[100,3095,3096],{},"关闭会改变出口的透明代理、策略路由或多 WAN 规则后对比；",[100,3098,3099],{},"保持监听端口唯一；",[100,3101,3102],{},"不要把动态端口当成固定端口写入客户端；",[100,3104,3105],{},"观察映射更新时间与 NATMap 进程是否持续运行；",[100,3107,3108],{},"对称型 NAT、运营商 CGNAT 或入站过滤严格时，改用 Tunnel、VPN 或中继方案。",[246,3110,3112],{"id":3111},"固定域名没有返回-302307","固定域名没有返回 302\u002F307",[16,3114,3059],{},[406,3116,3117,3120,3123,3126,3129,3132],{},[100,3118,3119],{},"入口 DNS 是否为代理状态（橙色云）；",[100,3121,3122],{},"Token 是否限定到正确 Zone；",[100,3124,3125],{},"是否具备 Zone 读取与单一重定向编辑权限；",[100,3127,3128],{},"开启自动 DNS 时，是否额外具备 DNS 编辑权限；",[100,3130,3131],{},"同名 DNS 或 Redirect Rule 是否由其他系统管理而发生冲突；",[100,3133,3134,3135,3138],{},"Cloudflare Rules 中是否存在 ",[25,3136,3137],{},"http_request_dynamic_redirect"," 阶段的活动规则。",[246,3140,3142],{"id":3141},"_302-能打开入口但第二跳失败","302 能打开入口，但第二跳失败",[16,3144,3145],{},"302 只更新入口。直接请求动态公网地址；如果仍失败，问题在 STUN、路由器映射、防火墙或目标服务，而不是 Cloudflare Redirect Rule。",[81,3147,3149],{"id":3148},"_12-更新备份与卸载","12. 更新、备份与卸载",[16,3151,3152,3153,3155,3156,3159],{},"更新前把 SQLite 数据库和 ",[25,3154,1444],{}," 一起备份，二者缺一不可。Compose 命名卷通常为 ",[25,3157,3158],{},"stundeck_stundeck-data","，先确认实际名称：",[33,3161,3163],{"className":35,"code":3162,"language":37,"meta":38,"style":38},"sudo docker volume ls\nsudo docker volume inspect stundeck_stundeck-data\n",[25,3164,3165,3177],{"__ignoreMap":38},[42,3166,3167,3169,3171,3174],{"class":44,"line":45},[42,3168,1552],{"class":48},[42,3170,1954],{"class":56},[42,3172,3173],{"class":56}," volume",[42,3175,3176],{"class":56}," ls\n",[42,3178,3179,3181,3183,3185,3187],{"class":44,"line":274},[42,3180,1552],{"class":48},[42,3182,1954],{"class":56},[42,3184,3173],{"class":56},[42,3186,2469],{"class":56},[42,3188,3189],{"class":56}," stundeck_stundeck-data\n",[16,3191,3192],{},"更新：",[33,3194,3196],{"className":35,"code":3195,"language":37,"meta":38,"style":38},"cd \u002Fopt\u002Fstundeck\nsudo docker compose pull\nsudo docker compose up -d --no-build\nsudo docker compose ps\n",[25,3197,3198,3204,3214,3228],{"__ignoreMap":38},[42,3199,3200,3202],{"class":44,"line":45},[42,3201,2065],{"class":52},[42,3203,2060],{"class":56},[42,3205,3206,3208,3210,3212],{"class":44,"line":274},[42,3207,1552],{"class":48},[42,3209,1954],{"class":56},[42,3211,1966],{"class":56},[42,3213,2407],{"class":56},[42,3215,3216,3218,3220,3222,3224,3226],{"class":44,"line":737},[42,3217,1552],{"class":48},[42,3219,1954],{"class":56},[42,3221,1966],{"class":56},[42,3223,2418],{"class":56},[42,3225,1597],{"class":52},[42,3227,2423],{"class":52},[42,3229,3230,3232,3234,3236],{"class":44,"line":1584},[42,3231,1552],{"class":48},[42,3233,1954],{"class":56},[42,3235,1966],{"class":56},[42,3237,2434],{"class":56},[16,3239,3240],{},"停止但保留数据：",[33,3242,3244],{"className":35,"code":3243,"language":37,"meta":38,"style":38},"sudo docker compose down\n",[25,3245,3246],{"__ignoreMap":38},[42,3247,3248,3250,3252,3254],{"class":44,"line":45},[42,3249,1552],{"class":48},[42,3251,1954],{"class":56},[42,3253,1966],{"class":56},[42,3255,3256],{"class":56}," down\n",[16,3258,3259],{},"只有确定不要恢复时才删除卷：",[33,3261,3263],{"className":35,"code":3262,"language":37,"meta":38,"style":38},"sudo docker compose down -v\n",[25,3264,3265],{"__ignoreMap":38},[42,3266,3267,3269,3271,3273,3276],{"class":44,"line":45},[42,3268,1552],{"class":48},[42,3270,1954],{"class":56},[42,3272,1966],{"class":56},[42,3274,3275],{"class":56}," down",[42,3277,3278],{"class":52}," -v\n",[16,3280,3281,3284],{},[25,3282,3283],{},"down -v"," 会删除管理员、服务、事件记录、Cloudflare 加密凭据以及本地主密钥，无法通过重新拉取镜像恢复。",[81,3286,3287],{"id":3287},"参考资料",[97,3289,3290,3297,3303,3310,3316,3323,3329,3336,3343,3350],{},[100,3291,3292],{},[385,3293,3296],{"href":3294,"rel":3295},"https:\u002F\u002Fgithub.com\u002FNciae-Zyh\u002Fstundeck",[389],"StunDeck 开源仓库",[100,3298,3299],{},[385,3300,3302],{"href":1388,"rel":3301},[389],"StunDeck v0.1.1 Release 与 FPK",[100,3304,3305],{},[385,3306,3309],{"href":3307,"rel":3308},"https:\u002F\u002Fdeveloper.fnnas.com\u002Fdocs\u002Fcli\u002Ffnpack",[389],"飞牛 fnOS 开发者文档：fnpack",[100,3311,3312],{},[385,3313,3315],{"href":1532,"rel":3314},[389],"Docker：Install Docker Engine on Debian",[100,3317,3318],{},[385,3319,3322],{"href":3320,"rel":3321},"https:\u002F\u002Fdocs.docker.com\u002Fcompose\u002Finstall\u002Flinux\u002F",[389],"Docker：Install the Compose plugin",[100,3324,3325],{},[385,3326,3328],{"href":1516,"rel":3327},[389],"Lucky：STUN 内网穿透",[100,3330,3331],{},[385,3332,3335],{"href":3333,"rel":3334},"https:\u002F\u002Fgithub.com\u002Fheiher\u002Fnatmap",[389],"NATMap 开源仓库",[100,3337,3338],{},[385,3339,3342],{"href":3340,"rel":3341},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Ffundamentals\u002Fapi\u002Fget-started\u002Fcreate-token\u002F",[389],"Cloudflare：创建 API Token",[100,3344,3345],{},[385,3346,3349],{"href":3347,"rel":3348},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Frules\u002Furl-forwarding\u002Fsingle-redirects\u002Fcreate-dashboard\u002F",[389],"Cloudflare：通过 Dashboard 创建 Single Redirect",[100,3351,3352],{},[385,3353,3356],{"href":3354,"rel":3355},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Frules\u002Furl-forwarding\u002Fsingle-redirects\u002Fsettings\u002F",[389],"Cloudflare：Single Redirect 设置与代理 DNS 要求",[16,3358,3359,3360,3363],{},"这次重建最重要的结论是把验证拆成五层：",[20,3361,3362],{},"目标端口可达、STUN Binding 成功、网关映射已下发、Cloudflare 固定入口返回 302\u002F307、独立外网跟随跳转后业务成功","。固定域名解决的是“入口不变”，不是“第二跳自动获得隧道保护”。",[733,3365,3366],{},"html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sxg3X, html code.shiki .sxg3X{--shiki-default:#85E89D;--shiki-dark:#85E89D}",{"title":38,"searchDepth":737,"depth":737,"links":3368},[3369,3370,3371,3372,3375,3376,3377,3378,3379,3380,3383,3384,3385,3392,3393],{"id":1348,"depth":274,"text":1349},{"id":1455,"depth":274,"text":1455},{"id":1487,"depth":274,"text":1488},{"id":1525,"depth":274,"text":1526,"children":3373},[3374],{"id":1821,"depth":737,"text":1822},{"id":2036,"depth":274,"text":2037},{"id":2370,"depth":274,"text":2371},{"id":2518,"depth":274,"text":2519},{"id":2580,"depth":274,"text":2581},{"id":2706,"depth":274,"text":2707},{"id":2744,"depth":274,"text":2745,"children":3381},[3382],{"id":2768,"depth":737,"text":2769},{"id":2824,"depth":274,"text":2825},{"id":2919,"depth":274,"text":2920},{"id":3048,"depth":274,"text":3049,"children":3386},[3387,3389,3390,3391],{"id":3052,"depth":737,"text":3388},"已取得公网映射，但浏览器是 ERR_EMPTY_RESPONSE",{"id":3087,"depth":737,"text":3088},{"id":3111,"depth":737,"text":3112},{"id":3141,"depth":737,"text":3142},{"id":3148,"depth":274,"text":3149},{"id":3287,"depth":274,"text":3287},"在飞牛 fnOS 手动安装 StunDeck FPK，或使用 Docker 部署；再配置 Cloudflare 最小权限 API Token、代理 DNS 与 Single Redirect，让固定域名跟随动态 STUN 公网地址。",{"src":2911,"alt":3396,"caption":3397,"width":3398,"height":3399},"StunDeck 运行概览，显示一个已配置服务和一个已发现公网映射","本文重建环境的真实界面；画面不含用户名、密码、Token、内网地址或公网端口。",1280,720,{},"\u002Fblog\u002Fstundeck-docker-deployment-guide","2026-08-03",35,{"title":1286,"description":3394},"blog\u002Fstundeck-docker-deployment-guide",[3407,3408,1357,3409,3410,3411,3412,1299,3413],"StunDeck","fnOS","Docker","STUN","UPnP","Cloudflare","Homelab","2026-08-26",{"src":3416,"poster":2911,"title":3417,"description":3418,"duration":3419,"chapters":1668,"width":3420,"height":3421},"\u002Fmedia\u002Fvideos\u002Fstundeck-docker-deployment-guide\u002Fstundeck-complete-guide-zh-cn-v1.mp4","StunDeck 完整教程：Docker、Cloudflare 302\u002F307 与 STUN 诊断","6 分钟完整操作演示，从部署、首次初始化和最小权限 Token，一路讲到映射服务、UPnP\u002FNAT-PMP、六层诊断与独立外网验证。",375,1920,1080,"7CElhx3NjQez2L0SG5p5OboJnxRy9nNWagwwtowpqQE",{"id":3424,"title":3425,"accent":3426,"body":3427,"description":5036,"draft":764,"experience":765,"extension":766,"featured":764,"image":765,"meta":5037,"navigation":767,"path":5038,"publishedAt":5039,"readingTime":2306,"seo":5040,"stem":5041,"tags":5042,"updatedAt":765,"video":765,"__hash__":5045,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Flucky-stun-cloudflare-tunnel-guide.md","Lucky STUN + Cloudflare 302 实战：用域名跟随动态公网端口","violet",{"type":13,"value":3428,"toc":5006},[3429,3439,3449,3459,3471,3474,3480,3487,3548,3552,3556,3563,3566,3580,3584,3587,3593,3602,3608,3613,3616,3620,3623,3629,3634,3733,3736,3742,3749,3753,3756,3759,3776,3780,3783,3800,3803,3807,3817,3821,3898,3916,3919,3923,3926,3967,3973,3976,3982,3988,3992,3994,4000,4003,4009,4022,4026,4029,4035,4038,4056,4060,4067,4073,4076,4229,4236,4242,4245,4268,4276,4279,4285,4288,4292,4295,4317,4320,4337,4340,4359,4369,4373,4396,4399,4403,4415,4419,4422,4428,4431,4437,4442,4448,4467,4471,4473,4479,4482,4488,4493,4561,4564,4575,4578,4582,4585,4591,4606,4617,4620,4655,4659,4662,4668,4675,4678,4682,4685,4691,4699,4702,4705,4840,4843,4846,4849,4875,4886,4889,4895,4898,4901,4904,4907,4909,5003],[16,3430,3431,3432],{},"这篇文章介绍的重点不是普通 DDNS，也不是让 Cloudflare 反向代理 STUN 的动态端口，而是下面这条组合链路：",[20,3433,3434,3435,3438],{},"Lucky 通过 STUN 获得 ",[25,3436,3437],{},"公网 IP:动态端口","，映射改变时再由 Webhook 修改 Cloudflare Single Redirect 的目标地址。",[16,3440,3441,3442,3445,3446,3448],{},"用户只需要记住 ",[25,3443,3444],{},"go.example.com","。浏览器先访问 Cloudflare，Cloudflare 返回一次临时重定向，再由浏览器直接连接最新的 STUN 地址。这与参考文章的 301 方案是同一种思路，但本文把状态码改为 ",[20,3447,1299],{},"：NAT 映射本来就是临时值，不应该让浏览器或搜索引擎把某个 IP 和端口当作永久地址。",[16,3450,3451,3452,434,3455,3458],{},"本文基于 Lucky 2.27.2 与 Cloudflare Dashboard 的实际界面，并用双方官方文档校正能力边界。正文示例只使用 ",[25,3453,3454],{},"example.com",[25,3456,3457],{},"192.0.2.0\u002F24"," 等文档值；实拍配图中的真实域名、IP、端口、实例名称、流量和 Token 均未展示，存在后台数据的区域已做不可逆强模糊处理。",[1341,3460,3461],{},[16,3462,3463,3464,3466,3467,3470],{},"先给结论：浏览器访问的临时入口使用 ",[20,3465,1299],{},"；不要使用 301。Cloudflare Single Redirect 当前正式支持 301、302、307、308，",[20,3468,3469],{},"不支持 303","。如果是 App、API、远程桌面或非 HTTP 协议，这种跳转通常不合适，应改用 307、Cloudflare Tunnel、VPN 或其他原生传输方案。",[81,3472,3473],{"id":3473},"先看清这条链路",[33,3475,3478],{"className":3476,"code":3477,"language":231,"meta":38},[229],"第一次请求\n\n浏览器访问 https:\u002F\u002Fgo.example.com\u002Fphotos?id=42\n  └─ Cloudflare Single Redirect 返回 302\n       Location: http:\u002F\u002F203.0.113.10:45678\u002Fphotos?id=42\n        └─ 浏览器直接连接 STUN 映射\n             └─ Lucky 内置转发（或路由器端口转发）\n                  └─ 内网 Web 服务\n\n映射变化\n\nLucky 得到 203.0.113.10:51234\n  └─ STUN Webhook PATCH Cloudflare Rulesets API\n       └─ 下一次访问收到新的 Location\n",[25,3479,3477],{"__ignoreMap":38},[16,3481,3482,3483,3486],{},"这里的“纯域名访问”只表示",[20,3484,3485],{},"用户输入的起始地址不带端口","。跳转完成后，浏览器地址栏仍会显示真实 IP 或目标域名及端口。Cloudflare 只处理第一次请求，后续业务流量不会经过 Cloudflare，因此 WAF、缓存、Access、隐藏源站等能力不会覆盖第二段连接。",[140,3488,3489,3505],{},[143,3490,3491],{},[146,3492,3493,3496,3499,3502],{},[149,3494,3495],{},"方案",[149,3497,3498],{},"用户入口",[149,3500,3501],{},"第二段流量",[149,3503,3504],{},"适合场景",[159,3506,3507,3521,3534],{},[146,3508,3509,3512,3515,3518],{},[164,3510,3511],{},"STUN 直连",[164,3513,3514],{},"IP\u002F域名加动态端口",[164,3516,3517],{},"访问者直连家庭网络",[164,3519,3520],{},"TCP\u002FUDP 或明确知道端口的客户端",[146,3522,3523,3526,3529,3531],{},[164,3524,3525],{},"STUN + Cloudflare 302",[164,3527,3528],{},"固定 HTTPS 域名，随后显示动态地址",[164,3530,3517],{},[164,3532,3533],{},"浏览器 GET 页面、临时入口、能接受暴露地址",[146,3535,3536,3539,3542,3545],{},[164,3537,3538],{},"Cloudflare Tunnel",[164,3540,3541],{},"固定 HTTPS 域名",[164,3543,3544],{},"经 Cloudflare Tunnel",[164,3546,3547],{},"Web、API、管理后台、需要 Access\u002FWAF 的服务",[81,3549,3551],{"id":3550},"第一条路径配置-lucky-stun","第一条路径：配置 Lucky STUN",[246,3553,3555],{"id":3554},"_1-先判断网络条件","1. 先判断网络条件",[16,3557,3558,3562],{},[385,3559,3561],{"href":1516,"rel":3560},[389],"Lucky 的 STUN 官方文档","把适用环境描述为 NAT1，并明确提醒公网端口无法保证固定、变化没有规律。不同工具对 NAT 类型的命名可能不完全一致，所以不要只凭路由器页面上的“NAT 类型”文字判断，应以实际穿透日志和外网测试为准。",[16,3564,3565],{},"开始前依次检查：",[406,3567,3568,3571,3574,3577],{},[100,3569,3570],{},"如果是光猫拨号，按 Lucky 文档把光猫 DMZ 指向主路由；如果是路由器拨号，则跳过这一步。",[100,3572,3573],{},"最省事的部署位置是主路由。Lucky 位于普通局域网主机时，还要处理主路由到 Lucky 的端口映射、UPnP 或 DMZ。",[100,3575,3576],{},"确认系统内核和防火墙允许所需的 TCP\u002FUDP 行为；代理软件、双重 NAT 和多 WAN 都可能改变结果。",[100,3578,3579],{},"不要把 Lucky 管理后台本身作为第一条公网测试规则。先选择一个无敏感数据、可以随时关闭的测试服务。",[246,3581,3583],{"id":3582},"_2-开启-stun-模块","2. 开启 STUN 模块",[16,3585,3586],{},"进入：",[33,3588,3591],{"className":3589,"code":3590,"language":231,"meta":38},[229],"Lucky → 内网穿透 → STUN 内网穿透 → 设置\n",[25,3592,3590],{"__ignoreMap":38},[16,3594,3595,3596,3601],{},"打开 STUN 模块，并检查“全局 STUN 服务器列表”。Lucky 界面会给出一个公开的",[385,3597,3600],{"href":3598,"rel":3599},"https:\u002F\u002Fgithub.com\u002Fpradt2\u002Falways-online-stun\u002Fblob\u002Fmaster\u002Fvalid_hosts_tcp.txt",[389],"可用 STUN 服务器列表","作为参考。服务器越多并不一定越稳定；应保留网络可达、经过测试的条目，不要把来源不明的地址批量粘贴进生产配置。",[16,3603,3604],{},[2024,3605],{"alt":3606,"src":3607},"Lucky 2.27.2 的 STUN 设置页，展示模块开关、公开服务器列表与 Webhook 开关","\u002Fmedia\u002Fposts\u002Flucky-stun-cloudflare-tunnel-guide\u002Flucky-stun-settings-v1.png",[16,3609,3610],{},[2031,3611,3612],{},"实拍界面；画面只包含公开 STUN 服务器地址，没有私有网络地址或访问凭据。",[16,3614,3615],{},"Webhook 会把公网映射变化发送给外部系统。只有确实需要自动化时才启用，并确保 URL、请求头和消息体中的密钥不会出现在日志或截图里。",[246,3617,3619],{"id":3618},"_3-用简易模式添加第一条规则","3. 用简易模式添加第一条规则",[16,3621,3622],{},"进入“穿透规则列表”并选择“添加穿透规则”。第一次测试建议使用简易模式。",[16,3624,3625],{},[2024,3626],{"alt":3627,"src":3628},"Lucky 的添加穿透规则表单，表单外的真实后台数据已被强模糊","\u002Fmedia\u002Fposts\u002Flucky-stun-cloudflare-tunnel-guide\u002Flucky-stun-rule-dialog-redacted-v1.png",[16,3630,3631],{},[2031,3632,3633],{},"表单保持空白；背景中的真实规则、IP、端口和流量统计已做不可逆强模糊。",[140,3635,3636,3646],{},[143,3637,3638],{},[146,3639,3640,3643],{},[149,3641,3642],{},"字段",[149,3644,3645],{},"建议",[159,3647,3648,3656,3664,3680,3688,3696,3704,3712,3726],{},[146,3649,3650,3653],{},[164,3651,3652],{},"规则名称",[164,3654,3655],{},"使用不暴露设备型号、位置或业务名称的内部备注",[146,3657,3658,3661],{},[164,3659,3660],{},"穿透类型",[164,3662,3663],{},"根据服务选择 IPv4-TCP 或 IPv4-UDP；一条规则只处理一种协议",[146,3665,3666,3669],{},[164,3667,3668],{},"穿透通道本地端口",[164,3670,3671,3672,3675,3676,3679],{},"手动填写时使用未占用且全局唯一的端口；Lucky 文档建议大于 ",[25,3673,3674],{},"10240","。配合 UPnP 时可按文档使用 ",[25,3677,3678],{},"0"," 让 Lucky 选择端口",[146,3681,3682,3685],{},[164,3683,3684],{},"防火墙自动放行",[164,3686,3687],{},"仅在 Lucky 运行于支持其防火墙控制的路由器、且你理解规则变化时开启",[146,3689,3690,3693],{},[164,3691,3692],{},"UPnP \u002F NAT-PMP",[164,3694,3695],{},"Lucky 位于局域网主机且主路由已启用相应能力时使用；不用时关闭，避免扩大自动映射范围",[146,3697,3698,3701],{},[164,3699,3700],{},"不使用 Lucky 内置端口转发",[164,3702,3703],{},"第一次测试保持关闭。确认穿透有效后，再评估用路由器转发换取更高性能",[146,3705,3706,3709],{},[164,3707,3708],{},"禁用通道有效性检测",[164,3710,3711],{},"保持关闭，它是调试选项",[146,3713,3714,3717],{},[164,3715,3716],{},"目标地址",[164,3718,3719,3720,437,3723],{},"填内网 IP 或主机名，不要带 ",[25,3721,3722],{},"http:\u002F\u002F",[25,3724,3725],{},"https:\u002F\u002F",[146,3727,3728,3730],{},[164,3729,2863],{},[164,3731,3732],{},"填服务实际监听端口；使用 Lucky 内置转发时必填",[16,3734,3735],{},"一个完全虚构的 TCP 示例：",[33,3737,3740],{"className":3738,"code":3739,"language":231,"meta":38},[229],"穿透类型：IPv4-TCP\n穿透通道本地端口：12080\n目标地址：192.0.2.10\n目标端口：8080\n",[25,3741,3739],{"__ignoreMap":38},[16,3743,3744,3745,3748],{},"这里的 ",[25,3746,3747],{},"192.0.2.10:8080"," 使用 RFC 5737 文档保留地址，只是教程占位符。发布文章、截图或求助日志前，应把真实内网地址与端口一起打码；二者组合往往足以识别某项服务。",[246,3750,3752],{"id":3751},"_4-定制模式先不要调参优化","4. 定制模式先不要“调参优化”",[16,3754,3755],{},"Lucky 2.27.2 的定制模式包含指定 IP\u002F网卡、IP 黑白名单、TCP 连接数、UDP 会话数、加密、STUN 等待时间、心跳间隔、重试间隔和日志级别等选项。这些参数互相关联，不应因为看到默认值就照搬到其他网络。",[16,3757,3758],{},"只有出现可复现问题时才逐项调整：",[97,3760,3761,3764,3767,3770,3773],{},[100,3762,3763],{},"多 WAN 或策略路由环境，才指定穿透通道 IP 或网卡；",[100,3765,3766],{},"连接数与 UDP 会话数根据服务并发和设备资源设上限；",[100,3768,3769],{},"等待、心跳、重试参数一次只改一项，并保留前后日志；",[100,3771,3772],{},"“穿透失败自动重试”可以提高可用性，但也要避免错误配置持续制造请求；",[100,3774,3775],{},"TLS 或流加密不能替代应用自身的认证和授权。",[246,3777,3779],{"id":3778},"_5-用真正的外网验证","5. 用真正的外网验证",[16,3781,3782],{},"保存后先看该规则的独立日志，再关闭 Wi-Fi，用移动网络或另一条宽带访问。至少验证：",[406,3784,3785,3788,3791,3794,3797],{},[100,3786,3787],{},"Lucky 是否获得公网映射；",[100,3789,3790],{},"访问时日志是否出现连接；",[100,3792,3793],{},"防火墙或路由器是否把流量送到正确的 Lucky 端口；",[100,3795,3796],{},"内网服务是否监听了正确地址，而不是只监听另一个网络命名空间；",[100,3798,3799],{},"公网端口变化后，客户端或自动化能否感知新地址。",[16,3801,3802],{},"如果日志完全没有访问记录，先查运营商 NAT、光猫\u002F路由器映射和防火墙，而不是反复修改目标服务。",[81,3804,3806],{"id":3805},"核心方案用-cloudflare-302-跟随-stun-动态端口","核心方案：用 Cloudflare 302 跟随 STUN 动态端口",[16,3808,3809,3810,3813,3814,3816],{},"STUN 给出的是“公网 IP + 公网端口”。A\u002FAAAA 记录只能保存 IP，不能把普通浏览器 URL 的端口一起动态更新。参考方案巧妙之处在于：DNS 只负责让固定入口到达 Cloudflare，真正的 ",[25,3811,3812],{},"IP:端口"," 写在重定向规则的 ",[25,3815,1336],{}," 里，并由 Lucky Webhook 持续更新。",[246,3818,3820],{"id":3819},"_1-为什么选-302而不是-301-或-303","1. 为什么选 302，而不是 301 或 303",[140,3822,3823,3836],{},[143,3824,3825],{},[146,3826,3827,3830,3833],{},[149,3828,3829],{},"状态码",[149,3831,3832],{},"语义与方法行为",[149,3834,3835],{},"是否适合本文",[159,3837,3838,3852,3865,3876,3887],{},[146,3839,3840,3843,3846],{},[164,3841,3842],{},"301",[164,3844,3845],{},"永久移动；客户端和搜索引擎可能长期记住目标",[164,3847,3848,3851],{},[20,3849,3850],{},"不适合","动态 NAT 映射",[146,3853,3854,3856,3859],{},[164,3855,1299],{},[164,3857,3858],{},"临时移动；常见浏览器适合用它处理 GET 页面",[164,3860,3861,3864],{},[20,3862,3863],{},"推荐","作为浏览器入口",[146,3866,3867,3870,3873],{},[164,3868,3869],{},"303",[164,3871,3872],{},"明确要求后续使用 GET，常用于 POST 后查看另一资源",[164,3874,3875],{},"Single Redirect 当前不可选",[146,3877,3878,3881,3884],{},[164,3879,3880],{},"307",[164,3882,3883],{},"临时移动，并要求保留原请求方法和请求体",[164,3885,3886],{},"App\u002FAPI 确需保留方法时评估",[146,3888,3889,3892,3895],{},[164,3890,3891],{},"308",[164,3893,3894],{},"永久移动并保留方法",[164,3896,3897],{},"不适合动态 NAT 映射",[16,3899,3900,3901,3905,3906,3908,3909,3912,3913,3915],{},"Cloudflare 的 ",[385,3902,3904],{"href":3354,"rel":3903},[389],"Single Redirect 设置文档","当前列出的状态码只有 301、302、307、308。Cloudflare 的 3xx 说明页介绍了 303 的 HTTP 语义，但这不代表 Rulesets API 的 ",[25,3907,3137],{}," 动作接受 303。把下面 JSON 的 ",[25,3910,3911],{},"status_code"," 改成 ",[25,3914,3869],{}," 很可能会被 API 校验拒绝。",[16,3917,3918],{},"如果业务必须返回 303，需要由 Cloudflare Worker 或自己的源站生成该响应；那已经不是本文这套 Single Redirect 配置。对于普通浏览器 GET 页面，302 更直接。对于必须保留 POST\u002FPUT 请求体的 API，优先重新评估架构，而不是盲目把 302 换成 307。",[246,3920,3922],{"id":3921},"_2-准备两个不同角色的域名","2. 准备两个不同角色的域名",[16,3924,3925],{},"建议把入口和直连目标分开：",[140,3927,3928,3940],{},[143,3929,3930],{},[146,3931,3932,3935,3938],{},[149,3933,3934],{},"域名",[149,3936,3937],{},"Cloudflare 状态",[149,3939,2631],{},[159,3941,3942,3954],{},[146,3943,3944,3948,3951],{},[164,3945,3946],{},[25,3947,3444],{},[164,3949,3950],{},"已代理（橙云）",[164,3952,3953],{},"用户输入的固定入口，触发 Redirect Rule",[146,3955,3956,3961,3964],{},[164,3957,3958],{},[25,3959,3960],{},"origin-direct.example.com",[164,3962,3963],{},"仅 DNS（灰云，可选）",[164,3965,3966],{},"希望目标仍使用域名和有效 TLS 证书时，由 Lucky DDNS 更新 IP",[16,3968,3969,3970,3972],{},"Cloudflare 官方明确要求 Single Redirect 所匹配的入口主机名必须经过代理。因此 ",[25,3971,3444],{}," 必须是橙云记录。规则命中后 Cloudflare 直接返回 302，不会请求这个记录指向的源站；但未命中的路径可能继续回源，所以生产环境应让规则覆盖完整主机名，并为漏网请求设计安全失败，而不是指向真实管理后台。",[16,3974,3975],{},"目标可以有两种写法：",[33,3977,3980],{"className":3978,"code":3979,"language":231,"meta":38},[229],"最简单： http:\u002F\u002F公网IP:动态端口\u002Fpath\n更适合 HTTPS： https:\u002F\u002Forigin-direct.example.com:动态端口\u002Fpath\n",[25,3981,3979],{"__ignoreMap":38},[16,3983,3984,3985,3987],{},"直接跳到 HTTPS IP 往往会遇到证书主机名不匹配。若需要 HTTPS，推荐让 Lucky DDNS 更新灰云目标域名，并让源站证书包含 ",[25,3986,3960],{},"。无论采用哪一种，第二段连接都会暴露目标 IP 和端口，也不会得到 Cloudflare WAF 或 Access 保护。",[246,3989,3991],{"id":3990},"_3-在-cloudflare-创建-302-动态重定向","3. 在 Cloudflare 创建 302 动态重定向",[16,3993,3586],{},[33,3995,3998],{"className":3996,"code":3997,"language":231,"meta":38},[229],"Cloudflare Dashboard → 目标 Zone → Rules → Redirect Rules\n→ Create rule → Single Redirects\n",[25,3999,3997],{"__ignoreMap":38},[16,4001,4002],{},"先建立一条占位规则：",[33,4004,4007],{"className":4005,"code":4006,"language":231,"meta":38},[229],"规则名：Lucky STUN temporary redirect\n匹配表达式：(http.host eq \"go.example.com\")\n类型：Dynamic\n目标表达式：concat(\"http:\u002F\u002F192.0.2.10:12080\", http.request.uri.path)\n状态码：302\n保留查询字符串：开启\n",[25,4008,4006],{"__ignoreMap":38},[16,4010,4011,4014,4015,4018,4019,4021],{},[25,4012,4013],{},"192.0.2.10"," 是文档保留地址。占位规则只是为了让 Cloudflare 创建规则集和规则 ID，不应拿它做真实连通性测试。部署后先用 ",[25,4016,4017],{},"curl -I"," 确认返回状态是 302，并检查 ",[25,4020,1336],{}," 是否保留路径与查询参数。",[246,4023,4025],{"id":4024},"_4-创建最小权限-api-token","4. 创建最小权限 API Token",[16,4027,4028],{},"为 Lucky 单独创建 Token：",[33,4030,4033],{"className":4031,"code":4032,"language":231,"meta":38},[229],"Permissions：Zone → Single Redirect → Edit\nResources：Include → Specific zone → example.com\n",[25,4034,4032],{"__ignoreMap":38},[16,4036,4037],{},"不要使用 Global API Key，也不要把 DNS、Workers、账户管理等无关权限一起勾选。这个 Token 能修改入口跳往何处，一旦泄漏可以被用于钓鱼重定向，应按生产密钥管理。",[16,4039,4040,4041,4044,4045,4048,4049,4052,4053,4055],{},"Webhook 还需要三个非密钥标识：",[25,4042,4043],{},"ZONE_ID","、动态重定向规则集的 ",[25,4046,4047],{},"RULESET_ID","、目标规则的 ",[25,4050,4051],{},"RULE_ID","。可以从 Dashboard 编辑规则时的请求中查看，也可以调用 Rulesets API 列出 Zone 规则集，查找 phase 为 ",[25,4054,3137],{}," 的条目，再读取其中规则。截图或文章仍建议统一打码这些标识。",[246,4057,4059],{"id":4058},"_5-在-lucky-规则里配置-webhook","5. 在 Lucky 规则里配置 Webhook",[16,4061,4062,4063,4066],{},"编辑对应 STUN 穿透规则，开启 Webhook。不要使用 Dashboard 的内部 ",[25,4064,4065],{},"\u002Fapi\u002Fv4\u002F"," 地址，应调用公开 API：",[33,4068,4071],{"className":4069,"code":4070,"language":231,"meta":38},[229],"请求地址：\nhttps:\u002F\u002Fapi.cloudflare.com\u002Fclient\u002Fv4\u002Fzones\u002F\u003CZONE_ID>\u002Frulesets\u002F\u003CRULESET_ID>\u002Frules\u002F\u003CRULE_ID>\n\n请求方法：PATCH\n\n请求头：\nAuthorization: Bearer \u003CCLOUDFLARE_API_TOKEN>\nContent-Type: application\u002Fjson\n",[25,4072,4070],{"__ignoreMap":38},[16,4074,4075],{},"请求体使用 302：",[33,4077,4081],{"className":4078,"code":4079,"language":4080,"meta":38,"style":38},"language-json shiki shiki-themes github-dark github-dark","{\n  \"action\": \"redirect\",\n  \"description\": \"Lucky STUN temporary redirect\",\n  \"enabled\": true,\n  \"expression\": \"(http.host eq \\\"go.example.com\\\")\",\n  \"action_parameters\": {\n    \"from_value\": {\n      \"status_code\": 302,\n      \"target_url\": {\n        \"expression\": \"concat(\\\"http:\u002F\u002F#{ipAddr}\\\", http.request.uri.path)\"\n      },\n      \"preserve_query_string\": true\n    }\n  }\n}\n","json",[25,4082,4083,4088,4101,4113,4125,4147,4155,4162,4173,4180,4200,4205,4214,4219,4224],{"__ignoreMap":38},[42,4084,4085],{"class":44,"line":45},[42,4086,4087],{"class":1797},"{\n",[42,4089,4090,4093,4095,4098],{"class":44,"line":274},[42,4091,4092],{"class":52},"  \"action\"",[42,4094,2105],{"class":1797},[42,4096,4097],{"class":56},"\"redirect\"",[42,4099,4100],{"class":1797},",\n",[42,4102,4103,4106,4108,4111],{"class":44,"line":737},[42,4104,4105],{"class":52},"  \"description\"",[42,4107,2105],{"class":1797},[42,4109,4110],{"class":56},"\"Lucky STUN temporary redirect\"",[42,4112,4100],{"class":1797},[42,4114,4115,4118,4120,4123],{"class":44,"line":1584},[42,4116,4117],{"class":52},"  \"enabled\"",[42,4119,2105],{"class":1797},[42,4121,4122],{"class":52},"true",[42,4124,4100],{"class":1797},[42,4126,4127,4130,4132,4135,4138,4140,4142,4145],{"class":44,"line":1603},[42,4128,4129],{"class":52},"  \"expression\"",[42,4131,2105],{"class":1797},[42,4133,4134],{"class":56},"\"(http.host eq ",[42,4136,4137],{"class":52},"\\\"",[42,4139,3444],{"class":56},[42,4141,4137],{"class":52},[42,4143,4144],{"class":56},")\"",[42,4146,4100],{"class":1797},[42,4148,4149,4152],{"class":44,"line":1618},[42,4150,4151],{"class":52},"  \"action_parameters\"",[42,4153,4154],{"class":1797},": {\n",[42,4156,4157,4160],{"class":44,"line":1627},[42,4158,4159],{"class":52},"    \"from_value\"",[42,4161,4154],{"class":1797},[42,4163,4164,4167,4169,4171],{"class":44,"line":1640},[42,4165,4166],{"class":52},"      \"status_code\"",[42,4168,2105],{"class":1797},[42,4170,1299],{"class":52},[42,4172,4100],{"class":1797},[42,4174,4175,4178],{"class":44,"line":1645},[42,4176,4177],{"class":52},"      \"target_url\"",[42,4179,4154],{"class":1797},[42,4181,4182,4185,4187,4190,4192,4195,4197],{"class":44,"line":1668},[42,4183,4184],{"class":52},"        \"expression\"",[42,4186,2105],{"class":1797},[42,4188,4189],{"class":56},"\"concat(",[42,4191,4137],{"class":52},[42,4193,4194],{"class":56},"http:\u002F\u002F#{ipAddr}",[42,4196,4137],{"class":52},[42,4198,4199],{"class":56},", http.request.uri.path)\"\n",[42,4201,4202],{"class":44,"line":1674},[42,4203,4204],{"class":1797},"      },\n",[42,4206,4207,4210,4212],{"class":44,"line":1680},[42,4208,4209],{"class":52},"      \"preserve_query_string\"",[42,4211,2105],{"class":1797},[42,4213,2138],{"class":52},[42,4215,4216],{"class":44,"line":777},[42,4217,4218],{"class":1797},"    }\n",[42,4220,4221],{"class":44,"line":1691},[42,4222,4223],{"class":1797},"  }\n",[42,4225,4226],{"class":44,"line":1697},[42,4227,4228],{"class":1797},"}\n",[16,4230,4231,4232,4235],{},"在 Lucky STUN Webhook 中，",[25,4233,4234],{},"#{ipAddr}"," 会在触发时替换为当前的公网地址与端口，因此生成的 Cloudflare 表达式类似：",[33,4237,4240],{"className":4238,"code":4239,"language":231,"meta":38},[229],"concat(\"http:\u002F\u002F203.0.113.10:45678\", http.request.uri.path)\n",[25,4241,4239],{"__ignoreMap":38},[16,4243,4244],{},"如果采用“灰云目标域名 + HTTPS”模式，请同时用 Lucky DDNS 更新目标域名，并把目标表达式改为：",[33,4246,4248],{"className":4078,"code":4247,"language":4080,"meta":38,"style":38},"\"expression\": \"concat(\\\"https:\u002F\u002Forigin-direct.example.com:#{port}\\\", http.request.uri.path)\"\n",[25,4249,4250],{"__ignoreMap":38},[42,4251,4252,4255,4257,4259,4261,4264,4266],{"class":44,"line":45},[42,4253,4254],{"class":56},"\"expression\"",[42,4256,2105],{"class":1797},[42,4258,4189],{"class":56},[42,4260,4137],{"class":52},[42,4262,4263],{"class":56},"https:\u002F\u002Forigin-direct.example.com:#{port}",[42,4265,4137],{"class":52},[42,4267,4199],{"class":56},[16,4269,4270,1369,4272,4275],{},[25,4271,4234],{},[25,4273,4274],{},"#{port}"," 是 Lucky Webhook 模板变量，不是 Cloudflare Rules 语言变量。引号必须是英文半角字符，JSON 中表达式内部的双引号必须保留反斜杠转义。",[16,4277,4278],{},"把 Webhook 的成功判断设置为响应中包含：",[33,4280,4283],{"className":4281,"code":4282,"language":231,"meta":38},[229],"\"success\": true\n",[25,4284,4282],{"__ignoreMap":38},[16,4286,4287],{},"不同界面或响应序列化可能包含空格，不应仅凭一次手动测试就认定长期有效。最终要同时查看 Lucky Webhook 日志与 Cloudflare API 响应，并重新读取规则确认目标已更新。",[246,4289,4291],{"id":4290},"_6-从外网验证完整链路","6. 从外网验证完整链路",[16,4293,4294],{},"不要只看浏览器“能打开”。用移动网络或另一条宽带分两段验证：",[33,4296,4298],{"className":35,"code":4297,"language":37,"meta":38,"style":38},"curl -sS -D - -o \u002Fdev\u002Fnull \"https:\u002F\u002Fgo.example.com\u002Fphotos?id=42\"\n",[25,4299,4300],{"__ignoreMap":38},[42,4301,4302,4304,4306,4308,4310,4312,4314],{"class":44,"line":45},[42,4303,262],{"class":48},[42,4305,2935],{"class":52},[42,4307,2944],{"class":52},[42,4309,2947],{"class":56},[42,4311,2938],{"class":52},[42,4313,2941],{"class":56},[42,4315,4316],{"class":56}," \"https:\u002F\u002Fgo.example.com\u002Fphotos?id=42\"\n",[16,4318,4319],{},"预期关键响应：",[33,4321,4325],{"className":4322,"code":4323,"language":4324,"meta":38,"style":38},"language-http shiki shiki-themes github-dark github-dark","HTTP\u002F2 302\nlocation: http:\u002F\u002F203.0.113.10:45678\u002Fphotos?id=42\n","http",[25,4326,4327,4332],{"__ignoreMap":38},[42,4328,4329],{"class":44,"line":45},[42,4330,4331],{},"HTTP\u002F2 302\n",[42,4333,4334],{"class":44,"line":274},[42,4335,4336],{},"location: http:\u002F\u002F203.0.113.10:45678\u002Fphotos?id=42\n",[16,4338,4339],{},"接着检查：",[406,4341,4342,4345,4350,4353,4356],{},[100,4343,4344],{},"状态码确实是 302，不是仍被旧规则返回 301；",[100,4346,4347,4349],{},[25,4348,1336],{}," 中的 IP、端口、路径和查询字符串正确；",[100,4351,4352],{},"让 STUN 重新获得映射后，Webhook 是否把规则改成新端口；",[100,4354,4355],{},"浏览器无痕窗口和另一台设备没有命中过去的永久缓存；",[100,4357,4358],{},"第二段直连是否具有应用自身的登录、限速、防爆破和 TLS 保护。",[16,4360,4361,4362,4364,4365,4368],{},"测试时使用 ",[25,4363,4017],{}," 只能验证 HEAD；部分服务对 HEAD 和 GET 的处理不同。需要完整验证页面时，使用上面的 ",[25,4366,4367],{},"-D - -o \u002Fdev\u002Fnull"," 发出 GET，但不要在终端输出私密响应正文。",[246,4370,4372],{"id":4371},"_7-这套-302-方案明确不解决什么","7. 这套 302 方案明确不解决什么",[97,4374,4375,4381,4384,4387,4390,4393],{},[100,4376,4377,4378,4380],{},"它不会真正隐藏端口；跳转后地址栏和 ",[25,4379,1336],{}," 都会显示端口。",[100,4382,4383],{},"它不会隐藏家庭公网 IP；访问者会直接连接目标。",[100,4385,4386],{},"它不会让 App、电视客户端、远程桌面或自定义协议自动兼容 HTTP 重定向。",[100,4388,4389],{},"它不会把第二段请求放到 Cloudflare WAF、Cache 或 Access 后面。",[100,4391,4392],{},"它不会自动解决 Cookie Domain、CORS、CSRF、绝对 URL、Host 校验或 HTTPS 证书问题。",[100,4394,4395],{},"它只适合 HTTP\u002FHTTPS 入口；UDP 不可能通过 HTTP 302 重定向建立会话。",[16,4397,4398],{},"因此，Cloudflare Redirect 不是“给 STUN 端口套橙云”。橙云只覆盖固定入口的第一次 HTTP 请求。",[81,4400,4402],{"id":4401},"替代方案在-lucky-中运行-cloudflare-tunnel","替代方案：在 Lucky 中运行 Cloudflare Tunnel",[16,4404,4405,4406,4409,4410,76],{},"如果目标是 Web、API 或后台管理页面，Cloudflare Tunnel 通常更简单。",[25,4407,4408],{},"cloudflared"," 从内网主动连接 Cloudflare，不需要公网 IP，也不需要在路由器开放入站端口。完整模型可参考 ",[385,4411,4414],{"href":4412,"rel":4413},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fcloudflare-one\u002Fnetworks\u002Fconnectors\u002Fcloudflare-tunnel\u002F",[389],"Cloudflare Tunnel 官方说明",[246,4416,4418],{"id":4417},"_1-在-cloudflare-创建远程管理隧道","1. 在 Cloudflare 创建远程管理隧道",[16,4420,4421],{},"进入 Cloudflare Dashboard：",[33,4423,4426],{"className":4424,"code":4425,"language":231,"meta":38},[229],"Networking → Tunnels → Create a tunnel → Cloudflared\n",[25,4427,4425],{"__ignoreMap":38},[16,4429,4430],{},"给隧道一个不包含家庭地址、设备序列号或个人姓名的名称。创建后，控制台会生成一条安装命令，其中包含 Tunnel Token。Lucky 只需要 Token 本身，不要把整条命令保存到笔记或截图中。",[16,4432,4433],{},[2024,4434],{"alt":4435,"src":4436},"Cloudflare Dashboard 的创建隧道空白表单","\u002Fmedia\u002Fposts\u002Flucky-stun-cloudflare-tunnel-guide\u002Fcloudflare-create-tunnel-v1.png",[16,4438,4439],{},[2031,4440,4441],{},"实拍界面；未填写名称、未生成 Token，也没有提交创建操作。账号侧栏处于折叠状态。",[16,4443,4444,4445,4447],{},"Tunnel Token 能让新的 ",[25,4446,4408],{}," 连接到这条隧道，应按密码管理：",[97,4449,4450,4453,4456,4459],{},[100,4451,4452],{},"不写进 Markdown、Shell 历史、Git、Dockerfile 或公开环境变量；",[100,4454,4455],{},"不放在浏览器端代码；",[100,4457,4458],{},"每条隧道使用独立 Token；",[100,4460,4461,4462,76],{},"泄漏后立即在隧道详情中刷新 Token，并清理旧连接。Cloudflare 也建议",[385,4463,4466],{"href":4464,"rel":4465},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fcloudflare-one\u002Fnetworks\u002Fconnectors\u002Fcloudflare-tunnel\u002Fconfigure-tunnels\u002Fremote-tunnel-permissions\u002F",[389],"定期轮换 Tunnel Token",[246,4468,4470],{"id":4469},"_2-在-lucky-添加-tunnel-实例","2. 在 Lucky 添加 Tunnel 实例",[16,4472,3586],{},[33,4474,4477],{"className":4475,"code":4476,"language":231,"meta":38},[229],"Lucky → 内网穿透 → Cloudflared → 添加实例\n",[25,4478,4476],{"__ignoreMap":38},[16,4480,4481],{},"选择“隧道”模式，然后配置：",[16,4483,4484],{},[2024,4485],{"alt":4486,"src":4487},"Lucky 的 Cloudflared 添加实例表单，表单外的真实实例信息已被强模糊","\u002Fmedia\u002Fposts\u002Flucky-stun-cloudflare-tunnel-guide\u002Flucky-cloudflared-instance-redacted-v1.png",[16,4489,4490],{},[2031,4491,4492],{},"Token 与 API Token 输入框保持空白；背景中的真实实例信息已做不可逆强模糊。",[140,4494,4495,4503],{},[143,4496,4497],{},[146,4498,4499,4501],{},[149,4500,3642],{},[149,4502,3645],{},[159,4504,4505,4513,4521,4529,4537,4545,4553],{},[146,4506,4507,4510],{},[164,4508,4509],{},"实例备注",[164,4511,4512],{},"使用唯一但不泄漏设备或位置的信息",[146,4514,4515,4518],{},[164,4516,4517],{},"令牌",[164,4519,4520],{},"粘贴 Tunnel Token；发布前必须完全打码",[146,4522,4523,4526],{},[164,4524,4525],{},"边缘网络类型",[164,4527,4528],{},"一般使用自动；只有 IPv4\u002FIPv6 链路确有问题时才固定",[146,4530,4531,4534],{},[164,4532,4533],{},"高可用连接数",[164,4535,4536],{},"保持合理数量；更多连接不等于无限可用性",[146,4538,4539,4542],{},[164,4540,4541],{},"连接协议",[164,4543,4544],{},"自动、HTTP\u002F2、QUIC 均需结合网络；UDP 受限时优先测试 HTTP\u002F2",[146,4546,4547,4550],{},[164,4548,4549],{},"边缘绑定 IP",[164,4551,4552],{},"单网卡环境留空；多 WAN 定向出口时再配置",[146,4554,4555,4558],{},[164,4556,4557],{},"不验证后端 TLS 证书",[164,4559,4560],{},"生产环境应关闭。优先填写正确的源服务器名或部署可信 CA",[16,4562,4563],{},"如果希望 Lucky 自动管理应用路由和 DNS，再提供一个单独的 Cloudflare API Token。Lucky 当前界面要求：",[97,4565,4566,4569,4572],{},[100,4567,4568],{},"Account → Cloudflare Tunnel → Edit；",[100,4570,4571],{},"Zone → DNS → Edit；",[100,4573,4574],{},"账户和 Zone 均缩小到实际使用范围。",[16,4576,4577],{},"不需要自动管理时可以不填 API Token，改在 Cloudflare Dashboard 手动维护路由和 DNS。最小权限永远比“为了省事选 All”更安全。",[246,4579,4581],{"id":4580},"_3-添加应用路由","3. 添加应用路由",[16,4583,4584],{},"在 Tunnel 实例右侧进入“应用路由”，添加：",[33,4586,4589],{"className":4587,"code":4588,"language":231,"meta":38},[229],"主机名：app.example.com\n路径：\u002F*\n目标服务：http:\u002F\u002F192.0.2.10:8080\n",[25,4590,4588],{"__ignoreMap":38},[16,4592,4593,4596,4597,4602,4603,4605],{},[25,4594,4595],{},"目标服务","必须包含协议。HTTP、HTTPS、TCP 等值的含义可以查阅 ",[385,4598,4601],{"href":4599,"rel":4600},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Ftunnel\u002Frouting\u002F",[389],"Cloudflare Tunnel 路由文档","。对于普通浏览器访问，优先使用 HTTP\u002FHTTPS 应用路由；非 HTTP 公共主机名通常要求客户端也运行 ",[25,4604,4408],{},"，任意 TCP\u002FUDP 公网代理则可能需要 Spectrum 或私网路由方案。",[16,4607,4608,4609,4612,4613,4616],{},"当一条主机名有多条路径规则时，把具体规则放在通配规则之前。例如 ",[25,4610,4611],{},"\u002Fapi\u002F*"," 应先于 ",[25,4614,4615],{},"\u002F*","，否则前面的宽泛规则可能提前匹配。",[16,4618,4619],{},"高级选项只在需要时使用：",[97,4621,4622,4628,4634,4640,4646],{},[100,4623,4624,4627],{},[20,4625,4626],{},"源服务器名","：源站证书上的主机名与目标地址不一致时填写；",[100,4629,4630,4633],{},[20,4631,4632],{},"Host 请求头","：源站按虚拟主机分流时覆盖；",[100,4635,4636,4639],{},[20,4637,4638],{},"HTTP\u002F2 连接","：只有源站确实支持时开启；",[100,4641,4642,4645],{},[20,4643,4644],{},"连接、TLS、保活超时","：依据日志和服务特征调整；",[100,4647,4648,4650,4651,4654],{},[20,4649,4557],{},"：只能作为临时排错手段。Cloudflare 官方也把 ",[25,4652,4653],{},"noTLSVerify"," 定义为生产环境不推荐的最后手段。",[246,4656,4658],{"id":4657},"_4-配置-cname","4. 配置 CNAME",[16,4660,4661],{},"每条 Tunnel 会得到形如下面的目标：",[33,4663,4666],{"className":4664,"code":4665,"language":231,"meta":38},[229],"\u003CTUNNEL_ID>.cfargotunnel.com\n",[25,4667,4665],{"__ignoreMap":38},[16,4669,4670,4671,4674],{},"为 ",[25,4672,4673],{},"app.example.com"," 创建 CNAME 指向它。通过 Cloudflare Dashboard 添加 Published application route 时，DNS 记录通常会自动创建；从 Lucky 管理路由时，是否自动写入取决于 API Token 和当前配置。保存后务必回到 DNS 页面确认，而不是假设已经成功。",[16,4676,4677],{},"Tunnel UUID 不是等同于 Token 的认证密钥，但仍然属于基础设施标识。截图中没有展示它的必要，应统一打码。",[246,4679,4681],{"id":4680},"_5-给管理后台加-cloudflare-access","5. 给管理后台加 Cloudflare Access",[16,4683,4684],{},"Tunnel 只解决“如何到达源站”，不会自动决定“谁可以访问”。管理后台、监控页面和私人服务建议再创建 Access 应用：",[33,4686,4689],{"className":4687,"code":4688,"language":231,"meta":38},[229],"Zero Trust → Access controls → Applications\n→ Create new application → Self-hosted and private\n",[25,4690,4688],{"__ignoreMap":38},[16,4692,4693,4694,76],{},"把 Tunnel 的公网主机名加入应用，创建 Allow 策略，并用邮箱、身份提供商、设备状态或 Service Token 限制访问。Cloudflare Access 的策略由 Action、Include、Require、Exclude 等条件组成，详见",[385,4695,4698],{"href":4696,"rel":4697},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fcloudflare-one\u002Faccess-controls\u002Fpolicies\u002F",[389],"官方 Access 策略文档",[16,4700,4701],{},"不要在 Access 保护的 URL 中依赖端口号；Cloudflare 的端口文档说明 Access 会移除 URL 里的端口。对外使用标准 HTTPS 主机名，把内网端口只保留在 Tunnel 的目标服务中。",[81,4703,4704],{"id":4704},"常见故障怎么定位",[140,4706,4707,4717],{},[143,4708,4709],{},[146,4710,4711,4714],{},[149,4712,4713],{},"现象",[149,4715,4716],{},"优先检查",[159,4718,4719,4727,4735,4743,4751,4761,4773,4781,4789,4797,4805,4813,4821,4832],{},[146,4720,4721,4724],{},[164,4722,4723],{},"STUN 没拿到映射",[164,4725,4726],{},"NAT 条件、光猫\u002F路由器拨号位置、双重 NAT、代理软件、内核与防火墙",[146,4728,4729,4732],{},[164,4730,4731],{},"STUN 有映射但外网无日志",[164,4733,4734],{},"公网端口、防火墙、路由器转发、运营商入站限制",[146,4736,4737,4740],{},[164,4738,4739],{},"STUN 有日志但服务打不开",[164,4741,4742],{},"目标地址、目标端口、协议类型、服务监听地址",[146,4744,4745,4748],{},[164,4746,4747],{},"域名能解析但端口不对",[164,4749,4750],{},"A\u002FAAAA 只保存 IP；检查公网端口变化通知或客户端配置",[146,4752,4753,4756],{},[164,4754,4755],{},"入口仍返回 301",[164,4757,4758,4759],{},"Cloudflare 是否还有更靠前的 Redirect\u002FPage Rule；读取当前规则的 ",[25,4760,3911],{},[146,4762,4763,4766],{},[164,4764,4765],{},"Webhook 返回 API 错误",[164,4767,4768,4769,4772],{},"Token 是否有 ",[25,4770,4771],{},"Single Redirect: Edit","、Zone\u002F规则 ID 是否正确、JSON 引号与转义是否完整",[146,4774,4775,4778],{},[164,4776,4777],{},"302 的 Location 仍是旧端口",[164,4779,4780],{},"Lucky 是否触发 Webhook、成功判断是否误判、Cloudflare 规则是否被其他自动化覆盖",[146,4782,4783,4786],{},[164,4784,4785],{},"浏览器正确、App 无法登录",[164,4787,4788],{},"App 是否支持重定向；Host、Cookie、认证回调和 TLS 是否绑定原域名",[146,4790,4791,4794],{},[164,4792,4793],{},"HTTPS 跳到 IP 后证书报错",[164,4795,4796],{},"改用灰云目标域名与匹配的源站证书，或改用 Tunnel；不要关闭客户端证书校验",[146,4798,4799,4802],{},[164,4800,4801],{},"Tunnel 显示连接但返回 502",[164,4803,4804],{},"Lucky 到目标服务的网络可达性、协议、端口和源站监听",[146,4806,4807,4810],{},[164,4808,4809],{},"HTTPS 源站出现 x509 错误",[164,4811,4812],{},"优先设置正确的源服务器名或 CA，不要长期启用跳过验证",[146,4814,4815,4818],{},[164,4816,4817],{},"Tunnel 域名返回 1016",[164,4819,4820],{},"CNAME 是否属于同一 Cloudflare 账户、隧道是否运行、目标 UUID 是否正确",[146,4822,4823,4826],{},[164,4824,4825],{},"路径访问到错误服务",[164,4827,4828,4829,4831],{},"检查路由顺序，让具体路径位于 ",[25,4830,4615],{}," 之前",[146,4833,4834,4837],{},[164,4835,4836],{},"QUIC 连接失败",[164,4838,4839],{},"检查出站 UDP；必要时切换 HTTP\u002F2 对照测试",[16,4841,4842],{},"排错时一次只改一个变量，并保留时间点。日志可以帮助定位，但分享前先删掉公网 IP、内网 IP、端口、域名、请求路径、Cookie、Authorization、Token 和实例备注。",[81,4844,4845],{"id":4845},"发布截图前的隐私检查清单",[16,4847,4848],{},"Lucky 的规则列表密度很高，一张未处理的截图可能同时暴露整个家庭网络拓扑。发布前逐项检查：",[97,4850,4851,4854,4857,4860,4863,4866,4869,4872],{},[100,4852,4853],{},"Lucky 后台地址、端口与安全入口路径；",[100,4855,4856],{},"公网 IP、映射端口、内网 IP、目标端口；",[100,4858,4859],{},"域名、主机名、实例名、规则名和设备名；",[100,4861,4862],{},"Tunnel Token、Cloudflare API Token、Access Service Token；",[100,4864,4865],{},"Account ID、Zone ID、Tunnel UUID 与 CNAME 目标；",[100,4867,4868],{},"请求路径、Webhook 地址与请求头；",[100,4870,4871],{},"日志、访问者 IP、流量统计与启用状态；",[100,4873,4874],{},"浏览器标签页、书签栏、账号头像和邮箱。",[16,4876,4877,4878,434,4880,464,4882,4885],{},"仅仅模糊 Token 的中间几位还不够。安全做法是用重新绘制的示意图，或者把所有真实值替换为 ",[25,4879,4673],{},[25,4881,4013],{},[25,4883,4884],{},"\u003CTOKEN>"," 这类占位符。",[81,4887,4888],{"id":4888},"最后的选择建议",[16,4890,4891,4892,4894],{},"如果只是给自己或少量可信用户提供一个浏览器 GET 页面入口，能够接受跳转后暴露 IP 与端口，并且已经验证 NAT 条件，可以选择 ",[20,4893,3525],{},"。它解决的是“记住固定入口”，不是反向代理、安全网关或真正的端口隐藏。",[16,4896,4897],{},"如果请求必须保留方法，可在确认客户端行为后评估 307；不要把 303 直接填进 Single Redirect JSON。必须使用 303 时，应由 Worker 或源站生成，并单独设计动态目标存储和鉴权。",[16,4899,4900],{},"如果你要发布的是 Web 页面或 API，并希望地址始终保持标准 HTTPS 域名，同时使用 Access 和 WAF，选择 Cloudflare Tunnel。它不要求 STUN 成功，也不会因为公网映射端口改变而修改访问 URL。",[16,4902,4903],{},"如果你明确需要直接 TCP\u002FUDP、延迟和点对点路径更重要，且网络满足 Lucky 的 STUN 条件，可以选择 STUN；同时接受公网端口可能变化、灰云直连暴露源地址，以及需要自行完成应用认证和防火墙控制。",[16,4905,4906],{},"STUN 直连、STUN + 302 入口和 Cloudflare Tunnel 都能帮助“从外网访问内网”，但只有后者让业务流量经过 Cloudflare。先确定协议、客户端和安全边界，再选择入口方式，通常比照抄一份 301 请求体更可靠。",[81,4908,3287],{"id":3287},[97,4910,4911,4916,4923,4930,4937,4943,4950,4957,4964,4970,4977,4983,4990,4996],{},[100,4912,4913],{},[385,4914,3328],{"href":1516,"rel":4915},[389],[100,4917,4918],{},[385,4919,4922],{"href":4920,"rel":4921},"https:\u002F\u002Flucky666.cn\u002Fdocs\u002Fmodules\u002Fcloudflared",[389],"Lucky：Cloudflare 隧道",[100,4924,4925],{},[385,4926,4929],{"href":4927,"rel":4928},"https:\u002F\u002Flucky666.cn\u002Fdocs\u002Fmodules\u002Fddns",[389],"Lucky：动态域名（DDNS）",[100,4931,4932],{},[385,4933,4936],{"href":4934,"rel":4935},"https:\u002F\u002Fwww.geekxw.top\u002F1814\u002F",[389],"参考思路：Lucky STUN + Cloudflare 301 重定向",[100,4938,4939],{},[385,4940,4942],{"href":3354,"rel":4941},[389],"Cloudflare：Single Redirect 可用状态码与动态表达式",[100,4944,4945],{},[385,4946,4949],{"href":4947,"rel":4948},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Frules\u002Furl-forwarding\u002Fsingle-redirects\u002Fcreate-api\u002F",[389],"Cloudflare：通过 API 创建 Single Redirect",[100,4951,4952],{},[385,4953,4956],{"href":4954,"rel":4955},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fapi\u002Fresources\u002Frulesets\u002F",[389],"Cloudflare：Rulesets API",[100,4958,4959],{},[385,4960,4963],{"href":4961,"rel":4962},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fsupport\u002Ftroubleshooting\u002Fhttp-status-codes\u002F3xx-redirection\u002F",[389],"Cloudflare：3xx 状态码语义",[100,4965,4966],{},[385,4967,4969],{"href":4412,"rel":4968},[389],"Cloudflare：Cloudflare Tunnel",[100,4971,4972],{},[385,4973,4976],{"href":4974,"rel":4975},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fcloudflare-one\u002Fnetworks\u002Fconnectors\u002Fcloudflare-tunnel\u002Fget-started\u002Fcreate-remote-tunnel\u002F",[389],"Cloudflare：创建远程管理 Tunnel",[100,4978,4979],{},[385,4980,4982],{"href":4599,"rel":4981},[389],"Cloudflare：Tunnel 路由与 CNAME",[100,4984,4985],{},[385,4986,4989],{"href":4987,"rel":4988},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Ffundamentals\u002Freference\u002Fnetwork-ports\u002F",[389],"Cloudflare：代理支持的网络端口",[100,4991,4992],{},[385,4993,4995],{"href":4464,"rel":4994},[389],"Cloudflare：Tunnel Token 权限与轮换",[100,4997,4998],{},[385,4999,5002],{"href":5000,"rel":5001},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Ftunnel\u002Ftroubleshooting\u002F",[389],"Cloudflare：Tunnel TLS 故障排查",[733,5004,5005],{},"html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--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 .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}",{"title":38,"searchDepth":737,"depth":737,"links":5007},[5008,5009,5016,5025,5032,5033,5034,5035],{"id":3473,"depth":274,"text":3473},{"id":3550,"depth":274,"text":3551,"children":5010},[5011,5012,5013,5014,5015],{"id":3554,"depth":737,"text":3555},{"id":3582,"depth":737,"text":3583},{"id":3618,"depth":737,"text":3619},{"id":3751,"depth":737,"text":3752},{"id":3778,"depth":737,"text":3779},{"id":3805,"depth":274,"text":3806,"children":5017},[5018,5019,5020,5021,5022,5023,5024],{"id":3819,"depth":737,"text":3820},{"id":3921,"depth":737,"text":3922},{"id":3990,"depth":737,"text":3991},{"id":4024,"depth":737,"text":4025},{"id":4058,"depth":737,"text":4059},{"id":4290,"depth":737,"text":4291},{"id":4371,"depth":737,"text":4372},{"id":4401,"depth":274,"text":4402,"children":5026},[5027,5028,5029,5030,5031],{"id":4417,"depth":737,"text":4418},{"id":4469,"depth":737,"text":4470},{"id":4580,"depth":737,"text":4581},{"id":4657,"depth":737,"text":4658},{"id":4680,"depth":737,"text":4681},{"id":4704,"depth":274,"text":4704},{"id":4845,"depth":274,"text":4845},{"id":4888,"depth":274,"text":4888},{"id":3287,"depth":274,"text":3287},"通过 Lucky STUN 获取公网映射，再用 Webhook 更新 Cloudflare Single Redirect，让用户从固定域名以 302 临时跳转到最新 IP 与端口；同时讲清 303 限制、TLS、App 兼容性与 Tunnel 替代方案。",{},"\u002Fblog\u002Flucky-stun-cloudflare-tunnel-guide","2026-08-02",{"title":3425,"description":5036},"blog\u002Flucky-stun-cloudflare-tunnel-guide",[5043,3410,5044,3538,3413],"Lucky","Cloudflare Redirect Rules","bo6v7kkJdfHWVBr56kRTAvoAvDtbHx8lj08nqYxvbN0",{"id":5047,"title":5048,"accent":1287,"body":5049,"description":6615,"draft":764,"experience":765,"extension":766,"featured":767,"image":6616,"meta":6622,"navigation":767,"path":6623,"publishedAt":6624,"readingTime":1709,"seo":6625,"stem":6626,"tags":6627,"updatedAt":765,"video":765,"__hash__":6631,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Fcloudflare-smtp-email-sending-guide.md","Cloudflare SMTP 发信实战：从控制台配置到 Nodemailer 上线",{"type":13,"value":5050,"toc":6597},[5051,5058,5061,5070,5088,5091,5176,5179,5192,5199,5202,5206,5209,5215,5218,5222,5228,5234,5239,5242,5261,5264,5284,5288,5291,5297,5303,5306,5328,5331,5399,5411,5414,5446,5450,5453,5480,5483,5500,5503,5766,5772,5781,5784,5991,6009,6013,6016,6023,6115,6118,6121,6178,6181,6185,6188,6267,6277,6280,6288,6324,6327,6330,6333,6336,6413,6416,6422,6428,6432,6437,6457,6460,6463,6557,6560,6563,6566,6568,6594],[16,5052,5053,5054,5057],{},"Cloudflare Email Service 在 2026 年加入了认证 SMTP。对于已经使用 Nodemailer、Python ",[25,5055,5056],{},"smtplib","、PHPMailer，或者某个只会配置 SMTP 的后台系统来说，这意味着不必为了更换邮件服务商重写整个发送层。",[16,5059,5060],{},"本文是一篇独立的 SMTP 教程。目标不是重复介绍邮件接收、Email Routing 或 Email Workers，而是完成一件具体的事：",[1341,5062,5063],{},[16,5064,5065,5066,5069],{},"使用 ",[25,5067,5068],{},"smtp.mx.cloudflare.net:465","，通过 Cloudflare API Token 完成 SMTPS 认证，并从自己的域名发送一封事务邮件。",[16,5071,5072,5073,5076,5077,5082,5083,76],{},"本文依据的是 ",[20,5074,5075],{},"2026 年 7 月 31 日","可见的 Cloudflare Email Service Beta 能力。Beta 阶段的配额和控制台界面仍可能变化，上线前应重新核对",[385,5078,5081],{"href":5079,"rel":5080},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fget-started\u002Fsend-emails\u002F",[389],"官方发送指南","与",[385,5084,5087],{"href":5085,"rel":5086},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fapi\u002Fsend-emails\u002Fsmtp\u002F",[389],"SMTP API 参考",[81,5089,5090],{"id":5090},"一分钟配置表",[140,5092,5093,5103],{},[143,5094,5095],{},[146,5096,5097,5100],{},[149,5098,5099],{},"项目",[149,5101,5102],{},"值",[159,5104,5105,5115,5125,5133,5144,5156,5168],{},[146,5106,5107,5110],{},[164,5108,5109],{},"SMTP 主机",[164,5111,5112],{},[25,5113,5114],{},"smtp.mx.cloudflare.net",[146,5116,5117,5120],{},[164,5118,5119],{},"端口",[164,5121,5122],{},[25,5123,5124],{},"465",[146,5126,5127,5130],{},[164,5128,5129],{},"加密",[164,5131,5132],{},"隐式 TLS，也就是 SMTPS",[146,5134,5135,5138],{},[164,5136,5137],{},"用户名",[164,5139,5140,5141],{},"固定为字面量 ",[25,5142,5143],{},"api_token",[146,5145,5146,5149],{},[164,5147,5148],{},"密码",[164,5150,5151,5152,5155],{},"具有 ",[25,5153,5154],{},"Email Sending: Edit"," 权限的 Cloudflare API Token",[146,5157,5158,5160],{},[164,5159,493],{},[164,5161,5162,437,5165],{},[25,5163,5164],{},"AUTH PLAIN",[25,5166,5167],{},"AUTH LOGIN",[146,5169,5170,5173],{},[164,5171,5172],{},"发件人",[164,5174,5175],{},"已在 Email Sending 中启用的域名地址",[16,5177,5178],{},"这里有三个不能互换的概念：",[406,5180,5181,5186,5189],{},[100,5182,5183,5184,367],{},"用户名不是 Cloudflare 邮箱，也不是 Account ID，而是固定字符串 ",[25,5185,5143],{},[100,5187,5188],{},"密码不是登录 Cloudflare 的密码，而是专用 API Token；",[100,5190,5191],{},"端口 465 使用连接建立时立即开始的 TLS，不能照搬端口 587 的 STARTTLS 配置。",[16,5193,5194,5195,76],{},"Cloudflare 当前不提供 587 STARTTLS，也不把 25 端口作为出站中继；25 端口用于入站 Email Routing。详细差异可查阅",[385,5196,5198],{"href":5085,"rel":5197},[389],"官方 SMTP 文档",[81,5200,5201],{"id":5201},"开始前需要什么",[246,5203,5205],{"id":5204},"_1-域名使用-cloudflare-dns","1. 域名使用 Cloudflare DNS",[16,5207,5208],{},"Email Sending 要求发件域由 Cloudflare DNS 托管。进入：",[33,5210,5213],{"className":5211,"code":5212,"language":231,"meta":38},[229],"Cloudflare Dashboard\n→ Compute\n→ Email Service\n→ Email Sending\n",[25,5214,5212],{"__ignoreMap":38},[16,5216,5217],{},"选择域名并完成启用。Cloudflare 会配置发件验证、DKIM 与退信处理需要的 DNS 记录。控制台显示为“已配置”以后，再继续调 SMTP；否则认证成功也可能因为发件域未获授权而被拒绝。",[246,5219,5221],{"id":5220},"_2-创建最小权限的-api-token","2. 创建最小权限的 API Token",[16,5223,5224,5225,5227],{},"进入域名详情的“连接”页面，切换到 SMTP。页面会提示创建具有 ",[25,5226,5154],{}," 权限的 API Token。",[16,5229,5230],{},[2024,5231],{"alt":5232,"src":5233},"Cloudflare Email Service 的 SMTP 连接页，显示主机、端口、用户名与 cURL 示例","\u002Fmedia\u002Fposts\u002Fcloudflare-smtp-email-sending-guide\u002Fdashboard-smtp-curl-v1.webp",[16,5235,5236],{},[2031,5237,5238],{},"截图采集于 2026 年 7 月 31 日。画面只包含 Cloudflare 提供的配置值和环境变量占位符；真实 Token 没有被创建、显示或写入文章。",[16,5240,5241],{},"建议使用账户级 Token，并尽可能限制：",[97,5243,5244,5247,5252,5255,5258],{},[100,5245,5246],{},"只授权需要发信的 Cloudflare 账户；",[100,5248,5249,5250,367],{},"只授予 ",[25,5251,5154],{},[100,5253,5254],{},"为 Token 设置合理有效期；",[100,5256,5257],{},"如果部署出口 IP 稳定，再增加客户端 IP 过滤；",[100,5259,5260],{},"每个应用使用独立 Token，方便吊销和审计。",[16,5262,5263],{},"Token 只在创建时完整显示一次。将它写入服务端 Secret 管理或运行环境变量，绝不能：",[97,5265,5266,5269,5275,5278,5281],{},[100,5267,5268],{},"写进 Git 仓库、Markdown 或构建日志；",[100,5270,5271,5272,367],{},"放进 ",[25,5273,5274],{},"NUXT_PUBLIC_*",[100,5276,5277],{},"交给浏览器 JavaScript；",[100,5279,5280],{},"在异常日志中打印 SMTP 配置对象；",[100,5282,5283],{},"与其他项目共享同一个长期有效 Token。",[81,5285,5287],{"id":5286},"先用-curl-验证链路","先用 cURL 验证链路",[16,5289,5290],{},"在接入框架以前，先用 cURL 发送最小测试邮件，可以把“Cloudflare 配置问题”和“应用代码问题”拆开排查。",[16,5292,5293,5294,2074],{},"创建 ",[25,5295,5296],{},"mail.txt",[33,5298,5301],{"className":5299,"code":5300,"language":231,"meta":38},[229],"From: Jackie Moon \u003Cnoreply@sparkles-editor.com>\nTo: recipient@example.com\nSubject: Cloudflare SMTP test\nMIME-Version: 1.0\nContent-Type: text\u002Fplain; charset=UTF-8\n\nHello,\n\nThis message was delivered through Cloudflare Email Service SMTP.\n",[25,5302,5300],{"__ignoreMap":38},[16,5304,5305],{},"把 Token 放入当前终端进程的环境变量。不要把真实值写进 Shell 历史：",[33,5307,5309],{"className":35,"code":5308,"language":37,"meta":38,"style":38},"read -s CF_EMAIL_SMTP_TOKEN\nexport CF_EMAIL_SMTP_TOKEN\n",[25,5310,5311,5321],{"__ignoreMap":38},[42,5312,5313,5316,5318],{"class":44,"line":45},[42,5314,5315],{"class":52},"read",[42,5317,284],{"class":52},[42,5319,5320],{"class":56}," CF_EMAIL_SMTP_TOKEN\n",[42,5322,5323,5326],{"class":44,"line":274},[42,5324,5325],{"class":277},"export",[42,5327,5320],{"class":1797},[16,5329,5330],{},"然后发送：",[33,5332,5334],{"className":35,"code":5333,"language":37,"meta":38,"style":38},"curl --url \"smtps:\u002F\u002Fsmtp.mx.cloudflare.net:465\" \\\n  --ssl-reqd \\\n  --user \"api_token:${CF_EMAIL_SMTP_TOKEN}\" \\\n  --mail-from \"noreply@sparkles-editor.com\" \\\n  --mail-rcpt \"recipient@example.com\" \\\n  --upload-file mail.txt\n",[25,5335,5336,5348,5355,5371,5381,5391],{"__ignoreMap":38},[42,5337,5338,5340,5343,5346],{"class":44,"line":45},[42,5339,262],{"class":48},[42,5341,5342],{"class":52}," --url",[42,5344,5345],{"class":56}," \"smtps:\u002F\u002Fsmtp.mx.cloudflare.net:465\"",[42,5347,271],{"class":52},[42,5349,5350,5353],{"class":44,"line":274},[42,5351,5352],{"class":52},"  --ssl-reqd",[42,5354,271],{"class":52},[42,5356,5357,5360,5363,5366,5369],{"class":44,"line":737},[42,5358,5359],{"class":52},"  --user",[42,5361,5362],{"class":56}," \"api_token:${",[42,5364,5365],{"class":1797},"CF_EMAIL_SMTP_TOKEN",[42,5367,5368],{"class":56},"}\"",[42,5370,271],{"class":52},[42,5372,5373,5376,5379],{"class":44,"line":1584},[42,5374,5375],{"class":52},"  --mail-from",[42,5377,5378],{"class":56}," \"noreply@sparkles-editor.com\"",[42,5380,271],{"class":52},[42,5382,5383,5386,5389],{"class":44,"line":1603},[42,5384,5385],{"class":52},"  --mail-rcpt",[42,5387,5388],{"class":56}," \"recipient@example.com\"",[42,5390,271],{"class":52},[42,5392,5393,5396],{"class":44,"line":1618},[42,5394,5395],{"class":52},"  --upload-file",[42,5397,5398],{"class":56}," mail.txt\n",[16,5400,5401,5402,5405,5406,76],{},"成功时，SMTP 会返回 ",[25,5403,5404],{},"250","。响应中包含的 Message-ID 可以用来关联 Email Service 控制台里的发送日志。完整格式可对照",[385,5407,5410],{"href":5408,"rel":5409},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fexamples\u002Femail-sending\u002Fsmtp\u002F",[389],"Cloudflare 的 cURL 示例",[16,5412,5413],{},"如果这里失败，先不要开始改 Nodemailer：",[97,5415,5416,5424,5430,5436],{},[100,5417,5418,5421,5422,367],{},[25,5419,5420],{},"535","：Token 无效、过期或缺少 ",[25,5423,5154],{},[100,5425,5426,5429],{},[25,5427,5428],{},"550","：发件地址或发件域没有被允许；",[100,5431,5432,5435],{},[25,5433,5434],{},"552","：邮件超过大小限制；",[100,5437,5438,5441,5442,5445],{},[25,5439,5440],{},"421"," \u002F ",[25,5443,5444],{},"451","：临时故障或限流，等待后重试。",[81,5447,5449],{"id":5448},"在-nodejs-中使用-nodemailer","在 Node.js 中使用 Nodemailer",[16,5451,5452],{},"下面的方案适用于 Node.js、Docker、VPS、传统 Serverless Function，或运行在 Node 兼容环境的 Nitro 服务端。安装依赖：",[33,5454,5456],{"className":35,"code":5455,"language":37,"meta":38,"style":38},"pnpm add nodemailer\npnpm add -D @types\u002Fnodemailer\n",[25,5457,5458,5469],{"__ignoreMap":38},[42,5459,5460,5463,5466],{"class":44,"line":45},[42,5461,5462],{"class":48},"pnpm",[42,5464,5465],{"class":56}," add",[42,5467,5468],{"class":56}," nodemailer\n",[42,5470,5471,5473,5475,5477],{"class":44,"line":274},[42,5472,5462],{"class":48},[42,5474,5465],{"class":56},[42,5476,2944],{"class":52},[42,5478,5479],{"class":56}," @types\u002Fnodemailer\n",[16,5481,5482],{},"服务端环境变量：",[33,5484,5488],{"className":5485,"code":5486,"language":5487,"meta":38,"style":38},"language-dotenv shiki shiki-themes github-dark github-dark","CF_EMAIL_SMTP_TOKEN=replace-with-a-server-side-secret\nSMTP_FROM=\"Jackie Moon \u003Cnoreply@sparkles-editor.com>\"\n","dotenv",[25,5489,5490,5495],{"__ignoreMap":38},[42,5491,5492],{"class":44,"line":45},[42,5493,5494],{},"CF_EMAIL_SMTP_TOKEN=replace-with-a-server-side-secret\n",[42,5496,5497],{"class":44,"line":274},[42,5498,5499],{},"SMTP_FROM=\"Jackie Moon \u003Cnoreply@sparkles-editor.com>\"\n",[16,5501,5502],{},"创建 Transport：",[33,5504,5508],{"className":5505,"code":5506,"language":5507,"meta":38,"style":38},"language-ts shiki shiki-themes github-dark github-dark","import nodemailer from 'nodemailer'\n\nconst transporter = nodemailer.createTransport({\n  host: 'smtp.mx.cloudflare.net',\n  port: 465,\n  secure: true,\n  auth: {\n    user: 'api_token',\n    pass: process.env.CF_EMAIL_SMTP_TOKEN,\n  },\n  connectionTimeout: 10_000,\n  greetingTimeout: 10_000,\n  socketTimeout: 30_000,\n})\n\nconst info = await transporter.sendMail({\n  from: process.env.SMTP_FROM,\n  to: 'recipient@example.com',\n  subject: 'Welcome to Signal Log',\n  text: 'Your account is ready.',\n  html: '\u003Cp>Your account is ready.\u003C\u002Fp>',\n})\n\nconsole.info('Email accepted', {\n  messageId: info.messageId,\n  accepted: info.accepted.length,\n  rejected: info.rejected.length,\n})\n","ts",[25,5509,5510,5524,5528,5548,5558,5567,5576,5581,5591,5600,5605,5615,5624,5634,5639,5643,5663,5673,5683,5693,5703,5713,5717,5721,5738,5743,5753,5762],{"__ignoreMap":38},[42,5511,5512,5515,5518,5521],{"class":44,"line":45},[42,5513,5514],{"class":277},"import",[42,5516,5517],{"class":1797}," nodemailer ",[42,5519,5520],{"class":277},"from",[42,5522,5523],{"class":56}," 'nodemailer'\n",[42,5525,5526],{"class":44,"line":274},[42,5527,1581],{"emptyLinePlaceholder":767},[42,5529,5530,5533,5536,5539,5542,5545],{"class":44,"line":737},[42,5531,5532],{"class":277},"const",[42,5534,5535],{"class":52}," transporter",[42,5537,5538],{"class":277}," =",[42,5540,5541],{"class":1797}," nodemailer.",[42,5543,5544],{"class":48},"createTransport",[42,5546,5547],{"class":1797},"({\n",[42,5549,5550,5553,5556],{"class":44,"line":1584},[42,5551,5552],{"class":1797},"  host: ",[42,5554,5555],{"class":56},"'smtp.mx.cloudflare.net'",[42,5557,4100],{"class":1797},[42,5559,5560,5563,5565],{"class":44,"line":1603},[42,5561,5562],{"class":1797},"  port: ",[42,5564,5124],{"class":52},[42,5566,4100],{"class":1797},[42,5568,5569,5572,5574],{"class":44,"line":1618},[42,5570,5571],{"class":1797},"  secure: ",[42,5573,4122],{"class":52},[42,5575,4100],{"class":1797},[42,5577,5578],{"class":44,"line":1627},[42,5579,5580],{"class":1797},"  auth: {\n",[42,5582,5583,5586,5589],{"class":44,"line":1640},[42,5584,5585],{"class":1797},"    user: ",[42,5587,5588],{"class":56},"'api_token'",[42,5590,4100],{"class":1797},[42,5592,5593,5596,5598],{"class":44,"line":1645},[42,5594,5595],{"class":1797},"    pass: process.env.",[42,5597,5365],{"class":52},[42,5599,4100],{"class":1797},[42,5601,5602],{"class":44,"line":1668},[42,5603,5604],{"class":1797},"  },\n",[42,5606,5607,5610,5613],{"class":44,"line":1674},[42,5608,5609],{"class":1797},"  connectionTimeout: ",[42,5611,5612],{"class":52},"10_000",[42,5614,4100],{"class":1797},[42,5616,5617,5620,5622],{"class":44,"line":1680},[42,5618,5619],{"class":1797},"  greetingTimeout: ",[42,5621,5612],{"class":52},[42,5623,4100],{"class":1797},[42,5625,5626,5629,5632],{"class":44,"line":777},[42,5627,5628],{"class":1797},"  socketTimeout: ",[42,5630,5631],{"class":52},"30_000",[42,5633,4100],{"class":1797},[42,5635,5636],{"class":44,"line":1691},[42,5637,5638],{"class":1797},"})\n",[42,5640,5641],{"class":44,"line":1697},[42,5642,1581],{"emptyLinePlaceholder":767},[42,5644,5645,5647,5650,5652,5655,5658,5661],{"class":44,"line":1703},[42,5646,5532],{"class":277},[42,5648,5649],{"class":52}," info",[42,5651,5538],{"class":277},[42,5653,5654],{"class":277}," await",[42,5656,5657],{"class":1797}," transporter.",[42,5659,5660],{"class":48},"sendMail",[42,5662,5547],{"class":1797},[42,5664,5665,5668,5671],{"class":44,"line":1709},[42,5666,5667],{"class":1797},"  from: process.env.",[42,5669,5670],{"class":52},"SMTP_FROM",[42,5672,4100],{"class":1797},[42,5674,5675,5678,5681],{"class":44,"line":1714},[42,5676,5677],{"class":1797},"  to: ",[42,5679,5680],{"class":56},"'recipient@example.com'",[42,5682,4100],{"class":1797},[42,5684,5685,5688,5691],{"class":44,"line":1723},[42,5686,5687],{"class":1797},"  subject: ",[42,5689,5690],{"class":56},"'Welcome to Signal Log'",[42,5692,4100],{"class":1797},[42,5694,5695,5698,5701],{"class":44,"line":1736},[42,5696,5697],{"class":1797},"  text: ",[42,5699,5700],{"class":56},"'Your account is ready.'",[42,5702,4100],{"class":1797},[42,5704,5705,5708,5711],{"class":44,"line":1744},[42,5706,5707],{"class":1797},"  html: ",[42,5709,5710],{"class":56},"'\u003Cp>Your account is ready.\u003C\u002Fp>'",[42,5712,4100],{"class":1797},[42,5714,5715],{"class":44,"line":1752},[42,5716,5638],{"class":1797},[42,5718,5719],{"class":44,"line":1760},[42,5720,1581],{"emptyLinePlaceholder":767},[42,5722,5723,5726,5729,5732,5735],{"class":44,"line":1768},[42,5724,5725],{"class":1797},"console.",[42,5727,5728],{"class":48},"info",[42,5730,5731],{"class":1797},"(",[42,5733,5734],{"class":56},"'Email accepted'",[42,5736,5737],{"class":1797},", {\n",[42,5739,5740],{"class":44,"line":2291},[42,5741,5742],{"class":1797},"  messageId: info.messageId,\n",[42,5744,5745,5748,5751],{"class":44,"line":2301},[42,5746,5747],{"class":1797},"  accepted: info.accepted.",[42,5749,5750],{"class":52},"length",[42,5752,4100],{"class":1797},[42,5754,5755,5758,5760],{"class":44,"line":2306},[42,5756,5757],{"class":1797},"  rejected: info.rejected.",[42,5759,5750],{"class":52},[42,5761,4100],{"class":1797},[42,5763,5764],{"class":44,"line":2314},[42,5765,5638],{"class":1797},[16,5767,5768],{},[2024,5769],{"alt":5770,"src":5771},"Cloudflare 控制台中的 Nodemailer 示例，密码从环境变量读取","\u002Fmedia\u002Fposts\u002Fcloudflare-smtp-email-sending-guide\u002Fdashboard-smtp-node-v1.webp",[16,5773,5774],{},[2031,5775,5776,5777,5780],{},"控制台示例使用 ",[25,5778,5779],{},"process.env.CLOUDFLARE_API_TOKEN","。实际项目可以使用更明确的变量名，但必须保证它只存在于服务端。",[16,5782,5783],{},"生产代码还应补上输入边界：",[33,5785,5787],{"className":5505,"code":5786,"language":5507,"meta":38,"style":38},"const allowedFrom = new Set([\n  'noreply@sparkles-editor.com',\n  'support@sparkles-editor.com',\n])\n\nfunction assertEmailEnvelope(from: string, to: string) {\n  if (!allowedFrom.has(from)) {\n    throw new Error('Sender is not allowed')\n  }\n\n  if (!\u002F^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$\u002F.test(to)) {\n    throw new Error('Recipient is invalid')\n  }\n}\n",[25,5788,5789,5807,5814,5821,5826,5830,5862,5882,5900,5904,5908,5968,5983,5987],{"__ignoreMap":38},[42,5790,5791,5793,5796,5798,5801,5804],{"class":44,"line":45},[42,5792,5532],{"class":277},[42,5794,5795],{"class":52}," allowedFrom",[42,5797,5538],{"class":277},[42,5799,5800],{"class":277}," new",[42,5802,5803],{"class":48}," Set",[42,5805,5806],{"class":1797},"([\n",[42,5808,5809,5812],{"class":44,"line":274},[42,5810,5811],{"class":56},"  'noreply@sparkles-editor.com'",[42,5813,4100],{"class":1797},[42,5815,5816,5819],{"class":44,"line":737},[42,5817,5818],{"class":56},"  'support@sparkles-editor.com'",[42,5820,4100],{"class":1797},[42,5822,5823],{"class":44,"line":1584},[42,5824,5825],{"class":1797},"])\n",[42,5827,5828],{"class":44,"line":1603},[42,5829,1581],{"emptyLinePlaceholder":767},[42,5831,5832,5835,5838,5840,5843,5846,5849,5852,5855,5857,5859],{"class":44,"line":1618},[42,5833,5834],{"class":277},"function",[42,5836,5837],{"class":48}," assertEmailEnvelope",[42,5839,5731],{"class":1797},[42,5841,5520],{"class":5842},"s-3mD",[42,5844,5845],{"class":277},":",[42,5847,5848],{"class":52}," string",[42,5850,5851],{"class":1797},", ",[42,5853,5854],{"class":5842},"to",[42,5856,5845],{"class":277},[42,5858,5848],{"class":52},[42,5860,5861],{"class":1797},") {\n",[42,5863,5864,5867,5870,5873,5876,5879],{"class":44,"line":1627},[42,5865,5866],{"class":277},"  if",[42,5868,5869],{"class":1797}," (",[42,5871,5872],{"class":277},"!",[42,5874,5875],{"class":1797},"allowedFrom.",[42,5877,5878],{"class":48},"has",[42,5880,5881],{"class":1797},"(from)) {\n",[42,5883,5884,5887,5889,5892,5894,5897],{"class":44,"line":1640},[42,5885,5886],{"class":277},"    throw",[42,5888,5800],{"class":277},[42,5890,5891],{"class":48}," Error",[42,5893,5731],{"class":1797},[42,5895,5896],{"class":56},"'Sender is not allowed'",[42,5898,5899],{"class":1797},")\n",[42,5901,5902],{"class":44,"line":1645},[42,5903,4223],{"class":1797},[42,5905,5906],{"class":44,"line":1668},[42,5907,1581],{"emptyLinePlaceholder":767},[42,5909,5910,5912,5914,5916,5919,5922,5925,5927,5930,5933,5937,5939,5941,5943,5945,5949,5951,5953,5955,5958,5960,5962,5965],{"class":44,"line":1674},[42,5911,5866],{"class":277},[42,5913,5869],{"class":1797},[42,5915,5872],{"class":277},[42,5917,5918],{"class":56},"\u002F",[42,5920,5921],{"class":277},"^",[42,5923,5924],{"class":52},"[",[42,5926,5921],{"class":277},[42,5928,5929],{"class":52},"\\s@]",[42,5931,5932],{"class":277},"+",[42,5934,5936],{"class":5935},"se7xv","@",[42,5938,5924],{"class":52},[42,5940,5921],{"class":277},[42,5942,5929],{"class":52},[42,5944,5932],{"class":277},[42,5946,5948],{"class":5947},"s-Lb2","\\.",[42,5950,5924],{"class":52},[42,5952,5921],{"class":277},[42,5954,5929],{"class":52},[42,5956,5957],{"class":277},"+$",[42,5959,5918],{"class":56},[42,5961,1791],{"class":1797},[42,5963,5964],{"class":48},"test",[42,5966,5967],{"class":1797},"(to)) {\n",[42,5969,5970,5972,5974,5976,5978,5981],{"class":44,"line":1680},[42,5971,5886],{"class":277},[42,5973,5800],{"class":277},[42,5975,5891],{"class":48},[42,5977,5731],{"class":1797},[42,5979,5980],{"class":56},"'Recipient is invalid'",[42,5982,5899],{"class":1797},[42,5984,5985],{"class":44,"line":777},[42,5986,4223],{"class":1797},[42,5988,5989],{"class":44,"line":1691},[42,5990,4228],{"class":1797},[16,5992,5993,5994,434,5996,434,5998,6001,6002,464,6005,6008],{},"不要提供一个接收任意 ",[25,5995,5520],{},[25,5997,5854],{},[25,5999,6000],{},"subject"," 和 HTML 的公开 API。否则应用很容易变成开放邮件中继，或者被用来发送钓鱼邮件。更安全的接口只接收业务参数，例如 ",[25,6003,6004],{},"userId",[25,6006,6007],{},"templateId","；服务端再从数据库取已验证地址，并从白名单模板生成标题与正文。",[81,6010,6012],{"id":6011},"nuxt-部署在-cloudflare-workers-时怎么办","Nuxt 部署在 Cloudflare Workers 时怎么办",[16,6014,6015],{},"这里需要特别区分运行时。",[16,6017,6018,6019,6022],{},"Nodemailer 是面向传统 Node SMTP Socket 的库。Cloudflare Workers 上的 Nuxt 应用已经运行在 Cloudflare 网络内，通常更适合使用 ",[25,6020,6021],{},"send_email"," binding：",[33,6024,6026],{"className":5505,"code":6025,"language":5507,"meta":38,"style":38},"interface Env {\n  EMAIL: SendEmail\n}\n\nawait env.EMAIL.send({\n  from: 'noreply@sparkles-editor.com',\n  to: 'recipient@example.com',\n  subject: 'Verification code',\n  text: 'Your code is 123456',\n})\n",[25,6027,6028,6039,6049,6053,6057,6075,6085,6093,6102,6111],{"__ignoreMap":38},[42,6029,6030,6033,6036],{"class":44,"line":45},[42,6031,6032],{"class":277},"interface",[42,6034,6035],{"class":48}," Env",[42,6037,6038],{"class":1797}," {\n",[42,6040,6041,6044,6046],{"class":44,"line":274},[42,6042,6043],{"class":5842},"  EMAIL",[42,6045,5845],{"class":277},[42,6047,6048],{"class":48}," SendEmail\n",[42,6050,6051],{"class":44,"line":737},[42,6052,4228],{"class":1797},[42,6054,6055],{"class":44,"line":1584},[42,6056,1581],{"emptyLinePlaceholder":767},[42,6058,6059,6062,6065,6068,6070,6073],{"class":44,"line":1603},[42,6060,6061],{"class":277},"await",[42,6063,6064],{"class":1797}," env.",[42,6066,6067],{"class":52},"EMAIL",[42,6069,1791],{"class":1797},[42,6071,6072],{"class":48},"send",[42,6074,5547],{"class":1797},[42,6076,6077,6080,6083],{"class":44,"line":1618},[42,6078,6079],{"class":1797},"  from: ",[42,6081,6082],{"class":56},"'noreply@sparkles-editor.com'",[42,6084,4100],{"class":1797},[42,6086,6087,6089,6091],{"class":44,"line":1627},[42,6088,5677],{"class":1797},[42,6090,5680],{"class":56},[42,6092,4100],{"class":1797},[42,6094,6095,6097,6100],{"class":44,"line":1640},[42,6096,5687],{"class":1797},[42,6098,6099],{"class":56},"'Verification code'",[42,6101,4100],{"class":1797},[42,6103,6104,6106,6109],{"class":44,"line":1645},[42,6105,5697],{"class":1797},[42,6107,6108],{"class":56},"'Your code is 123456'",[42,6110,4100],{"class":1797},[42,6112,6113],{"class":44,"line":1668},[42,6114,5638],{"class":1797},[16,6116,6117],{},"这样不需要在 Worker 中保存 SMTP API Token，权限也由 binding 管理。",[16,6119,6120],{},"选择原则很简单：",[140,6122,6123,6133],{},[143,6124,6125],{},[146,6126,6127,6130],{},[149,6128,6129],{},"场景",[149,6131,6132],{},"推荐入口",[159,6134,6135,6146,6154,6162,6170],{},[146,6136,6137,6140],{},[164,6138,6139],{},"Nuxt \u002F API 已经部署在 Cloudflare Workers",[164,6141,6142,6143,6145],{},"Workers ",[25,6144,6021],{}," binding",[146,6147,6148,6151],{},[164,6149,6150],{},"外部服务支持 HTTPS API，且便于管理 Token",[164,6152,6153],{},"REST API",[146,6155,6156,6159],{},[164,6157,6158],{},"现有 Node、Python、PHP 应用已经支持 SMTP",[164,6160,6161],{},"认证 SMTP",[146,6163,6164,6167],{},[164,6165,6166],{},"打印机、旧式 CMS 或第三方工具只能填 SMTP",[164,6168,6169],{},"认证 SMTP，但先确认能使用 465 SMTPS",[146,6171,6172,6175],{},[164,6173,6174],{},"浏览器前端直接发送",[164,6176,6177],{},"不允许；必须经过受控服务端",[16,6179,6180],{},"三个入口最后进入同一套 Cloudflare 投递管线、日志和域名认证系统。SMTP 的价值是兼容性，不是要求所有新服务都退回 Socket 协议。",[81,6182,6184],{"id":6183},"python-最小示例","Python 最小示例",[16,6186,6187],{},"Python 标准库无需额外依赖：",[33,6189,6193],{"className":6190,"code":6191,"language":6192,"meta":38,"style":38},"language-python shiki shiki-themes github-dark github-dark","import os\nimport smtplib\nfrom email.message import EmailMessage\n\ntoken = os.environ[\"CF_EMAIL_SMTP_TOKEN\"]\n\nmessage = EmailMessage()\nmessage[\"From\"] = \"Jackie Moon \u003Cnoreply@sparkles-editor.com>\"\nmessage[\"To\"] = \"recipient@example.com\"\nmessage[\"Subject\"] = \"Cloudflare SMTP test\"\nmessage.set_content(\"This email was sent over implicit TLS.\")\n\nwith smtplib.SMTP_SSL(\"smtp.mx.cloudflare.net\", 465, timeout=30) as smtp:\n    smtp.login(\"api_token\", token)\n    smtp.send_message(message)\n","python",[25,6194,6195,6200,6205,6210,6214,6219,6223,6228,6233,6238,6243,6248,6252,6257,6262],{"__ignoreMap":38},[42,6196,6197],{"class":44,"line":45},[42,6198,6199],{},"import os\n",[42,6201,6202],{"class":44,"line":274},[42,6203,6204],{},"import smtplib\n",[42,6206,6207],{"class":44,"line":737},[42,6208,6209],{},"from email.message import EmailMessage\n",[42,6211,6212],{"class":44,"line":1584},[42,6213,1581],{"emptyLinePlaceholder":767},[42,6215,6216],{"class":44,"line":1603},[42,6217,6218],{},"token = os.environ[\"CF_EMAIL_SMTP_TOKEN\"]\n",[42,6220,6221],{"class":44,"line":1618},[42,6222,1581],{"emptyLinePlaceholder":767},[42,6224,6225],{"class":44,"line":1627},[42,6226,6227],{},"message = EmailMessage()\n",[42,6229,6230],{"class":44,"line":1640},[42,6231,6232],{},"message[\"From\"] = \"Jackie Moon \u003Cnoreply@sparkles-editor.com>\"\n",[42,6234,6235],{"class":44,"line":1645},[42,6236,6237],{},"message[\"To\"] = \"recipient@example.com\"\n",[42,6239,6240],{"class":44,"line":1668},[42,6241,6242],{},"message[\"Subject\"] = \"Cloudflare SMTP test\"\n",[42,6244,6245],{"class":44,"line":1674},[42,6246,6247],{},"message.set_content(\"This email was sent over implicit TLS.\")\n",[42,6249,6250],{"class":44,"line":1680},[42,6251,1581],{"emptyLinePlaceholder":767},[42,6253,6254],{"class":44,"line":777},[42,6255,6256],{},"with smtplib.SMTP_SSL(\"smtp.mx.cloudflare.net\", 465, timeout=30) as smtp:\n",[42,6258,6259],{"class":44,"line":1691},[42,6260,6261],{},"    smtp.login(\"api_token\", token)\n",[42,6263,6264],{"class":44,"line":1697},[42,6265,6266],{},"    smtp.send_message(message)\n",[16,6268,6269,6270,6273,6274,76],{},"关键是 ",[25,6271,6272],{},"SMTP_SSL","，而不是先明文连接再调用 ",[25,6275,6276],{},"starttls()",[81,6278,6279],{"id":6279},"限额与附件",[16,6281,6282,6283,2074],{},"根据当前",[385,6284,6287],{"href":6285,"rel":6286},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fplatform\u002Flimits\u002F",[389],"Email Service 平台限制",[97,6289,6290,6293,6299,6302,6305,6312,6318,6321],{},[100,6291,6292],{},"每封邮件最多合计 50 个收件人；",[100,6294,6295,6296,367],{},"SMTP 会话最多接受 50 次 ",[25,6297,6298],{},"RCPT TO",[100,6300,6301],{},"普通邮件总大小上限为 5 MiB；",[100,6303,6304],{},"发往“已验证目标地址”时，总大小上限可到 25 MiB；",[100,6306,6307,6308,6311],{},"SMTP ",[25,6309,6310],{},"AUTH"," 超时为 30 秒；",[100,6313,6307,6314,6317],{},[25,6315,6316],{},"DATA"," 超时为 300 秒；",[100,6319,6320],{},"自定义邮件头合计上限为 16 KiB；",[100,6322,6323],{},"Subject 上限为 998 个字符。",[16,6325,6326],{},"邮件总大小包含 MIME 编码后的正文和附件。二进制附件经过 Base64 后通常会膨胀约三分之一，因此不要把一个 4.9 MiB 文件误认为一定能塞进 5 MiB 邮件。",[16,6328,6329],{},"控制台显示的每日配额可能因账户、Beta 阶段和使用情况而变化。不要把某次看到的数字写死在业务逻辑里；应读取当前控制台配额，并在自己的发送队列中保留余量。",[81,6331,6332],{"id":6332},"错误处理和重试",[16,6334,6335],{},"SMTP 的返回码已经表达了“要不要重试”：",[140,6337,6338,6349],{},[143,6339,6340],{},[146,6341,6342,6345,6347],{},[149,6343,6344],{},"返回码",[149,6346,157],{},[149,6348,3645],{},[159,6350,6351,6363,6377,6389,6401],{},[146,6352,6353,6357,6360],{},[164,6354,6355],{},[25,6356,5404],{},[164,6358,6359],{},"已被 Cloudflare 接受",[164,6361,6362],{},"记录 Message-ID，不再重复发送",[146,6364,6365,6371,6374],{},[164,6366,6367,5441,6369],{},[25,6368,5440],{},[25,6370,5444],{},[164,6372,6373],{},"临时故障或限流",[164,6375,6376],{},"指数退避并加入随机抖动",[146,6378,6379,6383,6386],{},[164,6380,6381],{},[25,6382,5420],{},[164,6384,6385],{},"认证失败",[164,6387,6388],{},"停止自动重试，检查 Token",[146,6390,6391,6395,6398],{},[164,6392,6393],{},[25,6394,5428],{},[164,6396,6397],{},"发件域、地址或收件策略被拒绝",[164,6399,6400],{},"修正配置或数据后再发",[146,6402,6403,6407,6410],{},[164,6404,6405],{},[25,6406,5434],{},[164,6408,6409],{},"邮件过大",[164,6411,6412],{},"缩小正文或附件",[16,6414,6415],{},"推荐的重试节奏可以是 30 秒、2 分钟、10 分钟、30 分钟，最多尝试固定次数。每次业务发送必须有幂等键，例如：",[33,6417,6420],{"className":6418,"code":6419,"language":231,"meta":38},[229],"password-reset:{userId}:{resetRequestId}\ninvoice:{invoiceId}:issued\n",[25,6421,6419],{"__ignoreMap":38},[16,6423,6424,6425,6427],{},"临时错误进入队列，永久错误进入人工检查或失败状态。不要把所有非 ",[25,6426,5404],{}," 都立即重试，也不要让同一个验证码因为网络抖动发送十次。",[81,6429,6431],{"id":6430},"投递率不是返回-250-就结束","投递率不是“返回 250 就结束”",[16,6433,6434,6436],{},[25,6435,5404],{}," 代表 Cloudflare 接受了消息，不代表最终收件箱一定出现了邮件。生产环境还要观察：",[97,6438,6439,6442,6445,6448,6451,6454],{},[100,6440,6441],{},"Delivery、Bounce、Deferred 与 Suppression；",[100,6443,6444],{},"Gmail、Outlook、QQ 邮箱等主要目的地的结果；",[100,6446,6447],{},"From、Return-Path、DKIM 和 DMARC 对齐；",[100,6449,6450],{},"硬退信地址是否立即停止发送；",[100,6452,6453],{},"投诉率与无效地址增长；",[100,6455,6456],{},"验证码和密码重置是否设置明确有效期。",[16,6458,6459],{},"事务邮件应该使用稳定、可识别的发件人，并同时提供纯文本与 HTML。不要伪造 Reply-To，不要用验证码通道发送营销群发，也不要让用户输入直接进入邮件头，避免 Header Injection。",[81,6461,6462],{"id":6462},"上线检查清单",[97,6464,6467,6476,6482,6490,6499,6505,6511,6517,6523,6533,6545,6551],{"className":6465},[6466],"contains-task-list",[100,6468,6471,6475],{"className":6469},[6470],"task-list-item",[6472,6473],"input",{"disabled":767,"type":6474},"checkbox"," 发件域在 Email Sending 中为“已配置”",[100,6477,6479,6481],{"className":6478},[6470],[6472,6480],{"disabled":767,"type":6474}," 使用 465 + 隐式 TLS",[100,6483,6485,6487,6488],{"className":6484},[6470],[6472,6486],{"disabled":767,"type":6474}," 用户名严格为 ",[25,6489,5143],{},[100,6491,6493,6495,6496,6498],{"className":6492},[6470],[6472,6494],{"disabled":767,"type":6474}," Token 只有 ",[25,6497,5154],{}," 最小权限",[100,6500,6502,6504],{"className":6501},[6470],[6472,6503],{"disabled":767,"type":6474}," Token 位于服务端 Secret，不进入 Git、日志和前端",[100,6506,6508,6510],{"className":6507},[6470],[6472,6509],{"disabled":767,"type":6474}," From 地址来自服务端白名单",[100,6512,6514,6516],{"className":6513},[6470],[6472,6515],{"disabled":767,"type":6474}," 收件地址经过验证、去重和长度限制",[100,6518,6520,6522],{"className":6519},[6470],[6472,6521],{"disabled":767,"type":6474}," HTML 使用固定模板并保留纯文本版本",[100,6524,6526,1445,6528,5441,6530,6532],{"className":6525},[6470],[6472,6527],{"disabled":767,"type":6474},[25,6529,5440],{},[25,6531,5444],{}," 使用带抖动的指数退避",[100,6534,6536,1445,6538,5441,6540,5441,6542,6544],{"className":6535},[6470],[6472,6537],{"disabled":767,"type":6474},[25,6539,5420],{},[25,6541,5428],{},[25,6543,5434],{}," 不做盲目重试",[100,6546,6548,6550],{"className":6547},[6470],[6472,6549],{"disabled":767,"type":6474}," 记录 Message-ID、模板名与业务幂等键，不记录 Token",[100,6552,6554,6556],{"className":6553},[6470],[6472,6555],{"disabled":767,"type":6474}," 监控退信、抑制、投诉和每日配额",[81,6558,6559],{"id":6559},"最后的选择",[16,6561,6562],{},"如果你有一个已经稳定运行的 Node、Python 或 PHP 应用，Cloudflare SMTP 的优势很直接：只换主机、端口和认证信息，就能接入 Cloudflare 的投递与日志体系。",[16,6564,6565],{},"如果应用原生运行在 Workers，则没有必要绕一圈模拟传统服务器，优先使用 Email binding。SMTP 是一座兼容旧有生态的桥，而不是唯一入口。",[246,6567,3287],{"id":3287},[97,6569,6570,6576,6582,6588],{},[100,6571,6572],{},[385,6573,6575],{"href":5079,"rel":6574},[389],"Cloudflare Email Service：发送邮件入门",[100,6577,6578],{},[385,6579,6581],{"href":5085,"rel":6580},[389],"Cloudflare Email Service：SMTP API 参考",[100,6583,6584],{},[385,6585,6587],{"href":5408,"rel":6586},[389],"Cloudflare Email Service：SMTP 示例",[100,6589,6590],{},[385,6591,6593],{"href":6285,"rel":6592},[389],"Cloudflare Email Service：平台限制",[733,6595,6596],{},"html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}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 .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .se7xv, html code.shiki .se7xv{--shiki-default:#DBEDFF;--shiki-dark:#DBEDFF}html pre.shiki code .s-Lb2, html code.shiki .s-Lb2{--shiki-default:#85E89D;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}",{"title":38,"searchDepth":737,"depth":737,"links":6598},[6599,6600,6604,6605,6606,6607,6608,6609,6610,6611,6612],{"id":5090,"depth":274,"text":5090},{"id":5201,"depth":274,"text":5201,"children":6601},[6602,6603],{"id":5204,"depth":737,"text":5205},{"id":5220,"depth":737,"text":5221},{"id":5286,"depth":274,"text":5287},{"id":5448,"depth":274,"text":5449},{"id":6011,"depth":274,"text":6012},{"id":6183,"depth":274,"text":6184},{"id":6279,"depth":274,"text":6279},{"id":6332,"depth":274,"text":6332},{"id":6430,"depth":274,"text":6431},{"id":6462,"depth":274,"text":6462},{"id":6559,"depth":274,"text":6559,"children":6613},[6614],{"id":3287,"depth":737,"text":3287},"一篇独立、可落地的 Cloudflare Email Service SMTP 教程，覆盖域名接入、API Token、cURL、Nodemailer、Python、限额、错误码与生产安全。",{"src":6617,"alt":6618,"caption":6619,"width":6620,"height":6621},"\u002Fmedia\u002Fposts\u002Fcloudflare-smtp-email-sending-guide\u002Fcover-v1.webp","一封事务邮件通过加密 SMTP 连接进入全球邮件投递网络","把现有应用接到 Cloudflare Email Service，不一定要重写发送层；标准 SMTP 就是一条兼容路径",1672,941,{},"\u002Fblog\u002Fcloudflare-smtp-email-sending-guide","2026-07-31",{"title":5048,"description":6615},"blog\u002Fcloudflare-smtp-email-sending-guide",[3412,6628,6629,6630],"SMTP","Email","Nodemailer","ioq36qTQ7AGVlLQpfduYQZC0FCJygbgoBDEtRMP7pJs",{"id":6633,"title":6634,"accent":1287,"body":6635,"description":8994,"draft":764,"experience":765,"extension":766,"featured":767,"image":8995,"meta":9001,"navigation":767,"path":9002,"publishedAt":9003,"readingTime":1752,"seo":9004,"stem":9005,"tags":9006,"updatedAt":765,"video":9009,"__hash__":9014,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Fcloudflare-email-service-send-receive-guide.md","Cloudflare Email Service 收发实战：从 Email Routing 到 Workers、API 与 SMTP",{"type":13,"value":6636,"toc":8961},[6637,6644,6647,6658,6678,6685,6688,6693,6697,6700,6750,6753,6759,6762,6768,6774,6779,6783,6786,6790,6793,6796,6818,6822,6825,6828,6842,6845,6849,6852,6855,6858,6869,6872,6880,6887,6904,6907,6917,6920,6926,6929,6933,6936,6951,6954,6961,6970,7302,7312,7405,7420,7424,7430,7560,7563,7580,7584,7610,7613,7633,7636,7640,7643,7650,7718,7724,7727,7746,7752,7756,7959,7965,7969,7972,8283,8286,8292,8297,8301,8304,8354,8357,8360,8379,8382,8385,8507,8510,8513,8516,8519,8525,8528,8551,8558,8561,8564,8567,8573,8576,8582,8586,8589,8613,8616,8636,8639,8643,8653,8659,8662,8665,8683,8686,8745,8748,8751,8783,8787,8790,8810,8813,8830,8833,8836,8839,8842,8845,8865,8868,8870,8958],[16,6638,6639,6640,6643],{},"很长一段时间里，Cloudflare 与邮件的关系主要停留在两件事上：管理 DNS，以及用 Email Routing 把 ",[25,6641,6642],{},"support@example.com"," 转发到一个真实邮箱。",[16,6645,6646],{},"2026 年，这个边界明显向前移动了。",[16,6648,6649,6650,6653,6654,6657],{},"Cloudflare 在 4 月把 ",[20,6651,6652],{},"Email Sending"," 推进公开 Beta，让 Workers 可以直接调用 ",[25,6655,6656],{},"env.EMAIL.send()"," 发送事务邮件；6 月又加入了认证 SMTP。加上原有的 Email Routing 与 Email Workers，现在一套 Cloudflare Email Service 已经可以覆盖：",[97,6659,6660,6663,6666,6669,6672,6675],{},[100,6661,6662],{},"接收发往自有域名的邮件；",[100,6664,6665],{},"转发到经过验证的真实邮箱；",[100,6667,6668],{},"把来信交给 Worker 解析、过滤、入库或触发自动化；",[100,6670,6671],{},"在原邮件线程中回复；",[100,6673,6674],{},"从 Worker、REST API 或 SMTP 发送事务邮件；",[100,6676,6677],{},"查看发送日志、退信、抑制与投递指标。",[16,6679,6680,6681,6684],{},"这篇文章以 ",[20,6682,6683],{},"2026 年 7 月 28 日","的官方能力为准，梳理 Cloudflare 邮件收发的完整模型，并给出一套适合 Nuxt、Workers 与 D1 项目的实现思路。",[16,6686,6687],{},"先说结论：",[1341,6689,6690],{},[16,6691,6692],{},"Cloudflare Email Service 不是传统的 IMAP \u002F POP3 邮箱，也不是让你在网页里收信的企业邮箱套件。它更像一组“邮件入口、发送通道和可编程事件”，适合验证码、密码重置、订单通知、系统告警、支持工单和邮件驱动的自动化。",[81,6694,6696],{"id":6695},"先把接收和发送分开","先把“接收”和“发送”分开",[16,6698,6699],{},"邮件收发看起来是一件事，在 Cloudflare 里却是两条独立链路。",[140,6701,6702,6718],{},[143,6703,6704],{},[146,6705,6706,6709,6712,6715],{},[149,6707,6708],{},"方向",[149,6710,6711],{},"Cloudflare 产品",[149,6713,6714],{},"入口",[149,6716,6717],{},"Worker 能做什么",[159,6719,6720,6734],{},[146,6721,6722,6725,6728,6731],{},[164,6723,6724],{},"接收",[164,6726,6727],{},"Email Routing \u002F Email Workers",[164,6729,6730],{},"外部邮件服务器连接 Cloudflare MX",[164,6732,6733],{},"转发、回复、拒收、解析、写入 D1\u002FR2、投递 Queue",[146,6735,6736,6739,6741,6744],{},[164,6737,6738],{},"发送",[164,6740,6652],{},[164,6742,6743],{},"Workers binding、REST API、认证 SMTP",[164,6745,6746,6747],{},"发送文本\u002FHTML、附件、自定义头，获得 ",[25,6748,6749],{},"messageId",[16,6751,6752],{},"接收链路大致是：",[33,6754,6757],{"className":6755,"code":6756,"language":231,"meta":38},[229],"发送方 SMTP\n  → Cloudflare MX\n  → SPF \u002F DKIM \u002F DMARC 与信誉检查\n  → 匹配地址规则或 Catch-all\n  → 转发到邮箱 \u002F 交给 Worker \u002F 丢弃\n",[25,6758,6756],{"__ignoreMap":38},[16,6760,6761],{},"发送链路则是：",[33,6763,6766],{"className":6764,"code":6765,"language":231,"meta":38},[229],"Nuxt API \u002F Worker \u002F 外部应用\n  → EMAIL binding \u002F REST API \u002F SMTP\n  → 发件域与参数校验\n  → Cloudflare 投递管线\n  → 收件方邮件服务器\n",[25,6767,6765],{"__ignoreMap":38},[16,6769,6770],{},[2024,6771],{"alt":6772,"src":6773},"邮件在边缘入口完成检查后，被分发到真实邮箱、Worker 处理节点或拒绝路径","\u002Fmedia\u002Fposts\u002Fcloudflare-email-service-send-receive-guide\u002Finbound-routing-v1.webp",[16,6775,6776],{},[2031,6777,6778],{},"这是一张概念图。真实判断还包含 SMTP 会话、邮件认证、规则匹配和 Worker 执行限制。",[81,6780,6782],{"id":6781},"_2026-年的产品边界与价格","2026 年的产品边界与价格",[16,6784,6785],{},"Cloudflare 当前把接收与发送放在同一个 Email Service 下，但两部分的可用计划不同。",[246,6787,6789],{"id":6788},"email-routing接收与转发","Email Routing：接收与转发",[16,6791,6792],{},"Email Routing 在 Workers Free 和 Workers Paid 都可以使用。官方价格页把入站邮件数量标为不限量，但如果邮件被交给 Worker，仍然会消耗对应的 Worker 请求与计算资源。",[16,6794,6795],{},"它适合：",[97,6797,6798,6805,6808,6815],{},[100,6799,6800,6801,6804],{},"把 ",[25,6802,6803],{},"support@yourdomain.com"," 转发到现有个人邮箱；",[100,6806,6807],{},"为不同网站创建独立的联系地址；",[100,6809,6810,6811,6814],{},"用 ",[25,6812,6813],{},"orders+shop-a@yourdomain.com"," 这样的子地址区分来源；",[100,6816,6817],{},"把来信交给 Worker 转换成工单、Webhook 或数据库记录。",[246,6819,6821],{"id":6820},"email-sending事务邮件","Email Sending：事务邮件",[16,6823,6824],{},"Email Sending 当前仍是公开 Beta。",[16,6826,6827],{},"向任意收件人发送需要 Workers Paid 计划。按照 2026 年 7 月的官方价格：",[97,6829,6830,6833,6836,6839],{},[100,6831,6832],{},"每个账户每月包含 3,000 封出站邮件；",[100,6834,6835],{},"超出后每 1,000 封为 0.35 美元；",[100,6837,6838],{},"发往账户内“已验证目标地址”的邮件始终免费，不计入包含额度；",[100,6840,6841],{},"邮件被 Email Service 接受后，即使随后发生硬退信，通常也会计入额度；在 API 边界直接拒绝的请求不会计入。",[16,6843,6844],{},"价格和 Beta 状态都可能继续调整，真正上线前应重新检查官方价格页，而不是把本文数字长期写死在预算模型里。",[81,6846,6848],{"id":6847},"第一步正确接入域名","第一步：正确接入域名",[16,6850,6851],{},"Email Service 要求域名使用 Cloudflare DNS。发送与接收会创建两组用途不同的 DNS 记录。",[246,6853,6854],{"id":6854},"接收邮件使用根域记录",[16,6856,6857],{},"启用 Email Routing 后，Cloudflare 会在域名根节点配置：",[97,6859,6860,6863,6866],{},[100,6861,6862],{},"MX：把来信送到 Cloudflare 的邮件入口；",[100,6864,6865],{},"SPF：授权 Cloudflare 转发邮件；",[100,6867,6868],{},"DKIM：为转发链路提供签名。",[16,6870,6871],{},"这些记录负责的是“别人发到你的域名”。",[246,6873,6875,6876,6879],{"id":6874},"发送邮件使用-cf-bounce-记录","发送邮件使用 ",[25,6877,6878],{},"cf-bounce"," 记录",[16,6881,6882,6883,6886],{},"启用 Email Sending 时，Cloudflare 会为 ",[25,6884,6885],{},"cf-bounce.yourdomain.com"," 配置：",[97,6888,6889,6892,6895,6898],{},[100,6890,6891],{},"MX：处理退信；",[100,6893,6894],{},"SPF：授权 Cloudflare 代域名发送；",[100,6896,6897],{},"DKIM：验证邮件内容未在途中被修改；",[100,6899,6900,6903],{},[25,6901,6902],{},"_dmarc.yourdomain.com","：声明认证失败时的处理策略。",[16,6905,6906],{},"发送和接收使用不同的 DKIM selector。看到两条 DKIM 记录并不意味着重复配置。",[16,6908,6909,6910,6913,6914,76],{},"如果域名已有 Google Workspace、Microsoft 365 或其他邮件服务，尤其要检查 MX 和 SPF 是否冲突。一个域名只能有一条有效 SPF 策略；需要同时授权多个服务时，应合并 ",[25,6911,6912],{},"include","，而不是再创建第二条 ",[25,6915,6916],{},"v=spf1",[16,6918,6919],{},"我的建议是把不同信誉用途拆到子域：",[33,6921,6924],{"className":6922,"code":6923,"language":231,"meta":38},[229],"noreply@example.com          账户验证码与密码重置\nnotify@mail.example.com      产品通知\nsupport@example.com          人工支持与来信处理\n",[25,6925,6923],{"__ignoreMap":38},[16,6927,6928],{},"事务通知、人工往来与未来可能出现的批量邮件互不拖累，故障和迁移边界也更清晰。",[81,6930,6932],{"id":6931},"接收邮件从转发规则到-email-worker","接收邮件：从转发规则到 Email Worker",[16,6934,6935],{},"最简单的接收方式完全不用代码：",[406,6937,6938,6941,6945,6948],{},[100,6939,6940],{},"在 Email Routing 中添加一个经过验证的目标邮箱；",[100,6942,5293,6943,367],{},[25,6944,6803],{},[100,6946,6947],{},"选择“发送到邮箱”；",[100,6949,6950],{},"来信会被 Cloudflare 转发到目标地址。",[16,6952,6953],{},"如果需要按主题分类、提取附件、建立工单或自动回复，就把动作改为“发送到 Worker”。",[246,6955,6957,6960],{"id":6956},"email-是入站邮件入口",[25,6958,6959],{},"email()"," 是入站邮件入口",[16,6962,6963,6964,6967,6968,2074],{},"Email Worker 的入口不是 ",[25,6965,6966],{},"fetch()","，而是 ",[25,6969,6959],{},[33,6971,6973],{"className":5505,"code":6972,"language":5507,"meta":38,"style":38},"interface Env {\n  SUPPORT_FORWARD_TO: string\n}\n\nexport default {\n  async email(message, env): Promise\u003Cvoid> {\n    const recipient = message.to.toLowerCase()\n    const subject = message.headers.get('subject') ?? ''\n\n    if (message.rawSize > 10 * 1024 * 1024) {\n      message.setReject('Message is too large for this mailbox')\n      return\n    }\n\n    if (recipient === 'support@example.com') {\n      console.log('Support email accepted', {\n        fromDomain: message.from.split('@').at(-1),\n        subjectLength: subject.length,\n        rawSize: message.rawSize,\n      })\n\n      await message.forward(env.SUPPORT_FORWARD_TO)\n      return\n    }\n\n    message.setReject('Unknown recipient')\n  },\n} satisfies ExportedHandler\u003CEnv>\n",[25,6974,6975,6983,6993,6997,7001,7010,7045,7064,7093,7097,7123,7138,7143,7147,7151,7166,7181,7211,7220,7225,7230,7234,7253,7257,7261,7265,7279,7283],{"__ignoreMap":38},[42,6976,6977,6979,6981],{"class":44,"line":45},[42,6978,6032],{"class":277},[42,6980,6035],{"class":48},[42,6982,6038],{"class":1797},[42,6984,6985,6988,6990],{"class":44,"line":274},[42,6986,6987],{"class":5842},"  SUPPORT_FORWARD_TO",[42,6989,5845],{"class":277},[42,6991,6992],{"class":52}," string\n",[42,6994,6995],{"class":44,"line":737},[42,6996,4228],{"class":1797},[42,6998,6999],{"class":44,"line":1584},[42,7000,1581],{"emptyLinePlaceholder":767},[42,7002,7003,7005,7008],{"class":44,"line":1603},[42,7004,5325],{"class":277},[42,7006,7007],{"class":277}," default",[42,7009,6038],{"class":1797},[42,7011,7012,7015,7018,7020,7023,7025,7028,7031,7033,7036,7039,7042],{"class":44,"line":1618},[42,7013,7014],{"class":277},"  async",[42,7016,7017],{"class":48}," email",[42,7019,5731],{"class":1797},[42,7021,7022],{"class":5842},"message",[42,7024,5851],{"class":1797},[42,7026,7027],{"class":5842},"env",[42,7029,7030],{"class":1797},")",[42,7032,5845],{"class":277},[42,7034,7035],{"class":48}," Promise",[42,7037,7038],{"class":1797},"\u003C",[42,7040,7041],{"class":52},"void",[42,7043,7044],{"class":1797},"> {\n",[42,7046,7047,7050,7053,7055,7058,7061],{"class":44,"line":1627},[42,7048,7049],{"class":277},"    const",[42,7051,7052],{"class":52}," recipient",[42,7054,5538],{"class":277},[42,7056,7057],{"class":1797}," message.to.",[42,7059,7060],{"class":48},"toLowerCase",[42,7062,7063],{"class":1797},"()\n",[42,7065,7066,7068,7071,7073,7076,7079,7081,7084,7087,7090],{"class":44,"line":1640},[42,7067,7049],{"class":277},[42,7069,7070],{"class":52}," subject",[42,7072,5538],{"class":277},[42,7074,7075],{"class":1797}," message.headers.",[42,7077,7078],{"class":48},"get",[42,7080,5731],{"class":1797},[42,7082,7083],{"class":56},"'subject'",[42,7085,7086],{"class":1797},") ",[42,7088,7089],{"class":277},"??",[42,7091,7092],{"class":56}," ''\n",[42,7094,7095],{"class":44,"line":1645},[42,7096,1581],{"emptyLinePlaceholder":767},[42,7098,7099,7102,7105,7108,7111,7114,7117,7119,7121],{"class":44,"line":1668},[42,7100,7101],{"class":277},"    if",[42,7103,7104],{"class":1797}," (message.rawSize ",[42,7106,7107],{"class":277},">",[42,7109,7110],{"class":52}," 10",[42,7112,7113],{"class":277}," *",[42,7115,7116],{"class":52}," 1024",[42,7118,7113],{"class":277},[42,7120,7116],{"class":52},[42,7122,5861],{"class":1797},[42,7124,7125,7128,7131,7133,7136],{"class":44,"line":1674},[42,7126,7127],{"class":1797},"      message.",[42,7129,7130],{"class":48},"setReject",[42,7132,5731],{"class":1797},[42,7134,7135],{"class":56},"'Message is too large for this mailbox'",[42,7137,5899],{"class":1797},[42,7139,7140],{"class":44,"line":1680},[42,7141,7142],{"class":277},"      return\n",[42,7144,7145],{"class":44,"line":777},[42,7146,4218],{"class":1797},[42,7148,7149],{"class":44,"line":1691},[42,7150,1581],{"emptyLinePlaceholder":767},[42,7152,7153,7155,7158,7161,7164],{"class":44,"line":1697},[42,7154,7101],{"class":277},[42,7156,7157],{"class":1797}," (recipient ",[42,7159,7160],{"class":277},"===",[42,7162,7163],{"class":56}," 'support@example.com'",[42,7165,5861],{"class":1797},[42,7167,7168,7171,7174,7176,7179],{"class":44,"line":1703},[42,7169,7170],{"class":1797},"      console.",[42,7172,7173],{"class":48},"log",[42,7175,5731],{"class":1797},[42,7177,7178],{"class":56},"'Support email accepted'",[42,7180,5737],{"class":1797},[42,7182,7183,7186,7189,7191,7194,7197,7200,7202,7205,7208],{"class":44,"line":1709},[42,7184,7185],{"class":1797},"        fromDomain: message.from.",[42,7187,7188],{"class":48},"split",[42,7190,5731],{"class":1797},[42,7192,7193],{"class":56},"'@'",[42,7195,7196],{"class":1797},").",[42,7198,7199],{"class":48},"at",[42,7201,5731],{"class":1797},[42,7203,7204],{"class":277},"-",[42,7206,7207],{"class":52},"1",[42,7209,7210],{"class":1797},"),\n",[42,7212,7213,7216,7218],{"class":44,"line":1714},[42,7214,7215],{"class":1797},"        subjectLength: subject.",[42,7217,5750],{"class":52},[42,7219,4100],{"class":1797},[42,7221,7222],{"class":44,"line":1723},[42,7223,7224],{"class":1797},"        rawSize: message.rawSize,\n",[42,7226,7227],{"class":44,"line":1736},[42,7228,7229],{"class":1797},"      })\n",[42,7231,7232],{"class":44,"line":1744},[42,7233,1581],{"emptyLinePlaceholder":767},[42,7235,7236,7239,7242,7245,7248,7251],{"class":44,"line":1752},[42,7237,7238],{"class":277},"      await",[42,7240,7241],{"class":1797}," message.",[42,7243,7244],{"class":48},"forward",[42,7246,7247],{"class":1797},"(env.",[42,7249,7250],{"class":52},"SUPPORT_FORWARD_TO",[42,7252,5899],{"class":1797},[42,7254,7255],{"class":44,"line":1760},[42,7256,7142],{"class":277},[42,7258,7259],{"class":44,"line":1768},[42,7260,4218],{"class":1797},[42,7262,7263],{"class":44,"line":2291},[42,7264,1581],{"emptyLinePlaceholder":767},[42,7266,7267,7270,7272,7274,7277],{"class":44,"line":2301},[42,7268,7269],{"class":1797},"    message.",[42,7271,7130],{"class":48},[42,7273,5731],{"class":1797},[42,7275,7276],{"class":56},"'Unknown recipient'",[42,7278,5899],{"class":1797},[42,7280,7281],{"class":44,"line":2306},[42,7282,5604],{"class":1797},[42,7284,7285,7288,7291,7294,7296,7299],{"class":44,"line":2314},[42,7286,7287],{"class":1797},"} ",[42,7289,7290],{"class":277},"satisfies",[42,7292,7293],{"class":48}," ExportedHandler",[42,7295,7038],{"class":1797},[42,7297,7298],{"class":48},"Env",[42,7300,7301],{"class":1797},">\n",[16,7303,7304,7305,7307,7308,7311],{},"其中 ",[25,7306,7022],{}," 是 ",[25,7309,7310],{},"ForwardableEmailMessage","，常用字段和操作包括：",[140,7313,7314,7323],{},[143,7315,7316],{},[146,7317,7318,7321],{},[149,7319,7320],{},"能力",[149,7322,2631],{},[159,7324,7325,7335,7345,7355,7365,7375,7385,7395],{},[146,7326,7327,7332],{},[164,7328,7329],{},[25,7330,7331],{},"message.from",[164,7333,7334],{},"SMTP envelope 的发件人",[146,7336,7337,7342],{},[164,7338,7339],{},[25,7340,7341],{},"message.to",[164,7343,7344],{},"SMTP envelope 的收件人",[146,7346,7347,7352],{},[164,7348,7349],{},[25,7350,7351],{},"message.headers",[164,7353,7354],{},"读取 Subject、Message-ID 等邮件头",[146,7356,7357,7362],{},[164,7358,7359],{},[25,7360,7361],{},"message.raw",[164,7363,7364],{},"原始 MIME 数据流",[146,7366,7367,7372],{},[164,7368,7369],{},[25,7370,7371],{},"message.rawSize",[164,7373,7374],{},"原始邮件大小",[146,7376,7377,7382],{},[164,7378,7379],{},[25,7380,7381],{},"message.forward(address)",[164,7383,7384],{},"转发到已验证目标地址",[146,7386,7387,7392],{},[164,7388,7389],{},[25,7390,7391],{},"message.reply(email)",[164,7393,7394],{},"在约束范围内回复原发件人",[146,7396,7397,7402],{},[164,7398,7399],{},[25,7400,7401],{},"message.setReject(reason)",[164,7403,7404],{},"返回永久 SMTP 拒收",[16,7406,7407,7408,5441,7410,7412,7413,5441,7416,7419],{},"这里有一个容易忽略的安全点：",[25,7409,7331],{},[25,7411,7341],{}," 是 SMTP envelope，邮件正文头部中的 ",[25,7414,7415],{},"From:",[25,7417,7418],{},"To:"," 则可能是另一组值。做授权、路由与审计时，不要把显示给用户看的头部地址误当成可信身份。",[246,7421,7423],{"id":7422},"需要正文时再解析-mime","需要正文时再解析 MIME",[16,7425,7426,7427,2074],{},"只做转发或按地址路由时，没有必要完整解析邮件。要提取正文和附件，可以使用 ",[25,7428,7429],{},"postal-mime",[33,7431,7433],{"className":5505,"code":7432,"language":5507,"meta":38,"style":38},"import PostalMime from 'postal-mime'\n\nexport default {\n  async email(message): Promise\u003Cvoid> {\n    const parsed = await PostalMime.parse(message.raw)\n\n    console.log({\n      subject: parsed.subject,\n      textLength: parsed.text?.length ?? 0,\n      attachmentCount: parsed.attachments.length,\n    })\n  },\n} satisfies ExportedHandler\n",[25,7434,7435,7447,7451,7459,7481,7501,7505,7514,7519,7533,7542,7547,7551],{"__ignoreMap":38},[42,7436,7437,7439,7442,7444],{"class":44,"line":45},[42,7438,5514],{"class":277},[42,7440,7441],{"class":1797}," PostalMime ",[42,7443,5520],{"class":277},[42,7445,7446],{"class":56}," 'postal-mime'\n",[42,7448,7449],{"class":44,"line":274},[42,7450,1581],{"emptyLinePlaceholder":767},[42,7452,7453,7455,7457],{"class":44,"line":737},[42,7454,5325],{"class":277},[42,7456,7007],{"class":277},[42,7458,6038],{"class":1797},[42,7460,7461,7463,7465,7467,7469,7471,7473,7475,7477,7479],{"class":44,"line":1584},[42,7462,7014],{"class":277},[42,7464,7017],{"class":48},[42,7466,5731],{"class":1797},[42,7468,7022],{"class":5842},[42,7470,7030],{"class":1797},[42,7472,5845],{"class":277},[42,7474,7035],{"class":48},[42,7476,7038],{"class":1797},[42,7478,7041],{"class":52},[42,7480,7044],{"class":1797},[42,7482,7483,7485,7488,7490,7492,7495,7498],{"class":44,"line":1603},[42,7484,7049],{"class":277},[42,7486,7487],{"class":52}," parsed",[42,7489,5538],{"class":277},[42,7491,5654],{"class":277},[42,7493,7494],{"class":1797}," PostalMime.",[42,7496,7497],{"class":48},"parse",[42,7499,7500],{"class":1797},"(message.raw)\n",[42,7502,7503],{"class":44,"line":1618},[42,7504,1581],{"emptyLinePlaceholder":767},[42,7506,7507,7510,7512],{"class":44,"line":1627},[42,7508,7509],{"class":1797},"    console.",[42,7511,7173],{"class":48},[42,7513,5547],{"class":1797},[42,7515,7516],{"class":44,"line":1640},[42,7517,7518],{"class":1797},"      subject: parsed.subject,\n",[42,7520,7521,7524,7526,7529,7531],{"class":44,"line":1645},[42,7522,7523],{"class":1797},"      textLength: parsed.text?.",[42,7525,5750],{"class":52},[42,7527,7528],{"class":277}," ??",[42,7530,2953],{"class":52},[42,7532,4100],{"class":1797},[42,7534,7535,7538,7540],{"class":44,"line":1668},[42,7536,7537],{"class":1797},"      attachmentCount: parsed.attachments.",[42,7539,5750],{"class":52},[42,7541,4100],{"class":1797},[42,7543,7544],{"class":44,"line":1674},[42,7545,7546],{"class":1797},"    })\n",[42,7548,7549],{"class":44,"line":1680},[42,7550,5604],{"class":1797},[42,7552,7553,7555,7557],{"class":44,"line":777},[42,7554,7287],{"class":1797},[42,7556,7290],{"class":277},[42,7558,7559],{"class":48}," ExportedHandler\n",[16,7561,7562],{},"解析后的 HTML、文件名、Content-Type 与附件都来自外部输入。正确做法是：",[97,7564,7565,7568,7571,7574,7577],{},[100,7566,7567],{},"不在管理页面直接插入来信 HTML；",[100,7569,7570],{},"限制正文、附件数量和解压后大小；",[100,7572,7573],{},"文件写入 R2 前重新生成对象键，不使用原始文件名作为路径；",[100,7575,7576],{},"不让来信内容直接组成 SQL、Shell、模板或 AI 工具参数；",[100,7578,7579],{},"大任务交给 Queue，不要把杀毒、OCR、模型调用全塞进入站事件的同步 CPU 时间。",[246,7581,7583],{"id":7582},"转发回复拒绝与静默丢弃的区别","转发、回复、拒绝与静默丢弃的区别",[97,7585,7586,7592,7598,7604],{},[100,7587,7588,7591],{},[20,7589,7590],{},"转发","：适合人工邮箱继续处理，目标地址必须提前验证；",[100,7593,7594,7597],{},[20,7595,7596],{},"回复","：适合自动确认或邮件机器人，但有额外限制；",[100,7599,7600,7603],{},[20,7601,7602],{},"拒绝","：在 SMTP 阶段明确告诉发送方没有接收；",[100,7605,7606,7609],{},[20,7607,7608],{},"丢弃","：规则匹配后不继续处理，也不给发送方业务层反馈。",[16,7611,7612],{},"自动回复不能被当成任意发送接口。官方当前要求：",[97,7614,7615,7618,7621,7624,7627],{},[100,7616,7617],{},"来信必须有有效 DMARC 结果；",[100,7619,7620],{},"每个入站事件只能调用一次回复；",[100,7622,7623],{},"回复收件人必须是原始来信的发件人；",[100,7625,7626],{},"回复使用的发件域必须与接收邮件的域一致；",[100,7628,7629,7632],{},[25,7630,7631],{},"References"," 超过 100 项时会拒绝回复，以避免邮件环路。",[16,7634,7635],{},"这组限制反而是一件好事：它让“回复这封邮件”和“向任意地址发一封新邮件”成为两种不同权限。",[81,7637,7639],{"id":7638},"发送邮件优先使用-workers-binding","发送邮件：优先使用 Workers binding",[16,7641,7642],{},"如果应用本身运行在 Cloudflare Workers，Workers binding 是最自然的选择。它不需要在业务代码里保存 Cloudflare API Token。",[16,7644,7645,7646,7649],{},"先在 ",[25,7647,7648],{},"wrangler.jsonc"," 中声明绑定：",[33,7651,7655],{"className":7652,"code":7653,"language":7654,"meta":38,"style":38},"language-jsonc shiki shiki-themes github-dark github-dark","{\n  \"send_email\": [\n    {\n      \"name\": \"EMAIL\",\n      \"allowed_sender_addresses\": [\n        \"noreply@example.com\"\n      ]\n    }\n  ],\n  \"vars\": {\n    \"EMAIL_FROM\": \"noreply@example.com\"\n  }\n}\n","jsonc",[25,7656,7657,7661,7666,7671,7676,7681,7686,7691,7695,7700,7705,7710,7714],{"__ignoreMap":38},[42,7658,7659],{"class":44,"line":45},[42,7660,4087],{},[42,7662,7663],{"class":44,"line":274},[42,7664,7665],{},"  \"send_email\": [\n",[42,7667,7668],{"class":44,"line":737},[42,7669,7670],{},"    {\n",[42,7672,7673],{"class":44,"line":1584},[42,7674,7675],{},"      \"name\": \"EMAIL\",\n",[42,7677,7678],{"class":44,"line":1603},[42,7679,7680],{},"      \"allowed_sender_addresses\": [\n",[42,7682,7683],{"class":44,"line":1618},[42,7684,7685],{},"        \"noreply@example.com\"\n",[42,7687,7688],{"class":44,"line":1627},[42,7689,7690],{},"      ]\n",[42,7692,7693],{"class":44,"line":1640},[42,7694,4218],{},[42,7696,7697],{"class":44,"line":1645},[42,7698,7699],{},"  ],\n",[42,7701,7702],{"class":44,"line":1668},[42,7703,7704],{},"  \"vars\": {\n",[42,7706,7707],{"class":44,"line":1674},[42,7708,7709],{},"    \"EMAIL_FROM\": \"noreply@example.com\"\n",[42,7711,7712],{"class":44,"line":1680},[42,7713,4223],{},[42,7715,7716],{"class":44,"line":777},[42,7717,4228],{},[16,7719,7720,7723],{},[25,7721,7722],{},"allowed_sender_addresses"," 不只是文档，它是运行时权限边界。即使某个接口被误用，这个绑定也不能伪造其他发件地址。",[16,7725,7726],{},"还可以用以下配置进一步约束收件人：",[97,7728,7729,7735,7741],{},[100,7730,7731,7734],{},[25,7732,7733],{},"destination_address","：固定为一个目标地址；",[100,7736,7737,7740],{},[25,7738,7739],{},"allowed_destination_addresses","：只允许一组目标地址；",[100,7742,7743,7745],{},[25,7744,7722],{},"：限制可使用的发件地址。",[16,7747,7748,7749,7751],{},"用于系统告警的 Worker 最适合固定 ",[25,7750,7733],{},"；用于用户验证码的服务则通常限制发件人，并在应用层校验收件人和频率。",[246,7753,7755],{"id":7754},"在-worker-中发送","在 Worker 中发送",[33,7757,7759],{"className":5505,"code":7758,"language":5507,"meta":38,"style":38},"interface Env {\n  EMAIL: SendEmail\n}\n\nexport default {\n  async fetch(_request, env): Promise\u003CResponse> {\n    const result = await env.EMAIL.send({\n      from: {\n        email: 'noreply@example.com',\n        name: 'Example App',\n      },\n      to: 'reader@example.net',\n      subject: '验证你的邮箱',\n      text: '你的验证码是 482913，5 分钟内有效。',\n      html: '\u003Cp>你的验证码是 \u003Cstrong>482913\u003C\u002Fstrong>，5 分钟内有效。\u003C\u002Fp>',\n    })\n\n    return Response.json({\n      messageId: result.messageId,\n    })\n  },\n} satisfies ExportedHandler\u003CEnv>\n",[25,7760,7761,7769,7777,7781,7785,7793,7822,7843,7848,7858,7868,7872,7882,7892,7902,7912,7916,7920,7932,7937,7941,7945],{"__ignoreMap":38},[42,7762,7763,7765,7767],{"class":44,"line":45},[42,7764,6032],{"class":277},[42,7766,6035],{"class":48},[42,7768,6038],{"class":1797},[42,7770,7771,7773,7775],{"class":44,"line":274},[42,7772,6043],{"class":5842},[42,7774,5845],{"class":277},[42,7776,6048],{"class":48},[42,7778,7779],{"class":44,"line":737},[42,7780,4228],{"class":1797},[42,7782,7783],{"class":44,"line":1584},[42,7784,1581],{"emptyLinePlaceholder":767},[42,7786,7787,7789,7791],{"class":44,"line":1603},[42,7788,5325],{"class":277},[42,7790,7007],{"class":277},[42,7792,6038],{"class":1797},[42,7794,7795,7797,7800,7802,7805,7807,7809,7811,7813,7815,7817,7820],{"class":44,"line":1618},[42,7796,7014],{"class":277},[42,7798,7799],{"class":48}," fetch",[42,7801,5731],{"class":1797},[42,7803,7804],{"class":5842},"_request",[42,7806,5851],{"class":1797},[42,7808,7027],{"class":5842},[42,7810,7030],{"class":1797},[42,7812,5845],{"class":277},[42,7814,7035],{"class":48},[42,7816,7038],{"class":1797},[42,7818,7819],{"class":48},"Response",[42,7821,7044],{"class":1797},[42,7823,7824,7826,7829,7831,7833,7835,7837,7839,7841],{"class":44,"line":1627},[42,7825,7049],{"class":277},[42,7827,7828],{"class":52}," result",[42,7830,5538],{"class":277},[42,7832,5654],{"class":277},[42,7834,6064],{"class":1797},[42,7836,6067],{"class":52},[42,7838,1791],{"class":1797},[42,7840,6072],{"class":48},[42,7842,5547],{"class":1797},[42,7844,7845],{"class":44,"line":1640},[42,7846,7847],{"class":1797},"      from: {\n",[42,7849,7850,7853,7856],{"class":44,"line":1645},[42,7851,7852],{"class":1797},"        email: ",[42,7854,7855],{"class":56},"'noreply@example.com'",[42,7857,4100],{"class":1797},[42,7859,7860,7863,7866],{"class":44,"line":1668},[42,7861,7862],{"class":1797},"        name: ",[42,7864,7865],{"class":56},"'Example App'",[42,7867,4100],{"class":1797},[42,7869,7870],{"class":44,"line":1674},[42,7871,4204],{"class":1797},[42,7873,7874,7877,7880],{"class":44,"line":1680},[42,7875,7876],{"class":1797},"      to: ",[42,7878,7879],{"class":56},"'reader@example.net'",[42,7881,4100],{"class":1797},[42,7883,7884,7887,7890],{"class":44,"line":777},[42,7885,7886],{"class":1797},"      subject: ",[42,7888,7889],{"class":56},"'验证你的邮箱'",[42,7891,4100],{"class":1797},[42,7893,7894,7897,7900],{"class":44,"line":1691},[42,7895,7896],{"class":1797},"      text: ",[42,7898,7899],{"class":56},"'你的验证码是 482913，5 分钟内有效。'",[42,7901,4100],{"class":1797},[42,7903,7904,7907,7910],{"class":44,"line":1697},[42,7905,7906],{"class":1797},"      html: ",[42,7908,7909],{"class":56},"'\u003Cp>你的验证码是 \u003Cstrong>482913\u003C\u002Fstrong>，5 分钟内有效。\u003C\u002Fp>'",[42,7911,4100],{"class":1797},[42,7913,7914],{"class":44,"line":1703},[42,7915,7546],{"class":1797},[42,7917,7918],{"class":44,"line":1709},[42,7919,1581],{"emptyLinePlaceholder":767},[42,7921,7922,7925,7928,7930],{"class":44,"line":1714},[42,7923,7924],{"class":277},"    return",[42,7926,7927],{"class":1797}," Response.",[42,7929,4080],{"class":48},[42,7931,5547],{"class":1797},[42,7933,7934],{"class":44,"line":1723},[42,7935,7936],{"class":1797},"      messageId: result.messageId,\n",[42,7938,7939],{"class":44,"line":1736},[42,7940,7546],{"class":1797},[42,7942,7943],{"class":44,"line":1744},[42,7944,5604],{"class":1797},[42,7946,7947,7949,7951,7953,7955,7957],{"class":44,"line":1752},[42,7948,7287],{"class":1797},[42,7950,7290],{"class":277},[42,7952,7293],{"class":48},[42,7954,7038],{"class":1797},[42,7956,7298],{"class":48},[42,7958,7301],{"class":1797},[16,7960,7961,7962,7964],{},"Workers binding 成功时返回一个 ",[25,7963,6749],{},"。这个结果代表 Cloudflare 接受了发送请求，不应该被解释为用户一定已经在收件箱里看到邮件。最终投递、延迟、退信和抑制状态还要结合 Email Service 日志与指标。",[246,7966,7968],{"id":7967},"在-nuxt-服务端-api-中发送","在 Nuxt 服务端 API 中发送",[16,7970,7971],{},"Nuxt 部署到 Workers 后，同样可以从服务端路由读取 binding：",[33,7973,7975],{"className":5505,"code":7974,"language":5507,"meta":38,"style":38},"import { z } from 'zod'\n\nconst schema = z.object({\n  email: z.string().trim().email().max(254),\n})\n\nexport default defineEventHandler(async (event) => {\n  const { email } = schema.parse(await readBody(event))\n  const env = event.context.cloudflare.env as {\n    EMAIL: SendEmail\n    EMAIL_FROM: string\n  }\n\n  \u002F\u002F 实际项目还应在这里执行同源校验、IP\u002F邮箱限流，\n  \u002F\u002F 并在 D1 中原子领取一次发送资格。\n  const result = await env.EMAIL.send({\n    from: {\n      email: env.EMAIL_FROM,\n      name: 'Example App',\n    },\n    to: email,\n    subject: '验证邮箱',\n    text: '验证码将在 5 分钟后失效。',\n    html: '\u003Cp>验证码将在 \u003Cstrong>5 分钟\u003C\u002Fstrong>后失效。\u003C\u002Fp>',\n  })\n\n  return {\n    ok: true,\n    messageId: result.messageId,\n  }\n})\n",[25,7976,7977,7989,7993,8010,8041,8045,8049,8075,8105,8121,8130,8139,8143,8147,8153,8158,8178,8183,8193,8202,8207,8212,8222,8232,8242,8247,8251,8258,8267,8273,8278],{"__ignoreMap":38},[42,7978,7979,7981,7984,7986],{"class":44,"line":45},[42,7980,5514],{"class":277},[42,7982,7983],{"class":1797}," { z } ",[42,7985,5520],{"class":277},[42,7987,7988],{"class":56}," 'zod'\n",[42,7990,7991],{"class":44,"line":274},[42,7992,1581],{"emptyLinePlaceholder":767},[42,7994,7995,7997,8000,8002,8005,8008],{"class":44,"line":737},[42,7996,5532],{"class":277},[42,7998,7999],{"class":52}," schema",[42,8001,5538],{"class":277},[42,8003,8004],{"class":1797}," z.",[42,8006,8007],{"class":48},"object",[42,8009,5547],{"class":1797},[42,8011,8012,8015,8018,8021,8024,8026,8029,8031,8034,8036,8039],{"class":44,"line":1584},[42,8013,8014],{"class":1797},"  email: z.",[42,8016,8017],{"class":48},"string",[42,8019,8020],{"class":1797},"().",[42,8022,8023],{"class":48},"trim",[42,8025,8020],{"class":1797},[42,8027,8028],{"class":48},"email",[42,8030,8020],{"class":1797},[42,8032,8033],{"class":48},"max",[42,8035,5731],{"class":1797},[42,8037,8038],{"class":52},"254",[42,8040,7210],{"class":1797},[42,8042,8043],{"class":44,"line":1603},[42,8044,5638],{"class":1797},[42,8046,8047],{"class":44,"line":1618},[42,8048,1581],{"emptyLinePlaceholder":767},[42,8050,8051,8053,8055,8058,8060,8063,8065,8068,8070,8073],{"class":44,"line":1627},[42,8052,5325],{"class":277},[42,8054,7007],{"class":277},[42,8056,8057],{"class":48}," defineEventHandler",[42,8059,5731],{"class":1797},[42,8061,8062],{"class":277},"async",[42,8064,5869],{"class":1797},[42,8066,8067],{"class":5842},"event",[42,8069,7086],{"class":1797},[42,8071,8072],{"class":277},"=>",[42,8074,6038],{"class":1797},[42,8076,8077,8080,8083,8085,8088,8090,8093,8095,8097,8099,8102],{"class":44,"line":1640},[42,8078,8079],{"class":277},"  const",[42,8081,8082],{"class":1797}," { ",[42,8084,8028],{"class":52},[42,8086,8087],{"class":1797}," } ",[42,8089,1845],{"class":277},[42,8091,8092],{"class":1797}," schema.",[42,8094,7497],{"class":48},[42,8096,5731],{"class":1797},[42,8098,6061],{"class":277},[42,8100,8101],{"class":48}," readBody",[42,8103,8104],{"class":1797},"(event))\n",[42,8106,8107,8109,8111,8113,8116,8119],{"class":44,"line":1645},[42,8108,8079],{"class":277},[42,8110,1897],{"class":52},[42,8112,5538],{"class":277},[42,8114,8115],{"class":1797}," event.context.cloudflare.env ",[42,8117,8118],{"class":277},"as",[42,8120,6038],{"class":1797},[42,8122,8123,8126,8128],{"class":44,"line":1668},[42,8124,8125],{"class":5842},"    EMAIL",[42,8127,5845],{"class":277},[42,8129,6048],{"class":48},[42,8131,8132,8135,8137],{"class":44,"line":1674},[42,8133,8134],{"class":5842},"    EMAIL_FROM",[42,8136,5845],{"class":277},[42,8138,6992],{"class":52},[42,8140,8141],{"class":44,"line":1680},[42,8142,4223],{"class":1797},[42,8144,8145],{"class":44,"line":777},[42,8146,1581],{"emptyLinePlaceholder":767},[42,8148,8149],{"class":44,"line":1691},[42,8150,8152],{"class":8151},"sJ8bj","  \u002F\u002F 实际项目还应在这里执行同源校验、IP\u002F邮箱限流，\n",[42,8154,8155],{"class":44,"line":1697},[42,8156,8157],{"class":8151},"  \u002F\u002F 并在 D1 中原子领取一次发送资格。\n",[42,8159,8160,8162,8164,8166,8168,8170,8172,8174,8176],{"class":44,"line":1703},[42,8161,8079],{"class":277},[42,8163,7828],{"class":52},[42,8165,5538],{"class":277},[42,8167,5654],{"class":277},[42,8169,6064],{"class":1797},[42,8171,6067],{"class":52},[42,8173,1791],{"class":1797},[42,8175,6072],{"class":48},[42,8177,5547],{"class":1797},[42,8179,8180],{"class":44,"line":1709},[42,8181,8182],{"class":1797},"    from: {\n",[42,8184,8185,8188,8191],{"class":44,"line":1714},[42,8186,8187],{"class":1797},"      email: env.",[42,8189,8190],{"class":52},"EMAIL_FROM",[42,8192,4100],{"class":1797},[42,8194,8195,8198,8200],{"class":44,"line":1723},[42,8196,8197],{"class":1797},"      name: ",[42,8199,7865],{"class":56},[42,8201,4100],{"class":1797},[42,8203,8204],{"class":44,"line":1736},[42,8205,8206],{"class":1797},"    },\n",[42,8208,8209],{"class":44,"line":1744},[42,8210,8211],{"class":1797},"    to: email,\n",[42,8213,8214,8217,8220],{"class":44,"line":1752},[42,8215,8216],{"class":1797},"    subject: ",[42,8218,8219],{"class":56},"'验证邮箱'",[42,8221,4100],{"class":1797},[42,8223,8224,8227,8230],{"class":44,"line":1760},[42,8225,8226],{"class":1797},"    text: ",[42,8228,8229],{"class":56},"'验证码将在 5 分钟后失效。'",[42,8231,4100],{"class":1797},[42,8233,8234,8237,8240],{"class":44,"line":1768},[42,8235,8236],{"class":1797},"    html: ",[42,8238,8239],{"class":56},"'\u003Cp>验证码将在 \u003Cstrong>5 分钟\u003C\u002Fstrong>后失效。\u003C\u002Fp>'",[42,8241,4100],{"class":1797},[42,8243,8244],{"class":44,"line":2291},[42,8245,8246],{"class":1797},"  })\n",[42,8248,8249],{"class":44,"line":2301},[42,8250,1581],{"emptyLinePlaceholder":767},[42,8252,8253,8256],{"class":44,"line":2306},[42,8254,8255],{"class":277},"  return",[42,8257,6038],{"class":1797},[42,8259,8260,8263,8265],{"class":44,"line":2314},[42,8261,8262],{"class":1797},"    ok: ",[42,8264,4122],{"class":52},[42,8266,4100],{"class":1797},[42,8268,8270],{"class":44,"line":8269},29,[42,8271,8272],{"class":1797},"    messageId: result.messageId,\n",[42,8274,8276],{"class":44,"line":8275},30,[42,8277,4223],{"class":1797},[42,8279,8281],{"class":44,"line":8280},31,[42,8282,5638],{"class":1797},[16,8284,8285],{},"不要把这个示例直接变成一个匿名、无限频率、可自由填写主题和正文的公开 API。那会把自己的域名变成垃圾邮件跳板。",[16,8287,8288],{},[2024,8289],{"alt":8290,"src":8291},"事务邮件从 Worker 生成，经过多层身份认证后投递到不同收件系统","\u002Fmedia\u002Fposts\u002Fcloudflare-email-service-send-receive-guide\u002Foutbound-delivery-v1.webp",[16,8293,8294],{},[2031,8295,8296],{},"SPF、DKIM 与 DMARC 解决的是“谁有权发送、内容是否被修改、失败时如何处置”，并不保证每封邮件都进入主收件箱。",[81,8298,8300],{"id":8299},"rest-api-与认证-smtp-什么时候更合适","REST API 与认证 SMTP 什么时候更合适",[16,8302,8303],{},"Cloudflare 目前提供三种发送入口：",[140,8305,8306,8319],{},[143,8307,8308],{},[146,8309,8310,8313,8316],{},[149,8311,8312],{},"方式",[149,8314,8315],{},"最适合",[149,8317,8318],{},"凭证与边界",[159,8320,8321,8332,8342],{},[146,8322,8323,8326,8329],{},[164,8324,8325],{},"Workers binding",[164,8327,8328],{},"已运行在 Workers 的 Nuxt、Hono、Agent",[164,8330,8331],{},"不在代码中保存 API Token，可通过 binding 限制发件人与收件人",[146,8333,8334,8336,8339],{},[164,8335,6153],{},[164,8337,8338],{},"运行在其他云、CI 或后端平台的程序",[164,8340,8341],{},"使用最小权限 API Token，响应会按收件人区分 delivered、queued、permanent bounce",[146,8343,8344,8346,8349],{},[164,8345,6161],{},[164,8347,8348],{},"已经使用 Nodemailer、PHPMailer、JavaMail 等 SMTP 客户端的旧系统",[164,8350,8351,8353],{},[25,8352,5068],{},"，仅隐式 TLS，密码是有 Email Sending 权限的 API Token",[16,8355,8356],{},"认证 SMTP 是 2026 年 6 月加入的 Beta 能力。当前只支持 465 端口的 SMTPS，不提供 587 端口 STARTTLS，也不是可以匿名使用的 25 端口 relay。",[16,8358,8359],{},"SMTP Token 一旦泄漏，持有者可以使用账户中已接入的发件域发送邮件。因此：",[97,8361,8362,8365,8370,8373,8376],{},[100,8363,8364],{},"优先使用 account-owned token；",[100,8366,8367,8368,367],{},"权限只给 ",[25,8369,5154],{},[100,8371,8372],{},"不写进仓库、镜像或前端环境变量；",[100,8374,8375],{},"不同系统使用不同 Token，便于单独吊销；",[100,8377,8378],{},"能用 Worker binding 的场景，不要为了“熟悉 SMTP”额外引入长期凭证。",[81,8380,8381],{"id":8381},"必须知道的限制",[16,8383,8384],{},"截至 2026 年 7 月，官方平台限制中最值得在代码里提前防守的是：",[140,8386,8387,8401],{},[143,8388,8389],{},[146,8390,8391,8394,8398],{},[149,8392,8393],{},"限制",[149,8395,8397],{"align":8396},"right","当前值",[149,8399,8400],{},"实现影响",[159,8402,8403,8414,8424,8435,8446,8457,8467,8478,8489],{},[146,8404,8405,8408,8411],{},[164,8406,8407],{},"每域 Email Routing 规则",[164,8409,8410],{"align":8396},"200",[164,8412,8413],{},"大量租户不要为每个人创建独立静态规则",[146,8415,8416,8419,8421],{},[164,8417,8418],{},"每账户已验证目标地址",[164,8420,8410],{"align":8396},[164,8422,8423],{},"转发到多邮箱前先规划账户级目标",[146,8425,8426,8429,8432],{},[164,8427,8428],{},"入站邮件大小",[164,8430,8431],{"align":8396},"25 MiB",[164,8433,8434],{},"更大邮件在 SMTP 阶段被拒绝",[146,8436,8437,8440,8443],{},[164,8438,8439],{},"单封出站收件人数",[164,8441,8442],{"align":8396},"50",[164,8444,8445],{},"To、Cc、Bcc 合计",[146,8447,8448,8451,8454],{},[164,8449,8450],{},"普通出站邮件总大小",[164,8452,8453],{"align":8396},"5 MiB",[164,8455,8456],{},"包含附件与 MIME 编码开销",[146,8458,8459,8462,8464],{},[164,8460,8461],{},"发往已验证目标的总大小",[164,8463,8431],{"align":8396},[164,8465,8466],{},"仅适用于已验证目标地址",[146,8468,8469,8472,8475],{},[164,8470,8471],{},"自定义头总大小",[164,8473,8474],{"align":8396},"16 KB",[164,8476,8477],{},"不要把业务数据塞进 Header",[146,8479,8480,8483,8486],{},[164,8481,8482],{},"单个 Zone 的发送\u002F路由域名总数",[164,8484,8485],{"align":8396},"30",[164,8487,8488],{},"根域与子域都计入",[146,8490,8491,8497,8500],{},[164,8492,8493,8494,8496],{},"回复的 ",[25,8495,7631],{}," 项数",[164,8498,8499],{"align":8396},"100",[164,8501,8502,8503,8506],{},"超过后 ",[25,8504,8505],{},"reply()"," 会失败",[16,8508,8509],{},"附件会经过 Base64 和 MIME 封装。3 MiB 的原始附件不等于最终邮件仍是 3 MiB，所以应用限制应给编码、正文和邮件头预留空间。",[16,8511,8512],{},"平台还会根据账户信誉、投递表现和使用历史调整每日发送额度。不要假设“每月额度未用完”就等于某一秒可以无限并发发送。",[81,8514,8515],{"id":8515},"验证码与密码重置应该怎样做",[16,8517,8518],{},"本博客的注册和密码重置邮件采用的是下面这条链路：",[33,8520,8523],{"className":8521,"code":8522,"language":231,"meta":38},[229],"用户提交邮箱\n  → 同源检查\n  → IP 与邮箱限流\n  → 生成 6 位验证码\n  → D1 只保存加盐哈希、用途、过期时间和尝试次数\n  → EMAIL binding 发送\n  → 验证成功后原子标记已使用\n",[25,8524,8522],{"__ignoreMap":38},[16,8526,8527],{},"关键策略包括：",[97,8529,8530,8533,8536,8539,8542,8545,8548],{},[100,8531,8532],{},"同一邮箱 5 分钟只能发送一封；",[100,8534,8535],{},"验证码 5 分钟后失效；",[100,8537,8538],{},"验证码只能使用一次；",[100,8540,8541],{},"D1 不保存验证码明文；",[100,8543,8544],{},"连续输错达到阈值后停止验证；",[100,8546,8547],{},"邮件发送失败时释放本次占位，避免用户被一个未送达的验证码锁住；",[100,8549,8550],{},"重置密码后撤销已有登录会话，而不是只替换密码哈希。",[16,8552,8553,8554,8557],{},"限流要在数据库中“原子领取资格”，不能写成“先查询上次发送时间，再插入一行”。两个并发请求可能同时通过查询。使用唯一键配合 ",[25,8555,8556],{},"INSERT ... ON CONFLICT ... WHERE","，才能让数据库决定谁获得发送资格。",[16,8559,8560],{},"邮件接口的响应也不应暴露“这个邮箱是否已经注册”。否则攻击者可以批量枚举用户。",[81,8562,8563],{"id":8563},"一个更完整的生产架构",[16,8565,8566],{},"当邮件从 Demo 进入生产，推荐把职责拆开：",[33,8568,8571],{"className":8569,"code":8570,"language":231,"meta":38},[229],"HTTP 请求 \u002F 业务事件\n  → 鉴权、同源、参数校验、限流、幂等\n  → Queue\n  → 邮件模板渲染\n  → EMAIL binding\n  → messageId \u002F 错误码写入 D1\n  → Email Service 日志与告警\n",[25,8572,8570],{"__ignoreMap":38},[16,8574,8575],{},"入站链路则可以是：",[33,8577,8580],{"className":8578,"code":8579,"language":231,"meta":38},[229],"support@yourdomain.com\n  → Email Routing\n  → Email Worker\n  → 认证与大小检查\n  → MIME 解析\n  → R2 保存附件 \u002F D1 建立工单\n  → Queue 做耗时处理\n  → 转发人工邮箱或发送受约束回复\n",[25,8581,8579],{"__ignoreMap":38},[246,8583,8585],{"id":8584},"d1-建议记录什么","D1 建议记录什么",[16,8587,8588],{},"可以记录：",[97,8590,8591,8594,8597,8601,8604,8607,8610],{},[100,8592,8593],{},"业务事件 ID 与幂等键；",[100,8595,8596],{},"模板 ID 和模板版本；",[100,8598,8599,367],{},[25,8600,6749],{},[100,8602,8603],{},"收件人数，不一定要保存完整地址；",[100,8605,8606],{},"accepted \u002F failed 等应用侧状态；",[100,8608,8609],{},"Cloudflare 错误码；",[100,8611,8612],{},"创建、发送和最后更新时间。",[16,8614,8615],{},"不建议默认记录：",[97,8617,8618,8621,8624,8627,8630,8633],{},[100,8619,8620],{},"验证码明文；",[100,8622,8623],{},"密码重置 Token；",[100,8625,8626],{},"完整邮件正文；",[100,8628,8629],{},"附件内容；",[100,8631,8632],{},"SMTP\u002FAPI Token；",[100,8634,8635],{},"为调试方便长期保留的原始来信。",[16,8637,8638],{},"日志本身也可能成为新的隐私数据源。",[246,8640,8642],{"id":8641},"不要把-email-routing-的-dropped-当成发送失败","不要把 Email Routing 的 “Dropped” 当成发送失败",[16,8644,8645,8646,8648,8649,8652],{},"官方特别说明：通过 ",[25,8647,6021],{}," binding 发出的邮件，在 Email Routing 汇总页面可能显示为 ",[20,8650,8651],{},"dropped","，即使邮件实际已经成功投递。",[16,8654,8655,8656,8658],{},"出站结果应该查看 Email Sending 的 Metrics 与 Logs，并用 ",[25,8657,6749],{}," 关联应用日志。Routing 页面描述的是入站路由处理，不是完整的出站投递状态机。",[81,8660,8661],{"id":8661},"本地怎么测试",[16,8663,8664],{},"Wrangler 已支持本地触发 Email Worker。运行：",[33,8666,8668],{"className":35,"code":8667,"language":37,"meta":38,"style":38},"pnpm exec wrangler dev\n",[25,8669,8670],{"__ignoreMap":38},[42,8671,8672,8674,8677,8680],{"class":44,"line":45},[42,8673,5462],{"class":48},[42,8675,8676],{"class":56}," exec",[42,8678,8679],{"class":56}," wrangler",[42,8681,8682],{"class":56}," dev\n",[16,8684,8685],{},"然后向本地入口发送一封原始邮件：",[33,8687,8689],{"className":35,"code":8688,"language":37,"meta":38,"style":38},"curl -X POST \\\n  'http:\u002F\u002Flocalhost:8787\u002Fcdn-cgi\u002Fhandler\u002Femail?from=sender@example.net&to=support@example.com' \\\n  -H 'Content-Type: text\u002Fplain' \\\n  --data-binary $'From: sender@example.net\\nTo: support@example.com\\nSubject: Local test\\n\\nHello from local development.'\n",[25,8690,8691,8703,8710,8720],{"__ignoreMap":38},[42,8692,8693,8695,8698,8701],{"class":44,"line":45},[42,8694,262],{"class":48},[42,8696,8697],{"class":52}," -X",[42,8699,8700],{"class":56}," POST",[42,8702,271],{"class":52},[42,8704,8705,8708],{"class":44,"line":274},[42,8706,8707],{"class":56},"  'http:\u002F\u002Flocalhost:8787\u002Fcdn-cgi\u002Fhandler\u002Femail?from=sender@example.net&to=support@example.com'",[42,8709,271],{"class":52},[42,8711,8712,8715,8718],{"class":44,"line":737},[42,8713,8714],{"class":52},"  -H",[42,8716,8717],{"class":56}," 'Content-Type: text\u002Fplain'",[42,8719,271],{"class":52},[42,8721,8722,8725,8728,8731,8734,8736,8739,8742],{"class":44,"line":1584},[42,8723,8724],{"class":52},"  --data-binary",[42,8726,8727],{"class":56}," $'From: sender@example.net",[42,8729,8730],{"class":52},"\\n",[42,8732,8733],{"class":56},"To: support@example.com",[42,8735,8730],{"class":52},[42,8737,8738],{"class":56},"Subject: Local test",[42,8740,8741],{"class":52},"\\n\\n",[42,8743,8744],{"class":56},"Hello from local development.'\n",[16,8746,8747],{},"本地发送 binding 会把文本和 HTML 保存成可检查的文件，适合验证模板。二进制附件的本地模拟仍有单独限制，涉及附件时应补一轮远程测试。",[16,8749,8750],{},"至少覆盖这些用例：",[97,8752,8753,8756,8759,8762,8765,8768,8771,8774,8777,8780],{},[100,8754,8755],{},"正常文本和 HTML；",[100,8757,8758],{},"非 ASCII 主题与发件人显示名；",[100,8760,8761],{},"重复请求与幂等；",[100,8763,8764],{},"超过发送冷却时间前的重复验证码；",[100,8766,8767],{},"大正文与附件边界；",[100,8769,8770],{},"Header 换行注入；",[100,8772,8773],{},"未验证发件域或受限收件人；",[100,8775,8776],{},"退信、抑制、额度和频率错误；",[100,8778,8779],{},"入站无匹配地址、Catch-all 和拒绝路径；",[100,8781,8782],{},"HTML 邮件中的脚本、远程资源与危险附件名。",[81,8784,8786],{"id":8785},"它适合什么不适合什么","它适合什么，不适合什么",[16,8788,8789],{},"适合：",[97,8791,8792,8795,8798,8801,8804,8807],{},[100,8793,8794],{},"注册验证、魔法链接、密码重置；",[100,8796,8797],{},"订单、账单、部署、监控通知；",[100,8799,8800],{},"个人域名邮件转发；",[100,8802,8803],{},"联系表单与支持工单；",[100,8805,8806],{},"邮件触发的 Agent 或自动化流程；",[100,8808,8809],{},"希望少维护一台 SMTP 服务的 Cloudflare 应用。",[16,8811,8812],{},"不应直接拿来做：",[97,8814,8815,8818,8821,8824,8827],{},[100,8816,8817],{},"未经同意的营销群发；",[100,8819,8820],{},"没有退订、投诉和抑制管理的 Newsletter；",[100,8822,8823],{},"匿名开放的“帮我发邮件”接口；",[100,8825,8826],{},"需要 IMAP 文件夹、日历、通讯录和多人邮箱协作的企业邮箱；",[100,8828,8829],{},"把任何来信正文直接交给有高权限工具的 Agent。",[16,8831,8832],{},"Cloudflare 替你维护了邮件入口与投递基础设施，但发件人信誉、用户授权、内容安全、频率控制和数据最小化仍然是应用自己的责任。",[81,8834,8835],{"id":8835},"最后的判断",[16,8837,8838],{},"Cloudflare Email Service 最有价值的地方，不是“又多了一个 SMTP 厂商”，而是它让邮件和 Worker、D1、R2、Queue、Agents 处于同一运行边界。",[16,8840,8841],{},"接收邮件不再只能转发到人类邮箱，它可以成为一个受约束的事件入口；发送邮件也不再需要为验证码单独接入一个外部 SDK 和密钥体系。",[16,8843,8844],{},"如果项目本来就部署在 Workers，优先从这条最小链路开始：",[406,8846,8847,8853,8856,8859,8862],{},[100,8848,8849,8850,367],{},"Email Routing 接收 ",[25,8851,8852],{},"support@",[100,8854,8855],{},"Worker binding 发送验证码和密码重置；",[100,8857,8858],{},"D1 实现冷却、一次性验证和最小日志；",[100,8860,8861],{},"sender allowlist 限制 binding 权限；",[100,8863,8864],{},"用 Email Sending 日志核对投递，而不是只看接口返回成功。",[16,8866,8867],{},"等确实需要从外部平台发送时，再引入 REST API；只有现有系统已经深度依赖 SMTP 客户端时，才使用认证 SMTP。",[81,8869,3287],{"id":3287},[97,8871,8872,8879,8885,8892,8899,8906,8913,8920,8926,8931,8937,8944,8951],{},[100,8873,8874],{},[385,8875,8878],{"href":8876,"rel":8877},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002F",[389],"Cloudflare Email Service 概览",[100,8880,8881],{},[385,8882,8884],{"href":5079,"rel":8883},[389],"接入 Email Sending",[100,8886,8887],{},[385,8888,8891],{"href":8889,"rel":8890},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fget-started\u002Froute-emails\u002F",[389],"接入 Email Routing",[100,8893,8894],{},[385,8895,8898],{"href":8896,"rel":8897},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fconfiguration\u002Fdomains\u002F",[389],"域名与 DNS 配置",[100,8900,8901],{},[385,8902,8905],{"href":8903,"rel":8904},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fconfiguration\u002Fsend-bindings\u002F",[389],"发送 binding 权限配置",[100,8907,8908],{},[385,8909,8912],{"href":8910,"rel":8911},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fapi\u002Fsend-emails\u002Fworkers-api\u002F",[389],"Workers 发送 API",[100,8914,8915],{},[385,8916,8919],{"href":8917,"rel":8918},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fapi\u002Froute-emails\u002Femail-handler\u002F",[389],"Email Worker 接收 API",[100,8921,8922],{},[385,8923,6153],{"href":8924,"rel":8925},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fapi\u002Fsend-emails\u002Frest-api\u002F",[389],[100,8927,8928],{},[385,8929,6161],{"href":5085,"rel":8930},[389],[100,8932,8933],{},[385,8934,8936],{"href":6285,"rel":8935},[389],"平台限制",[100,8938,8939],{},[385,8940,8943],{"href":8941,"rel":8942},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fplatform\u002Fpricing\u002F",[389],"价格说明",[100,8945,8946],{},[385,8947,8950],{"href":8948,"rel":8949},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Fconcepts\u002Femail-authentication\u002F",[389],"邮件认证与投递",[100,8952,8953],{},[385,8954,8957],{"href":8955,"rel":8956},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Femail-service\u002Flocal-development\u002Frouting\u002F",[389],"本地测试 Email Worker",[733,8959,8960],{},"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 .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":38,"searchDepth":737,"depth":737,"links":8962},[8963,8964,8968,8973,8979,8983,8984,8985,8986,8990,8991,8992,8993],{"id":6695,"depth":274,"text":6696},{"id":6781,"depth":274,"text":6782,"children":8965},[8966,8967],{"id":6788,"depth":737,"text":6789},{"id":6820,"depth":737,"text":6821},{"id":6847,"depth":274,"text":6848,"children":8969},[8970,8971],{"id":6854,"depth":737,"text":6854},{"id":6874,"depth":737,"text":8972},"发送邮件使用 cf-bounce 记录",{"id":6931,"depth":274,"text":6932,"children":8974},[8975,8977,8978],{"id":6956,"depth":737,"text":8976},"email() 是入站邮件入口",{"id":7422,"depth":737,"text":7423},{"id":7582,"depth":737,"text":7583},{"id":7638,"depth":274,"text":7639,"children":8980},[8981,8982],{"id":7754,"depth":737,"text":7755},{"id":7967,"depth":737,"text":7968},{"id":8299,"depth":274,"text":8300},{"id":8381,"depth":274,"text":8381},{"id":8515,"depth":274,"text":8515},{"id":8563,"depth":274,"text":8563,"children":8987},[8988,8989],{"id":8584,"depth":737,"text":8585},{"id":8641,"depth":737,"text":8642},{"id":8661,"depth":274,"text":8661},{"id":8785,"depth":274,"text":8786},{"id":8835,"depth":274,"text":8835},{"id":3287,"depth":274,"text":3287},"截至 2026 年 7 月，系统介绍 Cloudflare Email Service 如何接收、转发、解析、回复和发送事务邮件，并给出 Nuxt、Workers、D1 与安全限流实践。",{"src":8996,"alt":8997,"caption":8998,"width":8999,"height":9000},"\u002Fmedia\u002Fposts\u002Fcloudflare-email-service-send-receive-guide\u002Fcover-v1.webp","邮件经过全球边缘网络中的 Worker 节点完成接收、认证与发送","Cloudflare Email Service 正在把邮件从一台需要维护的服务器，变成一种可以由代码处理的事件",1600,900,{},"\u002Fblog\u002Fcloudflare-email-service-send-receive-guide","2026-07-28",{"title":6634,"description":8994},"blog\u002Fcloudflare-email-service-send-receive-guide",[3412,6629,9007,9008],"Workers","Serverless",{"src":9010,"poster":8996,"title":9011,"description":9012,"duration":9013,"width":3420,"height":3421},"\u002Fmedia\u002Fvideos\u002Fcloudflare-email-service-explained\u002Fcloudflare-email-service-explained-v1.mp4","Cloudflare Email Service 收发实战：11 分钟配置讲解","从 DNS、Email Routing、Email Worker 和 send_email binding，一路讲到 REST、SMTP、Nuxt、D1、五分钟验证码与生产检查。",661,"8xdTS_0QdYdFpDs8xaro-tFzQ7X8dtOlXKNmofjsMnA",{"id":9016,"title":9017,"accent":11,"body":9018,"description":10703,"draft":764,"experience":765,"extension":766,"featured":767,"image":10704,"meta":10708,"navigation":767,"path":10709,"publishedAt":10710,"readingTime":1714,"seo":10711,"stem":10712,"tags":10713,"updatedAt":765,"video":765,"__hash__":10717,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Fvue-3-6-rc-upgrade-guide.md","Vue 3.6 RC 升级全景：从 3.5 到 Vapor Mode 与 alien-signals",{"type":13,"value":9019,"toc":10664},[9020,9026,9039,9042,9051,9055,9195,9198,9203,9207,9210,9245,9248,9254,9258,9265,9268,9381,9384,9390,9395,9398,9401,9414,9420,9423,9426,9443,9446,9449,9465,9482,9485,9489,9492,9495,9503,9506,9510,9513,9636,9639,9667,9695,9698,9702,9705,9755,9758,9762,9769,9830,9833,9836,9842,9848,9851,9855,9858,9872,9881,9927,9934,9937,9949,9952,9956,9959,10096,10103,10109,10112,10199,10202,10236,10239,10252,10255,10259,10262,10265,10268,10272,10275,10279,10282,10285,10288,10292,10295,10309,10316,10333,10336,10362,10365,10369,10372,10392,10395,10399,10402,10425,10428,10432,10435,10438,10458,10461,10465,10468,10472,10475,10479,10482,10486,10489,10492,10574,10576,10579,10587,10590,10597,10600,10603,10606,10656,10661],[16,9021,9022,9023,76],{},"如果只看版本号，Vue 3.6 像是一次普通的次版本升级。但真正读完发布说明后，我的感受是：",[20,9024,9025],{},"它更像 Vue 为下一阶段准备的一次“换发动机”",[16,9027,9028,9029,434,9032,434,9035,9038],{},"一边是基于 alien-signals 重构的响应式内核，公开的 ",[25,9030,9031],{},"ref",[25,9033,9034],{},"computed",[25,9036,9037],{},"watch"," 用法基本不变；另一边是正式进入 RC 阶段的 Vapor Mode，它让 Vue 单文件组件可以绕过虚拟 DOM，编译为更直接的 DOM 更新逻辑。",[16,9040,9041],{},"不过，先把最重要的版本信息放在前面：",[1341,9043,9044],{},[16,9045,9046,9047,9050],{},"截至 2026 年 7 月 26 日，Vue 3.6 的最新版本是 ",[20,9048,9049],{},"3.6.0-rc.2","。RC 是候选发布版，不是稳定版。本文适合用来评估、试验和准备迁移，不代表所有生产项目都应该立即升级。",[81,9052,9054],{"id":9053},"先看结论36-改变了什么","先看结论：3.6 改变了什么",[140,9056,9057,9073],{},[143,9058,9059],{},[146,9060,9061,9064,9067,9070],{},[149,9062,9063],{},"对比维度",[149,9065,9066],{},"Vue 3.5",[149,9068,9069],{},"Vue 3.6 RC",[149,9071,9072],{},"对现有项目的实际影响",[159,9074,9075,9089,9103,9117,9131,9145,9163,9181],{},[146,9076,9077,9080,9083,9086],{},[164,9078,9079],{},"响应式系统",[164,9081,9082],{},"3.5 已做过内部优化",[164,9084,9085],{},"基于 alien-signals 大幅重构",[164,9087,9088],{},"常规 Composition API 写法通常不需要重写，但应回归复杂副作用与调度逻辑",[146,9090,9091,9094,9097,9100],{},[164,9092,9093],{},"默认渲染模式",[164,9095,9096],{},"Virtual DOM",[164,9098,9099],{},"仍然是 Virtual DOM",[164,9101,9102],{},"单纯升级依赖不会让应用自动进入 Vapor",[146,9104,9105,9108,9111,9114],{},[164,9106,9107],{},"Vapor Mode",[164,9109,9110],{},"尚未作为 3.5 的正式能力交付",[164,9112,9113],{},"功能集进入 RC，100% 显式启用",[164,9115,9116],{},"可以从单个性能敏感区域开始试验",[146,9118,9119,9122,9125,9128],{},[164,9120,9121],{},"应用体积",[164,9123,9124],{},"默认包含 VDOM runtime",[164,9126,9127],{},"纯 Vapor 应用可不引入 VDOM runtime",[164,9129,9130],{},"只有纯 Vapor 或边界清晰的场景，体积收益才容易兑现",[146,9132,9133,9136,9139,9142],{},[164,9134,9135],{},"API 覆盖",[164,9137,9138],{},"完整 Vue 3 API 生态",[164,9140,9141],{},"Vapor 支持 Vue API 的一个子集",[164,9143,9144],{},"Options API、实例代理相关能力不能直接迁移",[146,9146,9147,9150,9153,9160],{},[164,9148,9149],{},"VDOM \u002F Vapor 混用",[164,9151,9152],{},"不适用",[164,9154,9155,9156,9159],{},"通过 ",[25,9157,9158],{},"vaporInteropPlugin"," 互操作",[164,9161,9162],{},"标准 props、事件、slots 已覆盖，但复杂嵌套仍需测试",[146,9164,9165,9168,9171,9178],{},[164,9166,9167],{},"事件委托",[164,9169,9170],{},"原生监听语义",[164,9172,9173,9174,9177],{},"rc.2 起默认直接绑定；",[25,9175,9176],{},".delegate"," 才显式委托",[164,9179,9180],{},"不要沿用 rc.1 的默认委托认知",[146,9182,9183,9186,9189,9192],{},[164,9184,9185],{},"适合生产吗",[164,9187,9188],{},"稳定版",[164,9190,9191],{},"当前仍是预发布版",[164,9193,9194],{},"生产项目应等待稳定版和上层生态明确支持",[16,9196,9197],{},"一句话概括：",[16,9199,9200],{},[20,9201,9202],{},"Vue 3.5 的亮点更接近“开发体验和 SSR 能力升级”，Vue 3.6 的核心则是“响应式内核与渲染架构升级”。",[81,9204,9206],{"id":9205},"从-35-到-36变化重心已经不同","从 3.5 到 3.6：变化重心已经不同",[16,9208,9209],{},"Vue 3.5 没有破坏性变化，它带来的能力大多能直接被业务开发感知：",[97,9211,9212,9215,9221,9227,9233,9236,9242],{},[100,9213,9214],{},"响应式 Props 解构正式稳定；",[100,9216,9217,9220],{},[25,9218,9219],{},"useTemplateRef()"," 改善模板引用的组织方式；",[100,9222,9223,9226],{},[25,9224,9225],{},"onWatcherCleanup()"," 让 watcher 清理逻辑更自然；",[100,9228,9229,9232],{},[25,9230,9231],{},"useId()"," 提供 SSR 友好的稳定 ID；",[100,9234,9235],{},"异步组件支持懒激活（Lazy Hydration）；",[100,9237,9238,9241],{},[25,9239,9240],{},"data-allow-mismatch"," 可以声明允许的 hydration 差异；",[100,9243,9244],{},"Deferred Teleport 与自定义元素能力得到增强。",[16,9246,9247],{},"3.6 当然也包含大量修复，但它最值得关注的两件事——alien-signals 与 Vapor——都位于框架的底层。你可能不会在业务代码里多写一个新 API，却会面对新的性能特征、编译产物和运行时边界。",[16,9249,9250,9251],{},"这也决定了升级思路：",[20,9252,9253],{},"不要只检查“代码能不能编译”，还要检查响应式行为、SSR hydration、组件互操作和真实性能。",[81,9255,9257],{"id":9256},"变化一响应式内核切换到-alien-signals","变化一：响应式内核切换到 alien-signals",[16,9259,9260,9261,9264],{},"Vue 3.6 对 ",[25,9262,9263],{},"@vue\u002Freactivity"," 做了大规模重构，其基础来自 StackBlitz 开源的 alien-signals。",[16,9266,9267],{},"对于绝大多数业务开发者，下面这些代码仍然是熟悉的 Vue：",[33,9269,9271],{"className":5505,"code":9270,"language":5507,"meta":38,"style":38},"import { computed, ref, watchEffect } from 'vue'\n\nconst price = ref(99)\nconst count = ref(2)\nconst total = computed(() => price.value * count.value)\n\nwatchEffect(() => {\n  console.log(total.value)\n})\n",[25,9272,9273,9285,9289,9308,9326,9352,9356,9367,9377],{"__ignoreMap":38},[42,9274,9275,9277,9280,9282],{"class":44,"line":45},[42,9276,5514],{"class":277},[42,9278,9279],{"class":1797}," { computed, ref, watchEffect } ",[42,9281,5520],{"class":277},[42,9283,9284],{"class":56}," 'vue'\n",[42,9286,9287],{"class":44,"line":274},[42,9288,1581],{"emptyLinePlaceholder":767},[42,9290,9291,9293,9296,9298,9301,9303,9306],{"class":44,"line":737},[42,9292,5532],{"class":277},[42,9294,9295],{"class":52}," price",[42,9297,5538],{"class":277},[42,9299,9300],{"class":48}," ref",[42,9302,5731],{"class":1797},[42,9304,9305],{"class":52},"99",[42,9307,5899],{"class":1797},[42,9309,9310,9312,9315,9317,9319,9321,9324],{"class":44,"line":1584},[42,9311,5532],{"class":277},[42,9313,9314],{"class":52}," count",[42,9316,5538],{"class":277},[42,9318,9300],{"class":48},[42,9320,5731],{"class":1797},[42,9322,9323],{"class":52},"2",[42,9325,5899],{"class":1797},[42,9327,9328,9330,9333,9335,9338,9341,9343,9346,9349],{"class":44,"line":1603},[42,9329,5532],{"class":277},[42,9331,9332],{"class":52}," total",[42,9334,5538],{"class":277},[42,9336,9337],{"class":48}," computed",[42,9339,9340],{"class":1797},"(() ",[42,9342,8072],{"class":277},[42,9344,9345],{"class":1797}," price.value ",[42,9347,9348],{"class":277},"*",[42,9350,9351],{"class":1797}," count.value)\n",[42,9353,9354],{"class":44,"line":1618},[42,9355,1581],{"emptyLinePlaceholder":767},[42,9357,9358,9361,9363,9365],{"class":44,"line":1627},[42,9359,9360],{"class":48},"watchEffect",[42,9362,9340],{"class":1797},[42,9364,8072],{"class":277},[42,9366,6038],{"class":1797},[42,9368,9369,9372,9374],{"class":44,"line":1640},[42,9370,9371],{"class":1797},"  console.",[42,9373,7173],{"class":48},[42,9375,9376],{"class":1797},"(total.value)\n",[42,9378,9379],{"class":44,"line":1645},[42,9380,5638],{"class":1797},[16,9382,9383],{},"变化主要发生在内部：依赖如何建立、状态如何标记、计算属性何时失效、effect 如何沿依赖图传播，以及这些关系如何被清理和复用。",[16,9385,9386],{},[2024,9387],{"alt":9388,"src":9389},"由活跃与休眠路径组成的细粒度响应式依赖网络","\u002Fmedia\u002Fposts\u002Fvue-3-6-rc-upgrade-guide\u002Freactivity-core-v1.webp",[16,9391,9392],{},[2031,9393,9394],{},"这张图表达的是依赖传播的概念，不是 Vue 内部数据结构的逐字段示意。",[246,9396,9397],{"id":9397},"性能数字应该怎样理解",[16,9399,9400],{},"Vue 核心仓库的 alien-signals 合并 PR 给出过两类微基准结果：",[97,9402,9403,9411],{},[100,9404,9405,9406,434,9408,9410],{},"在大量 ",[25,9407,9031],{},[25,9409,9034],{}," 和 effect 实例的构造场景中，示例内存从约 2.3 MB 降至约 2.0 MB，约减少 13%；",[100,9412,9413],{},"在“一个源变化后，大量 computed 被读取”的特定压力场景中，性能提升可以超过 30 倍。",[16,9415,9416,9417],{},"第二个数字很亮眼，但不能直接翻译成“Vue 3.6 应用快 30 倍”。它证明的是：",[20,9418,9419],{},"新的依赖传播算法在某些过去成本会随规模显著上升的图结构中，复杂度表现更好。",[16,9421,9422],{},"真实页面还包含 DOM、布局、网络、组件库、序列化和业务计算。升级前后的判断应基于自己项目的 flame chart、交互延迟、内存曲线和打包结果，而不是套用框架微基准。",[246,9424,9425],{"id":9425},"哪些项目最可能感受到收益",[97,9427,9428,9431,9434,9437,9440],{},[100,9429,9430],{},"大量 computed 相互依赖的数据看板；",[100,9432,9433],{},"高频修改、筛选和派生状态很多的编辑器；",[100,9435,9436],{},"长时间运行、创建和销毁大量 effect 的工作台；",[100,9438,9439],{},"表单字段多、验证规则和联动关系复杂的应用；",[100,9441,9442],{},"基于 Vue 响应式原语构建的状态管理或工具库。",[16,9444,9445],{},"普通内容页也能得到内部改进，但用户未必会感知到明显差异。",[246,9447,9448],{"id":9448},"谁需要格外谨慎",[16,9450,9451,9452,434,9454,434,9457,434,9459,1369,9461,9464],{},"如果代码只使用公开的 ",[25,9453,9031],{},[25,9455,9456],{},"reactive",[25,9458,9034],{},[25,9460,9037],{},[25,9462,9463],{},"effectScope","，迁移风险相对可控。真正需要警惕的是：",[97,9466,9467,9470,9473,9476,9479],{},[100,9468,9469],{},"直接读取或修改 Vue 响应式内部字段；",[100,9471,9472],{},"依赖未公开的 effect、Dep 或 scheduler 实现细节；",[100,9474,9475],{},"自行 monkey patch 响应式函数；",[100,9477,9478],{},"对 watcher 执行顺序、同步刷新和清理时机有隐含假设；",[100,9480,9481],{},"库代码同时支持多个 Vue 次版本，却没有覆盖这些边界测试。",[16,9483,9484],{},"内部重构的意义，正是 Vue 可以在保持公开 API 的同时更换实现。反过来说，依赖内部实现的代码也最容易在这类升级中暴露问题。",[81,9486,9488],{"id":9487},"变化二vapor-mode-不再只是实验概念","变化二：Vapor Mode 不再只是实验概念",[16,9490,9491],{},"Vapor Mode 是 Vue SFC 的一种新编译模式。传统 Vue 模板会生成 VNode，由 Virtual DOM runtime 对比前后两棵树，再把差异更新到真实 DOM；Vapor 则让编译器提前识别动态部分，生成更直接、粒度更细的更新逻辑。",[16,9493,9494],{},"它的目标有两个：",[406,9496,9497,9500],{},[100,9498,9499],{},"降低应用的基础包体积；",[100,9501,9502],{},"减少运行时创建、遍历和比较 VNode 的成本。",[16,9504,9505],{},"官方发布说明称，Vapor 已在第三方 js-framework-benchmark 中达到与 Solid、Svelte 5 相近的级别。这个结论同样应该按“基准场景”理解，而不是把不同框架、不同应用的真实性能压缩成一条排名。",[246,9507,9509],{"id":9508},"vapor-不会在升级后自动开启","Vapor 不会在升级后自动开启",[16,9511,9512],{},"Vue 3.6 依然默认使用 VDOM。要让一个 SFC 使用 Vapor，需要显式标记：",[33,9514,9518],{"className":9515,"code":9516,"language":9517,"meta":38,"style":38},"language-vue shiki shiki-themes github-dark github-dark","\u003Cscript setup vapor lang=\"ts\">\nimport { ref } from 'vue'\n\nconst count = ref(0)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cbutton @click=\"count++\">\n    Count: {{ count }}\n  \u003C\u002Fbutton>\n\u003C\u002Ftemplate>\n","vue",[25,9519,9520,9543,9554,9558,9574,9583,9587,9596,9614,9619,9628],{"__ignoreMap":38},[42,9521,9522,9524,9527,9530,9533,9536,9538,9541],{"class":44,"line":45},[42,9523,7038],{"class":1797},[42,9525,9526],{"class":2086},"script",[42,9528,9529],{"class":48}," setup",[42,9531,9532],{"class":48}," vapor",[42,9534,9535],{"class":48}," lang",[42,9537,1845],{"class":1797},[42,9539,9540],{"class":56},"\"ts\"",[42,9542,7301],{"class":1797},[42,9544,9545,9547,9550,9552],{"class":44,"line":274},[42,9546,5514],{"class":277},[42,9548,9549],{"class":1797}," { ref } ",[42,9551,5520],{"class":277},[42,9553,9284],{"class":56},[42,9555,9556],{"class":44,"line":737},[42,9557,1581],{"emptyLinePlaceholder":767},[42,9559,9560,9562,9564,9566,9568,9570,9572],{"class":44,"line":1584},[42,9561,5532],{"class":277},[42,9563,9314],{"class":52},[42,9565,5538],{"class":277},[42,9567,9300],{"class":48},[42,9569,5731],{"class":1797},[42,9571,3678],{"class":52},[42,9573,5899],{"class":1797},[42,9575,9576,9579,9581],{"class":44,"line":1603},[42,9577,9578],{"class":1797},"\u003C\u002F",[42,9580,9526],{"class":2086},[42,9582,7301],{"class":1797},[42,9584,9585],{"class":44,"line":1618},[42,9586,1581],{"emptyLinePlaceholder":767},[42,9588,9589,9591,9594],{"class":44,"line":1627},[42,9590,7038],{"class":1797},[42,9592,9593],{"class":2086},"template",[42,9595,7301],{"class":1797},[42,9597,9598,9601,9604,9607,9609,9612],{"class":44,"line":1640},[42,9599,9600],{"class":1797},"  \u003C",[42,9602,9603],{"class":2086},"button",[42,9605,9606],{"class":48}," @click",[42,9608,1845],{"class":1797},[42,9610,9611],{"class":56},"\"count++\"",[42,9613,7301],{"class":1797},[42,9615,9616],{"class":44,"line":1645},[42,9617,9618],{"class":1797},"    Count: {{ count }}\n",[42,9620,9621,9624,9626],{"class":44,"line":1668},[42,9622,9623],{"class":1797},"  \u003C\u002F",[42,9625,9603],{"class":2086},[42,9627,7301],{"class":1797},[42,9629,9630,9632,9634],{"class":44,"line":1674},[42,9631,9578],{"class":1797},[42,9633,9593],{"class":2086},[42,9635,7301],{"class":1797},[16,9637,9638],{},"官方还支持两个等价入口：",[33,9640,9642],{"className":9515,"code":9641,"language":9517,"meta":38,"style":38},"\u003Cscript vapor>\n\u002F\u002F \u003Cscript setup vapor> 的简写\n\u003C\u002Fscript>\n",[25,9643,9644,9654,9659],{"__ignoreMap":38},[42,9645,9646,9648,9650,9652],{"class":44,"line":45},[42,9647,7038],{"class":1797},[42,9649,9526],{"class":2086},[42,9651,9532],{"class":48},[42,9653,7301],{"class":1797},[42,9655,9656],{"class":44,"line":274},[42,9657,9658],{"class":8151},"\u002F\u002F \u003Cscript setup vapor> 的简写\n",[42,9660,9661,9663,9665],{"class":44,"line":737},[42,9662,9578],{"class":1797},[42,9664,9526],{"class":2086},[42,9666,7301],{"class":1797},[33,9668,9670],{"className":9515,"code":9669,"language":9517,"meta":38,"style":38},"\u003Ctemplate vapor>\n  \u003C!-- 整个 SFC 使用 Vapor 编译 -->\n\u003C\u002Ftemplate>\n",[25,9671,9672,9682,9687],{"__ignoreMap":38},[42,9673,9674,9676,9678,9680],{"class":44,"line":45},[42,9675,7038],{"class":1797},[42,9677,9593],{"class":2086},[42,9679,9532],{"class":48},[42,9681,7301],{"class":1797},[42,9683,9684],{"class":44,"line":274},[42,9685,9686],{"class":8151},"  \u003C!-- 整个 SFC 使用 Vapor 编译 -->\n",[42,9688,9689,9691,9693],{"class":44,"line":737},[42,9690,9578],{"class":1797},[42,9692,9593],{"class":2086},[42,9694,7301],{"class":1797},[16,9696,9697],{},"也就是说，团队可以升级到 3.6 后继续运行现有 VDOM 应用，再有选择地迁移某个组件或页面。",[246,9699,9701],{"id":9700},"纯-vapor-应用","纯 Vapor 应用",[16,9703,9704],{},"如果应用完全由 Vapor 组件组成，可以使用：",[33,9706,9708],{"className":5505,"code":9707,"language":5507,"meta":38,"style":38},"import { createVaporApp } from 'vue'\nimport App from '.\u002FApp.vue'\n\ncreateVaporApp(App).mount('#app')\n",[25,9709,9710,9721,9733,9737],{"__ignoreMap":38},[42,9711,9712,9714,9717,9719],{"class":44,"line":45},[42,9713,5514],{"class":277},[42,9715,9716],{"class":1797}," { createVaporApp } ",[42,9718,5520],{"class":277},[42,9720,9284],{"class":56},[42,9722,9723,9725,9728,9730],{"class":44,"line":274},[42,9724,5514],{"class":277},[42,9726,9727],{"class":1797}," App ",[42,9729,5520],{"class":277},[42,9731,9732],{"class":56}," '.\u002FApp.vue'\n",[42,9734,9735],{"class":44,"line":737},[42,9736,1581],{"emptyLinePlaceholder":767},[42,9738,9739,9742,9745,9748,9750,9753],{"class":44,"line":1584},[42,9740,9741],{"class":48},"createVaporApp",[42,9743,9744],{"class":1797},"(App).",[42,9746,9747],{"class":48},"mount",[42,9749,5731],{"class":1797},[42,9751,9752],{"class":56},"'#app'",[42,9754,5899],{"class":1797},[16,9756,9757],{},"这种模式不需要引入 Virtual DOM runtime，最容易获得基础包体积收益。它更适合小型新应用、交互小工具，或可以明确约束技术栈的独立页面。",[246,9759,9761],{"id":9760},"在现有-vdom-应用中使用-vapor","在现有 VDOM 应用中使用 Vapor",[16,9763,9764,9765,9768],{},"现有 ",[25,9766,9767],{},"createApp()"," 项目需要安装互操作插件：",[33,9770,9772],{"className":5505,"code":9771,"language":5507,"meta":38,"style":38},"import { createApp, vaporInteropPlugin } from 'vue'\nimport App from '.\u002FApp.vue'\n\ncreateApp(App)\n  .use(vaporInteropPlugin)\n  .mount('#app')\n",[25,9773,9774,9785,9795,9799,9807,9818],{"__ignoreMap":38},[42,9775,9776,9778,9781,9783],{"class":44,"line":45},[42,9777,5514],{"class":277},[42,9779,9780],{"class":1797}," { createApp, vaporInteropPlugin } ",[42,9782,5520],{"class":277},[42,9784,9284],{"class":56},[42,9786,9787,9789,9791,9793],{"class":44,"line":274},[42,9788,5514],{"class":277},[42,9790,9727],{"class":1797},[42,9792,5520],{"class":277},[42,9794,9732],{"class":56},[42,9796,9797],{"class":44,"line":737},[42,9798,1581],{"emptyLinePlaceholder":767},[42,9800,9801,9804],{"class":44,"line":1584},[42,9802,9803],{"class":48},"createApp",[42,9805,9806],{"class":1797},"(App)\n",[42,9808,9809,9812,9815],{"class":44,"line":1603},[42,9810,9811],{"class":1797},"  .",[42,9813,9814],{"class":48},"use",[42,9816,9817],{"class":1797},"(vaporInteropPlugin)\n",[42,9819,9820,9822,9824,9826,9828],{"class":44,"line":1618},[42,9821,9811],{"class":1797},[42,9823,9747],{"class":48},[42,9825,5731],{"class":1797},[42,9827,9752],{"class":56},[42,9829,5899],{"class":1797},[16,9831,9832],{},"反过来，Vapor 应用也能安装这个插件以容纳 VDOM 组件，但这样会重新带入 VDOM runtime，削弱体积优势。",[16,9834,9835],{},"Render Function 与 JSX 编写的组件仍然属于 VDOM 组件。在纯 Vapor 应用中使用它们，同样需要互操作。",[16,9837,9838],{},[2024,9839],{"alt":9840,"src":9841},"现有 VDOM 区域通过明确的互操作边界连接到独立 Vapor 区域","\u002Fmedia\u002Fposts\u002Fvue-3-6-rc-upgrade-guide\u002Fvapor-boundaries-v1.webp",[16,9843,9844,9845],{},"我的建议与官方一致：",[20,9846,9847],{},"把两种渲染模式组织成边界清楚的区域，而不是在每一层组件中来回套娃。",[16,9849,9850],{},"例如，把一个高频更新的数据画布做成 Vapor 页面或子树，周围的后台导航、弹窗和成熟组件库继续使用 VDOM。这样的收益、故障范围和回退路径都更容易测量。",[81,9852,9854],{"id":9853},"rc2-最容易踩坑的变化事件委托改为显式启用","rc.2 最容易踩坑的变化：事件委托改为显式启用",[16,9856,9857],{},"如果你在 rc.1 阶段已经试过 Vapor，这一节尤其重要。",[16,9859,9860,9861,9864,9865,9868,9869,9871],{},"rc.1 的说明中，符合条件的事件会自动委托到 ",[25,9862,9863],{},"document","。但这与原生 DOM 和标准 Vue 的行为存在差异：如果祖先元素提前调用 ",[25,9866,9867],{},"stopPropagation()","，事件到不了 ",[25,9870,9863],{},"，子元素被委托的 handler 也不会执行。",[16,9873,9874,9875,9877,9878,9880],{},"从 ",[20,9876,9049],{}," 开始，Vapor 默认把 DOM 监听器直接绑定到元素。只有明确希望使用文档级委托时，才添加 Vapor 专用的 ",[25,9879,9176],{}," 修饰符：",[33,9882,9884],{"className":9515,"code":9883,"language":9517,"meta":38,"style":38},"\u003Cbutton @click.delegate=\"onClick\">\n  保存\n\u003C\u002Fbutton>\n",[25,9885,9886,9914,9919],{"__ignoreMap":38},[42,9887,9888,9890,9892,9895,9898,9900,9903,9905,9907,9910,9912],{"class":44,"line":45},[42,9889,7038],{"class":1797},[42,9891,9603],{"class":2086},[42,9893,9894],{"class":1797}," @",[42,9896,9897],{"class":48},"click",[42,9899,1791],{"class":1797},[42,9901,9902],{"class":48},"delegate",[42,9904,1845],{"class":1797},[42,9906,1869],{"class":56},[42,9908,9909],{"class":1797},"onClick",[42,9911,1869],{"class":56},[42,9913,7301],{"class":1797},[42,9915,9916],{"class":44,"line":274},[42,9917,9918],{"class":1797},"  保存\n",[42,9920,9921,9923,9925],{"class":44,"line":737},[42,9922,9578],{"class":1797},[42,9924,9603],{"class":2086},[42,9926,7301],{"class":1797},[16,9928,9929,9930,9933],{},"同时，旧的 ",[25,9931,9932],{},"compilerOptions.eventDelegation"," 已被移除。",[16,9935,9936],{},"迁移时应该做两件事：",[406,9938,9939,9944],{},[100,9940,9941,9942,367],{},"删除已有的 ",[25,9943,9932],{},[100,9945,9946,9947,76],{},"只对数量大、事件稳定，并且传播路径已验证的监听器使用 ",[25,9948,9176],{},[16,9950,9951],{},"这不是一个适合全局机械替换的优化。事件是否值得委托，要结合节点数量、生命周期和传播语义判断。",[81,9953,9955],{"id":9954},"vapor-与标准-vue-的兼容边界","Vapor 与标准 Vue 的兼容边界",[16,9957,9958],{},"Vapor 的目标不是在第一天覆盖 Vue 的所有历史能力。官方明确说明，它支持现有 Vue API 的一个子集；依赖 VNode 或组件公开实例代理的功能，目前不能直接使用。",[140,9960,9961,9973],{},[143,9962,9963],{},[146,9964,9965,9967,9970],{},[149,9966,7320],{},[149,9968,9969],{},"VDOM 组件",[149,9971,9972],{},"Vapor 组件",[159,9974,9975,9986,9996,10007,10023,10034,10046,10058,10085],{},[146,9976,9977,9980,9983],{},[164,9978,9979],{},"Composition API",[164,9981,9982],{},"支持",[164,9984,9985],{},"主要编程方式",[146,9987,9988,9991,9993],{},[164,9989,9990],{},"Options API",[164,9992,9982],{},[164,9994,9995],{},"不支持",[146,9997,9998,10003,10005],{},[164,9999,10000],{},[25,10001,10002],{},"app.config.globalProperties",[164,10004,9982],{},[164,10006,9995],{},[146,10008,10009,10014,10017],{},[164,10010,10011],{},[25,10012,10013],{},"getCurrentInstance()",[164,10015,10016],{},"返回当前实例",[164,10018,10019,10020],{},"返回 ",[25,10021,10022],{},"null",[146,10024,10025,10028,10031],{},[164,10026,10027],{},"Render Function \u002F JSX",[164,10029,10030],{},"原生 VDOM",[164,10032,10033],{},"仍是 VDOM，混用需要 interop",[146,10035,10036,10041,10043],{},[164,10037,10038],{},[25,10039,10040],{},"v-memo",[164,10042,9982],{},[164,10044,10045],{},"不适用 \u002F 不支持",[146,10047,10048,10054,10056],{},[164,10049,10050,10053],{},[25,10051,10052],{},"@vue:xxx"," 元素生命周期事件",[164,10055,9982],{},[164,10057,9995],{},[146,10059,10060,10063,10066],{},[164,10061,10062],{},"组件模板引用",[164,10064,10065],{},"暴露公开实例能力",[164,10067,10068,10069,434,10072,434,10075,434,10078,434,10081,10084],{},"不暴露 ",[25,10070,10071],{},"$el",[25,10073,10074],{},"$props",[25,10076,10077],{},"$attrs",[25,10079,10080],{},"$slots",[25,10082,10083],{},"$refs"," 等属性",[146,10086,10087,10090,10093],{},[164,10088,10089],{},"自定义指令",[164,10091,10092],{},"标准指令生命周期对象",[164,10094,10095],{},"使用 Vapor 专用函数接口",[246,10097,10099,10102],{"id":10098},"slotsdefault-不能再当作无副作用探测",[25,10100,10101],{},"slots.default()"," 不能再当作无副作用探测",[16,10104,10105,10106,10108],{},"在 VDOM 世界中，一些组件会先调用 ",[25,10107,10101],{},"，检查返回内容，再决定渲染 fallback。Vapor 中调用 slot 可能直接创建 Block 和 DOM、注册响应式 effect，或在 hydration 时认领已有 SSR DOM。",[16,10110,10111],{},"因此不要这样写：",[33,10113,10115],{"className":9515,"code":10114,"language":9517,"meta":38,"style":38},"\u003Cscript setup vapor>\nimport { useSlots } from 'vue'\n\nconst slots = useSlots()\nconst content = slots.default?.()\nconst showFallback = !content\n\u003C\u002Fscript>\n",[25,10116,10117,10129,10140,10144,10158,10176,10191],{"__ignoreMap":38},[42,10118,10119,10121,10123,10125,10127],{"class":44,"line":45},[42,10120,7038],{"class":1797},[42,10122,9526],{"class":2086},[42,10124,9529],{"class":48},[42,10126,9532],{"class":48},[42,10128,7301],{"class":1797},[42,10130,10131,10133,10136,10138],{"class":44,"line":274},[42,10132,5514],{"class":277},[42,10134,10135],{"class":1797}," { useSlots } ",[42,10137,5520],{"class":277},[42,10139,9284],{"class":56},[42,10141,10142],{"class":44,"line":737},[42,10143,1581],{"emptyLinePlaceholder":767},[42,10145,10146,10148,10151,10153,10156],{"class":44,"line":1584},[42,10147,5532],{"class":277},[42,10149,10150],{"class":52}," slots",[42,10152,5538],{"class":277},[42,10154,10155],{"class":48}," useSlots",[42,10157,7063],{"class":1797},[42,10159,10160,10162,10165,10167,10170,10173],{"class":44,"line":1603},[42,10161,5532],{"class":277},[42,10163,10164],{"class":52}," content",[42,10166,5538],{"class":277},[42,10168,10169],{"class":1797}," slots.",[42,10171,10172],{"class":48},"default",[42,10174,10175],{"class":1797},"?.()\n",[42,10177,10178,10180,10183,10185,10188],{"class":44,"line":1618},[42,10179,5532],{"class":277},[42,10181,10182],{"class":52}," showFallback",[42,10184,5538],{"class":277},[42,10186,10187],{"class":277}," !",[42,10189,10190],{"class":1797},"content\n",[42,10192,10193,10195,10197],{"class":44,"line":1627},[42,10194,9578],{"class":1797},[42,10196,9526],{"class":2086},[42,10198,7301],{"class":1797},[16,10200,10201],{},"让模板负责 slot 的实际渲染，通常更安全：",[33,10203,10205],{"className":9515,"code":10204,"language":9517,"meta":38,"style":38},"\u003Ctemplate>\n  \u003Cslot \u002F>\n\u003C\u002Ftemplate>\n",[25,10206,10207,10215,10228],{"__ignoreMap":38},[42,10208,10209,10211,10213],{"class":44,"line":45},[42,10210,7038],{"class":1797},[42,10212,9593],{"class":2086},[42,10214,7301],{"class":1797},[42,10216,10217,10219,10222,10226],{"class":44,"line":274},[42,10218,9600],{"class":1797},[42,10220,10221],{"class":2086},"slot",[42,10223,10225],{"class":10224},"sX7ps"," \u002F",[42,10227,7301],{"class":1797},[42,10229,10230,10232,10234],{"class":44,"line":737},[42,10231,9578],{"class":1797},[42,10233,9593],{"class":2086},[42,10235,7301],{"class":1797},[246,10237,10238],{"id":10238},"自定义指令需要单独迁移",[16,10240,10241,10242,434,10245,434,10248,10251],{},"Vapor 自定义指令接收节点和响应式 getter，并且可以返回 cleanup 函数。它不是把 VDOM 指令的 ",[25,10243,10244],{},"mounted",[25,10246,10247],{},"updated",[25,10249,10250],{},"unmounted"," 对象原样搬过去。",[16,10253,10254],{},"如果项目或组件库依赖大量自定义指令，应该把它们列成独立迁移项，逐个验证响应式更新、清理和 SSR 行为。",[81,10256,10258],{"id":10257},"nuxtrouterpinia-和组件库应该怎么看","Nuxt、Router、Pinia 和组件库应该怎么看",[16,10260,10261],{},"Vue 核心可用，不代表上层框架已经自动完成 Vapor 集成。",[16,10263,10264],{},"Vue 官方的 Vapor Roadmap 单独列出了 Router、Pinia、Nuxt、DevTools 和 Vue Test Utils 等生态工作，其中 Nuxt 还依赖 SSR hydration 能力。对真实项目来说，编译器能否识别 Vapor、服务端产物如何生成、客户端如何 hydration、开发工具怎样检查组件，都是完整链路的一部分。",[16,10266,10267],{},"因此我会把项目分成两类：",[246,10269,10271],{"id":10270},"普通-vue-vite-应用","普通 Vue \u002F Vite 应用",[16,10273,10274],{},"可以在分支中升级 3.6 RC，先验证现有 VDOM 模式，再挑一个叶子页面试用 Vapor。依赖关系比较直接，问题也更容易归因。",[246,10276,10278],{"id":10277},"nuxtssr-或重度组件库应用","Nuxt、SSR 或重度组件库应用",[16,10280,10281],{},"不要仅通过 package override 强行把底层 Vue 换成 RC，就把它视为完成 Vapor 升级。应该先确认当前框架、构建插件和 SSR 链路明确支持对应版本，再进行 hydration、路由切换、异步组件和组件库互操作测试。",[16,10283,10284],{},"即使暂时不使用 Vapor，测试 Vue 3.6 的新响应式内核仍然有价值；只是这类试验最好留在独立分支或测试环境。",[81,10286,10287],{"id":10287},"一套更稳妥的升级路线",[246,10289,10291],{"id":10290},"第一阶段只升级依赖不启用-vapor","第一阶段：只升级依赖，不启用 Vapor",[16,10293,10294],{},"普通 Vue \u002F Vite 项目可以在实验分支中安装 RC：",[33,10296,10298],{"className":35,"code":10297,"language":37,"meta":38,"style":38},"pnpm add vue@3.6.0-rc.2\n",[25,10299,10300],{"__ignoreMap":38},[42,10301,10302,10304,10306],{"class":44,"line":45},[42,10303,5462],{"class":48},[42,10305,5465],{"class":56},[42,10307,10308],{"class":56}," vue@3.6.0-rc.2\n",[16,10310,10311,10312,10315],{},"如果项目直接依赖 ",[25,10313,10314],{},"@vue\u002Fserver-renderer","，让它与 Vue 保持同一版本：",[33,10317,10319],{"className":35,"code":10318,"language":37,"meta":38,"style":38},"pnpm add vue@3.6.0-rc.2 @vue\u002Fserver-renderer@3.6.0-rc.2\n",[25,10320,10321],{"__ignoreMap":38},[42,10322,10323,10325,10327,10330],{"class":44,"line":45},[42,10324,5462],{"class":48},[42,10326,5465],{"class":56},[42,10328,10329],{"class":56}," vue@3.6.0-rc.2",[42,10331,10332],{"class":56}," @vue\u002Fserver-renderer@3.6.0-rc.2\n",[16,10334,10335],{},"然后先验证原有 VDOM 应用：",[97,10337,10338,10341,10344,10347,10350,10353,10356,10359],{},[100,10339,10340],{},"单元测试与组件测试；",[100,10342,10343],{},"首屏 SSR 与客户端 hydration；",[100,10345,10346],{},"watch、computed、effectScope 的复杂场景；",[100,10348,10349],{},"快速路由切换后的副作用清理；",[100,10351,10352],{},"自定义指令、Teleport、Suspense 和异步组件；",[100,10354,10355],{},"开发与生产构建；",[100,10357,10358],{},"浏览器控制台 warning 和错误；",[100,10360,10361],{},"关键交互的耗时与内存基线。",[16,10363,10364],{},"如果这一阶段失败，问题大概率来自新响应式实现或 RC 回归，而不是 Vapor，定位会简单得多。",[246,10366,10368],{"id":10367},"第二阶段选择一个边界清楚的区域","第二阶段：选择一个边界清楚的区域",[16,10370,10371],{},"合适的试点通常同时满足：",[97,10373,10374,10377,10380,10383,10386,10389],{},[100,10375,10376],{},"使用 Composition API；",[100,10378,10379],{},"很少依赖组件实例；",[100,10381,10382],{},"不依赖复杂的 VDOM 组件库；",[100,10384,10385],{},"更新频率高，确实存在性能压力；",[100,10387,10388],{},"有独立路由或明确的组件树边界；",[100,10390,10391],{},"有可重复的性能测试。",[16,10393,10394],{},"先记录迁移前的 JavaScript 体积、交互延迟、CPU 时间和内存，再启用 Vapor。没有基线，就很容易把“技术上成功运行”误判成“值得迁移”。",[246,10396,10398],{"id":10397},"第三阶段验证互操作与-ssr","第三阶段：验证互操作与 SSR",[16,10400,10401],{},"至少覆盖这些组合：",[406,10403,10404,10407,10410,10413,10416,10419,10422],{},[100,10405,10406],{},"VDOM 父组件嵌套 Vapor 子组件；",[100,10408,10409],{},"Vapor 父组件嵌套 VDOM 子组件；",[100,10411,10412],{},"props、emit 与双向绑定；",[100,10414,10415],{},"默认 slot、具名 slot 与作用域 slot；",[100,10417,10418],{},"Suspense、异步组件与错误边界；",[100,10420,10421],{},"服务端输出、hydration 和 mismatch 恢复；",[100,10423,10424],{},"组件卸载后的 watcher、事件与指令 cleanup。",[16,10426,10427],{},"rc.2 的修复列表中有相当多内容正好集中在 hydration、Suspense、slot anchor 和 VDOM interop。这说明这些区域正在快速收敛，也说明它们是当前最值得测试的地方。",[246,10429,10431],{"id":10430},"第四阶段等稳定版再决定生产节奏","第四阶段：等稳定版再决定生产节奏",[16,10433,10434],{},"RC 的意义是功能集已经接近确定，接下来集中发现回归和边缘问题；它不等于生态已经完全稳定。",[16,10436,10437],{},"生产项目至少应该同时满足：",[97,10439,10440,10443,10446,10449,10452,10455],{},[100,10441,10442],{},"Vue 3.6 正式版已发布；",[100,10444,10445],{},"使用的框架和构建工具明确支持；",[100,10447,10448],{},"核心组件库通过互操作验证；",[100,10450,10451],{},"DevTools、测试工具和 SSR 链路可用；",[100,10453,10454],{},"性能收益足以覆盖迁移与维护成本；",[100,10456,10457],{},"有随时退回 VDOM 的方案。",[81,10459,10460],{"id":10460},"不建议做的四件事",[246,10462,10464],{"id":10463},"_1-不要把-rc-当成普通补丁版","1. 不要把 RC 当成普通补丁版",[16,10466,10467],{},"预发布阶段的行为仍可能调整。rc.1 到 rc.2 的事件委托变化，就是最直接的例子。",[246,10469,10471],{"id":10470},"_2-不要为了用上-vapor迁移全部组件","2. 不要为了“用上 Vapor”迁移全部组件",[16,10473,10474],{},"导航、静态内容、低频设置页未必能获得有意义的收益。优先迁移性能瓶颈，而不是追求迁移比例。",[246,10476,10478],{"id":10477},"_3-不要把微基准数字当作应用承诺","3. 不要把微基准数字当作应用承诺",[16,10480,10481],{},"“超过 30 倍”来自特定响应式压力场景；“与 Solid、Svelte 5 同级”来自第三方基准。它们适合证明技术方向，不适合替代项目测量。",[246,10483,10485],{"id":10484},"_4-不要忽略生态边界","4. 不要忽略生态边界",[16,10487,10488],{},"Vue 核心、SFC 编译器、Vite 插件、Nuxt、SSR、Router、Pinia、组件库、DevTools 和测试工具共同组成开发体验。只让其中一个包进入 RC 版本，不代表整条链路已经升级完毕。",[81,10490,10491],{"id":10491},"我的项目类型建议",[140,10493,10494,10506],{},[143,10495,10496],{},[146,10497,10498,10501,10504],{},[149,10499,10500],{},"项目类型",[149,10502,10503],{},"现在是否值得试",[149,10505,3645],{},[159,10507,10508,10519,10530,10541,10552,10563],{},[146,10509,10510,10513,10516],{},[164,10511,10512],{},"小型新工具、内部实验",[164,10514,10515],{},"值得",[164,10517,10518],{},"可以尝试纯 Vapor，重点测包体积与交互",[146,10520,10521,10524,10527],{},[164,10522,10523],{},"已有 Vue SPA",[164,10525,10526],{},"值得在分支试",[164,10528,10529],{},"先保持 VDOM，再迁移单个性能敏感页面",[146,10531,10532,10535,10538],{},[164,10533,10534],{},"Nuxt \u002F SSR 站点",[164,10536,10537],{},"适合研究，生产宜等待",[164,10539,10540],{},"先确认框架支持，重点测 hydration",[146,10542,10543,10546,10549],{},[164,10544,10545],{},"组件库",[164,10547,10548],{},"应尽早做兼容测试",[164,10550,10551],{},"检查实例代理、slots、自定义指令和 interop",[146,10553,10554,10557,10560],{},[164,10555,10556],{},"大型数据看板 \u002F 编辑器",[164,10558,10559],{},"很值得压测",[164,10561,10562],{},"单独比较 alien-signals 与 Vapor 各自带来的收益",[146,10564,10565,10568,10571],{},[164,10566,10567],{},"内容型官网",[164,10569,10570],{},"优先级较低",[164,10572,10573],{},"等稳定版和生态成熟，避免为有限收益增加复杂度",[81,10575,8835],{"id":8835},[16,10577,10578],{},"Vue 3.6 RC 最有价值的地方，不是提供了多少个可以立刻写进业务的新 API，而是同时推进了两个长期问题：",[97,10580,10581,10584],{},[100,10582,10583],{},"Vue 的响应式系统能否在更复杂的依赖图中保持高效；",[100,10585,10586],{},"Vue 的模板能否在保留熟悉开发体验的同时，摆脱必须依赖虚拟 DOM 的成本。",[16,10588,10589],{},"alien-signals 回答第一个问题，Vapor 尝试回答第二个问题。",[16,10591,10592,10593,10596],{},"对于现有项目，我认为最合理的策略是：",[20,10594,10595],{},"先把 3.6 当作响应式内核升级来测试，再把 Vapor 当作可选的局部编译模式来评估。"," 两件事分开验证，才能知道性能变化来自哪里，也能把风险控制在清晰的边界内。",[16,10598,10599],{},"Vue 3.6 还没有到“所有人今天就应该升级”的阶段，但它已经到了“值得所有 Vue 团队开始理解”的阶段。",[10601,10602],"hr",{},[81,10604,10605],{"id":10605},"资料说明",[97,10607,10608,10616,10624,10632,10640,10648],{},[100,10609,10610,10615],{},[385,10611,10614],{"href":10612,"rel":10613},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fblob\u002Fminor\u002FCHANGELOG.md",[389],"Vue 3.6 minor 分支 Changelog","：3.6.0-rc.1 的 Vapor 总览、兼容边界，以及 rc.2 的事件委托变更。",[100,10617,10618,10623],{},[385,10619,10622],{"href":10620,"rel":10621},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Freleases\u002Ftag\u002Fv3.6.0-rc.2",[389],"Vue 3.6.0-rc.2 Release","：本文发布时的最新候选版本。",[100,10625,10626,10631],{},[385,10627,10630],{"href":10628,"rel":10629},"https:\u002F\u002Fblog.vuejs.org\u002Fposts\u002Fvue-3-5",[389],"Vue 3.5 发布说明","：Vue 3.5 的响应式优化、SSR 与开发者 API 基线。",[100,10633,10634,10639],{},[385,10635,10638],{"href":10636,"rel":10637},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fpull\u002F12349",[389],"Vue core：alien-signals 重构 PR","：响应式实现、内存与特定压力场景微基准。",[100,10641,10642,10647],{},[385,10643,10646],{"href":10644,"rel":10645},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fissues\u002F13687",[389],"Vapor Mode Roadmap","：核心能力与 Vue 生态集成路线。",[100,10649,10650,10655],{},[385,10651,10654],{"href":10652,"rel":10653},"https:\u002F\u002Fgithub.com\u002Fkrausest\u002Fjs-framework-benchmark",[389],"js-framework-benchmark","：Vue 官方发布说明引用的第三方前端框架基准。",[1341,10657,10658],{},[16,10659,10660],{},"本文中的升级建议是我基于官方资料和工程经验做出的判断，文章由 AI 协助资料整理与语言校对；具体项目仍应以自身测试结果为准。",[733,10662,10663],{},"html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--shiki-dark:#9ECBFF}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}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 .sxg3X, html code.shiki .sxg3X{--shiki-default:#85E89D;--shiki-dark:#85E89D}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sX7ps, html code.shiki .sX7ps{--shiki-default:#FDAEB7;--shiki-default-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic}",{"title":38,"searchDepth":737,"depth":737,"links":10665},[10666,10667,10668,10673,10678,10679,10684,10688,10694,10700,10701,10702],{"id":9053,"depth":274,"text":9054},{"id":9205,"depth":274,"text":9206},{"id":9256,"depth":274,"text":9257,"children":10669},[10670,10671,10672],{"id":9397,"depth":737,"text":9397},{"id":9425,"depth":737,"text":9425},{"id":9448,"depth":737,"text":9448},{"id":9487,"depth":274,"text":9488,"children":10674},[10675,10676,10677],{"id":9508,"depth":737,"text":9509},{"id":9700,"depth":737,"text":9701},{"id":9760,"depth":737,"text":9761},{"id":9853,"depth":274,"text":9854},{"id":9954,"depth":274,"text":9955,"children":10680},[10681,10683],{"id":10098,"depth":737,"text":10682},"slots.default() 不能再当作无副作用探测",{"id":10238,"depth":737,"text":10238},{"id":10257,"depth":274,"text":10258,"children":10685},[10686,10687],{"id":10270,"depth":737,"text":10271},{"id":10277,"depth":737,"text":10278},{"id":10287,"depth":274,"text":10287,"children":10689},[10690,10691,10692,10693],{"id":10290,"depth":737,"text":10291},{"id":10367,"depth":737,"text":10368},{"id":10397,"depth":737,"text":10398},{"id":10430,"depth":737,"text":10431},{"id":10460,"depth":274,"text":10460,"children":10695},[10696,10697,10698,10699],{"id":10463,"depth":737,"text":10464},{"id":10470,"depth":737,"text":10471},{"id":10477,"depth":737,"text":10478},{"id":10484,"depth":737,"text":10485},{"id":10491,"depth":274,"text":10491},{"id":8835,"depth":274,"text":8835},{"id":10605,"depth":274,"text":10605},"截至 Vue 3.6.0-rc.2，系统对比 Vue 3.5 与 3.6 的响应式内核、Vapor Mode、事件委托、互操作边界和渐进升级策略。",{"src":10705,"alt":10706,"caption":10707,"width":8999,"height":9000},"\u002Fmedia\u002Fposts\u002Fvue-3-6-rc-upgrade-guide\u002Fcover-v1.webp","Vue 3.5 的虚拟 DOM 组件树经过晶体通道转向 Vue 3.6 的细粒度信号网络","Vue 3.6 RC 更像一次编译器与运行时的架构换挡，而不是一份新 API 清单",{},"\u002Fblog\u002Fvue-3-6-rc-upgrade-guide","2026-07-26",{"title":9017,"description":10703},"blog\u002Fvue-3-6-rc-upgrade-guide",[10714,10715,10716],"Vue","JavaScript","前端工程","L8sIMfVAKUKqWTcVSvsseRJeOmYbzw7h958oo1EPLCo",{"id":10719,"title":10720,"accent":3426,"body":10721,"description":10955,"draft":764,"experience":10956,"extension":766,"featured":767,"image":10961,"meta":10965,"navigation":767,"path":10966,"publishedAt":10967,"readingTime":1640,"seo":10968,"stem":10969,"tags":10970,"updatedAt":765,"video":765,"__hash__":10974,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Ffrom-gpt-4-invite-to-agents.md","从一封 GPT‑4 邀请邮件，到智能体时代",{"type":13,"value":10722,"toc":10947},[10723,10726,10729,10734,10741,10747,10750,10753,10757,10760,10763,10769,10772,10775,10778,10781,10795,10798,10815,10821,10824,10827,10831,10834,10837,10851,10854,10868,10871,10875,10878,10881,10898,10901,10905,10908,10911,10914,10917,10919,10921],[16,10724,10725],{},"今天整理旧邮件时，我重新翻到了一封 2023 年 3 月 18 日的邮件。",[16,10727,10728],{},"邮件的标题很直接：",[1341,10730,10731],{},[16,10732,10733],{},"You’re invited to use the OpenAI GPT-4 API.",[16,10735,10736,10737,10740],{},"正文告诉我，已经可以通过 API 使用拥有 ",[20,10738,10739],{},"8K context"," 的 GPT‑4。",[16,10742,10743],{},[2024,10744],{"alt":10745,"src":10746},"2023 年 GPT‑4 API 邀请邮件，正文说明可使用 8K context","\u002Fmedia\u002Fposts\u002Ffrom-gpt-4-invite-to-agents\u002Femail-invite-v1.webp",[16,10748,10749],{},"那时 GPT‑4 才刚发布几天。API 需要申请、等待，再由 OpenAI 逐步邀请开发者。官方发布资料里，标准 GPT‑4 的上下文长度是 8,192 tokens，同时有限开放 32,768-token 版本。对当时的我来说，一次 API 访问资格本身，就像一扇刚刚推开的小门。",[16,10751,10752],{},"三年多以后再看，这封邮件已经不像一封产品通知，更像一枚时间胶囊。",[81,10754,10756],{"id":10755},"数字变大只是最容易看到的变化","数字变大，只是最容易看到的变化",[16,10758,10759],{},"早期开发者熟悉的是 4K、8K 量级的上下文。写提示词时，我们会计算还剩多少空间，主动裁剪资料，把长文拆成小段，再想办法让下一次请求接住上一次的结果。",[16,10761,10762],{},"到了 2026 年，百万级上下文已经进入主流前沿模型。OpenAI 当前模型目录列出的 GPT‑5.6 系列拥有约 1.05M context window；Google 的长上下文文档也说明，多款 Gemini 模型支持 1M 或更多 tokens。",[16,10764,10765],{},[2024,10766],{"alt":10767,"src":10768},"从 4K、8K 到 1M 以上，上下文从短纸条延伸为多模态资料长廊","\u002Fmedia\u002Fposts\u002Ffrom-gpt-4-invite-to-agents\u002Fcontext-window-v1.webp",[16,10770,10771],{},"这不只是“能塞进更多文字”。代码、图片、音频、视频和大批文档可以共同成为一次任务的背景。以前必须先切碎、索引和摘要的资料，现在有更多机会被放进同一个工作上下文。",[16,10773,10774],{},"但更长的上下文不等于完美记忆，也不等于模型会自动抓住重点。资料越多，噪声、成本、延迟和错误关联也可能越多。真正重要的能力，仍然是选择什么信息、以什么顺序提供，以及如何验证模型是否理解了关键约束。",[81,10776,10777],{"id":10777},"真正改变的是交互单位",[16,10779,10780],{},"三年前，我最常做的是“提问—回答”：",[406,10782,10783,10786,10789,10792],{},[100,10784,10785],{},"写一段提示词；",[100,10787,10788],{},"等待模型生成；",[100,10790,10791],{},"复制结果；",[100,10793,10794],{},"自己完成剩余工作。",[16,10796,10797],{},"今天，越来越多任务变成了“目标—上下文—行动—验证”：",[406,10799,10800,10803,10806,10809,10812],{},[100,10801,10802],{},"说明最终目标与完成标准；",[100,10804,10805],{},"提供代码库、文档和当前状态；",[100,10807,10808],{},"允许模型调用搜索、文件、终端或其他工具；",[100,10810,10811],{},"让它执行一连串相互依赖的步骤；",[100,10813,10814],{},"用测试、构建、差异检查或人工评审确认结果。",[16,10816,10817],{},[2024,10818],{"alt":10819,"src":10820},"聊天框扩展为包含文档、代码、浏览器、工具调用和人工确认的智能体工作流","\u002Fmedia\u002Fposts\u002Ffrom-gpt-4-invite-to-agents\u002Fagent-workflow-v1.webp",[16,10822,10823],{},"OpenAI 当前的模型与 Responses API 文档已经把函数调用、网页搜索、文件搜索和计算机操作列为可用工具。模型不再只告诉我们“可以怎么做”，而是开始在获得授权的范围内参与真实工作过程。",[16,10825,10826],{},"这也是我认为“聊天模型”和“智能体”之间最重要的区别：前者交付一段内容，后者推进一个状态会变化的任务。",[81,10828,10830],{"id":10829},"程序员的工作没有消失而是向两端移动","程序员的工作没有消失，而是向两端移动",[16,10832,10833],{},"当模型可以读代码、改文件、运行命令和检查结果时，程序员并不是突然变得不重要。相反，我们的注意力开始向任务的两端移动。",[16,10835,10836],{},"在行动之前，需要把问题定义得更清楚：",[97,10838,10839,10842,10845,10848],{},[100,10840,10841],{},"目标是什么，什么不在范围内；",[100,10843,10844],{},"哪些资料可信，哪些只是参考；",[100,10846,10847],{},"哪些权限可以授予，哪些操作必须确认；",[100,10849,10850],{},"失败时应该停止、重试，还是回滚。",[16,10852,10853],{},"在行动之后，需要把结果验证得更扎实：",[97,10855,10856,10859,10862,10865],{},[100,10857,10858],{},"代码能否通过测试与构建；",[100,10860,10861],{},"页面是否真的符合预期，而不只是生成成功；",[100,10863,10864],{},"外部状态是否已经改变；",[100,10866,10867],{},"结果能否被解释、复现和审计。",[16,10869,10870],{},"三年前，我们努力学习怎样写出一个更好的提示词。今天，我们还要学习怎样设计上下文、工具、权限、检查点和验收标准。",[81,10872,10874],{"id":10873},"能力可以委托责任不能外包","能力可以委托，责任不能外包",[16,10876,10877],{},"智能体越能执行真实操作，边界就越重要。",[16,10879,10880],{},"让模型总结一篇文章，与让它修改生产数据库，不是同一种授权；让它提出邮件草稿，与让它直接发送给客户，也不是同一种风险。一个成熟的智能体系统，不应该只追求“能做多少”，还应该明确：",[97,10882,10883,10886,10889,10892,10895],{},[100,10884,10885],{},"默认只读还是默认可写；",[100,10887,10888],{},"哪些动作需要人在执行前确认；",[100,10890,10891],{},"如何限制目标、目录、账号和数据范围；",[100,10893,10894],{},"如何保存必要的操作记录；",[100,10896,10897],{},"发生错误时怎样停止并恢复。",[16,10899,10900],{},"模型可以承担越来越多行动，但判断和责任并不会因此消失。最终仍然需要有人回答：为什么要这样做，依据是什么，风险由谁承担，结果是否可以接受。",[81,10902,10904],{"id":10903},"再过三年我们会怎样看今天","再过三年，我们会怎样看今天",[16,10906,10907],{},"这封旧邮件如今很像一张偶然保存下来的车票。",[16,10909,10910],{},"2023 年，我申请的是一次访问模型的机会。2026 年，我们已经在学习如何让模型理解一个长期目标、使用工具、跨越多个步骤，并在人的监督下完成工作。",[16,10912,10913],{},"让我感慨的不是某一个参数增长了多少倍，而是时间被压缩了。许多曾经只存在于想象中的能力，没有在遥远的未来慢慢到来，而是在几次版本更新之间，悄悄变成了日常。",[16,10915,10916],{},"也许再过三年回头看，今天让我们惊叹的百万级上下文和智能体，也会像这封邮件一样，成为未来刚刚开始时留下的一件旧物。",[10601,10918],{},[81,10920,10605],{"id":10605},[97,10922,10923,10931,10939],{},[100,10924,10925,10930],{},[385,10926,10929],{"href":10927,"rel":10928},"https:\u002F\u002Fopenai.com\u002Findex\u002Fgpt-4-research\u002F",[389],"OpenAI：GPT‑4 发布资料","：GPT‑4 于 2023 年 3 月发布，标准上下文为 8,192 tokens，并有限开放 32,768-token 版本。",[100,10932,10933,10938],{},[385,10934,10937],{"href":10935,"rel":10936},"https:\u002F\u002Fdevelopers.openai.com\u002Fapi\u002Fdocs\u002Fmodels",[389],"OpenAI：模型目录","：截至本文发布时，前沿 GPT‑5.6 系列列出约 1.05M context window，并支持多类工具。",[100,10940,10941,10946],{},[385,10942,10945],{"href":10943,"rel":10944},"https:\u002F\u002Fai.google.dev\u002Fgemini-api\u002Fdocs\u002Flong-context",[389],"Google AI：Long context","：介绍 Gemini 模型的 1M+ 长上下文能力、使用场景与限制。",{"title":38,"searchDepth":737,"depth":737,"links":10948},[10949,10950,10951,10952,10953,10954],{"id":10755,"depth":274,"text":10756},{"id":10777,"depth":274,"text":10777},{"id":10829,"depth":274,"text":10830},{"id":10873,"depth":274,"text":10874},{"id":10903,"depth":274,"text":10904},{"id":10605,"depth":274,"text":10605},"2023 年，我等来的是一次 8K context 的 API 访问资格；三年后，我们开始学习如何向智能体交代目标、权限、边界与验收标准。",{"src":10957,"title":10958,"description":10959,"duration":10960,"hasAudio":767},"\u002Fexperiences\u002Ffrom-gpt-4-invite-to-agents","从 GPT-4 邀请到智能体时代 · 六幕互动叙事","用 2 分 26 秒回看上下文、智能体工作流，以及能力与责任的变化。",146,{"src":10962,"alt":10963,"caption":10964,"width":8999,"height":9000},"\u002Fmedia\u002Fposts\u002Ffrom-gpt-4-invite-to-agents\u002Fcover-v1.webp","一封来自 2023 年的邮件从玻璃时间胶囊延伸为现代智能体工作空间","三年前申请访问模型，三年后学习与模型共同工作",{},"\u002Fblog\u002Ffrom-gpt-4-invite-to-agents","2026-07-25",{"title":10720,"description":10955},"blog\u002Ffrom-gpt-4-invite-to-agents",[10971,10972,10973],"AI","GPT-4","智能体","P0HWhOcEJwLRdrvZlwkQks0C-1y9Q98neHDcWHX6Xow",{"id":10976,"title":10977,"accent":1287,"body":10978,"description":11410,"draft":764,"experience":765,"extension":766,"featured":767,"image":11411,"meta":11415,"navigation":767,"path":11416,"publishedAt":11417,"readingTime":1640,"seo":11418,"stem":11419,"tags":11420,"updatedAt":11422,"video":765,"__hash__":11423,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Fedge-stateful-apps.md","在边缘构建有状态应用",{"type":13,"value":10979,"toc":11402},[10980,10986,10989,10992,10995,11044,11047,11052,11055,11058,11061,11129,11132,11214,11217,11220,11223,11300,11314,11317,11320,11323,11326,11337,11340,11343,11346,11349,11363,11366,11370,11373,11376,11393,11399],[16,10981,10982,10983],{},"边缘计算最吸引人的承诺，是让代码离用户更近。但当应用开始保存账号、会话和留言，问题就不再只是“把函数部署到更多节点”，而是：",[20,10984,10985],{},"状态究竟在哪里，谁对它负责，失败时系统会怎样退化？",[16,10987,10988],{},"这篇文章用一个带登录和留言的博客作为最小案例，梳理在 Cloudflare Workers 与 D1 上构建有状态应用时真正重要的设计边界。",[81,10990,10991],{"id":10991},"先画清楚状态地图",[16,10993,10994],{},"一个看起来很小的博客，至少有三类完全不同的状态：",[140,10996,10997,11009],{},[143,10998,10999],{},[146,11000,11001,11003,11006],{},[149,11002,2778],{},[149,11004,11005],{},"来源",[149,11007,11008],{},"生命周期",[159,11010,11011,11022,11033],{},[146,11012,11013,11016,11019],{},[164,11014,11015],{},"文章正文",[164,11017,11018],{},"Git 中的 Markdown",[164,11020,11021],{},"随部署更新",[146,11023,11024,11027,11030],{},[164,11025,11026],{},"用户与留言",[164,11028,11029],{},"D1",[164,11031,11032],{},"长期持久化",[146,11034,11035,11038,11041],{},[164,11036,11037],{},"登录会话",[164,11039,11040],{},"D1 + Cookie",[164,11042,11043],{},"有明确过期时间",[16,11045,11046],{},"文章内容适合跟着代码版本走。它需要评审、回滚和构建时校验，却不需要读者在运行时修改。用户数据则刚好相反：它必须脱离部署生命周期存在。",[1341,11048,11049],{},[16,11050,11051],{},"选择数据库之前，先判断一份数据应该跟随代码版本，还是跟随用户行为。",[16,11053,11054],{},"这个区分会自然导向一个简单架构：Nuxt Content 负责 Markdown，D1 负责运行时业务数据，Worker 负责把两者组合成一次请求。",[81,11056,11057],{"id":11057},"绑定比网络请求更重要",[16,11059,11060],{},"Worker 访问 D1 时不需要自己保存 API Token，也不应该在运行时调用 Cloudflare REST API。D1 通过 binding 直接进入运行时：",[33,11062,11064],{"className":4078,"code":11063,"filename":7648,"language":4080,"meta":38,"style":38},"{\n  \"d1_databases\": [\n    {\n      \"binding\": \"DB\",\n      \"database_name\": \"signal-log-db\",\n      \"database_id\": \"\u003CYOUR_DATABASE_ID>\"\n    }\n  ]\n}\n",[25,11065,11066,11070,11078,11082,11094,11106,11116,11120,11125],{"__ignoreMap":38},[42,11067,11068],{"class":44,"line":45},[42,11069,4087],{"class":1797},[42,11071,11072,11075],{"class":44,"line":274},[42,11073,11074],{"class":52},"  \"d1_databases\"",[42,11076,11077],{"class":1797},": [\n",[42,11079,11080],{"class":44,"line":737},[42,11081,7670],{"class":1797},[42,11083,11084,11087,11089,11092],{"class":44,"line":1584},[42,11085,11086],{"class":52},"      \"binding\"",[42,11088,2105],{"class":1797},[42,11090,11091],{"class":56},"\"DB\"",[42,11093,4100],{"class":1797},[42,11095,11096,11099,11101,11104],{"class":44,"line":1603},[42,11097,11098],{"class":52},"      \"database_name\"",[42,11100,2105],{"class":1797},[42,11102,11103],{"class":56},"\"signal-log-db\"",[42,11105,4100],{"class":1797},[42,11107,11108,11111,11113],{"class":44,"line":1618},[42,11109,11110],{"class":52},"      \"database_id\"",[42,11112,2105],{"class":1797},[42,11114,11115],{"class":56},"\"\u003CYOUR_DATABASE_ID>\"\n",[42,11117,11118],{"class":44,"line":1627},[42,11119,4218],{"class":1797},[42,11121,11122],{"class":44,"line":1640},[42,11123,11124],{"class":1797},"  ]\n",[42,11126,11127],{"class":44,"line":1645},[42,11128,4228],{"class":1797},[16,11130,11131],{},"在服务端路由里，数据库对象来自当前请求上下文。每一条 SQL 都使用 prepared statement：",[33,11133,11135],{"className":5505,"code":11134,"language":5507,"meta":38,"style":38},"const comment = await db\n  .prepare(`\n    SELECT id, body, created_at\n    FROM comments\n    WHERE post_slug = ?1 AND status = 'published'\n    ORDER BY created_at DESC\n    LIMIT 100\n  `)\n  .bind(postSlug)\n  .all()\n",[25,11136,11137,11151,11163,11168,11173,11178,11183,11188,11195,11205],{"__ignoreMap":38},[42,11138,11139,11141,11144,11146,11148],{"class":44,"line":45},[42,11140,5532],{"class":277},[42,11142,11143],{"class":52}," comment",[42,11145,5538],{"class":277},[42,11147,5654],{"class":277},[42,11149,11150],{"class":1797}," db\n",[42,11152,11153,11155,11158,11160],{"class":44,"line":274},[42,11154,9811],{"class":1797},[42,11156,11157],{"class":48},"prepare",[42,11159,5731],{"class":1797},[42,11161,11162],{"class":56},"`\n",[42,11164,11165],{"class":44,"line":737},[42,11166,11167],{"class":56},"    SELECT id, body, created_at\n",[42,11169,11170],{"class":44,"line":1584},[42,11171,11172],{"class":56},"    FROM comments\n",[42,11174,11175],{"class":44,"line":1603},[42,11176,11177],{"class":56},"    WHERE post_slug = ?1 AND status = 'published'\n",[42,11179,11180],{"class":44,"line":1618},[42,11181,11182],{"class":56},"    ORDER BY created_at DESC\n",[42,11184,11185],{"class":44,"line":1627},[42,11186,11187],{"class":56},"    LIMIT 100\n",[42,11189,11190,11193],{"class":44,"line":1640},[42,11191,11192],{"class":56},"  `",[42,11194,5899],{"class":1797},[42,11196,11197,11199,11202],{"class":44,"line":1645},[42,11198,9811],{"class":1797},[42,11200,11201],{"class":48},"bind",[42,11203,11204],{"class":1797},"(postSlug)\n",[42,11206,11207,11209,11212],{"class":44,"line":1668},[42,11208,9811],{"class":1797},[42,11210,11211],{"class":48},"all",[42,11213,7063],{"class":1797},[16,11215,11216],{},"参数绑定不是代码风格问题，而是输入边界的一部分。文章路径、邮箱、昵称和留言正文都来自用户，任何一个值都不应该被拼接进 SQL。",[81,11218,11219],{"id":11219},"会话只存不可逆摘要",[16,11221,11222],{},"登录成功后，浏览器拿到一个随机会话令牌；数据库只保存它的 SHA-256 摘要。这样即使会话表意外暴露，原始令牌也不能被直接拿去登录。",[33,11224,11226],{"className":5505,"code":11225,"language":5507,"meta":38,"style":38},"const token = createRandomToken()\nconst tokenHash = await sha256(token)\n\nawait db\n  .prepare('INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)')\n  .bind(tokenHash, userId, expiresAt)\n  .run()\n",[25,11227,11228,11242,11259,11263,11269,11282,11291],{"__ignoreMap":38},[42,11229,11230,11232,11235,11237,11240],{"class":44,"line":45},[42,11231,5532],{"class":277},[42,11233,11234],{"class":52}," token",[42,11236,5538],{"class":277},[42,11238,11239],{"class":48}," createRandomToken",[42,11241,7063],{"class":1797},[42,11243,11244,11246,11249,11251,11253,11256],{"class":44,"line":274},[42,11245,5532],{"class":277},[42,11247,11248],{"class":52}," tokenHash",[42,11250,5538],{"class":277},[42,11252,5654],{"class":277},[42,11254,11255],{"class":48}," sha256",[42,11257,11258],{"class":1797},"(token)\n",[42,11260,11261],{"class":44,"line":737},[42,11262,1581],{"emptyLinePlaceholder":767},[42,11264,11265,11267],{"class":44,"line":1584},[42,11266,6061],{"class":277},[42,11268,11150],{"class":1797},[42,11270,11271,11273,11275,11277,11280],{"class":44,"line":1603},[42,11272,9811],{"class":1797},[42,11274,11157],{"class":48},[42,11276,5731],{"class":1797},[42,11278,11279],{"class":56},"'INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)'",[42,11281,5899],{"class":1797},[42,11283,11284,11286,11288],{"class":44,"line":1618},[42,11285,9811],{"class":1797},[42,11287,11201],{"class":48},[42,11289,11290],{"class":1797},"(tokenHash, userId, expiresAt)\n",[42,11292,11293,11295,11298],{"class":44,"line":1627},[42,11294,9811],{"class":1797},[42,11296,11297],{"class":48},"run",[42,11299,7063],{"class":1797},[16,11301,11302,11303,11306,11307,11310,11311,76],{},"Cookie 使用 ",[25,11304,11305],{},"HttpOnly","，让前端 JavaScript 无法读取；使用 ",[25,11308,11309],{},"SameSite=Lax"," 降低跨站请求风险；生产环境再加上 ",[25,11312,11313],{},"Secure",[16,11315,11316],{},"这并不意味着系统从此“绝对安全”。更准确的说法是：我们让每一层只持有它完成工作所需的最少能力。",[81,11318,11319],{"id":11319},"一致性要围绕用户动作设计",[16,11321,11322],{},"D1 是关系数据库，但边缘应用仍然需要明确哪些流程要求强一致。",[16,11324,11325],{},"对博客来说，关键动作很少：",[406,11327,11328,11331,11334],{},[100,11329,11330],{},"注册后应当立刻能够登录；",[100,11332,11333],{},"留言成功后应当立刻在当前页面出现；",[100,11335,11336],{},"退出后旧会话应当立刻失效。",[16,11338,11339],{},"这些流程都应该从同一个主数据库完成写入和紧随其后的读取。文章列表、公开留言列表等纯读取场景，才有更大的缓存空间。",[16,11341,11342],{},"不要从“哪里可以缓存”出发，而要从“用户刚刚完成了什么承诺”出发。",[81,11344,11345],{"id":11345},"失败也需要产品设计",[16,11347,11348],{},"边缘运行时会失败，数据库也可能暂时不可用。区别只在于，我们有没有把失败变成用户能理解的状态。",[97,11350,11351,11354,11357,11360],{},[100,11352,11353],{},"登录失败时，不区分“邮箱不存在”和“密码错误”，避免暴露账号枚举信息；",[100,11355,11356],{},"留言写入失败时，保留输入内容，让用户可以重试；",[100,11358,11359],{},"内容查询失败时，返回真正的错误状态，而不是空白页面；",[100,11361,11362],{},"所有服务端错误使用结构化日志，但不把内部 SQL 或堆栈发给浏览器。",[16,11364,11365],{},"系统可靠性不是“永远不出错”，而是错误发生时边界仍然完整。",[81,11367,11369],{"id":11368},"最后让架构保持无聊","最后：让架构保持无聊",[16,11371,11372],{},"这个方案没有消息队列、没有分布式锁，也没有额外的身份服务。不是因为那些工具没有价值，而是当前问题还没有提出相应要求。",[16,11374,11375],{},"一个长期可维护的个人站，最好的基础通常是：",[97,11377,11378,11381,11384,11387,11390],{},[100,11379,11380],{},"内容跟着 Git；",[100,11382,11383],{},"数据进入 D1；",[100,11385,11386],{},"密钥留在 Worker secret；",[100,11388,11389],{},"输入在边界验证；",[100,11391,11392],{},"失败清楚、可观察、可恢复。",[16,11394,11395,11396],{},"当流量或产品需求真正变化时，再让架构跟着证据演进。",[20,11397,11398],{},"边缘已经足够快，工程的任务是让它继续简单。",[733,11400,11401],{},"html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--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":38,"searchDepth":737,"depth":737,"links":11403},[11404,11405,11406,11407,11408,11409],{"id":10991,"depth":274,"text":10991},{"id":11057,"depth":274,"text":11057},{"id":11219,"depth":274,"text":11219},{"id":11319,"depth":274,"text":11319},{"id":11345,"depth":274,"text":11345},{"id":11368,"depth":274,"text":11369},"Cloudflare Workers 很快，D1 很近，但真正困难的是把状态、一致性与失败边界设计清楚。",{"src":11412,"alt":11413,"caption":11414,"width":8999,"height":9000},"\u002Fmedia\u002Fposts\u002Fedge-stateful-apps\u002Fcover-v1.webp","边缘节点围绕中心数据库形成有状态应用拓扑","状态、边缘节点与一致性边界",{},"\u002Fblog\u002Fedge-stateful-apps","2026-07-21",{"title":10977,"description":11410},"blog\u002Fedge-stateful-apps",[3412,11029,11421],"工程","2026-07-24","gsFDt7C9LtEnc-0ELg6T7tdWM-k-26r6jVfx424yrSM",{"id":11425,"title":11426,"accent":11,"body":11427,"description":11811,"draft":764,"experience":765,"extension":766,"featured":764,"image":11812,"meta":11816,"navigation":767,"path":11817,"publishedAt":11818,"readingTime":1618,"seo":11819,"stem":11820,"tags":11821,"updatedAt":765,"video":765,"__hash__":11824,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Fnuxt-4-architecture-subtraction.md","Nuxt 4 的架构减法",{"type":13,"value":11428,"toc":11803},[11429,11432,11438,11442,11445,11496,11499,11506,11509,11512,11636,11639,11657,11660,11663,11666,11669,11672,11677,11681,11688,11694,11697,11700,11703,11706,11771,11774,11777,11780,11797,11800],[16,11430,11431],{},"很多 Nuxt 项目变复杂，并不是因为业务真的复杂，而是因为我们太早把所有可能性都放进了架构。",[16,11433,11434,11435,76],{},"个人博客是一个很好的校准器：页面数量有限、内容结构稳定、交互集中在少数区域。它提醒我们，框架最有价值的部分往往不是增加能力，而是",[20,11436,11437],{},"删除不必要的选择",[81,11439,11441],{"id":11440},"从默认-ssr-开始","从默认 SSR 开始",[16,11443,11444],{},"技术文章需要被搜索引擎理解，也需要在弱网下尽早出现正文。Nuxt 默认的服务端渲染刚好符合这个目标。",[33,11446,11449],{"className":5505,"code":11447,"filename":11448,"language":5507,"meta":38,"style":38},"export default defineNuxtConfig({\n  modules: ['@nuxt\u002Fcontent'],\n  nitro: {\n    preset: 'cloudflare',\n  },\n})\n","nuxt.config.ts",[25,11450,11451,11462,11473,11478,11488,11492],{"__ignoreMap":38},[42,11452,11453,11455,11457,11460],{"class":44,"line":45},[42,11454,5325],{"class":277},[42,11456,7007],{"class":277},[42,11458,11459],{"class":48}," defineNuxtConfig",[42,11461,5547],{"class":1797},[42,11463,11464,11467,11470],{"class":44,"line":274},[42,11465,11466],{"class":1797},"  modules: [",[42,11468,11469],{"class":56},"'@nuxt\u002Fcontent'",[42,11471,11472],{"class":1797},"],\n",[42,11474,11475],{"class":44,"line":737},[42,11476,11477],{"class":1797},"  nitro: {\n",[42,11479,11480,11483,11486],{"class":44,"line":1584},[42,11481,11482],{"class":1797},"    preset: ",[42,11484,11485],{"class":56},"'cloudflare'",[42,11487,4100],{"class":1797},[42,11489,11490],{"class":44,"line":1603},[42,11491,5604],{"class":1797},[42,11493,11494],{"class":44,"line":1618},[42,11495,5638],{"class":1797},[16,11497,11498],{},"这里没有把整个站改成 SPA，也没有为每个页面单独发明数据加载方式。页面在服务端完成第一次查询和渲染，客户端接管后继续使用同一套路由。",[16,11500,11501,11502,11505],{},"如果某一小块必须依赖浏览器 API，再局部使用 ",[25,11503,11504],{},"ClientOnly","。不要为了一个客户端组件牺牲整页的语义和首屏。",[81,11507,11508],{"id":11508},"内容查询留在页面边界",[16,11510,11511],{},"首页、文章列表和文章详情需要的数据粒度不同。查询应该贴近消费它的页面：",[33,11513,11515],{"className":5505,"code":11514,"language":5507,"meta":38,"style":38},"const { data: posts } = await useAsyncData('latest-posts', () => {\n  return queryCollection('posts')\n    .where('draft', '=', false)\n    .order('publishedAt', 'DESC')\n    .limit(6)\n    .all()\n})\n",[25,11516,11517,11552,11566,11591,11610,11624,11632],{"__ignoreMap":38},[42,11518,11519,11521,11523,11526,11528,11531,11533,11535,11537,11540,11542,11545,11548,11550],{"class":44,"line":45},[42,11520,5532],{"class":277},[42,11522,8082],{"class":1797},[42,11524,11525],{"class":5842},"data",[42,11527,2105],{"class":1797},[42,11529,11530],{"class":52},"posts",[42,11532,8087],{"class":1797},[42,11534,1845],{"class":277},[42,11536,5654],{"class":277},[42,11538,11539],{"class":48}," useAsyncData",[42,11541,5731],{"class":1797},[42,11543,11544],{"class":56},"'latest-posts'",[42,11546,11547],{"class":1797},", () ",[42,11549,8072],{"class":277},[42,11551,6038],{"class":1797},[42,11553,11554,11556,11559,11561,11564],{"class":44,"line":274},[42,11555,8255],{"class":277},[42,11557,11558],{"class":48}," queryCollection",[42,11560,5731],{"class":1797},[42,11562,11563],{"class":56},"'posts'",[42,11565,5899],{"class":1797},[42,11567,11568,11571,11574,11576,11579,11581,11584,11586,11589],{"class":44,"line":737},[42,11569,11570],{"class":1797},"    .",[42,11572,11573],{"class":48},"where",[42,11575,5731],{"class":1797},[42,11577,11578],{"class":56},"'draft'",[42,11580,5851],{"class":1797},[42,11582,11583],{"class":56},"'='",[42,11585,5851],{"class":1797},[42,11587,11588],{"class":52},"false",[42,11590,5899],{"class":1797},[42,11592,11593,11595,11598,11600,11603,11605,11608],{"class":44,"line":1584},[42,11594,11570],{"class":1797},[42,11596,11597],{"class":48},"order",[42,11599,5731],{"class":1797},[42,11601,11602],{"class":56},"'publishedAt'",[42,11604,5851],{"class":1797},[42,11606,11607],{"class":56},"'DESC'",[42,11609,5899],{"class":1797},[42,11611,11612,11614,11617,11619,11622],{"class":44,"line":1603},[42,11613,11570],{"class":1797},[42,11615,11616],{"class":48},"limit",[42,11618,5731],{"class":1797},[42,11620,11621],{"class":52},"6",[42,11623,5899],{"class":1797},[42,11625,11626,11628,11630],{"class":44,"line":1618},[42,11627,11570],{"class":1797},[42,11629,11211],{"class":48},[42,11631,7063],{"class":1797},[42,11633,11634],{"class":44,"line":1627},[42,11635,5638],{"class":1797},[16,11637,11638],{},"这样做有几个直接好处：",[97,11640,11641,11648,11651,11654],{},[100,11642,11643,11644,11647],{},"数据参与 SSR，不需要 ",[25,11645,11646],{},"onMounted"," 后再闪烁加载；",[100,11649,11650],{},"查询键明确，Nuxt 可以在 hydration 时复用 payload；",[100,11652,11653],{},"页面知道自己需要什么，不必经过一层通用 store；",[100,11655,11656],{},"草稿过滤与排序规则出现在真正相关的位置。",[16,11658,11659],{},"只有当多个页面共享了同一段复杂业务状态时，才值得抽成 composable 或 store。",[81,11661,11662],{"id":11662},"组件只抽取稳定重复",[16,11664,11665],{},"“看起来可能复用”不是组件边界。真正稳定的重复通常同时包含结构、语义和行为。",[16,11667,11668],{},"这个博客里，文章卡片是合适的组件：它在首页和索引页重复出现，拥有固定的元数据、标题、摘要、标签和跳转行为。",[16,11670,11671],{},"相反，首页 Hero 即使很大，也未必需要拆成五个小组件。它只在一个页面出现，内部元素共同完成一段视觉叙事。过早拆分只会让样式关系跨文件跳转。",[1341,11673,11674],{},[16,11675,11676],{},"组件不是文件整理工具，而是可独立理解和复用的 UI 契约。",[81,11678,11680],{"id":11679},"服务端-api-保持窄接口","服务端 API 保持窄接口",[16,11682,11683,11684,11687],{},"登录与留言属于运行时数据，放在 ",[25,11685,11686],{},"server\u002Fapi","。每个 endpoint 只做一件事：",[33,11689,11692],{"className":11690,"code":11691,"language":231,"meta":38},[229],"POST \u002Fapi\u002Fauth\u002Fregister\nPOST \u002Fapi\u002Fauth\u002Flogin\nPOST \u002Fapi\u002Fauth\u002Flogout\nGET  \u002Fapi\u002Fauth\u002Fsession\nGET  \u002Fapi\u002Fcomments\u002F:post\nPOST \u002Fapi\u002Fcomments\u002F:post\n",[25,11693,11691],{"__ignoreMap":38},[16,11695,11696],{},"窄接口使授权规则更容易检查。读取留言是公开的，创建留言需要会话；退出只销毁当前令牌，不需要触碰用户资料。",[16,11698,11699],{},"如果一个 endpoint 同时处理读取、写入、登录和格式转换，它就已经失去了清晰边界。",[81,11701,11702],{"id":11702},"把状态放回正确的位置",[16,11704,11705],{},"Vue 的响应式状态很方便，但不是所有状态都应该进入 JavaScript：",[140,11707,11708,11717],{},[143,11709,11710],{},[146,11711,11712,11714],{},[149,11713,2778],{},[149,11715,11716],{},"最合适的位置",[159,11718,11719,11727,11737,11745,11756,11763],{},[146,11720,11721,11724],{},[164,11722,11723],{},"当前文章",[164,11725,11726],{},"路由",[146,11728,11729,11732],{},[164,11730,11731],{},"搜索词",[164,11733,11734,11735],{},"页面 ",[25,11736,9031],{},[146,11738,11739,11742],{},[164,11740,11741],{},"标签筛选",[164,11743,11744],{},"URL query + 页面状态",[146,11746,11747,11750],{},[164,11748,11749],{},"登录用户",[164,11751,11752,11753],{},"SSR 安全的 ",[25,11754,11755],{},"useState",[146,11757,11758,11760],{},[164,11759,11015],{},[164,11761,11762],{},"Nuxt Content",[146,11764,11765,11768],{},[164,11766,11767],{},"会话真实性",[164,11769,11770],{},"服务端 D1",[16,11772,11773],{},"当状态位置正确时，大量“同步逻辑”会自然消失。我们不再需要监听路由去修复 store，也不需要用 localStorage 假装登录状态可靠。",[81,11775,11776],{"id":11776},"减法不是功能更少",[16,11778,11779],{},"好的架构减法不会让用户少得到什么。相反，它把复杂度预算留给真正有感知的部分：",[97,11781,11782,11785,11788,11791,11794],{},[100,11783,11784],{},"更清晰的排版；",[100,11786,11787],{},"更快的首屏；",[100,11789,11790],{},"更可靠的登录；",[100,11792,11793],{},"更舒服的移动端阅读；",[100,11795,11796],{},"更容易坚持的 Markdown 写作流程。",[16,11798,11799],{},"Nuxt 4 提供了足够多的能力。项目成熟的标志，不是把它们全部用上，而是知道哪些能力可以暂时不用。",[733,11801,11802],{},"html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--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 .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}",{"title":38,"searchDepth":737,"depth":737,"links":11804},[11805,11806,11807,11808,11809,11810],{"id":11440,"depth":274,"text":11441},{"id":11508,"depth":274,"text":11508},{"id":11662,"depth":274,"text":11662},{"id":11679,"depth":274,"text":11680},{"id":11702,"depth":274,"text":11702},{"id":11776,"depth":274,"text":11776},"一个内容型站点不需要复杂前端状态。让路由、服务端渲染和内容查询各自回到最擅长的位置。",{"src":11813,"alt":11814,"caption":11815,"width":8999,"height":9000},"\u002Fmedia\u002Fposts\u002Fnuxt-4-architecture-subtraction\u002Fcover-v1.webp","复杂模块逐步收敛为清晰精简的 Nuxt 应用核心","用架构减法换取清晰边界",{},"\u002Fblog\u002Fnuxt-4-architecture-subtraction","2026-07-16",{"title":11426,"description":11811},"blog\u002Fnuxt-4-architecture-subtraction",[11822,10714,11823],"Nuxt","架构","Czr5nd2U1OiyR7yB5-ruM4B8FGFAAjFQQISDmWg9Fis",{"id":11826,"title":11827,"accent":3426,"body":11828,"description":12175,"draft":764,"experience":765,"extension":766,"featured":764,"image":12176,"meta":12180,"navigation":767,"path":12181,"publishedAt":12182,"readingTime":1627,"seo":12183,"stem":12184,"tags":12185,"updatedAt":765,"video":765,"__hash__":12187,"contentLocale":4,"isFallback":764},"posts_zh\u002Fblog\u002Fobservable-ai-systems.md","AI 系统的可观测性，不只是 Token 账单",{"type":13,"value":11829,"toc":12162},[11830,11833,11838,11841,11844,11847,11851,11854,11858,11861,11865,11868,11872,11875,11878,11882,11889,12002,12005,12008,12025,12028,12031,12034,12088,12091,12094,12097,12114,12117,12120,12123,12126,12140,12143,12147,12150,12156,12159],[16,11831,11832],{},"传统服务出问题时，我们习惯查看状态码、延迟和错误堆栈。AI 应用出问题时，这些指标仍然重要，却经常不足以回答最关键的问题：",[16,11834,11835],{},[20,11836,11837],{},"模型为什么做出了这个选择？",[16,11839,11840],{},"答案不在某一条日志里，而在从用户目标、上下文组装、模型决策、工具执行到最终结果的整条链路中。",[81,11842,11843],{"id":11843},"先区分四种失败",[16,11845,11846],{},"把所有失败都记成“模型效果不好”，团队就无法采取具体行动。至少应该区分：",[246,11848,11850],{"id":11849},"_1-输入失败","1. 输入失败",[16,11852,11853],{},"用户目标不完整，或系统没有拿到完成任务所需的上下文。此时升级模型通常没有帮助。",[246,11855,11857],{"id":11856},"_2-推理失败","2. 推理失败",[16,11859,11860],{},"上下文正确，但模型选择了错误路径、遗漏约束或过早得出结论。",[246,11862,11864],{"id":11863},"_3-工具失败","3. 工具失败",[16,11866,11867],{},"模型选择正确，实际执行却因为权限、网络、参数或外部状态失败。",[246,11869,11871],{"id":11870},"_4-交付失败","4. 交付失败",[16,11873,11874],{},"内部工作已经完成，但最终回答没有把结果、限制和下一步说清楚。",[16,11876,11877],{},"这四类问题需要不同的修复者：产品、提示设计、平台工程或交互设计。可观测性的第一份价值，是让问题回到正确的人手里。",[81,11879,11881],{"id":11880},"一次运行需要共同的-trace","一次运行需要共同的 Trace",[16,11883,11884,11885,11888],{},"每个用户请求都应该有稳定的 ",[25,11886,11887],{},"trace_id","，贯穿模型调用和工具执行：",[33,11890,11892],{"className":5505,"code":11891,"language":5507,"meta":38,"style":38},"type AgentEvent = {\n  traceId: string\n  spanId: string\n  parentSpanId?: string\n  type: 'model' | 'tool' | 'handoff' | 'result'\n  startedAt: string\n  durationMs?: number\n  status: 'running' | 'ok' | 'error'\n}\n",[25,11893,11894,11906,11915,11924,11934,11959,11968,11978,11998],{"__ignoreMap":38},[42,11895,11896,11899,11902,11904],{"class":44,"line":45},[42,11897,11898],{"class":277},"type",[42,11900,11901],{"class":48}," AgentEvent",[42,11903,5538],{"class":277},[42,11905,6038],{"class":1797},[42,11907,11908,11911,11913],{"class":44,"line":274},[42,11909,11910],{"class":5842},"  traceId",[42,11912,5845],{"class":277},[42,11914,6992],{"class":52},[42,11916,11917,11920,11922],{"class":44,"line":737},[42,11918,11919],{"class":5842},"  spanId",[42,11921,5845],{"class":277},[42,11923,6992],{"class":52},[42,11925,11926,11929,11932],{"class":44,"line":1584},[42,11927,11928],{"class":5842},"  parentSpanId",[42,11930,11931],{"class":277},"?:",[42,11933,6992],{"class":52},[42,11935,11936,11939,11941,11944,11946,11949,11951,11954,11956],{"class":44,"line":1603},[42,11937,11938],{"class":5842},"  type",[42,11940,5845],{"class":277},[42,11942,11943],{"class":56}," 'model'",[42,11945,309],{"class":277},[42,11947,11948],{"class":56}," 'tool'",[42,11950,309],{"class":277},[42,11952,11953],{"class":56}," 'handoff'",[42,11955,309],{"class":277},[42,11957,11958],{"class":56}," 'result'\n",[42,11960,11961,11964,11966],{"class":44,"line":1618},[42,11962,11963],{"class":5842},"  startedAt",[42,11965,5845],{"class":277},[42,11967,6992],{"class":52},[42,11969,11970,11973,11975],{"class":44,"line":1627},[42,11971,11972],{"class":5842},"  durationMs",[42,11974,11931],{"class":277},[42,11976,11977],{"class":52}," number\n",[42,11979,11980,11983,11985,11988,11990,11993,11995],{"class":44,"line":1640},[42,11981,11982],{"class":5842},"  status",[42,11984,5845],{"class":277},[42,11986,11987],{"class":56}," 'running'",[42,11989,309],{"class":277},[42,11991,11992],{"class":56}," 'ok'",[42,11994,309],{"class":277},[42,11996,11997],{"class":56}," 'error'\n",[42,11999,12000],{"class":44,"line":1645},[42,12001,4228],{"class":1797},[16,12003,12004],{},"工具调用是模型决策的外部表现。只记录工具返回值而不记录调用原因，会失去决策上下文；只保存提示词而不保存工具结果，又无法判断执行是否符合预期。",[16,12006,12007],{},"需要注意的是，观测并不意味着无限制收集。原始提示词可能包含个人信息、凭据或业务数据。更稳妥的做法是：",[97,12009,12010,12013,12016,12019,12022],{},[100,12011,12012],{},"默认记录结构化元数据；",[100,12014,12015],{},"对正文做字段级脱敏；",[100,12017,12018],{},"为调试样本设置短保留期；",[100,12020,12021],{},"允许用户和业务明确选择不进入训练或分析；",[100,12023,12024],{},"不在日志中保存密钥与完整身份信息。",[81,12026,12027],{"id":12027},"质量指标必须贴近任务",[16,12029,12030],{},"Token、延迟和模型错误率是平台指标，但它们不能代表用户是否得到结果。",[16,12032,12033],{},"一个代码修复 Agent 更有意义的指标可能是：",[140,12035,12036,12046],{},[143,12037,12038],{},[146,12039,12040,12043],{},[149,12041,12042],{},"层级",[149,12044,12045],{},"指标示例",[159,12047,12048,12056,12064,12072,12080],{},[146,12049,12050,12053],{},[164,12051,12052],{},"结果",[164,12054,12055],{},"受影响测试是否通过",[146,12057,12058,12061],{},[164,12059,12060],{},"过程",[164,12062,12063],{},"是否读取了相关约束文件",[146,12065,12066,12069],{},[164,12067,12068],{},"安全",[164,12070,12071],{},"是否修改了任务范围外文件",[146,12073,12074,12077],{},[164,12075,12076],{},"效率",[164,12078,12079],{},"有效工具调用 \u002F 总工具调用",[146,12081,12082,12085],{},[164,12083,12084],{},"交付",[164,12086,12087],{},"是否提供可验证的文件或提交",[16,12089,12090],{},"“有效工具调用”比单纯调用次数更有价值。一次准确的搜索可能替代十次猜测式读取。",[81,12092,12093],{"id":12093},"把评估变成日常反馈",[16,12095,12096],{},"离线 benchmark 能比较模型版本，却不能覆盖你的真实工作流。更可持续的评估集来自生产中的高价值样本：",[406,12098,12099,12102,12105,12108,12111],{},[100,12100,12101],{},"收集明确成功与明确失败的运行；",[100,12103,12104],{},"删除隐私信息，保留决策结构；",[100,12106,12107],{},"为失败标注类型，而不是只打一个总分；",[100,12109,12110],{},"在提示、模型或工具变化时重新运行；",[100,12112,12113],{},"同时比较结果质量、成本和延迟。",[16,12115,12116],{},"评估集不需要一开始就很大。二十个边界清楚、能稳定复现的案例，通常比两千个来源模糊的样本更能指导工程。",[81,12118,12119],{"id":12119},"面向人的可观测性",[16,12121,12122],{},"最后一层观测不在仪表盘，而在产品界面。",[16,12124,12125],{},"当 Agent 正在读取文件、等待授权或执行部署时，用户需要知道它处于什么阶段。好的状态更新不是倾倒内部思维，而是给出：",[97,12127,12128,12131,12134,12137],{},[100,12129,12130],{},"当前正在完成什么；",[100,12132,12133],{},"已经确认了什么；",[100,12135,12136],{},"是否需要用户采取行动；",[100,12138,12139],{},"哪些风险仍未关闭。",[16,12141,12142],{},"这类可见性会直接改变用户对系统可靠性的判断。一个需要几十秒完成的任务，如果过程清楚，往往比十秒后突然返回不透明结果更让人放心。",[81,12144,12146],{"id":12145},"可解释不等于暴露全部过程","可解释，不等于暴露全部过程",[16,12148,12149],{},"AI 系统不需要把每一个内部 token 展示给用户，也不应该把敏感上下文原样写入日志。真正需要的是一条足够清楚的因果链：",[33,12151,12154],{"className":12152,"code":12153,"language":231,"meta":38},[229],"用户目标\n  → 使用了哪些上下文\n  → 选择了什么工具\n  → 工具返回了什么状态\n  → 最终交付了什么结果\n",[25,12155,12153],{"__ignoreMap":38},[16,12157,12158],{},"当这条链路可以被查询、比较和复现时，团队才有能力把“偶尔很聪明”变成“长期可信赖”。",[733,12160,12161],{},"html pre.shiki code .sOPea, html code.shiki .sOPea{--shiki-default:#F97583;--shiki-dark:#F97583}html pre.shiki code .sFR8T, html code.shiki .sFR8T{--shiki-default:#B392F0;--shiki-dark:#B392F0}html pre.shiki code .suv1-, html code.shiki .suv1-{--shiki-default:#E1E4E8;--shiki-dark:#E1E4E8}html pre.shiki code .s-3mD, html code.shiki .s-3mD{--shiki-default:#FFAB70;--shiki-dark:#FFAB70}html pre.shiki code .s8ozJ, html code.shiki .s8ozJ{--shiki-default:#79B8FF;--shiki-dark:#79B8FF}html pre.shiki code .s4wv1, html code.shiki .s4wv1{--shiki-default:#9ECBFF;--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":38,"searchDepth":737,"depth":737,"links":12163},[12164,12170,12171,12172,12173,12174],{"id":11843,"depth":274,"text":11843,"children":12165},[12166,12167,12168,12169],{"id":11849,"depth":737,"text":11850},{"id":11856,"depth":737,"text":11857},{"id":11863,"depth":737,"text":11864},{"id":11870,"depth":737,"text":11871},{"id":11880,"depth":274,"text":11881},{"id":12027,"depth":274,"text":12027},{"id":12093,"depth":274,"text":12093},{"id":12119,"depth":274,"text":12119},{"id":12145,"depth":274,"text":12146},"从提示词到工具调用，再到用户可见结果，AI 应用需要一条能解释“为什么”的观测链路。",{"src":12177,"alt":12178,"caption":12179,"width":8999,"height":9000},"\u002Fmedia\u002Fposts\u002Fobservable-ai-systems\u002Fcover-v1.webp","AI 请求从输入经过模型和工具节点形成完整观测链路","从提示词到结果的可解释 Trace",{},"\u002Fblog\u002Fobservable-ai-systems","2026-07-09",{"title":11827,"description":12175},"blog\u002Fobservable-ai-systems",[10971,12186,11421],"可观测性","HwqaP2v2TJ3UNRY0hMgfErIMN1Z-buqu7-QNk_P-4Vk",1787735716819]