<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>constantine的博客</title>
    <link>https://neoisconstantine-github-io.pages.dev/</link>
    <description>constantine的博客 - 专注微服务架构、AI开发实践、OAuth认证与Cloudflare边缘计算的技术分享</description>
    <language>zh-CN</language>
    <copyright>All rights reserved 2026, constantine</copyright>
    <lastBuildDate>Fri, 07 Aug 2026 12:00:34 GMT</lastBuildDate>
    <generator>Hexo</generator>
    <atom:link href="https://neoisconstantine-github-io.pages.dev/rss2.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>在 Cloudflare Workers 上部署 PeerJS 信令服务器</title>
      <link>https://neoisconstantine-github-io.pages.dev/2026/08/06/%E5%9C%A8Cloudflare%E4%B8%8A%E9%83%A8%E7%BD%B2PeerJS%E4%BF%A1%E4%BB%A4%E6%9C%8D%E5%8A%A1%E5%99%A8/</link>
      <description>用 Cloudflare Workers + Durable Objects 自建 PeerJS 信令服务器，为 Hexo 博客的联机小游戏提供可靠的信令转发。包含完整 Worker 代码、Durable Object 绑定配置、API 直传部署与前端 PeerJS 接入。</description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/Cloudflare/">Cloudflare</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Cloudflare/">Cloudflare</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Workers/">Workers</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Durable-Objects/">Durable Objects</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/PeerJS/">PeerJS</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/WebRTC/">WebRTC</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/%E8%81%94%E6%9C%BA%E6%B8%B8%E6%88%8F/">联机游戏</category>
      <pubDate>Thu, 06 Aug 2026 16:00:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="自建-PeerJS-信令服务器（Cloudflare-Workers）"><a href="#自建-PeerJS-信令服务器（Cloudflare-Workers）" class="headerlink" title="自建 PeerJS 信令服务器（Cloudflare Workers）"></a>自建 PeerJS 信令服务器（Cloudflare Workers）</h1><p>给 Hexo 博客加了联机小游戏（德州扑克）后，遇到了最头疼的一环：<strong>WebRTC 建立连接前必须先经过信令服务器交换 SDP 与 ICE 候选</strong>。官方 <code>PeerJS Cloud Server</code> 是实验性的、延迟高、还不稳定，于是决定自己部署一个，正好博客本身跑在 Cloudflare 上，直接用一个 Worker 搞定。</p><blockquote><p>下面的代码与步骤都经过双客户端实测通过（两个浏览器窗口正常互通握手）。</p></blockquote><h2 id="为什么需要信令服务器"><a href="#为什么需要信令服务器" class="headerlink" title="为什么需要信令服务器"></a>为什么需要信令服务器</h2><p>WebRTC 的数据通道（DataChannel）虽然是点对点的，但<strong>建立对等连接的过程</strong>需要第三方帮忙：</p><ol><li>双方首先要交换各自的连接信息（SDP + ICE Candidate）</li><li>这个”撮合”过程必须走一个双方都能访问的中转服务器</li><li>WebRTC 本身不提供这个中转，PeerJS 引用了它</li></ol><p>PeerJS 的工作流程大致是：</p><figure class="highlight brainfuck"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs brainfuck"><span class="hljs-title">[</span><span class="hljs-comment">A</span><span class="hljs-title">]</span> <span class="hljs-literal">--</span><span class="hljs-comment">WS</span><span class="hljs-literal">--</span>&gt; <span class="hljs-comment">信令服务器</span> <span class="hljs-literal">--</span><span class="hljs-comment">WS</span><span class="hljs-literal">--</span>&gt; <span class="hljs-title">[</span><span class="hljs-comment">B</span><span class="hljs-title">]</span><br>   &lt;<span class="hljs-literal">--</span><span class="hljs-comment">OFFER/ANSWER/ICE</span><span class="hljs-literal">--</span><span class="hljs-comment">转发</span><span class="hljs-literal">--</span>&gt;<br><span class="hljs-comment">连接建立后：A 与 B 改为 P2P 直连，信令服务器退出数据路径</span><br></code></pre></td></tr></table></figure><p>所以信令服务器只负责”介绍”，<strong>游戏数据本身不经过它</strong>，这也是它可以用非常轻量的 Worker 来实现的原因。</p><h2 id="为什么选-Cloudflare-Workers-Durable-Objects"><a href="#为什么选-Cloudflare-Workers-Durable-Objects" class="headerlink" title="为什么选 Cloudflare Workers + Durable Objects"></a>为什么选 Cloudflare Workers + Durable Objects</h2><p>PeerJS 的信令需要<strong>有状态的 WebSocket 长连接</strong>（要记住每个玩家 id 对应哪个连接，以便转发）。普通 Worker 是无状态的，无法跨请求保存 WebSocket 状态。而 <strong>Durable Object（DO）</strong> 正是为这种有状态 WebSocket 场景设计的：</p><table><thead><tr><th>需求</th><th>方案</th></tr></thead><tbody><tr><td>保存玩家与连接对应的映射</td><td>Durable Object 单实例内持有状态</td></tr><tr><td>长连接生命周期</td><td>DO 提供 <code>WebSocket</code> 一内建支持</td></tr><tr><td>低成本 &#x2F; 全球节点</td><td>Workers 免费套餐够用</td></tr></tbody></table><h2 id="完整-Worker-代码"><a href="#完整-Worker-代码" class="headerlink" title="完整 Worker 代码"></a>完整 Worker 代码</h2><p>新建 <code>worker.js</code>，粘贴下面代码：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">import</span> &#123; <span class="hljs-title class_">DurableObject</span> &#125; <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;cloudflare:workers&#x27;</span>;<br><br><span class="hljs-comment">// ── 入口 ────────────────────────────────────────────────</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> &#123;<br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-params">request, env</span>) &#123;<br>    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);<br><br>    <span class="hljs-comment">// 健康检查 / 根路径提示</span><br>    <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span> === <span class="hljs-string">&#x27;/&#x27;</span> || url.<span class="hljs-property">pathname</span> === <span class="hljs-string">&#x27;&#x27;</span>) &#123;<br>      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">&#x27;PeerJS Server is running on Cloudflare Workers!&#x27;</span>, &#123;<br>        <span class="hljs-attr">headers</span>: &#123; <span class="hljs-string">&#x27;content-type&#x27;</span>: <span class="hljs-string">&#x27;text/plain&#x27;</span> &#125;,<br>      &#125;);<br>    &#125;<br><br>    <span class="hljs-comment">// 解析信令路径：/peerjs/id/&lt;peerId&gt;?key=peerjs</span><br>    <span class="hljs-keyword">const</span> peerId = url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">split</span>(<span class="hljs-string">&#x27;/peerjs/id/&#x27;</span>)[<span class="hljs-number">1</span>] || url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">split</span>(<span class="hljs-string">&#x27;/id/&#x27;</span>)[<span class="hljs-number">1</span>];<br>    <span class="hljs-keyword">if</span> (!peerId) &#123;<br>      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">&#x27;Not found&#x27;</span>, &#123; <span class="hljs-attr">status</span>: <span class="hljs-number">404</span> &#125;);<br>    &#125;<br>    <span class="hljs-comment">// URL 中的 id 参数优先，其次取路径中的 peerId（PeerJS 会带 ?id=&lt;id&gt;&amp;key=peerjs）</span><br>    <span class="hljs-keyword">const</span> finalId = url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;id&#x27;</span>) || peerId;<br><br>    <span class="hljs-comment">// 用 peerId 生成一个稳定 socket，保证同一个 id 落到同一个 DO 实例</span><br>    <span class="hljs-keyword">const</span> id = env.<span class="hljs-property">PEER_SERVER</span>.<span class="hljs-title function_">idFromName</span>(finalId);<br>    <span class="hljs-keyword">const</span> stub = env.<span class="hljs-property">PEER_SERVER</span>.<span class="hljs-title function_">get</span>(id);<br>    <span class="hljs-keyword">return</span> stub.<span class="hljs-title function_">fetch</span>(request);<br>  &#125;,<br>&#125;;<br><br><span class="hljs-comment">// ── Durable Object：真正处理 WebSocket ──────────────────</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">PeerServer</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_ inherited__">DurableObject</span> &#123;<br>  <span class="hljs-title function_">constructor</span>(<span class="hljs-params">ctx, env</span>) &#123;<br>    <span class="hljs-variable language_">super</span>(ctx, env);<br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> = <span class="hljs-literal">null</span>;         <span class="hljs-comment">// 本实例持有的玩家 WebSocket</span><br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">peerId</span> = <span class="hljs-literal">null</span>;     <span class="hljs-comment">// 当前连接的玩家 id</span><br>  &#125;<br><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-params">request</span>) &#123;<br>    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);<br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">peerId</span> = url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;id&#x27;</span>) || <span class="hljs-string">&#x27;&#x27;</span>;<br><br>    <span class="hljs-comment">// PeerJS 会发送 WebSocket 升级请求</span><br>    <span class="hljs-keyword">const</span> upgradeHeader = request.<span class="hljs-property">headers</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;Upgrade&#x27;</span>) || <span class="hljs-string">&#x27;&#x27;</span>;<br>    <span class="hljs-keyword">if</span> (upgradeHeader.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">includes</span>(<span class="hljs-string">&#x27;websocket&#x27;</span>)) &#123;<br>      <span class="hljs-keyword">const</span> [client, server] = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">values</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">WebSocketPair</span>());<br>      server.<span class="hljs-title function_">acceptWith</span>(<span class="hljs-variable language_">this</span>);<br><br>      <span class="hljs-comment">// 已有一个 id 持有连接 → 提示冲突，拒绝第二个</span><br>      <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span>) &#123;<br>        server.<span class="hljs-title function_">send</span>(<span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123;<br>          <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;ALERT&#x27;</span>,<br>          <span class="hljs-attr">payload</span>: &#123; <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;unavailable&#x27;</span> &#125;,<br>          <span class="hljs-attr">src</span>: <span class="hljs-string">&#x27;server&#x27;</span>,<br>          <span class="hljs-attr">dst</span>: <span class="hljs-variable language_">this</span>.<span class="hljs-property">peerId</span>,<br>        &#125;));<br>      &#125;<br>      <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> = server;<br><br>      <span class="hljs-comment">// 回发 OPEN 确认（PeerJS 注册成功的关键）</span><br>      server.<span class="hljs-title function_">send</span>(<span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123;<br>        <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;OPEN&#x27;</span>,<br>        <span class="hljs-attr">src</span>: <span class="hljs-string">&#x27;server&#x27;</span>,<br>        <span class="hljs-attr">id</span>: <span class="hljs-variable language_">this</span>.<span class="hljs-property">peerId</span>,<br>      &#125;));<br><br>      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-literal">null</span>, &#123; <span class="hljs-attr">status</span>: <span class="hljs-number">101</span>, <span class="hljs-attr">webSocket</span>: client &#125;);<br>    &#125;<br>    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">&#x27;Expected WebSocket&#x27;</span>, &#123; <span class="hljs-attr">status</span>: <span class="hljs-number">400</span> &#125;);<br>  &#125;<br><br>  <span class="hljs-comment">// 收到的消息</span><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">websocketMessage</span>(<span class="hljs-params">ws, message</span>) &#123;<br>    <span class="hljs-keyword">let</span> msg;<br>    <span class="hljs-keyword">try</span> &#123; msg = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(message); &#125; <span class="hljs-keyword">catch</span> &#123; <span class="hljs-keyword">return</span>; &#125;<br><br>    <span class="hljs-comment">// DELETE/LEAVE 类型 → 掉线清理</span><br>    <span class="hljs-keyword">if</span> (msg.<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;DELETE&#x27;</span> || msg.<span class="hljs-property">eventType</span> === <span class="hljs-string">&#x27;leave&#x27;</span> || msg.<span class="hljs-property">type</span> === <span class="hljs-string">&#x27;CLOSE&#x27;</span>) &#123;<br>      <span class="hljs-keyword">try</span> &#123; ws.<span class="hljs-title function_">close</span>(<span class="hljs-number">1000</span>, <span class="hljs-string">&#x27;bye&#x27;</span>); &#125; <span class="hljs-keyword">catch</span> (e) &#123;&#125;<br>      <span class="hljs-comment">// 通知同伴该玩家已离开</span><br>      <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> &amp;&amp; <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> !== ws) &#123;<br>        <span class="hljs-keyword">try</span> &#123;<br>          <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span>.<span class="hljs-title function_">send</span>(<span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(&#123;<br>            <span class="hljs-attr">type</span>: <span class="hljs-string">&#x27;unavailable&#x27;</span>,<br>            <span class="hljs-attr">src</span>: <span class="hljs-string">&#x27;server&#x27;</span>,<br>            <span class="hljs-attr">dst</span>: msg.<span class="hljs-property">src</span>,<br>          &#125;));<br>        &#125; <span class="hljs-keyword">catch</span> (e) &#123;&#125;<br>      &#125;<br>      <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> = ws === <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> ? <span class="hljs-literal">null</span> : <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span>;<br>      <span class="hljs-keyword">return</span>;<br>    &#125;<br><br>    <span class="hljs-comment">// 是发给对端玩家的信令（OFFER/ANSWER/ICE 等），通过另一个握手接线的对方转发</span><br>    <span class="hljs-comment">// 这里只是单房间实现，多玩家房间需要维护 this.clients 映射按 dst 转发</span><br>    <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> &amp;&amp; <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> !== ws) &#123;<br>      <span class="hljs-keyword">try</span> &#123;<br>        <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span>.<span class="hljs-title function_">send</span>(<span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(msg));<br>      &#125; <span class="hljs-keyword">catch</span> (e) &#123;&#125;<br>    &#125;<br>  &#125;<br><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">websocketClose</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">ws</span> = <span class="hljs-literal">null</span>;<br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><blockquote><p><strong>说明</strong>：上面是一个<strong>一房一级</strong>（房间 &#x3D; ID）的最小实现，适合「一人建房一人入房」的双人对局（每个玩家 id 对应一个独立 DO 实例，互不干扰）。如果你的游戏是多人房间，需要改为所有玩家进入同一个 DO（用同一个 name）并在内部维护 <code>Map&lt;peerId, ws&gt;</code> 按目标转发，同时把 <code>dst</code> 回写为对端 peerId 防止篡改。</p></blockquote><h2 id="踩坑记录（重要）"><a href="#踩坑记录（重要）" class="headerlink" title="踩坑记录（重要）"></a>踩坑记录（重要）</h2><p>部署过程中最容易在这里卡住，逐个说明：</p><h3 id="坑-1：新版-Durable-Object-语法（最关键）"><a href="#坑-1：新版-Durable-Object-语法（最关键）" class="headerlink" title="坑 1：新版 Durable Object 语法（最关键）"></a>坑 1：新版 Durable Object 语法（最关键）</h3><p>Cloudflare 在 2024 年起<strong>已废弃旧的 <code>class extends DurableObject</code> 隐式导出</strong>，新语法必须：</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">import</span> &#123; <span class="hljs-title class_">DurableObject</span> &#125; <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;cloudflare:workers&#x27;</span>;<br><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">PeerServer</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_ inherited__">DurableObject</span> &#123;<br>  ...<br>&#125;<br></code></pre></td></tr></table></figure><p><strong>原因</strong>：控制台「添加绑定」界面会扫描 Worker 代码里的 <code>extends DurableObject</code> 来列出可选类名。如果你用的是旧式写法，绑定界面会显示「未找到 Durable Object &#x2F; 0 个选项」，导致无法添加绑定。</p><h3 id="坑-2：ESM-代码-Content-Type-必须是-application-javascript-module"><a href="#坑-2：ESM-代码-Content-Type-必须是-application-javascript-module" class="headerlink" title="坑 2：ESM 代码 Content-Type 必须是 application/javascript+module"></a>坑 2：ESM 代码 Content-Type 必须是 <code>application/javascript+module</code></h3><p>如果你通过 <strong>Cloudflare API</strong>（<code>/accounts/{id}/workers/services/{name}</code>）上传代码，脚本文件必须带上：</p><figure class="highlight fortran"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs fortran">Content-<span class="hljs-keyword">Type</span>: application/javascript+<span class="hljs-keyword">module</span><br></code></pre></td></tr></table></figure><p>否则会报 <code>Unexpected token &#39;export&#39;</code>——因为服务端把 ESM 当作了经典脚本。很多人首传都栽在这。</p><h3 id="坑-3：绑定配置（metadata）要与非绑定一起提交"><a href="#坑-3：绑定配置（metadata）要与非绑定一起提交" class="headerlink" title="坑 3：绑定配置（metadata）要与非绑定一起提交"></a>坑 3：绑定配置（metadata）要与非绑定一起提交</h3><p>用 API 部署时，<strong>绑定属于 metadata 的一部分</strong>。要用 HTTP 上传（新版本 API）同时提交 <code>metadata</code>（内含有 <code>main_module</code> 与 <code>bindings</code>）。如果不带 <code>bindings</code>，页面看着部署了，实际 WebSocket 会返回 <code>500</code>（代码 1101），因为 Worker 里 <code>env.PEER_SERVER</code> 是 undefined。</p><h2 id="部署步骤"><a href="#部署步骤" class="headerlink" title="部署步骤"></a>部署步骤</h2><h3 id="方式-A：Dashboard-手动（适合首次尝试）"><a href="#方式-A：Dashboard-手动（适合首次尝试）" class="headerlink" title="方式 A：Dashboard 手动（适合首次尝试）"></a>方式 A：Dashboard 手动（适合首次尝试）</h3><ol><li>进入 Cloudflare Dashboard → <strong>Workers &amp; Pages</strong> → <strong>创建 Worker</strong></li><li>粘贴上面的 <code>worker.js</code> 代码 → <strong>保存并部署</strong></li><li>进入 Worker 的 <strong>设置 → 绑定 → 添加绑定</strong></li><li>绑定类型选择 <strong>Durable Object</strong>（注意别选成 KV）</li><li>变量名填 <code>PEER_SERVER</code>，绑定类名选代码里的 <code>PeerServer</code></li><li>部署完成后，浏览器访问 <code>https://你的-worker-子域.workers.dev</code> 应返回 <code>PeerJS Server is running on Cloudflare Workers!</code></li></ol><h3 id="方式-B：Wrangler-CLI（推荐用于项目化管理）"><a href="#方式-B：Wrangler-CLI（推荐用于项目化管理）" class="headerlink" title="方式 B：Wrangler CLI（推荐用于项目化管理）"></a>方式 B：Wrangler CLI（推荐用于项目化管理）</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm init -y<br>npm install -D wrangler<br></code></pre></td></tr></table></figure><p>创建 <code>wrangler.toml</code>：</p><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs toml"><span class="hljs-attr">name</span> = <span class="hljs-string">&quot;peerjs-server&quot;</span><br><span class="hljs-attr">main</span> = <span class="hljs-string">&quot;worker.js&quot;</span><br><span class="hljs-attr">compatibility_date</span> = <span class="hljs-string">&quot;2024-01-01&quot;</span><br><br><span class="hljs-section">[[durable_objects.bindings]]</span><br><span class="hljs-attr">name</span> = <span class="hljs-string">&quot;PEER_SERVER&quot;</span><br><span class="hljs-attr">class_name</span> = <span class="hljs-string">&quot;PeerServer&quot;</span><br><br><span class="hljs-section">[[migrations]]</span><br><span class="hljs-attr">tag</span> = <span class="hljs-string">&quot;v1&quot;</span><br><span class="hljs-attr">new_sqlite_classes</span> = [<span class="hljs-string">&quot;PeerServer&quot;</span>]<br></code></pre></td></tr></table></figure><p>登录并部署：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">npx wrangler login<br>npx wrangler deploy<br></code></pre></td></tr></table></figure><blockquote><p>DO 迁移是<strong>一次性操作</strong>，第二次部署若重复声明 <code>new_sqlite_classes</code> 会报 <code>Migration conflict</code>。新增类改成：</p></blockquote><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs toml"><span class="hljs-section">[[migrations]]</span><br><span class="hljs-attr">tag</span> = <span class="hljs-string">&quot;v2&quot;</span><br><span class="hljs-attr">new_classes</span> = [<span class="hljs-string">&quot;AnotherClass&quot;</span>]<br></code></pre></td></tr></table></figure><h2 id="前端接入（Hexo-页面）"><a href="#前端接入（Hexo-页面）" class="headerlink" title="前端接入（Hexo 页面）"></a>前端接入（Hexo 页面）</h2><p>在一个 HTML 游戏页面引入 peerjs，并连接到你的 Worker：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><code class="hljs html"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">&quot;https://unpkg.com/peerjs@1.5.4/dist/peerjs.min.js&quot;</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span><br><span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="language-javascript"></span><br><span class="language-javascript">  <span class="hljs-keyword">var</span> myId = <span class="hljs-string">&#x27;my-room-id&#x27;</span>; <span class="hljs-comment">// 房间名 / 玩家 id</span></span><br><span class="language-javascript"></span><br><span class="language-javascript">  <span class="hljs-keyword">var</span> peer = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Peer</span>(myId, &#123;</span><br><span class="language-javascript">    <span class="hljs-attr">host</span>: <span class="hljs-string">&#x27;你的-worker子域.workers.dev&#x27;</span>,</span><br><span class="language-javascript">    <span class="hljs-attr">port</span>: <span class="hljs-number">443</span>,        <span class="hljs-comment">// 部署在 HTTPS，443</span></span><br><span class="language-javascript">    <span class="hljs-attr">secure</span>: <span class="hljs-literal">true</span>,</span><br><span class="language-javascript">    <span class="hljs-attr">debug</span>: <span class="hljs-number">1</span>,         <span class="hljs-comment">// 1 输出关键日志，便于排查</span></span><br><span class="language-javascript">    <span class="hljs-comment">// 默认 path 就是 /peerjs，无需改</span></span><br><span class="language-javascript">  &#125;);</span><br><span class="language-javascript"></span><br><span class="language-javascript">  peer.<span class="hljs-title function_">on</span>(<span class="hljs-string">&#x27;open&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params">id</span>) &#123;</span><br><span class="language-javascript">    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">&#x27;已注册，id =&#x27;</span>, id);</span><br><span class="language-javascript">  &#125;);</span><br><span class="language-javascript"></span><br><span class="language-javascript">  peer.<span class="hljs-title function_">on</span>(<span class="hljs-string">&#x27;connection&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params">conn</span>) &#123;</span><br><span class="language-javascript">    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">&#x27;对方接入&#x27;</span>, conn);</span><br><span class="language-javascript">    conn.<span class="hljs-title function_">on</span>(<span class="hljs-string">&#x27;data&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params">data</span>) &#123;</span><br><span class="language-javascript">      <span class="hljs-comment">// 处理对方发来的信令数据</span></span><br><span class="language-javascript">    &#125;);</span><br><span class="language-javascript">    conn.<span class="hljs-title function_">on</span>(<span class="hljs-string">&#x27;open&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) &#123; conn.<span class="hljs-title function_">send</span>(<span class="hljs-string">&#x27;hello&#x27;</span>); &#125;);</span><br><span class="language-javascript">  &#125;);</span><br><span class="language-javascript"></span><br><span class="language-javascript">  peer.<span class="hljs-title function_">on</span>(<span class="hljs-string">&#x27;error&#x27;</span>, <span class="hljs-keyword">function</span> (<span class="hljs-params">err</span>) &#123;</span><br><span class="language-javascript">    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">&#x27;PeerJS 错误 :&#x27;</span>, err.<span class="hljs-property">type</span>, <span class="hljs-string">&#x27;-&#x27;</span>, err.<span class="hljs-property">message</span>);</span><br><span class="language-javascript">  &#125;);</span><br><span class="language-javascript"></span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span><br></code></pre></td></tr></table></figure><p><strong>关键点</strong>：</p><ul><li><code>host</code> 必须指向你的 Worker 子域（<code>xxx.workers.dev</code>）</li><li><code>secure: true</code>（你的 Worker 域名是 HTTPS）</li><li>连接若总是报 <code>peer-unavailable</code>，检查 DO 绑定是否真的存在（最常见坑 3）</li></ul><h2 id="安全提示"><a href="#安全提示" class="headerlink" title="安全提示"></a>安全提示</h2><ul><li><strong>不要泄露敏感信息</strong>：<code>wrangler.toml</code> 中的绑定 ID、Dashboard 里的账号 ID 属于你的私有资源，不要在博客&#x2F;代码里公开。</li><li>这个实现<strong>没有做鉴权</strong>（任意 id 都能注册），只适合个人博客的娱乐联机，有心者可以伪造身份——<strong>要防外挂请在设计层面解决</strong>：联机扑克重要的防作弊手段是<strong>对手的手牌永不发送到你的浏览器</strong>，只发送公共牌与结算结果，这是由游戏逻辑保证的，而不是信令服务器。</li></ul><h2 id="测试验证"><a href="#测试验证" class="headerlink" title="测试验证"></a>测试验证</h2><p>部署后可同样在浏览器开两个窗口各跑上面那段连接代码，用两个不同的房间 id，观察：</p><ul><li>两个窗口都打印 <code>已连接</code></li><li>A 发送 OFFER，B 能收到并回发 ANSWER</li><li>消息能到达两端（P2P 建立）</li></ul><p>我这边实测两窗口互通正常。</p><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><ul><li>Cloudflare Workers + DO 完全可以托管 PeerJS 信令服务器，免费、低延迟</li><li>核心 3 坑：新版 DO 语法、ESM Content-Type、绑定 metadata</li><li>信令中转很「透明」，真正的防作弊&#x2F;逻辑在应用层</li></ul>]]>
      </content:encoded>
    </item>
    <item>
      <title>RSS 订阅指南</title>
      <link>https://neoisconstantine-github-io.pages.dev/2026/06/23/RSS%E8%AE%A2%E9%98%85%E6%8C%87%E5%8D%97/</link>
      <description>本博客现已支持 Atom 和 RSS 2.0 两种 Feed 格式，本文介绍如何使用 RSS 阅读器订阅博客更新。</description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/%E5%8D%9A%E5%AE%A2%E6%90%AD%E5%BB%BA/">博客搭建</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/RSS/">RSS</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Hexo/">Hexo</category>
      <pubDate>Tue, 23 Jun 2026 12:00:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h2 id="什么是-RSS？"><a href="#什么是-RSS？" class="headerlink" title="什么是 RSS？"></a>什么是 RSS？</h2><p>RSS（Really Simple Syndication）是一种网页内容聚合格式，让你可以通过 <strong>RSS 阅读器</strong> 一站式订阅多个博客、新闻站点，不用一个个网站去刷。</p><p>当博客发布新文章时，阅读器会自动拉取更新。</p><h2 id="本博客支持的-Feed-地址"><a href="#本博客支持的-Feed-地址" class="headerlink" title="本博客支持的 Feed 地址"></a>本博客支持的 Feed 地址</h2><p>两种格式任选其一，功能完全一样：</p><table><thead><tr><th>格式</th><th>地址</th></tr></thead><tbody><tr><td><strong>Atom</strong></td><td><code>https://neoisconstantine-github-io.pages.dev/atom.xml</code></td></tr><tr><td><strong>RSS 2.0</strong></td><td><code>https://neoisconstantine-github-io.pages.dev/rss2.xml</code></td></tr></tbody></table><h2 id="Atom-vs-RSS-2-0-有什么区别？"><a href="#Atom-vs-RSS-2-0-有什么区别？" class="headerlink" title="Atom vs RSS 2.0 有什么区别？"></a>Atom vs RSS 2.0 有什么区别？</h2><table><thead><tr><th></th><th>Atom</th><th>RSS 2.0</th></tr></thead><tbody><tr><td>标准化</td><td>IETF 标准（较新）</td><td>更早的标准</td></tr><tr><td>语法规范</td><td>更严格</td><td>较宽松</td></tr><tr><td>阅读器兼容</td><td>全部支持</td><td>全部支持</td></tr></tbody></table><p>简单说：<strong>对你没区别</strong>，选哪个都行。如果你不确定，用 Atom。</p><h2 id="推荐的-RSS-阅读器"><a href="#推荐的-RSS-阅读器" class="headerlink" title="推荐的 RSS 阅读器"></a>推荐的 RSS 阅读器</h2><h3 id="在线服务"><a href="#在线服务" class="headerlink" title="在线服务"></a>在线服务</h3><ul><li><strong>Feedly</strong> — 最流行的在线 RSS 阅读器，支持多平台</li><li><strong>Inoreader</strong> — 功能强大，支持规则过滤和搜索</li></ul><h3 id="桌面端"><a href="#桌面端" class="headerlink" title="桌面端"></a>桌面端</h3><ul><li><strong>NetNewsWire</strong>（macOS）— 免费开源</li><li><strong>Fluent Reader</strong>（Windows &#x2F; macOS &#x2F; Linux）— 免费开源</li><li><strong>irreader</strong>（Windows）— 国产，支持订阅和阅读</li></ul><h3 id="移动端"><a href="#移动端" class="headerlink" title="移动端"></a>移动端</h3><ul><li><strong>Feedly</strong>（iOS &#x2F; Android）</li><li><strong>Inoreader</strong>（iOS &#x2F; Android）</li><li><strong>NetNewsWire</strong>（iOS）</li></ul><h2 id="如何订阅"><a href="#如何订阅" class="headerlink" title="如何订阅"></a>如何订阅</h2><p>以 Feedly 为例：</p><ol><li>打开 <a href="https://feedly.com/">Feedly</a></li><li>点击右上角 <strong>“Follow”</strong></li><li>输入 <code>https://neoisconstantine-github-io.pages.dev/atom.xml</code></li><li>点击 <strong>“Follow”</strong> 即可</li></ol><p>其他阅读器操作类似，一般都有”添加订阅”或”Add Feed”的入口，粘贴 Feed 地址就行。</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>OpenCode + Chrome DevTools MCP 使用说明</title>
      <link>https://neoisconstantine-github-io.pages.dev/2026/06/22/OpenCode%20+%20Chrome%20DevTools%20MCP%20%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E/</link>
      <description>OpenCode + Chrome DevTools MCP 使用说明</description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/OpenCode/">OpenCode</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/AI/">AI</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/OpenCode/">OpenCode</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Chrome/">Chrome</category>
      <pubDate>Mon, 22 Jun 2026 16:18:22 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h2 id="前置条件"><a href="#前置条件" class="headerlink" title="前置条件"></a>前置条件</h2><ul><li>Node.js &gt;&#x3D; 20</li><li>Chrome 浏览器</li><li>已安装 <code>chrome-devtools-mcp</code>（首次运行自动安装）</li></ul><hr><h2 id="第一步：Chrome-开启远程调试"><a href="#第一步：Chrome-开启远程调试" class="headerlink" title="第一步：Chrome 开启远程调试"></a>第一步：Chrome 开启远程调试</h2><ol><li><strong>正常打开 Chrome</strong>（不需要任何命令行参数）</li><li>地址栏输入：<figure class="highlight avrasm"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs avrasm"><span class="hljs-symbol">chrome:</span>//inspect<span class="hljs-meta">#remote-debugging</span><br></code></pre></td></tr></table></figure></li><li>勾选 <strong>“Enable remote debugging”</strong></li><li>页面显示 <code>Server running at: starting…</code> 即表示就绪</li></ol><blockquote><p>⚠️ 每次重启 Chrome 后都需要重新开启此设置</p></blockquote><hr><h2 id="第二步：获取-WebSocket-地址"><a href="#第二步：获取-WebSocket-地址" class="headerlink" title="第二步：获取 WebSocket 地址"></a>第二步：获取 WebSocket 地址</h2><p>Chrome 会在用户数据目录生成 <code>DevToolsActivePort</code> 文件，里面包含调试端口和 WebSocket 路径：</p><figure class="highlight crystal"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs crystal">文件位置：<br>%LOCALAPPDATA%\Google\Chrome\User Data\DevToolsActivePort<br><br>内容示例：<br><span class="hljs-number">9222</span><br><span class="hljs-regexp">/devtools/browser</span><span class="hljs-regexp">/1e19be11-dcf8-4255-9133-f63c7a971911</span><br><span class="hljs-regexp"></span><br><span class="hljs-regexp">WebSocket 地址 = ws:/</span><span class="hljs-regexp">/127.0.0.1:9222/devtools</span><span class="hljs-regexp">/browser/</span><span class="hljs-number">1e19</span>be11-xxx<br></code></pre></td></tr></table></figure><hr><h2 id="第三步：启动-MCP-服务器连接"><a href="#第三步：启动-MCP-服务器连接" class="headerlink" title="第三步：启动 MCP 服务器连接"></a>第三步：启动 MCP 服务器连接</h2><h3 id="方式一：通过-WebSocket-直连（推荐）"><a href="#方式一：通过-WebSocket-直连（推荐）" class="headerlink" title="方式一：通过 WebSocket 直连（推荐）"></a>方式一：通过 WebSocket 直连（推荐）</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npx --<span class="hljs-built_in">yes</span> chrome-devtools-mcp@latest --wsEndpoint=ws://127.0.0.1:9222/devtools/browser/xxx<br></code></pre></td></tr></table></figure><h3 id="方式二：自动连接（需要-Chrome-已开启远程调试）"><a href="#方式二：自动连接（需要-Chrome-已开启远程调试）" class="headerlink" title="方式二：自动连接（需要 Chrome 已开启远程调试）"></a>方式二：自动连接（需要 Chrome 已开启远程调试）</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npx --<span class="hljs-built_in">yes</span> chrome-devtools-mcp@latest --autoConnect<br></code></pre></td></tr></table></figure><hr><h2 id="注意事项"><a href="#注意事项" class="headerlink" title="注意事项"></a>注意事项</h2><h3 id="⚠️-不要做的事"><a href="#⚠️-不要做的事" class="headerlink" title="⚠️ 不要做的事"></a>⚠️ 不要做的事</h3><table><thead><tr><th>错误做法</th><th>后果</th></tr></thead><tbody><tr><td>用 <code>--remote-debugging-port=9222</code> 启动 Chrome</td><td>部分 Windows 系统上绑不上端口</td></tr><tr><td>用 <code>--user-data-dir</code> 开新配置</td><td>失去所有登录态和扩展</td></tr><tr><td>用 <code>--browser-url=http://127.0.0.1:9222</code></td><td>Chrome 的 inspect 端口只接受 WebSocket，不接受 HTTP</td></tr><tr><td>杀掉 Chrome 进程重开</td><td>失去当前浏览会话</td></tr></tbody></table><h3 id="✅-正确做法"><a href="#✅-正确做法" class="headerlink" title="✅ 正确做法"></a>✅ 正确做法</h3><ul><li><strong>Chrome 正常启动</strong>，不加任何额外参数</li><li>只需在 <code>chrome://inspect#remote-debugging</code> 开启调试</li><li>MCP 连接用 <code>--wsEndpoint</code> 参数，从 <code>DevToolsActivePort</code> 文件获取地址</li></ul><h3 id="端口说明"><a href="#端口说明" class="headerlink" title="端口说明"></a>端口说明</h3><p>Chrome 的 inspect 远程调试端口特点：</p><ul><li>端口号写在于 <code>DevToolsActivePort</code> 文件中</li><li><strong>只接受 WebSocket 连接</strong>，不接受 HTTP 请求</li><li>访问 <code>http://127.0.0.1:9222/json/version</code> 会返回 <strong>404</strong></li></ul><hr><h2 id="可用-MCP-工具"><a href="#可用-MCP-工具" class="headerlink" title="可用 MCP 工具"></a>可用 MCP 工具</h2><p>连接成功后可通过 MCP 协议使用以下工具：</p><h3 id="导航"><a href="#导航" class="headerlink" title="导航"></a>导航</h3><ul><li><code>navigate_page</code> — 跳转到 URL</li><li><code>list_pages</code> — 列出所有标签页</li><li><code>select_page</code> — 切换标签页</li><li><code>new_page</code> — 打开新标签页</li><li><code>close_page</code> — 关闭标签页</li></ul><h3 id="操作"><a href="#操作" class="headerlink" title="操作"></a>操作</h3><ul><li><code>click</code> — 点击元素</li><li><code>fill</code> — 填写输入框</li><li><code>fill_form</code> — 填写整个表单</li><li><code>type_text</code> — 输入文本</li><li><code>press_key</code> — 按键</li><li><code>hover</code> — 悬停</li></ul><h3 id="调试"><a href="#调试" class="headerlink" title="调试"></a>调试</h3><ul><li><code>take_screenshot</code> — 截图</li><li><code>take_snapshot</code> — 获取页面无障碍树</li><li><code>evaluate_script</code> — 执行 JavaScript</li><li><code>get_console_message</code> — 获取控制台日志</li><li><code>list_console_messages</code> — 列出控制台消息</li></ul><h3 id="网络"><a href="#网络" class="headerlink" title="网络"></a>网络</h3><ul><li><code>list_network_requests</code> — 列出网络请求</li><li><code>get_network_request</code> — 获取网络请求详情</li></ul><h3 id="性能"><a href="#性能" class="headerlink" title="性能"></a>性能</h3><ul><li><code>performance_start_trace</code> — 开始性能录制</li><li><code>performance_stop_trace</code> — 停止性能录制</li><li><code>performance_analyze_insight</code> — 分析性能洞察</li></ul><hr><h2 id="OpenCode-配置"><a href="#OpenCode-配置" class="headerlink" title="OpenCode 配置"></a>OpenCode 配置</h2><p>在项目根目录创建 <code>opencode.json</code>：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;$schema&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;https://opencode.ai/config.json&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;mcp&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>    <span class="hljs-attr">&quot;chrome-devtools&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>      <span class="hljs-attr">&quot;type&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;local&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;command&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;npx&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;-y&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;chrome-devtools-mcp@latest&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;--autoConnect&quot;</span><span class="hljs-punctuation">]</span><br>    <span class="hljs-punctuation">&#125;</span><br>  <span class="hljs-punctuation">&#125;</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><blockquote><p>注意：配置后需要重启 OpenCode 才能生效</p></blockquote><hr><h2 id="故障排查"><a href="#故障排查" class="headerlink" title="故障排查"></a>故障排查</h2><h3 id="MCP-初始化成功但工具调用失败"><a href="#MCP-初始化成功但工具调用失败" class="headerlink" title="MCP 初始化成功但工具调用失败"></a>MCP 初始化成功但工具调用失败</h3><figure class="highlight subunit"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs subunit"><span class="hljs-keyword">Error: </span>Failed to fetch browser webSocket URL from http://.../json/version: HTTP Not Found<br></code></pre></td></tr></table></figure><p>→ 改用 <code>--wsEndpoint</code> 代替 <code>--browser-url</code></p><h3 id="DevToolsActivePort-文件不存在"><a href="#DevToolsActivePort-文件不存在" class="headerlink" title="DevToolsActivePort 文件不存在"></a>DevToolsActivePort 文件不存在</h3><p>→ 打开 <code>chrome://inspect#remote-debugging</code> 开启远程调试</p><h3 id="Chrome-提示”正受到自动测试软件控制”"><a href="#Chrome-提示”正受到自动测试软件控制”" class="headerlink" title="Chrome 提示”正受到自动测试软件控制”"></a>Chrome 提示”正受到自动测试软件控制”</h3><p>→ 这是正常的，表示 MCP 已连接成功</p><h3 id="连接断开"><a href="#连接断开" class="headerlink" title="连接断开"></a>连接断开</h3><p>→ 重新打开 <code>chrome://inspect#remote-debugging</code> 获取新的 WebSocket 地址重连</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>LLM 上下文压缩方案全面对比</title>
      <link>https://neoisconstantine-github-io.pages.dev/2026/06/17/LLM%20%E4%B8%8A%E4%B8%8B%E6%96%87%E5%8E%8B%E7%BC%A9%E6%96%B9%E6%A1%88%E5%85%A8%E9%9D%A2%E5%AF%B9%E6%AF%94/</link>
      <description>深入对比业界主流LLM上下文压缩方案：滑动窗口、摘要压缩、语义压缩、结构化压缩等，从实现原理、优缺点、适用场景全面分析，附代码示例。</description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/AI/">AI</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/AI/">AI</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/LLM/">LLM</category>
      <pubDate>Wed, 17 Jun 2026 15:08:36 GMT</pubDate>
      <content:encoded>
        <![CDATA[<hr><blockquote><p>业界主流方案、优势、实现方式与代码示例<br>更新日期：2026-06-17</p></blockquote><hr><h2 id="背景问题"><a href="#背景问题" class="headerlink" title="背景问题"></a>背景问题</h2><p>AI 对话中，<strong>每次请求都会把完整的历史消息（包括 thinking、工具调用输出、文件内容等）传给模型</strong>。随着对话拉长，上下文迅速膨胀，带来三个问题：</p><ul><li><strong>成本高</strong>：按 token 计费，上下文越长越贵</li><li><strong>速度慢</strong>：首 token 延迟随上下文长度线性增加</li><li><strong>质量下降</strong>：”Lost in the Middle” 现象——模型对上下文中间部分关注力最差</li></ul><figure class="highlight gauss"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs gauss"><span class="hljs-built_in">Token</span> 消耗 ≈ 系统提示 + 历史对话 × 轮数 + 工具输出 × 调用次数<br></code></pre></td></tr></table></figure><p>即使模型支持 200K&#x2F;1M token 窗口，<strong>装得下 ≠ 效果好 + 花钱少</strong>。</p><hr><h2 id="方案一：删除压缩（Compaction）"><a href="#方案一：删除压缩（Compaction）" class="headerlink" title="方案一：删除压缩（Compaction）"></a>方案一：删除压缩（Compaction）</h2><h3 id="原理"><a href="#原理" class="headerlink" title="原理"></a>原理</h3><p><strong>直接删除低信号内容</strong>，保留的原文一字不改。不生成新 token，零幻觉风险。</p><p>典型删除对象：</p><ul><li>工具调用的原始输出（grep 返回的 100 个文件 → 只保留匹配行）</li><li>文件 diff 的全量内容 → 只保留变更摘要</li><li>浏览器截屏的冗长 HTML → 只保留关键文本</li><li>重复的 thinking 过程</li></ul><h3 id="优势"><a href="#优势" class="headerlink" title="优势"></a>优势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>压缩比</td><td>50-70%</td></tr><tr><td>准确性</td><td>100%（保留下来的内容 &#x3D; 原文）</td></tr><tr><td>幻觉风险</td><td><strong>0%</strong></td></tr><tr><td>速度</td><td>快（本地正则&#x2F;规则即可，无需 LLM）</td></tr></tbody></table><h3 id="业界工具"><a href="#业界工具" class="headerlink" title="业界工具"></a>业界工具</h3><ul><li><strong><a href="https://github.com/headroom/headroom">Headroom</a></strong> — 开源上下文压缩层，10k+ star。SmartCrusher(JSON) + CodeCompressor(AST) + Kompress-base(文本)，压缩率 90%+</li><li><strong>RTK</strong> — 压缩 CLI 命令输出（<code>git show</code>、<code>ls</code> 等）</li><li><strong>lean-ctx</strong> — CLI + MCP 工具的上下文精简</li></ul><h3 id="实现示例"><a href="#实现示例" class="headerlink" title="实现示例"></a>实现示例</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-comment">// 简单的 compaction：只保留 grep 结果中的文件名和匹配行</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">compactGrepOutput</span>(<span class="hljs-params"><span class="hljs-attr">raw</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-built_in">string</span> &#123;<br>  <span class="hljs-keyword">const</span> lines = raw.<span class="hljs-title function_">split</span>(<span class="hljs-string">&#x27;\n&#x27;</span>);<br>  <span class="hljs-keyword">const</span> significant = lines.<span class="hljs-title function_">filter</span>(<span class="hljs-function"><span class="hljs-params">line</span> =&gt;</span> &#123;<br>    <span class="hljs-comment">// 过滤掉：空行、只有行号的行、重复的分隔线</span><br>    <span class="hljs-keyword">return</span> line.<span class="hljs-title function_">trim</span>() &amp;&amp; !<span class="hljs-regexp">/^─+$/</span>.<span class="hljs-title function_">test</span>(line) &amp;&amp; !<span class="hljs-regexp">/^\d+$/</span>.<span class="hljs-title function_">test</span>(line);<br>  &#125;);<br>  <span class="hljs-keyword">return</span> significant.<span class="hljs-title function_">join</span>(<span class="hljs-string">&#x27;\n&#x27;</span>);<br>&#125;<br><br><span class="hljs-comment">// 更智能的：JSON 响应只保留关键字段</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">compactJsonResponse</span>(<span class="hljs-params"><span class="hljs-attr">raw</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">keepFields</span>: <span class="hljs-built_in">string</span>[]</span>): <span class="hljs-built_in">string</span> &#123;<br>  <span class="hljs-keyword">const</span> parsed = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(raw);<br>  <span class="hljs-keyword">if</span> (<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(parsed)) &#123;<br>    <span class="hljs-keyword">return</span> <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(parsed.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">item</span> =&gt;</span> &#123;<br>      <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">any</span>&gt; = &#123;&#125;;<br>      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> field <span class="hljs-keyword">of</span> keepFields) &#123;<br>        <span class="hljs-keyword">if</span> (field <span class="hljs-keyword">in</span> item) result[field] = item[field];<br>      &#125;<br>      <span class="hljs-keyword">return</span> result;<br>    &#125;));<br>  &#125;<br>  <span class="hljs-keyword">return</span> raw;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="方案二：摘要压缩（Summarization）"><a href="#方案二：摘要压缩（Summarization）" class="headerlink" title="方案二：摘要压缩（Summarization）"></a>方案二：摘要压缩（Summarization）</h2><h3 id="原理-1"><a href="#原理-1" class="headerlink" title="原理"></a>原理</h3><p><strong>用 LLM 将多轮对话&#x2F;长文本</strong>提炼成一段摘要，替换原始内容。后续对话基于摘要 + 最近的 N 轮完整消息进行。</p><h3 id="优势-1"><a href="#优势-1" class="headerlink" title="优势"></a>优势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>压缩比</td><td>极高（100 轮对话 → 200 字摘要）</td></tr><tr><td>信息密度</td><td>高（关键点被提炼出来）</td></tr><tr><td>适用场景</td><td>长周期对话、客服、教育辅导</td></tr></tbody></table><h3 id="劣势"><a href="#劣势" class="headerlink" title="劣势"></a>劣势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>幻觉风险</td><td><strong>有</strong>（摘要可能漏掉或歪曲细节）</td></tr><tr><td>额外开销</td><td>每次压缩需要一次 LLM 调用</td></tr><tr><td>反复压缩衰减</td><td>摘要的摘要会不断丢失信息</td></tr></tbody></table><h3 id="实现示例-1"><a href="#实现示例-1" class="headerlink" title="实现示例"></a>实现示例</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">ConversationSummarizer</span> &#123;<br>  <span class="hljs-keyword">private</span> summary = <span class="hljs-string">&#x27;&#x27;</span>;<br>  <span class="hljs-keyword">private</span> <span class="hljs-attr">recentMessages</span>: <span class="hljs-title class_">Message</span>[] = [];<br>  <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> threshold = <span class="hljs-number">4000</span>; <span class="hljs-comment">// token 阈值</span><br><br>  <span class="hljs-title function_">addMessage</span>(<span class="hljs-params"><span class="hljs-attr">msg</span>: <span class="hljs-title class_">Message</span></span>) &#123;<br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">recentMessages</span>.<span class="hljs-title function_">push</span>(msg);<br>    <span class="hljs-keyword">if</span> (<span class="hljs-variable language_">this</span>.<span class="hljs-title function_">estimateTokens</span>() &gt; <span class="hljs-variable language_">this</span>.<span class="hljs-property">threshold</span>) &#123;<br>      <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">compress</span>();<br>    &#125;<br>  &#125;<br><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">compress</span>(<span class="hljs-params"></span>) &#123;<br>    <span class="hljs-keyword">const</span> toCompress = <span class="hljs-variable language_">this</span>.<span class="hljs-property">summary</span><br>      + <span class="hljs-string">&#x27;\n&#x27;</span> + <span class="hljs-variable language_">this</span>.<span class="hljs-property">recentMessages</span>.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> <span class="hljs-string">`<span class="hljs-subst">$&#123;m.role&#125;</span>: <span class="hljs-subst">$&#123;m.content&#125;</span>`</span>).<span class="hljs-title function_">join</span>(<span class="hljs-string">&#x27;\n&#x27;</span>);<br><br>    <span class="hljs-keyword">const</span> prompt = <span class="hljs-string">`请将以下对话历史压缩成一段简洁摘要，保留：</span><br><span class="hljs-string">1. 用户的关键信息（身份、偏好、已做的决定）</span><br><span class="hljs-string">2. 重要的任务状态和上下文</span><br><span class="hljs-string">3. 后续对话可能需要的事实</span><br><span class="hljs-string"></span><br><span class="hljs-string">原始对话：</span><br><span class="hljs-string"><span class="hljs-subst">$&#123;toCompress&#125;</span></span><br><span class="hljs-string"></span><br><span class="hljs-string">摘要：`</span>;<br><br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">summary</span> = <span class="hljs-keyword">await</span> llm.<span class="hljs-title function_">chat</span>(prompt);<br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">recentMessages</span> = []; <span class="hljs-comment">// 压缩后清空</span><br>    <span class="hljs-comment">// 或保留最后 1-2 轮防止断层</span><br>  &#125;<br><br>  <span class="hljs-title function_">buildContext</span>(): <span class="hljs-built_in">string</span> &#123;<br>    <span class="hljs-keyword">if</span> (!<span class="hljs-variable language_">this</span>.<span class="hljs-property">summary</span>) <span class="hljs-keyword">return</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">recentMessages</span>.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> <span class="hljs-string">`<span class="hljs-subst">$&#123;m.role&#125;</span>: <span class="hljs-subst">$&#123;m.content&#125;</span>`</span>).<span class="hljs-title function_">join</span>(<span class="hljs-string">&#x27;\n&#x27;</span>);<br>    <span class="hljs-keyword">return</span> <span class="hljs-string">`【历史摘要】\n<span class="hljs-subst">$&#123;<span class="hljs-variable language_">this</span>.summary&#125;</span>\n\n【最近对话】\n<span class="hljs-subst">$&#123;</span></span><br><span class="hljs-subst"><span class="hljs-string">      <span class="hljs-variable language_">this</span>.recentMessages.map(m =&gt; <span class="hljs-string">`<span class="hljs-subst">$&#123;m.role&#125;</span>: <span class="hljs-subst">$&#123;m.content&#125;</span>`</span>).join(<span class="hljs-string">&#x27;\n&#x27;</span>)</span></span><br><span class="hljs-subst"><span class="hljs-string">    &#125;</span>`</span>;<br>  &#125;<br><br>  <span class="hljs-keyword">private</span> <span class="hljs-title function_">estimateTokens</span>(): <span class="hljs-built_in">number</span> &#123;<br>    <span class="hljs-keyword">const</span> totalChars = <span class="hljs-variable language_">this</span>.<span class="hljs-property">summary</span>.<span class="hljs-property">length</span><br>      + <span class="hljs-variable language_">this</span>.<span class="hljs-property">recentMessages</span>.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> m.<span class="hljs-property">content</span>.<span class="hljs-property">length</span>).<span class="hljs-title function_">reduce</span>(<span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> a + b, <span class="hljs-number">0</span>);<br>    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">ceil</span>(totalChars / <span class="hljs-number">2</span>); <span class="hljs-comment">// 中文字符估算</span><br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="业界使用案例"><a href="#业界使用案例" class="headerlink" title="业界使用案例"></a>业界使用案例</h3><table><thead><tr><th>产品</th><th>做法</th></tr></thead><tbody><tr><td><strong>OpenAI Codex CLI</strong></td><td>调用 <code>compact()</code> API 对历史对话做摘要压缩</td></tr><tr><td><strong>Claude Code</strong></td><td>内置 compaction 机制，达到阈值后自动摘要</td></tr><tr><td><strong>Anthropic Compaction API</strong></td><td>服务端侧对话历史摘要（需付费）</td></tr></tbody></table><hr><h2 id="方案三：滑动窗口（Sliding-Window）"><a href="#方案三：滑动窗口（Sliding-Window）" class="headerlink" title="方案三：滑动窗口（Sliding Window）"></a>方案三：滑动窗口（Sliding Window）</h2><h3 id="原理-2"><a href="#原理-2" class="headerlink" title="原理"></a>原理</h3><p><strong>只保留最近 N 轮对话</strong>，更早的全部丢弃。实现最简单。</p><h3 id="常见策略"><a href="#常见策略" class="headerlink" title="常见策略"></a>常见策略</h3><table><thead><tr><th>策略</th><th>做法</th></tr></thead><tbody><tr><td>按轮数截断</td><td>保留最后 K 条消息</td></tr><tr><td>按 token 数截断</td><td>从尾部往前数，裁到预算内</td></tr><tr><td>按重要性排序截断</td><td>结合评分（相关性 + 时效性）丢弃最低分</td></tr></tbody></table><h3 id="优势-2"><a href="#优势-2" class="headerlink" title="优势"></a>优势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>实现难度</td><td>★☆☆☆☆ 极简单</td></tr><tr><td>速度</td><td>无额外开销</td></tr><tr><td>可预测</td><td>token 消耗固定</td></tr></tbody></table><h3 id="劣势-1"><a href="#劣势-1" class="headerlink" title="劣势"></a>劣势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>信息丢失</td><td>早期关键信息直接丢失</td></tr><tr><td>长任务中断</td><td>做了一半的任务，模型”失忆”</td></tr></tbody></table><h3 id="实现示例-2"><a href="#实现示例-2" class="headerlink" title="实现示例"></a>实现示例</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-comment">// 按 token 数滑动窗口</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">slidingWindow</span>(<span class="hljs-params"><span class="hljs-attr">messages</span>: <span class="hljs-title class_">Message</span>[], <span class="hljs-attr">maxTokens</span>: <span class="hljs-built_in">number</span></span>): <span class="hljs-title class_">Message</span>[] &#123;<br>  <span class="hljs-keyword">let</span> total = <span class="hljs-number">0</span>;<br>  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Message</span>[] = [];<br><br>  <span class="hljs-comment">// 从最新的消息开始往前数</span><br>  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = messages.<span class="hljs-property">length</span> - <span class="hljs-number">1</span>; i &gt;= <span class="hljs-number">0</span>; i--) &#123;<br>    <span class="hljs-keyword">const</span> tokens = <span class="hljs-title function_">estimateTokens</span>(messages[i].<span class="hljs-property">content</span>);<br>    <span class="hljs-keyword">if</span> (total + tokens &gt; maxTokens) <span class="hljs-keyword">break</span>;<br>    total += tokens;<br>    result.<span class="hljs-title function_">unshift</span>(messages[i]); <span class="hljs-comment">// 保持顺序</span><br>  &#125;<br><br>  <span class="hljs-comment">// 始终保留系统提示（第一条）</span><br>  <span class="hljs-keyword">if</span> (messages[<span class="hljs-number">0</span>]?.<span class="hljs-property">role</span> === <span class="hljs-string">&#x27;system&#x27;</span>) &#123;<br>    <span class="hljs-keyword">if</span> (result[<span class="hljs-number">0</span>]?.<span class="hljs-property">role</span> !== <span class="hljs-string">&#x27;system&#x27;</span>) &#123;<br>      result.<span class="hljs-title function_">unshift</span>(messages[<span class="hljs-number">0</span>]);<br>    &#125;<br>  &#125;<br><br>  <span class="hljs-keyword">return</span> result;<br>&#125;<br><br><span class="hljs-comment">// 更精细的：保留首尾，截断中间（Lost in the Middle 优化）</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">smartWindow</span>(<span class="hljs-params"><span class="hljs-attr">messages</span>: <span class="hljs-title class_">Message</span>[], <span class="hljs-attr">maxTokens</span>: <span class="hljs-built_in">number</span></span>): <span class="hljs-title class_">Message</span>[] &#123;<br>  <span class="hljs-keyword">if</span> (<span class="hljs-title function_">estimateTotalTokens</span>(messages) &lt;= maxTokens) <span class="hljs-keyword">return</span> messages;<br><br>  <span class="hljs-keyword">const</span> systemMsg = messages.<span class="hljs-title function_">filter</span>(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> m.<span class="hljs-property">role</span> === <span class="hljs-string">&#x27;system&#x27;</span>);<br>  <span class="hljs-keyword">const</span> history = messages.<span class="hljs-title function_">filter</span>(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> m.<span class="hljs-property">role</span> !== <span class="hljs-string">&#x27;system&#x27;</span>);<br><br>  <span class="hljs-comment">// 保留最近 30% 的 token 预算给尾部</span><br>  <span class="hljs-keyword">const</span> tailBudget = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(maxTokens * <span class="hljs-number">0.3</span>);<br>  <span class="hljs-keyword">const</span> headBudget = maxTokens - tailBudget - <span class="hljs-title function_">estimateTotalTokens</span>(systemMsg);<br><br>  <span class="hljs-keyword">const</span> <span class="hljs-attr">tail</span>: <span class="hljs-title class_">Message</span>[] = [];<br>  <span class="hljs-keyword">let</span> tailTokens = <span class="hljs-number">0</span>;<br>  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = history.<span class="hljs-property">length</span> - <span class="hljs-number">1</span>; i &gt;= <span class="hljs-number">0</span>; i--) &#123;<br>    <span class="hljs-keyword">const</span> t = <span class="hljs-title function_">estimateTokens</span>(history[i].<span class="hljs-property">content</span>);<br>    <span class="hljs-keyword">if</span> (tailTokens + t &gt; tailBudget) <span class="hljs-keyword">break</span>;<br>    tailTokens += t;<br>    tail.<span class="hljs-title function_">unshift</span>(history[i]);<br>  &#125;<br><br>  <span class="hljs-comment">// 从头部取到 headBudget</span><br>  <span class="hljs-keyword">const</span> <span class="hljs-attr">head</span>: <span class="hljs-title class_">Message</span>[] = [];<br>  <span class="hljs-keyword">let</span> headTokens = <span class="hljs-number">0</span>;<br>  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> msg <span class="hljs-keyword">of</span> history) &#123;<br>    <span class="hljs-keyword">if</span> (head.<span class="hljs-title function_">includes</span>(msg) || tail.<span class="hljs-title function_">includes</span>(msg)) <span class="hljs-keyword">break</span>; <span class="hljs-comment">// 不重复</span><br>    <span class="hljs-keyword">const</span> t = <span class="hljs-title function_">estimateTokens</span>(msg.<span class="hljs-property">content</span>);<br>    <span class="hljs-keyword">if</span> (headTokens + t &gt; headBudget) <span class="hljs-keyword">break</span>;<br>    headTokens += t;<br>    head.<span class="hljs-title function_">push</span>(msg);<br>  &#125;<br><br>  <span class="hljs-keyword">return</span> [...systemMsg, ...head, ...tail];<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="方案四：向量记忆-RAG（检索式记忆）"><a href="#方案四：向量记忆-RAG（检索式记忆）" class="headerlink" title="方案四：向量记忆 &#x2F; RAG（检索式记忆）"></a>方案四：向量记忆 &#x2F; RAG（检索式记忆）</h2><h3 id="原理-3"><a href="#原理-3" class="headerlink" title="原理"></a>原理</h3><p><strong>不保存全部历史</strong>，而是把消息向量化存入数据库（Chroma、Milvus 等），每次只检索最相关的几条。</p><h3 id="优势-3"><a href="#优势-3" class="headerlink" title="优势"></a>优势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>理论上限</td><td>近乎无限记忆（只受存储限制）</td></tr><tr><td>相关性</td><td>高（语义检索找到最相关的）</td></tr><tr><td>Token 消耗</td><td>极低（只塞相关片段）</td></tr></tbody></table><h3 id="劣势-2"><a href="#劣势-2" class="headerlink" title="劣势"></a>劣势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>实现复杂度</td><td>★★★★☆ 高</td></tr><tr><td>额外组件</td><td>需要向量数据库 + embedding 模型</td></tr><tr><td>检索质量</td><td>依赖 embedding 质量，可能漏检</td></tr><tr><td>延迟</td><td>每次多一次向量检索（通常 &lt; 50ms）</td></tr></tbody></table><h3 id="实现示例-3"><a href="#实现示例-3" class="headerlink" title="实现示例"></a>实现示例</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">VectorMemory</span> &#123;<br>  <span class="hljs-title function_">constructor</span>(<span class="hljs-params"></span><br><span class="hljs-params">    <span class="hljs-keyword">private</span> <span class="hljs-attr">embeddingClient</span>: <span class="hljs-title class_">EmbeddingAPI</span>,</span><br><span class="hljs-params">    <span class="hljs-keyword">private</span> <span class="hljs-attr">vectorDb</span>: <span class="hljs-title class_">VectorDatabase</span>,</span><br><span class="hljs-params">    <span class="hljs-keyword">private</span> topK = <span class="hljs-number">5</span></span><br><span class="hljs-params">  </span>) &#123;&#125;<br><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">addMessage</span>(<span class="hljs-params"><span class="hljs-attr">msg</span>: <span class="hljs-title class_">Message</span></span>) &#123;<br>    <span class="hljs-keyword">const</span> embedding = <span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">embeddingClient</span>.<span class="hljs-title function_">embed</span>(msg.<span class="hljs-property">content</span>);<br>    <span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">vectorDb</span>.<span class="hljs-title function_">insert</span>(&#123;<br>      <span class="hljs-attr">id</span>: msg.<span class="hljs-property">id</span>,<br>      <span class="hljs-attr">vector</span>: embedding,<br>      <span class="hljs-attr">metadata</span>: &#123;<br>        <span class="hljs-attr">role</span>: msg.<span class="hljs-property">role</span>,<br>        <span class="hljs-attr">timestamp</span>: msg.<span class="hljs-property">timestamp</span>,<br>        <span class="hljs-attr">content</span>: msg.<span class="hljs-property">content</span><br>      &#125;<br>    &#125;);<br>  &#125;<br><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">retrieve</span>(<span class="hljs-attr">query</span>: <span class="hljs-built_in">string</span>, limit = <span class="hljs-number">5</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Message</span>[]&gt; &#123;<br>    <span class="hljs-keyword">const</span> queryEmbedding = <span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">embeddingClient</span>.<span class="hljs-title function_">embed</span>(query);<br>    <span class="hljs-keyword">const</span> results = <span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-property">vectorDb</span>.<span class="hljs-title function_">search</span>(queryEmbedding, limit);<br>    <span class="hljs-keyword">return</span> results.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> (&#123;<br>      <span class="hljs-attr">role</span>: r.<span class="hljs-property">metadata</span>.<span class="hljs-property">role</span>,<br>      <span class="hljs-attr">content</span>: r.<span class="hljs-property">metadata</span>.<span class="hljs-property">content</span>,<br>      <span class="hljs-attr">timestamp</span>: r.<span class="hljs-property">metadata</span>.<span class="hljs-property">timestamp</span><br>    &#125;));<br>  &#125;<br><br>  <span class="hljs-comment">// 构建上下文：系统提示 + 检索结果 + 最近 N 轮</span><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">buildContext</span>(<span class="hljs-attr">query</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">recentMessages</span>: <span class="hljs-title class_">Message</span>[]): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">string</span>&gt; &#123;<br>    <span class="hljs-keyword">const</span> relevant = <span class="hljs-keyword">await</span> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">retrieve</span>(query, <span class="hljs-variable language_">this</span>.<span class="hljs-property">topK</span>);<br>    <span class="hljs-keyword">const</span> context = [<br>      <span class="hljs-string">&#x27;【相关历史】&#x27;</span>,<br>      ...relevant.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> <span class="hljs-string">`<span class="hljs-subst">$&#123;m.role&#125;</span>: <span class="hljs-subst">$&#123;m.content&#125;</span>`</span>),<br>      <span class="hljs-string">&#x27;&#x27;</span>,<br>      <span class="hljs-string">&#x27;【最近对话】&#x27;</span>,<br>      ...recentMessages.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> <span class="hljs-string">`<span class="hljs-subst">$&#123;m.role&#125;</span>: <span class="hljs-subst">$&#123;m.content&#125;</span>`</span>)<br>    ].<span class="hljs-title function_">join</span>(<span class="hljs-string">&#x27;\n&#x27;</span>);<br>    <span class="hljs-keyword">return</span> context;<br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="方案五：多代理隔离（Multi-Agent-Isolation）"><a href="#方案五：多代理隔离（Multi-Agent-Isolation）" class="headerlink" title="方案五：多代理隔离（Multi-Agent Isolation）"></a>方案五：多代理隔离（Multi-Agent Isolation）</h2><h3 id="原理-4"><a href="#原理-4" class="headerlink" title="原理"></a>原理</h3><p>每个子代理维护自己的小上下文，只关注自己的任务。主代理只持有摘要级别的全局上下文。</p><h3 id="优势-4"><a href="#优势-4" class="headerlink" title="优势"></a>优势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>单 agent 上下文</td><td>小（只关注子任务）</td></tr><tr><td>隔离性好</td><td>一个 agent 的 thinking 不会污染另一个</td></tr><tr><td>可并行</td><td>多个 agent 可同时工作</td></tr></tbody></table><h3 id="劣势-3"><a href="#劣势-3" class="headerlink" title="劣势"></a>劣势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>架构复杂度</td><td>★★★★★ 很高</td></tr><tr><td>协调开销</td><td>需要主 agent 做路由和汇总</td></tr><tr><td>信息孤岛</td><td>子 agent 看不到全局，可能做出次优决策</td></tr></tbody></table><h3 id="实现示意"><a href="#实现示意" class="headerlink" title="实现示意"></a>实现示意</h3><figure class="highlight sqf"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs sqf">                 ┌─────────────────┐<br>                 │   Orchestrator   │  ← 只持有摘要上下文<br>                 │   (主代理)       │<br>                 └────────┬────────┘<br>                         │<br>       ┌─────────────────┼─────────────────┐<br>       ▼                 ▼                  ▼<br>┌────────────┐   ┌────────────┐   ┌────────────┐<br>│   Frontend  │   │    Backend  │   │  Database  │  ← 每个只关心自己的上下文<br>│   <span class="hljs-built_in">Agent</span>     │   │    <span class="hljs-built_in">Agent</span>    │   │   <span class="hljs-built_in">Agent</span>    │<br>└────────────┘   └────────────┘   └────────────┘<br></code></pre></td></tr></table></figure><hr><h2 id="方案六：上下文缓存（Context-Caching）"><a href="#方案六：上下文缓存（Context-Caching）" class="headerlink" title="方案六：上下文缓存（Context Caching）"></a>方案六：上下文缓存（Context Caching）</h2><h3 id="原理-5"><a href="#原理-5" class="headerlink" title="原理"></a>原理</h3><p><strong>静态上下文（系统提示、文档、知识库）只在第一次完整传入</strong>，后续请求复用缓存结果，只传变化的部分。</p><h3 id="提供商支持"><a href="#提供商支持" class="headerlink" title="提供商支持"></a>提供商支持</h3><table><thead><tr><th>平台</th><th>功能</th><th>效果</th></tr></thead><tbody><tr><td><strong>Kimi (月之暗面)</strong></td><td>上下文缓存 API</td><td>降本 90%，首 token 延迟降 83%</td></tr><tr><td><strong>OpenAI</strong></td><td>Prompt Caching</td><td>缓存命中时 50% 折扣</td></tr><tr><td><strong>Anthropic</strong></td><td>Prompt Caching</td><td>缓存命中时降价 85-90%</td></tr><tr><td><strong>Google Gemini</strong></td><td>Context Caching</td><td>长文档场景高效</td></tr></tbody></table><h3 id="适用场景"><a href="#适用场景" class="headerlink" title="适用场景"></a>适用场景</h3><ul><li>固定文档的大量提问（产品说明书、法律文档、代码库分析）</li><li>高流量的 AI 应用（同一份知识库被反复查询）</li><li>Agent 长期任务（系统提示不变，只变用户输入）</li></ul><h3 id="实现示例-4"><a href="#实现示例-4" class="headerlink" title="实现示例"></a>实现示例</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-comment">// Pseudocode：上下文缓存 + 增量更新</span><br><span class="hljs-keyword">class</span> <span class="hljs-title class_">ContextCache</span> &#123;<br>  <span class="hljs-keyword">private</span> <span class="hljs-attr">cache</span>: <span class="hljs-title class_">Map</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-title class_">CachedContext</span>&gt; = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Map</span>();<br><br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">getOrCreate</span>(<span class="hljs-attr">key</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">buildContext</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">string</span>&gt;): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">string</span>&gt; &#123;<br>    <span class="hljs-keyword">const</span> cached = <span class="hljs-variable language_">this</span>.<span class="hljs-property">cache</span>.<span class="hljs-title function_">get</span>(key);<br>    <span class="hljs-keyword">if</span> (cached &amp;&amp; !<span class="hljs-variable language_">this</span>.<span class="hljs-title function_">isExpired</span>(cached)) &#123;<br>      <span class="hljs-keyword">return</span> cached.<span class="hljs-property">context</span>;<br>    &#125;<br><br>    <span class="hljs-keyword">const</span> context = <span class="hljs-keyword">await</span> <span class="hljs-title function_">buildContext</span>();<br>    <span class="hljs-variable language_">this</span>.<span class="hljs-property">cache</span>.<span class="hljs-title function_">set</span>(key, &#123;<br>      context,<br>      <span class="hljs-attr">createdAt</span>: <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>(),<br>      <span class="hljs-attr">ttl</span>: <span class="hljs-number">10</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span> <span class="hljs-comment">// 10 分钟</span><br>    &#125;);<br>    <span class="hljs-keyword">return</span> context;<br>  &#125;<br><br>  <span class="hljs-keyword">private</span> <span class="hljs-title function_">isExpired</span>(<span class="hljs-attr">cached</span>: <span class="hljs-title class_">CachedContext</span>): <span class="hljs-built_in">boolean</span> &#123;<br>    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() - cached.<span class="hljs-property">createdAt</span> &gt; cached.<span class="hljs-property">ttl</span>;<br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="方案七：Token-预算管理（Token-Budgeting）"><a href="#方案七：Token-预算管理（Token-Budgeting）" class="headerlink" title="方案七：Token 预算管理（Token Budgeting）"></a>方案七：Token 预算管理（Token Budgeting）</h2><h3 id="原理-6"><a href="#原理-6" class="headerlink" title="原理"></a>原理</h3><p><strong>把上下文窗口当成预算来分配</strong>，不同部分有不同的优先级。</p><h3 id="常见分配"><a href="#常见分配" class="headerlink" title="常见分配"></a>常见分配</h3><figure class="highlight mathematica"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs mathematica">总窗口 <span class="hljs-number">100</span><span class="hljs-built_in">K</span> <span class="hljs-variable">tokens</span><br>├── 系统提示<span class="hljs-operator">:</span>          <span class="hljs-number">2</span><span class="hljs-built_in">K</span>  <span class="hljs-punctuation">(</span><span class="hljs-number">2</span><span class="hljs-operator">%</span><span class="hljs-punctuation">)</span>    ← 固定<br>├── 对话历史<span class="hljs-operator">:</span>         <span class="hljs-number">10</span><span class="hljs-built_in">K</span>  <span class="hljs-punctuation">(</span><span class="hljs-number">10</span><span class="hljs-operator">%</span><span class="hljs-punctuation">)</span>   ← 滑动窗口 <span class="hljs-operator">+</span> 摘要<br>├── 工具输出<span class="hljs-operator">:</span>         <span class="hljs-number">30</span><span class="hljs-built_in">K</span>  <span class="hljs-punctuation">(</span><span class="hljs-number">30</span><span class="hljs-operator">%</span><span class="hljs-punctuation">)</span>   ← <span class="hljs-variable">compaction</span> 压缩<br>├── 检索文档<span class="hljs-operator">:</span>         <span class="hljs-number">30</span><span class="hljs-built_in">K</span>  <span class="hljs-punctuation">(</span><span class="hljs-number">30</span><span class="hljs-operator">%</span><span class="hljs-punctuation">)</span>   ← 按相关性排序<br>├── 当前用户输入<span class="hljs-operator">:</span>      <span class="hljs-number">3</span><span class="hljs-built_in">K</span>  <span class="hljs-punctuation">(</span><span class="hljs-number">3</span><span class="hljs-operator">%</span><span class="hljs-punctuation">)</span>    ← 完整保留<br>└── 模型回答预留<span class="hljs-operator">:</span>     <span class="hljs-number">25</span><span class="hljs-built_in">K</span>  <span class="hljs-punctuation">(</span><span class="hljs-number">25</span><span class="hljs-operator">%</span><span class="hljs-punctuation">)</span>   ← 回复空间<br></code></pre></td></tr></table></figure><h3 id="实现示例-5"><a href="#实现示例-5" class="headerlink" title="实现示例"></a>实现示例</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-keyword">class</span> <span class="hljs-title class_">TokenBudget</span> &#123;<br>  <span class="hljs-keyword">private</span> <span class="hljs-keyword">readonly</span> budget = &#123;<br>    <span class="hljs-attr">system</span>: <span class="hljs-number">0.02</span>,     <span class="hljs-comment">// 2%</span><br>    <span class="hljs-attr">history</span>: <span class="hljs-number">0.10</span>,    <span class="hljs-comment">// 10%</span><br>    <span class="hljs-attr">tools</span>: <span class="hljs-number">0.30</span>,      <span class="hljs-comment">// 30%</span><br>    <span class="hljs-attr">retrieval</span>: <span class="hljs-number">0.30</span>,  <span class="hljs-comment">// 30%</span><br>    <span class="hljs-attr">input</span>: <span class="hljs-number">0.03</span>,      <span class="hljs-comment">// 3%</span><br>    <span class="hljs-attr">output</span>: <span class="hljs-number">0.25</span>      <span class="hljs-comment">// 25%</span><br>  &#125;;<br><br>  <span class="hljs-title function_">constructor</span>(<span class="hljs-params"><span class="hljs-keyword">private</span> <span class="hljs-attr">maxTokens</span>: <span class="hljs-built_in">number</span></span>) &#123;&#125;<br><br>  <span class="hljs-title function_">allocate</span>(): <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">number</span>&gt; &#123;<br>    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">fromEntries</span>(<br>      <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">entries</span>(<span class="hljs-variable language_">this</span>.<span class="hljs-property">budget</span>).<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">[key, ratio]</span>) =&gt;</span> [<br>        key, <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(<span class="hljs-variable language_">this</span>.<span class="hljs-property">maxTokens</span> * ratio)<br>      ])<br>    );<br>  &#125;<br><br>  <span class="hljs-title function_">enforce</span>(<span class="hljs-attr">section</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">content</span>: <span class="hljs-built_in">string</span>): <span class="hljs-built_in">string</span> &#123;<br>    <span class="hljs-keyword">const</span> limit = <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">allocate</span>()[section];<br>    <span class="hljs-keyword">const</span> tokens = <span class="hljs-title function_">estimateTokens</span>(content);<br>    <span class="hljs-keyword">if</span> (tokens &lt;= limit) <span class="hljs-keyword">return</span> content;<br><br>    <span class="hljs-comment">// 超预算时截断到预算的 90%，留余量</span><br>    <span class="hljs-keyword">const</span> target = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(limit * <span class="hljs-number">0.9</span>);<br>    <span class="hljs-keyword">return</span> <span class="hljs-title function_">truncateToTokens</span>(content, target);<br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="方案八：Thinking-过滤（Thinking-Stripping）"><a href="#方案八：Thinking-过滤（Thinking-Stripping）" class="headerlink" title="方案八：Thinking 过滤（Thinking Stripping）"></a>方案八：Thinking 过滤（Thinking Stripping）</h2><h3 id="原理-7"><a href="#原理-7" class="headerlink" title="原理"></a>原理</h3><p><strong>在把上下文传给模型之前，剥离 assistant 回复中的 thinking&#x2F;推理部分</strong>，只保留最终回答。</p><h3 id="优势-5"><a href="#优势-5" class="headerlink" title="优势"></a>优势</h3><table><thead><tr><th>维度</th><th>评价</th></tr></thead><tbody><tr><td>信息损失</td><td><strong>最小</strong>（最终回答已包含结论和关键推理）</td></tr><tr><td>压缩比</td><td>因模型而异（thinking 可占回复的 30-70%）</td></tr><tr><td>实现难度</td><td>★☆☆☆☆ 简单</td></tr></tbody></table><h3 id="实现示例-6"><a href="#实现示例-6" class="headerlink" title="实现示例"></a>实现示例</h3><figure class="highlight typescript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-comment">// 过滤 thinking 内容（如 内容块）</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">stripThinking</span>(<span class="hljs-params"><span class="hljs-attr">messages</span>: <span class="hljs-title class_">Message</span>[]</span>): <span class="hljs-title class_">Message</span>[] &#123;<br>  <span class="hljs-keyword">return</span> messages.<span class="hljs-title function_">map</span>(<span class="hljs-function"><span class="hljs-params">msg</span> =&gt;</span> &#123;<br>    <span class="hljs-keyword">if</span> (msg.<span class="hljs-property">role</span> !== <span class="hljs-string">&#x27;assistant&#x27;</span>) <span class="hljs-keyword">return</span> msg;<br><br>    <span class="hljs-comment">// 移除 块</span><br>    <span class="hljs-keyword">const</span> cleaned = msg.<span class="hljs-property">content</span>.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/[\s\S]*?&lt;\/thinking&gt;/g</span>, <span class="hljs-string">&#x27;&#x27;</span>);<br>    <span class="hljs-keyword">return</span> &#123; ...msg, <span class="hljs-attr">content</span>: cleaned.<span class="hljs-title function_">trim</span>() &#125;;<br>  &#125;);<br>&#125;<br><br><span class="hljs-comment">// 过滤工具调用过程中的中间输出</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">stripToolOutput</span>(<span class="hljs-params"><span class="hljs-attr">messages</span>: <span class="hljs-title class_">Message</span>[]</span>): <span class="hljs-title class_">Message</span>[] &#123;<br>  <span class="hljs-keyword">return</span> messages.<span class="hljs-title function_">filter</span>(<span class="hljs-function"><span class="hljs-params">msg</span> =&gt;</span> &#123;<br>    <span class="hljs-comment">// 保留最终的 tool result，但过滤中间的阶段性输出</span><br>    <span class="hljs-keyword">if</span> (msg.<span class="hljs-property">role</span> === <span class="hljs-string">&#x27;tool&#x27;</span>) &#123;<br>      <span class="hljs-keyword">return</span> msg.<span class="hljs-property">isFinalResult</span> ?? <span class="hljs-literal">true</span>; <span class="hljs-comment">// 只保留标记为最终结果的</span><br>    &#125;<br>    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;<br>  &#125;);<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="方案对比总结"><a href="#方案对比总结" class="headerlink" title="方案对比总结"></a>方案对比总结</h2><table><thead><tr><th>方案</th><th>压缩比</th><th>信息损失</th><th>幻觉风险</th><th>实现难度</th><th>额外开销</th><th>适用场景</th></tr></thead><tbody><tr><td><strong>① 删除压缩</strong></td><td>50-70%</td><td>低</td><td><strong>0%</strong></td><td>★☆☆☆☆</td><td>无</td><td>工具输出、日志、grep结果</td></tr><tr><td><strong>② 摘要压缩</strong></td><td>90%+</td><td>中</td><td><strong>有</strong></td><td>★★★☆☆</td><td>LLM调用</td><td>长对话、客服、辅导</td></tr><tr><td><strong>③ 滑动窗口</strong></td><td>可控</td><td>高</td><td>无</td><td>★☆☆☆☆</td><td>无</td><td>简单对话、短期任务</td></tr><tr><td><strong>④ 向量记忆</strong></td><td>极高</td><td>中</td><td>低</td><td>★★★★☆</td><td>向量检索</td><td>超长期对话、知识库问答</td></tr><tr><td><strong>⑤ 多代理隔离</strong></td><td>极高</td><td>低</td><td>低</td><td>★★★★★</td><td>协调开销</td><td>复杂工程、多步骤任务</td></tr><tr><td><strong>⑥ 上下文缓存</strong></td><td>依场景</td><td>0%（静态部分）</td><td>0%</td><td>★★☆☆☆</td><td>无</td><td>固定文档高频查询</td></tr><tr><td><strong>⑦ Token预算管理</strong></td><td>可控</td><td>可控</td><td>无</td><td>★★☆☆☆</td><td>无</td><td>生产系统、成本控制</td></tr><tr><td><strong>⑧ Thinking过滤</strong></td><td>30-70%</td><td><strong>极低</strong></td><td>0%</td><td>★☆☆☆☆</td><td>无</td><td>AI Agent对话、编程助手</td></tr></tbody></table><hr><h2 id="组合方案示例"><a href="#组合方案示例" class="headerlink" title="组合方案示例"></a>组合方案示例</h2><p>实际生产环境不会只用一种方案，而是<strong>多层组合</strong>：</p><figure class="highlight scss"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><code class="hljs scss">接收用户输入<br>    │<br>    ▼<br>① Token 预算分配          ← 系统提示 <span class="hljs-number">2%</span> + 回答预留 <span class="hljs-number">25%</span><br>    │<br>    ▼<br>② Thinking 过滤           ← 剥离上一轮 assistant 的 <br>    │<br>    ▼<br>③ 删除压缩 (Compaction)   ← 压缩工具输出、JSON 响应、日志<br>    │<br>    ▼<br>④ 滑动窗口 + 摘要          ← 最近 <span class="hljs-number">20</span> 轮完整 + 更早的历史摘要<br>    │<br>    ▼<br>⑤ 向量检索 (RAG)          ← 根据当前问题，检索最相关的历史<br>    │<br>    ▼<br>   组合成最终上下文 → 传给 LLM<br></code></pre></td></tr></table></figure><h3 id="真实案例：OpenCode-当前可以做的是"><a href="#真实案例：OpenCode-当前可以做的是" class="headerlink" title="真实案例：OpenCode 当前可以做的是"></a>真实案例：OpenCode 当前可以做的是</h3><figure class="highlight markdown"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs markdown">当前状态（你正在用的）：<br>  ✅ 系统提示 + Behavior Instructions（大几千 token）<br>  ❌ Thinking 不过滤（我的  全部进上下文）<br>  ❌ 工具输出不做压缩<br>  ❌ 对话不做摘要<br><br>可以立即做的优化：<br><span class="hljs-bullet">  1.</span> 自定义 agent + prompt 规范输出格式<br><span class="hljs-bullet">  2.</span> 创建 output-format skill 控制回答结构<br><span class="hljs-bullet">  3.</span> 等待 OpenCode 支持 thinking 剥离（目前不支持）<br></code></pre></td></tr></table></figure><hr><h2 id="参考资料"><a href="#参考资料" class="headerlink" title="参考资料"></a>参考资料</h2><ul><li><a href="https://github.com/headroom/headroom">Headroom - 开源上下文压缩工具</a></li><li><a href="https://blog.langchain.com/context-engineering-for-agents">LangChain Context Engineering for Agents</a></li><li><a href="https://platform.claude.com/docs/en/build-with-claude/compaction">Anthropic Compaction API</a></li><li><a href="https://sureprompts.com/blog/context-window-management-strategies">SurePrompts Context Window Management (2026)</a></li><li><a href="https://www.moonshot.cn/">Kimi 上下文缓存技术</a></li><li><a href="https://aidev.fit/en/ai/llm-context-window.html">LLM Context Window Management Guide</a></li></ul>]]>
      </content:encoded>
    </item>
    <item>
      <title>Hexo 分类与标签的使用</title>
      <link>https://neoisconstantine-github-io.pages.dev/2026/06/11/hexo%E5%88%86%E7%B1%BB%E4%B8%8E%E6%A0%87%E7%AD%BE%E7%9A%84%E4%BD%BF%E7%94%A8/</link>
      <description>Hexo博客中分类(categories)与标签(tags)的配置与使用方法详解，包括层级分类、标签云、Front-matter配置与主题集成。</description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/%E5%8D%9A%E5%AE%A2%E6%90%AD%E5%BB%BA/">博客搭建</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Hexo/">Hexo</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/%E5%88%86%E7%B1%BB/">分类</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/%E6%A0%87%E7%AD%BE/">标签</category>
      <pubDate>Thu, 11 Jun 2026 09:30:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h2 id="分类（Categories）"><a href="#分类（Categories）" class="headerlink" title="分类（Categories）"></a>分类（Categories）</h2><p>在文章的 front-matter 中通过 <code>categories</code> 字段设置，支持层级结构：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-meta">---</span><br><span class="hljs-attr">title:</span> <span class="hljs-string">文章标题</span><br><span class="hljs-attr">categories:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">技术</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">前端</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">React</span><br><span class="hljs-meta">---</span><br></code></pre></td></tr></table></figure><p>这会呈现为 <code>技术 &gt; 前端 &gt; React</code> 三级分类。也可以只用一级：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-meta">---</span><br><span class="hljs-attr">title:</span> <span class="hljs-string">文章标题</span><br><span class="hljs-attr">categories:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">Cloudflare</span><br><span class="hljs-meta">---</span><br></code></pre></td></tr></table></figure><p>新建文章时也可以通过路径自动归类：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">hexo new post <span class="hljs-string">&quot;学习 Rust&quot;</span> --path rust/learning-rust<br></code></pre></td></tr></table></figure><p>Hexo 会自动从 <code>rust/</code> 路径推断分类为 <code>rust</code>。</p><h2 id="标签（Tags）"><a href="#标签（Tags）" class="headerlink" title="标签（Tags）"></a>标签（Tags）</h2><p>标签和分类类似，但没有层级关系，纯粹是平铺的关键词：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-meta">---</span><br><span class="hljs-attr">title:</span> <span class="hljs-string">文章标题</span><br><span class="hljs-attr">tags:</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">Cloudflare</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">Workers</span><br>  <span class="hljs-bullet">-</span> <span class="hljs-string">Hexo</span><br><span class="hljs-meta">---</span><br></code></pre></td></tr></table></figure><p>也可以写在一行：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-meta">---</span><br><span class="hljs-attr">title:</span> <span class="hljs-string">文章标题</span><br><span class="hljs-attr">tags:</span> [<span class="hljs-string">Cloudflare</span>, <span class="hljs-string">Workers</span>, <span class="hljs-string">Hexo</span>]<br><span class="hljs-meta">---</span><br></code></pre></td></tr></table></figure><h2 id="分类-vs-标签"><a href="#分类-vs-标签" class="headerlink" title="分类 vs 标签"></a>分类 vs 标签</h2><table><thead><tr><th></th><th>分类</th><th>标签</th></tr></thead><tbody><tr><td>层级</td><td>支持（多级嵌套）</td><td>平铺，无层级</td></tr><tr><td>数量</td><td>一篇文章通常归 1-2 个</td><td>可以有很多个</td></tr><tr><td>用途</td><td>内容的体系结构</td><td>具体的关键词</td></tr><tr><td>页面路由</td><td><code>/categories/分类名/</code></td><td><code>/tags/标签名/</code></td></tr></tbody></table><h2 id="修改-scaffold-模板"><a href="#修改-scaffold-模板" class="headerlink" title="修改 scaffold 模板"></a>修改 scaffold 模板</h2><p>如果不希望每次新建文章都手动写 <code>categories</code>，可以修改 <code>scaffolds/post.md</code>：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-meta">---</span><br><span class="hljs-attr">title:</span> &#123;&#123; <span class="hljs-string">title</span> &#125;&#125;<br><span class="hljs-attr">date:</span> &#123;&#123; <span class="hljs-string">date</span> &#125;&#125;<br><span class="hljs-attr">categories:</span><br><span class="hljs-attr">tags:</span><br><span class="hljs-meta">---</span><br></code></pre></td></tr></table></figure><p>这样 <code>hexo new post</code> 生成的文章就会自动带有 <code>categories:</code> 字段。</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>cloudflare部署worker记录博客访问数量</title>
      <link>https://neoisconstantine-github-io.pages.dev/2026/06/11/cloudflare%E9%83%A8%E7%BD%B2worker%E8%AE%B0%E5%BD%95%E5%8D%9A%E5%AE%A2%E8%AE%BF%E9%97%AE%E6%95%B0%E9%87%8F/</link>
      <description>基于Cloudflare Workers自建博客访问计数器OpenKounter，兼容Hexo Fluid主题。包含完整Worker代码、KV存储与前端集成方案。</description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/Cloudflare/">Cloudflare</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Hexo/">Hexo</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Cloudflare/">Cloudflare</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/Workers/">Workers</category>
      <pubDate>Thu, 11 Jun 2026 09:24:22 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="OpenKounter-—-Cloudflare-Worker-自建计数器"><a href="#OpenKounter-—-Cloudflare-Worker-自建计数器" class="headerlink" title="OpenKounter — Cloudflare Worker 自建计数器"></a>OpenKounter — Cloudflare Worker 自建计数器</h1><p>兼容 <a href="https://github.com/fluid-dev/hexo-theme-fluid">Hexo Fluid 主题</a> OpenKounter 统计插件的自建后端。</p><h2 id="API"><a href="#API" class="headerlink" title="API"></a>API</h2><table><thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead><tbody><tr><td>GET</td><td><code>/api/counter?target=&lt;target&gt;</code></td><td>查询计数器值</td></tr><tr><td>POST</td><td><code>/api/counter</code></td><td>批量递增计数器 <code>{ action: &quot;batch_inc&quot;, requests: [{ target: &quot;xxx&quot; }] }</code></td></tr><tr><td>GET</td><td><code>/health</code></td><td>健康检查</td></tr></tbody></table><h3 id="响应格式"><a href="#响应格式" class="headerlink" title="响应格式"></a>响应格式</h3><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span> <span class="hljs-attr">&quot;code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">0</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;data&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span> ... <span class="hljs-punctuation">&#125;</span> <span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><h2 id="部署"><a href="#部署" class="headerlink" title="部署"></a>部署</h2><h3 id="前置条件"><a href="#前置条件" class="headerlink" title="前置条件"></a>前置条件</h3><ul><li>Node.js &gt;&#x3D; 18</li><li><a href="https://dash.cloudflare.com/">Cloudflare 账号</a></li><li>已安装 Wrangler CLI (通过下方命令安装)</li></ul><h3 id="1-安装依赖"><a href="#1-安装依赖" class="headerlink" title="1. 安装依赖"></a>1. 安装依赖</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">cd</span> workers/openkounter<br>npm install<br></code></pre></td></tr></table></figure><h3 id="2-登录-Cloudflare"><a href="#2-登录-Cloudflare" class="headerlink" title="2. 登录 Cloudflare"></a>2. 登录 Cloudflare</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npx wrangler login<br></code></pre></td></tr></table></figure><h3 id="3-创建-KV-命名空间"><a href="#3-创建-KV-命名空间" class="headerlink" title="3. 创建 KV 命名空间"></a>3. 创建 KV 命名空间</h3><blockquote><p>⚠️ <strong>Wrangler v4+</strong> 使用空格分隔子命令（旧版 <code>kv:namespace</code> 冒号语法已废弃）。</p></blockquote><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 方式 A：使用 npm script（推荐）</span><br>npm run kv:create<br><br><span class="hljs-comment"># 方式 B：直接使用 npx</span><br>npx wrangler kv namespace create COUNTERS<br></code></pre></td></tr></table></figure><p>输出类似：</p><figure class="highlight lua"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs lua">📦 Creating namespace with title <span class="hljs-string">&quot;openkounter-COUNTERS&quot;</span><br>✨ Success!<br>Add the following to your wrangler.toml:<br><span class="hljs-string">[[kv_namespaces]]</span><br>binding = <span class="hljs-string">&quot;COUNTERS&quot;</span><br>id = <span class="hljs-string">&quot;abc123def...&quot;</span><br></code></pre></td></tr></table></figure><p>将输出的 <code>id</code> 填入 <code>wrangler.toml</code>：</p><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs toml"><span class="hljs-section">[[kv_namespaces]]</span><br><span class="hljs-attr">binding</span> = <span class="hljs-string">&quot;COUNTERS&quot;</span><br><span class="hljs-attr">id</span> = <span class="hljs-string">&quot;abc123def...&quot;</span>           <span class="hljs-comment"># ← 替换为实际 ID</span><br><span class="hljs-attr">preview_id</span> = <span class="hljs-string">&quot;abc123def...&quot;</span>   <span class="hljs-comment"># ← 替换为实际 preview ID（如需要本地开发）</span><br></code></pre></td></tr></table></figure><p>预览环境可选，仅在本地 <code>wrangler dev</code> 时需要：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm run kv:create-preview<br></code></pre></td></tr></table></figure><h3 id="4-部署"><a href="#4-部署" class="headerlink" title="4. 部署"></a>4. 部署</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm run deploy<br></code></pre></td></tr></table></figure><h3 id="5-本地开发"><a href="#5-本地开发" class="headerlink" title="5. 本地开发"></a>5. 本地开发</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">npm run dev<br></code></pre></td></tr></table></figure><h2 id="Git-提交与-CI-部署"><a href="#Git-提交与-CI-部署" class="headerlink" title="Git 提交与 CI 部署"></a>Git 提交与 CI 部署</h2><h3 id="wrangler-toml-中的-ID-如何处理？"><a href="#wrangler-toml-中的-ID-如何处理？" class="headerlink" title="wrangler.toml 中的 ID 如何处理？"></a>wrangler.toml 中的 ID 如何处理？</h3><p><code>wrangler.toml</code> 中的 KV Namespace ID <strong>不是敏感信息</strong>，它只是一个 Cloudflare 资源标识符，没有 API Token 就无法操作 KV 数据，<strong>可以安全地提交到 Git</strong>。</p><p>如果你希望其他开发者也能拉取代码并本地开发，clone 后按上述步骤 3 创建自己的 KV 命名空间并替换 <code>id</code> 即可。</p><h3 id="提交到-Git"><a href="#提交到-Git" class="headerlink" title="提交到 Git"></a>提交到 Git</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 初始化仓库</span><br>git init<br><br><span class="hljs-comment"># 2. 添加所有文件</span><br>git add .<br><br><span class="hljs-comment"># 3. 提交</span><br>git commit -m <span class="hljs-string">&quot;feat: init openkounter&quot;</span><br></code></pre></td></tr></table></figure><h3 id="关联远程仓库-部署"><a href="#关联远程仓库-部署" class="headerlink" title="关联远程仓库 &amp; 部署"></a>关联远程仓库 &amp; 部署</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 关联远程（替换为你的仓库地址）</span><br>git remote add origin https://github.com/你的用户名/openkounter.git<br>git push -u origin main<br><br><span class="hljs-comment"># 部署到 Cloudflare Workers</span><br>npm run deploy<br></code></pre></td></tr></table></figure><blockquote><p><code>npm run deploy</code> 时会自动读取 <code>wrangler.toml</code> 中的配置（包括 KV 绑定），无需额外操作。</p></blockquote><h2 id="配置博客"><a href="#配置博客" class="headerlink" title="配置博客"></a>配置博客</h2><p>在 <code>_config.fluid.yml</code> 中修改：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">web_analytics:</span><br>  <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>  <span class="hljs-attr">openkounter:</span><br>    <span class="hljs-comment"># 替换为你的 Worker 地址</span><br>    <span class="hljs-attr">server_url:</span> <span class="hljs-string">https://openkounter.your-domain.workers.dev</span><br>    <span class="hljs-attr">path:</span> <span class="hljs-string">window.location.pathname</span><br>    <span class="hljs-attr">ignore_local:</span> <span class="hljs-literal">false</span><br><br><span class="hljs-attr">footer:</span><br>  <span class="hljs-attr">statistics:</span><br>    <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>    <span class="hljs-attr">source:</span> <span class="hljs-string">openkounter</span><br><br><span class="hljs-attr">post:</span><br>  <span class="hljs-attr">meta:</span><br>    <span class="hljs-attr">views:</span><br>      <span class="hljs-attr">enable:</span> <span class="hljs-literal">true</span><br>      <span class="hljs-attr">source:</span> <span class="hljs-string">openkounter</span><br></code></pre></td></tr></table></figure>]]>
      </content:encoded>
    </item>
    <item>
      <title>GitHub OAuth 重定向授权流（标准登录）</title>
      <link>https://neoisconstantine-github-io.pages.dev/2025/06/18/GitHub%20OAuth%20%E9%87%8D%E5%AE%9A%E5%90%91%E6%8E%88%E6%9D%83%E6%B5%81%EF%BC%88%E6%A0%87%E5%87%86%E7%99%BB%E5%BD%95%EF%BC%89/</link>
      <description>
        <![CDATA[<h2 id="概述"><a href="#概述" class="headerlink" title="概述"></a>概述</h2><p>本模块实现了 <strong>GitHub OAuth2 Authorization Code Flow</strong>（授权码模式），即]]>
      </description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/OAuth/">OAuth</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/%E7%99%BB%E5%BD%95/">登录</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/GitHub/">GitHub</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/OAuth/">OAuth</category>
      <pubDate>Wed, 18 Jun 2025 14:53:22 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h2 id="概述"><a href="#概述" class="headerlink" title="概述"></a>概述</h2><p>本模块实现了 <strong>GitHub OAuth2 Authorization Code Flow</strong>（授权码模式），即点击”GitHub”按钮 → 跳转 GitHub 授权 → 回调登录的标准三方登录方式。</p><p>与 Device Flow（扫码登录）的区别：</p><table><thead><tr><th>特性</th><th>标准重定向流</th><th>Device Flow（扫码）</th></tr></thead><tbody><tr><td>用户操作</td><td>点按钮 → 跳 GitHub → 授权 → 跳回</td><td>扫二维码 → 手机确认</td></tr><tr><td>回调方式</td><td>GitHub 直接回调后端 <code>redirect_uri</code></td><td>前端轮询后端，后端轮询 GitHub</td></tr><tr><td>适用场景</td><td>PC 浏览器</td><td>大屏&#x2F;无法跳转的环境</td></tr><tr><td>复杂度</td><td>低（标准 OAuth）</td><td>中（需要轮询）</td></tr></tbody></table><hr><h2 id="完整流程"><a href="#完整流程" class="headerlink" title="完整流程"></a>完整流程</h2><figure class="highlight pf"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br></pre></td><td class="code"><pre><code class="hljs pf">┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐<br>│  前端 (浏览器)    │         │  后端 (<span class="hljs-number">8081</span>)      │         │  GitHub API      │<br>│  localhost:<span class="hljs-number">8082</span>  │         │                  │         │                  │<br>└────────┬────────┘         └────────┬─────────┘         └────────┬────────┘<br>         │                           │                           │<br>         │  ① 点击 <span class="hljs-string">&quot;GitHub&quot;</span> 按钮     │                           │<br>         │  跳转到:                   │                           │<br>         │  /shop/oauth/github       │                           │<br>         ├──────────<span class="hljs-number">302</span>─────────────► │                           │<br>         │                           │                           │<br>         │                           │  ② 构造授权 URL           │<br>         │                           │  (含 client_id, scope,   │<br>         │                           │   redirect_uri, <span class="hljs-keyword">state</span>)   │<br>         │                           │                           │<br>         │  ③ <span class="hljs-number">302</span> 重定向             │                           │<br>         │◄────── <span class="hljs-number">302</span> ───────────────┤                           │<br>         │                           │                           │<br>         │  ④ 浏览器跳转到 GitHub     │                           │<br>         │  https://github.com/       │                           │<br>         │  login/oauth/authorize    │                           │<br>         ├─────────────────────────────────────────────────────► │<br>         │                           │                           │<br>         │  ⑤ 用户在 GitHub 上       │                           │<br>         │  确认授权                   │                           │<br>         │                           │                           │<br>         │  ⑥ GitHub 回调后端         │                           │<br>         │  GET /shop/oauth/callback  │                           │<br>         │  /github?code=xxx&amp;<span class="hljs-keyword">state</span>=yyy│                           │<br>         ├──────────────────────────► │                           │<br>         │                           │                           │<br>         │                           │  ⑦ 用 code 换 access_token│<br>         │                           │  POST /login/oauth/       │<br>         │                           │  access_token             │<br>         │                           │  ───────────────────────► │<br>         │                           │  ◄── 返回 access_token   │<br>         │                           │                           │<br>         │                           │  ⑧ 获取 GitHub 用户信息   │<br>         │                           │  GET /<span class="hljs-keyword">user</span>                │<br>         │                           │  ───────────────────────► │<br>         │                           │  ◄── 返回 GitHub 用户    │<br>         │                           │                           │<br>         │                           │  ⑨ 查 / 创建 ShopUser      │<br>         │                           │  生成 JWT token           │<br>         │                           │  生成 refresh token       │<br>         │                           │  用户信息 → JSON → Base64  │<br>         │                           │                           │<br>         │  ⑩ 重定向到前端            │                           │<br>         │  /<span class="hljs-comment">#/shop/oauth/callback    │                           │</span><br>         │  ?token=xxx&amp;refreshToken=  │                           │<br>         │  yyy&amp;<span class="hljs-keyword">user</span>=base64...        │                           │<br>         │◄────── <span class="hljs-number">302</span> ───────────────┤                           │<br>         │                           │                           │<br>         │  ⑪ 前端口令页面：          │                           │<br>         │  解析 URL query:           │                           │<br>         │  - 保存 token / refresh    │                           │<br>         │  - 解码 <span class="hljs-keyword">user</span> Base64 → JSON │                           │<br>         │  - 存 localStorage         │                           │<br>         │  - 跳转 /shop              │                           │<br>         │                           │                           │<br></code></pre></td></tr></table></figure><hr><h2 id="后端实现"><a href="#后端实现" class="headerlink" title="后端实现"></a>后端实现</h2><h3 id="1-OAuthService-接口"><a href="#1-OAuthService-接口" class="headerlink" title="1. OAuthService 接口"></a>1. OAuthService 接口</h3><p><strong>文件</strong>: <code>src/main/java/com/.../service/OAuthService.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">// 构造 GitHub 授权 URL，返回重定向地址</span><br>String <span class="hljs-title function_">authorize</span><span class="hljs-params">(String provider)</span>;<br><br><span class="hljs-comment">// 处理 GitHub 回调：换 token → 取用户 → 建/查用户 → 生成 JWT → 重定向到前端</span><br><span class="hljs-keyword">void</span> <span class="hljs-title function_">callback</span><span class="hljs-params">(String provider, String code, String state, HttpServletResponse response)</span> <span class="hljs-keyword">throws</span> IOException;<br></code></pre></td></tr></table></figure><h3 id="2-OAuthServiceImpl-实现"><a href="#2-OAuthServiceImpl-实现" class="headerlink" title="2. OAuthServiceImpl 实现"></a>2. OAuthServiceImpl 实现</h3><p><strong>文件</strong>: <code>src/main/java/com/.../service/impl/OAuthServiceImpl.java</code></p><h4 id="常量"><a href="#常量" class="headerlink" title="常量"></a>常量</h4><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_AUTHORIZE_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://github.com/login/oauth/authorize&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_TOKEN_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://github.com/login/oauth/access_token&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_USER_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://api.github.com/user&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_EMAIL_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://api.github.com/user/emails&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">REFRESH_KEY_PREFIX</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;weblog:refresh:&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">long</span> <span class="hljs-variable">REFRESH_TOKEN_TTL_SECONDS</span> <span class="hljs-operator">=</span> <span class="hljs-number">7</span> * <span class="hljs-number">24</span> * <span class="hljs-number">3600</span>; <span class="hljs-comment">// 7 天</span><br></code></pre></td></tr></table></figure><h4 id="authorize"><a href="#authorize" class="headerlink" title="authorize()"></a>authorize()</h4><p><strong>流程</strong>: 构造 GitHub OAuth 授权 URL 并返回</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">public</span> String <span class="hljs-title function_">authorize</span><span class="hljs-params">(String provider)</span> &#123;<br>    <span class="hljs-type">String</span> <span class="hljs-variable">state</span> <span class="hljs-operator">=</span> UUID.randomUUID().toString().replace(<span class="hljs-string">&quot;-&quot;</span>, <span class="hljs-string">&quot;&quot;</span>);<br>    <span class="hljs-keyword">return</span> GITHUB_AUTHORIZE_URL<br>        + <span class="hljs-string">&quot;?client_id=&quot;</span> + oauthProperties.getClientId()<br>        + <span class="hljs-string">&quot;&amp;redirect_uri=&quot;</span> + URLEncoder.encode(oauthProperties.getRedirectUri(), StandardCharsets.UTF_8)<br>        + <span class="hljs-string">&quot;&amp;scope=read:user,user:email&quot;</span><br>        + <span class="hljs-string">&quot;&amp;state=&quot;</span> + state;<br>&#125;<br></code></pre></td></tr></table></figure><p>生成的 URL 示例：</p><figure class="highlight pf"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs pf">https://github.com/login/oauth/authorize<br>  ?client_id=xxx<br>  &amp;redirect_uri=http://localhost:<span class="hljs-number">8081</span>/shop/oauth/callback/github<br>  &amp;scope=read:<span class="hljs-keyword">user</span>,<span class="hljs-keyword">user</span>:email<br>  &amp;<span class="hljs-keyword">state</span>=<span class="hljs-number">3</span>f7a2b1c...<br></code></pre></td></tr></table></figure><h4 id="callback"><a href="#callback" class="headerlink" title="callback()"></a>callback()</h4><p><strong>完整步骤</strong>:</p><figure class="highlight pgsql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs pgsql"><span class="hljs-number">1.</span> 验证 provider = &quot;github&quot;<br><span class="hljs-number">2.</span> 调用 getGitHubAccessToken(code) → 换取 access_token<br><span class="hljs-number">3.</span> 用 access_token 调 <span class="hljs-keyword">GET</span> /<span class="hljs-keyword">user</span> → 获取 GitHub 用户信息<br><span class="hljs-number">4.</span> 用 access_token 调 <span class="hljs-keyword">GET</span> /<span class="hljs-keyword">user</span>/emails → 获取主邮箱（备用）<br><span class="hljs-number">5.</span> 查数据库 shop_user 表：<span class="hljs-keyword">WHERE</span> oauth_provider=<span class="hljs-string">&#x27;github&#x27;</span> <span class="hljs-keyword">AND</span> oauth_id=github_id<br><span class="hljs-number">6.</span> 如果不存在 → 创建新用户（用 <span class="hljs-type">UUID</span> 作为 <span class="hljs-type">uuid</span>）<br><span class="hljs-number">7.</span> 如果存在 → 更新昵称/头像<br><span class="hljs-number">8.</span> 生成 JWT token（subject = <span class="hljs-type">uuid</span>）+ <span class="hljs-keyword">refresh</span> token（存 Redis）<br><span class="hljs-number">9.</span> 将用户信息序列化 <span class="hljs-type">JSON</span> → Base64（URL-safe 编码）<br><span class="hljs-number">10.</span> <span class="hljs-number">302</span> 重定向到前端：/#/shop/oauth/callback?token=xxx&amp;refreshToken=yyy&amp;<span class="hljs-keyword">user</span>=base64<br></code></pre></td></tr></table></figure><p><strong>错误处理</strong>: 任何步骤失败 → 重定向到 <code>/#/shop/auth?oauth_error=错误信息</code></p><h4 id="getGitHubAccessToken"><a href="#getGitHubAccessToken" class="headerlink" title="getGitHubAccessToken()"></a>getGitHubAccessToken()</h4><p><strong>文件</strong>: 同文件 380-410 行</p><figure class="highlight http"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs http">POST https://github.com/login/oauth/access_token<br><span class="hljs-attribute">Content-Type</span><span class="hljs-punctuation">: </span>application/x-www-form-urlencoded<br><span class="hljs-attribute">Accept</span><span class="hljs-punctuation">: </span>application/json<br><br>client_id=xxx<br>&amp;client_secret=xxx<br>&amp;code=xxx<br>&amp;redirect_uri=http://localhost:8081/shop/oauth/callback/github<br></code></pre></td></tr></table></figure><p><strong>Response</strong>:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;access_token&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;gho_xxx&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;token_type&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;bearer&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;scope&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;read:user,user:email&quot;</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p><strong>重试机制</strong>: 内置 3 次重试，指数退避（1s → 2s → 4s），应对国内网络访问 GitHub 不稳定的情况。</p><h4 id="getGitHubUser"><a href="#getGitHubUser" class="headerlink" title="getGitHubUser()"></a>getGitHubUser()</h4><figure class="highlight http"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs http">GET https://api.github.com/user<br><span class="hljs-attribute">Authorization</span><span class="hljs-punctuation">: </span>Bearer gho_xxx<br><span class="hljs-attribute">Accept</span><span class="hljs-punctuation">: </span>application/json<br></code></pre></td></tr></table></figure><p><strong>Response</strong>（关键字段）:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;id&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">12345678</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;login&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;octocat&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;name&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;monalisa octocat&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;avatar_url&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;https://avatars.githubusercontent.com/u/12345678?v=4&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;email&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;octocat@github.com&quot;</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><h4 id="getGitHubPrimaryEmail"><a href="#getGitHubPrimaryEmail" class="headerlink" title="getGitHubPrimaryEmail()"></a>getGitHubPrimaryEmail()</h4><p>当 <code>/user</code> 返回的 <code>email</code> 为 null 时，调用 <code>/user/emails</code> 获取已验证的主邮箱：</p><figure class="highlight http"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs http">GET https://api.github.com/user/emails<br><span class="hljs-attribute">Authorization</span><span class="hljs-punctuation">: </span>Bearer gho_xxx<br><span class="hljs-attribute">Accept</span><span class="hljs-punctuation">: </span>application/json<br></code></pre></td></tr></table></figure><p><strong>Response</strong>:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">[</span><br>  <span class="hljs-punctuation">&#123;</span><span class="hljs-attr">&quot;email&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;octocat@github.com&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;primary&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;verified&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;visibility&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;public&quot;</span><span class="hljs-punctuation">&#125;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-punctuation">&#123;</span><span class="hljs-attr">&quot;email&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;octocat@users.noreply.github.com&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;primary&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">false</span></span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;verified&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span> <span class="hljs-attr">&quot;visibility&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">null</span></span><span class="hljs-punctuation">&#125;</span><br><span class="hljs-punctuation">]</span><br></code></pre></td></tr></table></figure><h4 id="buildUserBase64"><a href="#buildUserBase64" class="headerlink" title="buildUserBase64()"></a>buildUserBase64()</h4><p>将用户 ID、昵称、头像序列化为 JSON → URL-safe Base64：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-type">String</span> <span class="hljs-variable">json</span> <span class="hljs-operator">=</span> objectMapper.writeValueAsString(userMap);<br><span class="hljs-keyword">return</span> Base64.getUrlEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));<br></code></pre></td></tr></table></figure><p><strong>注意</strong>: <code>Base64.getUrlEncoder()</code> 使用 <code>-</code> 和 <code>_</code> 替代 <code>+</code> 和 <code>/</code>，前端 <code>atob()</code> 不认识这种变体，需手动替换。</p><h3 id="3-OAuthController"><a href="#3-OAuthController" class="headerlink" title="3. OAuthController"></a>3. OAuthController</h3><p><strong>文件</strong>: <code>src/main/java/com/.../controller/OAuthController.java</code></p><table><thead><tr><th>端点</th><th>方法</th><th>说明</th></tr></thead><tbody><tr><td><code>/shop/oauth/{provider}</code></td><td>GET</td><td>跳转到 OAuth 提供商授权页（302）</td></tr><tr><td><code>/shop/oauth/callback/{provider}</code></td><td>GET</td><td>OAuth 回调处理</td></tr></tbody></table><h4 id="authorize-端点"><a href="#authorize-端点" class="headerlink" title="authorize 端点"></a>authorize 端点</h4><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@GetMapping(&quot;/&#123;provider&#125;&quot;)</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">authorize</span><span class="hljs-params">(<span class="hljs-meta">@PathVariable</span> String provider, HttpServletResponse response)</span> <span class="hljs-keyword">throws</span> IOException &#123;<br>    <span class="hljs-type">String</span> <span class="hljs-variable">url</span> <span class="hljs-operator">=</span> oAuthService.authorize(provider);<br>    response.sendRedirect(url);<br>&#125;<br></code></pre></td></tr></table></figure><p>访问 <code>http://localhost:8082/shop/oauth/github</code> → Vite 代理到 <code>http://localhost:8081/shop/oauth/github</code> → 302 到 GitHub 授权页</p><h4 id="callback-端点"><a href="#callback-端点" class="headerlink" title="callback 端点"></a>callback 端点</h4><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@GetMapping(&quot;/callback/&#123;provider&#125;&quot;)</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">callback</span><span class="hljs-params">(<span class="hljs-meta">@PathVariable</span> String provider,</span><br><span class="hljs-params">                     <span class="hljs-meta">@RequestParam</span> String code,</span><br><span class="hljs-params">                     <span class="hljs-meta">@RequestParam(required = false)</span> String state,</span><br><span class="hljs-params">                     HttpServletResponse response)</span> <span class="hljs-keyword">throws</span> IOException &#123;<br>    oAuthService.callback(provider, code, state, response);<br>&#125;<br></code></pre></td></tr></table></figure><p>GitHub 授权后回调到 <code>http://localhost:8081/shop/oauth/callback/github?code=xxx&amp;state=yyy</code></p><h3 id="4-用户模型"><a href="#4-用户模型" class="headerlink" title="4. 用户模型"></a>4. 用户模型</h3><p><strong>表</strong>: <code>shop_user</code></p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><code class="hljs sql"><span class="hljs-keyword">CREATE TABLE</span> `shop_user` (<br>  `id` <span class="hljs-type">bigint</span>(<span class="hljs-number">20</span>) <span class="hljs-keyword">NOT NULL</span> AUTO_INCREMENT,<br>  `uuid` <span class="hljs-type">varchar</span>(<span class="hljs-number">64</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;用户唯一标识（JWT subject）&#x27;</span>,<br>  `nickname` <span class="hljs-type">varchar</span>(<span class="hljs-number">64</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;昵称&#x27;</span>,<br>  `avatar` <span class="hljs-type">varchar</span>(<span class="hljs-number">512</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;头像&#x27;</span>,<br>  `phone` <span class="hljs-type">varchar</span>(<span class="hljs-number">20</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;手机号&#x27;</span>,<br>  `password` <span class="hljs-type">varchar</span>(<span class="hljs-number">256</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;密码&#x27;</span>,<br>  `oauth_provider` <span class="hljs-type">varchar</span>(<span class="hljs-number">20</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;OAuth 提供商&#x27;</span>,<br>  `oauth_id` <span class="hljs-type">varchar</span>(<span class="hljs-number">128</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;OAuth 平台用户 ID&#x27;</span>,<br>  `status` <span class="hljs-type">int</span>(<span class="hljs-number">11</span>) <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">&#x27;1&#x27;</span>,<br>  `register_time` datetime <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span>,<br>  `update_time` datetime <span class="hljs-keyword">DEFAULT</span> <span class="hljs-built_in">CURRENT_TIMESTAMP</span> <span class="hljs-keyword">ON</span> <span class="hljs-keyword">UPDATE</span> <span class="hljs-built_in">CURRENT_TIMESTAMP</span>,<br>  <span class="hljs-keyword">PRIMARY KEY</span> (`id`),<br>  <span class="hljs-keyword">UNIQUE</span> KEY `uk_phone` (`phone`),<br>  <span class="hljs-keyword">UNIQUE</span> KEY `uk_oauth` (`oauth_provider`,`oauth_id`),<br>  <span class="hljs-keyword">UNIQUE</span> KEY `uk_uuid` (`uuid`)<br>) ENGINE<span class="hljs-operator">=</span>InnoDB <span class="hljs-keyword">DEFAULT</span> CHARSET<span class="hljs-operator">=</span>utf8mb4;<br></code></pre></td></tr></table></figure><p><strong>OAuth 用户</strong>的 phone&#x2F;password 为空，通过 <code>oauth_provider</code> + <code>oauth_id</code> 唯一标识。</p><p><strong>UUID</strong> 作为 JWT 的 subject，不暴露 <code>oauth_id</code> 等外部平台 ID。</p><h3 id="5-JWT-Token-体系"><a href="#5-JWT-Token-体系" class="headerlink" title="5. JWT Token 体系"></a>5. JWT Token 体系</h3><h4 id="生成"><a href="#生成" class="headerlink" title="生成"></a>生成</h4><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-type">String</span> <span class="hljs-variable">token</span> <span class="hljs-operator">=</span> jwtTokenHelper.generateToken(uuid, <span class="hljs-string">&quot;shop&quot;</span>, accessTokenExpireTime);<br></code></pre></td></tr></table></figure><ul><li><strong>subject</strong>: <code>shop_user.uuid</code>（随机 UUID，不暴露业务 ID）</li><li><strong>type</strong>: <code>shop</code>（区分 admin 和 shop 用户）</li><li><strong>expire</strong>: 由 <code>jwt.accessTokenExpireTime</code> 配置</li></ul><h4 id="Refresh-Token"><a href="#Refresh-Token" class="headerlink" title="Refresh Token"></a>Refresh Token</h4><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-type">String</span> <span class="hljs-variable">refreshToken</span> <span class="hljs-operator">=</span> UUID.randomUUID().toString().replace(<span class="hljs-string">&quot;-&quot;</span>, <span class="hljs-string">&quot;&quot;</span>);<br><span class="hljs-type">String</span> <span class="hljs-variable">refreshKey</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;weblog:refresh:&quot;</span> + refreshToken;<br>redisTemplate.opsForValue().set(refreshKey, uuid, <span class="hljs-number">7</span>, TimeUnit.DAYS);<br></code></pre></td></tr></table></figure><ul><li>refresh token 存 Redis，7 天过期</li><li>subject 存为 Redis value，用于刷新时验证</li><li>刷新端点在：<code>POST /shop/auth/refresh</code></li></ul><h3 id="6-安全配置"><a href="#6-安全配置" class="headerlink" title="6. 安全配置"></a>6. 安全配置</h3><p><strong>文件</strong>: <code>WebSecurityConfig.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs java">.antMatchers(<span class="hljs-string">&quot;/shop/oauth/**&quot;</span>).permitAll()<br>.antMatchers(<span class="hljs-string">&quot;/shop/auth/**&quot;</span>).permitAll()<br></code></pre></td></tr></table></figure><p>OAuth 端点需要匿名访问，因为用户未登录时才能点击 GitHub 授权。</p><h3 id="7-SSL-握手失败重试"><a href="#7-SSL-握手失败重试" class="headerlink" title="7. SSL 握手失败重试"></a>7. SSL 握手失败重试</h3><p>请求 GitHub Token 端点时，RestTemplate 配置了 10s 连接超时、30s 读取超时，并添加重试：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">// getGitHubAccessToken() 中</span><br><span class="hljs-type">int</span> <span class="hljs-variable">maxRetries</span> <span class="hljs-operator">=</span> <span class="hljs-number">3</span>;<br><span class="hljs-type">int</span> <span class="hljs-variable">retryDelayMs</span> <span class="hljs-operator">=</span> <span class="hljs-number">1000</span>;  <span class="hljs-comment">// 指数退避: 1s → 2s → 4s</span><br></code></pre></td></tr></table></figure><p>并通过系统属性强制 TLSv1.2 避免 TLS 1.3 在某些网络环境被中断：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs java">System.setProperty(<span class="hljs-string">&quot;https.protocols&quot;</span>, <span class="hljs-string">&quot;TLSv1.2&quot;</span>);<br></code></pre></td></tr></table></figure><hr><h2 id="前端实现"><a href="#前端实现" class="headerlink" title="前端实现"></a>前端实现</h2><h3 id="1-登录页"><a href="#1-登录页" class="headerlink" title="1. 登录页"></a>1. 登录页</h3><p><strong>文件</strong>: <code>weblog-vue3/src/pages/frontend/shop-login.vue</code></p><h4 id="GitHub-按钮"><a href="#GitHub-按钮" class="headerlink" title="GitHub 按钮"></a>GitHub 按钮</h4><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs html"><span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">:href</span>=<span class="hljs-string">&quot;githubAuthUrl&quot;</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;...&quot;</span>&gt;</span><br>  <span class="hljs-tag">&lt;<span class="hljs-name">svg</span> <span class="hljs-attr">class</span>=<span class="hljs-string">&quot;w-5 h-5 mr-1&quot;</span>&gt;</span><span class="hljs-comment">&lt;!-- GitHub Icon --&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">svg</span>&gt;</span><br>  GitHub<br><span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span><br></code></pre></td></tr></table></figure><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-keyword">const</span> githubAuthUrl = <span class="hljs-string">`<span class="hljs-subst">$&#123;<span class="hljs-variable language_">window</span>.location.origin&#125;</span>/shop/oauth/github`</span><br></code></pre></td></tr></table></figure><ul><li>直接使用 <code>&lt;a&gt;</code> 标签跳转（需要有 <code>redirect_uri</code> 白名单，不能用前端路由）</li><li>URL 是 <code>http://localhost:8082/shop/oauth/github</code> → Vite 开发服务器代理到后端</li></ul><h4 id="OAuth-错误显示"><a href="#OAuth-错误显示" class="headerlink" title="OAuth 错误显示"></a>OAuth 错误显示</h4><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-title function_">onMounted</span>(<span class="hljs-function">() =&gt;</span> &#123;<br>  <span class="hljs-keyword">const</span> oauthError = route.<span class="hljs-property">query</span>.<span class="hljs-property">oauth_error</span><br>  <span class="hljs-keyword">if</span> (oauthError) &#123;<br>    <span class="hljs-title class_">ElMessage</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">&#x27;GitHub 登录失败: &#x27;</span> + <span class="hljs-built_in">decodeURIComponent</span>(oauthError))<br>  &#125;<br>&#125;)<br></code></pre></td></tr></table></figure><p>后端 callback 失败时会 302 到 <code>/#/shop/auth?oauth_error=xxx</code>，前端解析并弹窗显示。</p><h4 id="自动登录"><a href="#自动登录" class="headerlink" title="自动登录"></a>自动登录</h4><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-title function_">onMounted</span>(<span class="hljs-function">() =&gt;</span> &#123;<br>  <span class="hljs-title function_">tryAutoLogin</span>()<br>&#125;)<br><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">tryAutoLogin</span>(<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-keyword">const</span> token = <span class="hljs-title function_">getShopToken</span>()<br>  <span class="hljs-keyword">const</span> refreshToken = <span class="hljs-title function_">getShopRefreshToken</span>()<br>  <span class="hljs-keyword">if</span> (!token &amp;&amp; !refreshToken) <span class="hljs-keyword">return</span><br><br>  <span class="hljs-keyword">const</span> payload = <span class="hljs-title function_">parseJwt</span>(token)<br>  <span class="hljs-keyword">const</span> now = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(<span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() / <span class="hljs-number">1000</span>)<br><br>  <span class="hljs-comment">// access token 未过期 → 直接跳转</span><br>  <span class="hljs-keyword">if</span> (payload &amp;&amp; payload.<span class="hljs-property">exp</span> &gt; now) &#123;<br>    router.<span class="hljs-title function_">replace</span>(redirect)<br>    <span class="hljs-keyword">return</span><br>  &#125;<br><br>  <span class="hljs-comment">// 尝试用 refresh token 续期</span><br>  <span class="hljs-keyword">if</span> (refreshToken) &#123;<br>    <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> axios.<span class="hljs-title function_">post</span>(<span class="hljs-string">&#x27;/shop/auth/refresh&#x27;</span>, &#123; refreshToken &#125;)<br>    <span class="hljs-keyword">if</span> (res.<span class="hljs-property">data</span>?.<span class="hljs-property">data</span>) &#123;<br>      <span class="hljs-title function_">setShopTokens</span>(res.<span class="hljs-property">data</span>.<span class="hljs-property">data</span>, refreshToken)<br>      router.<span class="hljs-title function_">replace</span>(redirect)<br>    &#125;<br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="2-回调页"><a href="#2-回调页" class="headerlink" title="2. 回调页"></a>2. 回调页</h3><p><strong>文件</strong>: <code>weblog-vue3/src/pages/frontend/shop-oauth-callback.vue</code></p><p>GitHub 授权完成后，后端 302 到该页面（hash 路由参数形式）：</p><figure class="highlight awk"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs awk">http:<span class="hljs-regexp">//</span>localhost:<span class="hljs-number">8082</span><span class="hljs-regexp">/#/</span>shop<span class="hljs-regexp">/oauth/</span>callback?token=xxx&amp;refreshToken=yyy&amp;user=base64...<br></code></pre></td></tr></table></figure><p>页面逻辑：</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-title function_">onMounted</span>(<span class="hljs-function">() =&gt;</span> &#123;<br>  <span class="hljs-keyword">const</span> token = route.<span class="hljs-property">query</span>.<span class="hljs-property">token</span><br>  <span class="hljs-keyword">const</span> refreshToken = route.<span class="hljs-property">query</span>.<span class="hljs-property">refreshToken</span><br>  <span class="hljs-keyword">const</span> userBase64 = route.<span class="hljs-property">query</span>.<span class="hljs-property">user</span><br><br>  <span class="hljs-comment">// 1. 验证 token 存在</span><br>  <span class="hljs-keyword">if</span> (!token || !refreshToken) &#123;<br>    <span class="hljs-title class_">ElMessage</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">&#x27;登录失败：缺少认证信息&#x27;</span>)<br>    router.<span class="hljs-title function_">replace</span>(<span class="hljs-string">&#x27;/shop/auth&#x27;</span>)<br>    <span class="hljs-keyword">return</span><br>  &#125;<br><br>  <span class="hljs-comment">// 2. 保存 token</span><br>  <span class="hljs-title function_">setShopTokens</span>(token, refreshToken)<br><br>  <span class="hljs-comment">// 3. 解析用户信息（Base64 → JSON）</span><br>  <span class="hljs-keyword">if</span> (userBase64) &#123;<br>    <span class="hljs-keyword">const</span> standardBase64 = userBase64.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/-/g</span>, <span class="hljs-string">&#x27;+&#x27;</span>).<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/_/g</span>, <span class="hljs-string">&#x27;/&#x27;</span>)<br>    <span class="hljs-keyword">const</span> userStr = <span class="hljs-title function_">atob</span>(standardBase64)<br>    <span class="hljs-keyword">const</span> user = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(userStr)<br>    <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">setItem</span>(<span class="hljs-string">&#x27;shop_user&#x27;</span>, <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(user))<br>  &#125;<br><br>  <span class="hljs-comment">// 4. 跳转到商城首页</span><br>  <span class="hljs-title class_">ElMessage</span>.<span class="hljs-title function_">success</span>(<span class="hljs-string">&#x27;登录成功&#x27;</span>)<br>  router.<span class="hljs-title function_">replace</span>(<span class="hljs-string">&#x27;/shop&#x27;</span>)<br>&#125;)<br></code></pre></td></tr></table></figure><h3 id="3-Token-管理"><a href="#3-Token-管理" class="headerlink" title="3. Token 管理"></a>3. Token 管理</h3><p><strong>文件</strong>: <code>weblog-vue3/src/composables/shopAuth.js</code></p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-comment">// 存</span><br><span class="hljs-title function_">setShopTokens</span>(token, refreshToken)<br><span class="hljs-comment">// -&gt; localStorage: shop_token, shop_refresh_token</span><br><br><span class="hljs-comment">// 读</span><br><span class="hljs-title function_">getShopToken</span>()      <span class="hljs-comment">// -&gt; shop_token</span><br><span class="hljs-title function_">getShopRefreshToken</span>() <span class="hljs-comment">// -&gt; shop_refresh_token</span><br></code></pre></td></tr></table></figure><h3 id="4-Vite-代理配置"><a href="#4-Vite-代理配置" class="headerlink" title="4. Vite 代理配置"></a>4. Vite 代理配置</h3><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-comment">// vite.config.js</span><br><span class="hljs-attr">proxy</span>: &#123;<br>  <span class="hljs-string">&#x27;/shop/&#x27;</span>: &#123;<br>    <span class="hljs-attr">target</span>: <span class="hljs-string">&#x27;http://localhost:8081&#x27;</span>,<br>    <span class="hljs-attr">changeOrigin</span>: <span class="hljs-literal">true</span><br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="数据流详解"><a href="#数据流详解" class="headerlink" title="数据流详解"></a>数据流详解</h2><h3 id="用户信息流"><a href="#用户信息流" class="headerlink" title="用户信息流"></a>用户信息流</h3><figure class="highlight mipsasm"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs mipsasm">GitHub API                 后端                            前端<br>┌────────┐                ┌────────┐                ┌────────┐<br>│  id     │ ──→ oauthId   │        │                │        │<br>│  login  │ ──→ nickname  │  <span class="hljs-keyword">JSON </span> │ → <span class="hljs-keyword">Base64 </span>→     │  <span class="hljs-keyword">JSON </span> │<br>│  name   │ ──→ nickname  │  →     │                │  →     │<br>│  avatar │ ──→ avatar    │ <span class="hljs-keyword">Base64 </span>│                │ 存储   │<br>│  email  │ ──→ email     │        │                │        │<br>└────────┘                └────────┘                └────────┘<br></code></pre></td></tr></table></figure><h3 id="Token-流"><a href="#Token-流" class="headerlink" title="Token 流"></a>Token 流</h3><figure class="highlight mipsasm"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs mipsasm">后端                                          前端<br>┌─────────────────────────────────┐         ┌────────────┐<br>│ <span class="hljs-keyword">JWT </span>(<span class="hljs-keyword">subject=uuid, </span>type=<span class="hljs-keyword">shop) </span>  │ ──→    │ localStorage │<br>│ Refresh Token (Redis, <span class="hljs-number">7</span>天)      │ ──→    │ <span class="hljs-keyword">shop_token </span> │<br>│                                 │         │ <span class="hljs-keyword">shop_refresh│</span><br><span class="hljs-keyword"></span>│ 每次请求携带 <span class="hljs-keyword">JWT </span>(Authorization  │         └────────────┘<br>│ <span class="hljs-keyword">Bearer </span>header)                  │<br>│ → TokenAuthenticationFilter     │<br>│   验证 <span class="hljs-keyword">JWT </span>签名 + 过期 + 撤销检查  │<br>└─────────────────────────────────┘<br></code></pre></td></tr></table></figure><h3 id="错误异常流"><a href="#错误异常流" class="headerlink" title="错误异常流"></a>错误异常流</h3><figure class="highlight routeros"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs routeros">流程中任何异常 → catch(Exception) → log.<span class="hljs-built_in">error</span> → 302 到<br>/#/shop/auth?<span class="hljs-attribute">oauth_error</span>=URL编码的错误信息<br><br>前端解析 route.query.oauth_error → ElMessage.<span class="hljs-built_in">error</span> 弹窗<br></code></pre></td></tr></table></figure><hr><h2 id="配置项"><a href="#配置项" class="headerlink" title="配置项"></a>配置项</h2><h3 id="application-yml"><a href="#application-yml" class="headerlink" title="application.yml"></a>application.yml</h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">oauth:</span><br>  <span class="hljs-attr">github:</span><br>    <span class="hljs-attr">client-id:</span> <span class="hljs-string">$&#123;GITHUB_CLIENT_ID&#125;</span><br>    <span class="hljs-attr">client-secret:</span> <span class="hljs-string">$&#123;GITHUB_CLIENT_SECRET&#125;</span><br>    <span class="hljs-attr">redirect-uri:</span> <span class="hljs-string">http://localhost:8081/shop/oauth/callback/github</span><br>    <span class="hljs-attr">frontend-url:</span> <span class="hljs-string">http://localhost:8082</span><br><br><span class="hljs-attr">jwt:</span><br>  <span class="hljs-attr">accessTokenExpireTime:</span> <span class="hljs-number">7200000</span><br></code></pre></td></tr></table></figure><table><thead><tr><th>配置</th><th>说明</th></tr></thead><tbody><tr><td><code>oauth.github.client-id</code></td><td>GitHub OAuth App 的 Client ID</td></tr><tr><td><code>oauth.github.client-secret</code></td><td>GitHub OAuth App 的 Client Secret（敏感！）</td></tr><tr><td><code>oauth.github.redirect-uri</code></td><td>GitHub 回调地址，需在 GitHub App 设置中添加白名单</td></tr><tr><td><code>oauth.github.frontend-url</code></td><td>前端地址，用于回调成功后的重定向</td></tr><tr><td><code>jwt.accessTokenExpireTime</code></td><td>JWT 过期时间（毫秒），默认 2 小时</td></tr></tbody></table><h3 id="回调地址白名单"><a href="#回调地址白名单" class="headerlink" title="回调地址白名单"></a>回调地址白名单</h3><p>在 GitHub OAuth App 设置页面，需将 <code>redirect_uri</code> 加入 <strong>Authorization callback URL</strong>：</p><figure class="highlight awk"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs awk">http:<span class="hljs-regexp">//</span>localhost:<span class="hljs-number">8081</span><span class="hljs-regexp">/shop/</span>oauth<span class="hljs-regexp">/callback/gi</span>thub<br></code></pre></td></tr></table></figure><hr><h2 id="踩坑记录"><a href="#踩坑记录" class="headerlink" title="踩坑记录"></a>踩坑记录</h2><h3 id="1-Vite-代理路径匹配"><a href="#1-Vite-代理路径匹配" class="headerlink" title="1. Vite 代理路径匹配"></a>1. Vite 代理路径匹配</h3><p><strong>问题</strong>: 开发环境前端 8082，后端 8081，直接请求 <code>/shop/oauth/github</code> 需要代理</p><p><strong>解决</strong>: Vite 配置 <code>proxy: { &#39;/shop/&#39;: { target: &#39;http://localhost:8081&#39; } }</code>，前端 <code>githubAuthUrl = window.location.origin + &#39;/shop/oauth/github&#39;</code> → 浏览器请求 <code>localhost:8082/shop/oauth/github</code> → Vite 代理到 <code>localhost:8081/shop/oauth/github</code></p><h3 id="2-Hash-路由回调处理"><a href="#2-Hash-路由回调处理" class="headerlink" title="2. Hash 路由回调处理"></a>2. Hash 路由回调处理</h3><p><strong>问题</strong>: 后端回调重定向到 <code>/#/shop/oauth/callback?token=xxx</code>，但 Vue Router 是 hash 模式</p><p><strong>解决</strong>: redirect URL 拼接为 <code>frontendUrl + &quot;/#/shop/oauth/callback?token=&quot; + token</code>。注意 <code>#</code> 前不能有 <code>?</code>，否则 hash 会被当作 query parameter 的一部分。</p><h3 id="3-Base64-URL-安全编码兼容"><a href="#3-Base64-URL-安全编码兼容" class="headerlink" title="3. Base64 URL 安全编码兼容"></a>3. Base64 URL 安全编码兼容</h3><p><strong>现象</strong>: 前端 <code>atob()</code> 解码用户信息时报错 <code>Invalid character</code></p><p><strong>原因</strong>: 后端 <code>Base64.getUrlEncoder()</code> 使用 URL-safe 字符集（<code>-</code> 和 <code>_</code>），前端 <code>atob()</code> 只认标准 Base64（<code>+</code> 和 <code>/</code>）</p><p><strong>解决</strong>:</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-keyword">const</span> standardBase64 = userBase64.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/-/g</span>, <span class="hljs-string">&#x27;+&#x27;</span>).<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/_/g</span>, <span class="hljs-string">&#x27;/&#x27;</span>)<br><span class="hljs-keyword">const</span> userStr = <span class="hljs-title function_">atob</span>(standardBase64)<br></code></pre></td></tr></table></figure><h3 id="4-SSL-握手失败（国内网络）"><a href="#4-SSL-握手失败（国内网络）" class="headerlink" title="4. SSL 握手失败（国内网络）"></a>4. SSL 握手失败（国内网络）</h3><p><strong>现象</strong>: <code>RestTemplate</code> 调用 GitHub API 时报错 <code>Remote host terminated the handshake</code></p><p><strong>原因</strong>: 国内访问 GitHub 偶发 TLS 连接中断；部分代理&#x2F;VPN 对 TLS 1.3 兼容性不好</p><p><strong>解决</strong>:</p><ul><li>强制 TLSv1.2: <code>System.setProperty(&quot;https.protocols&quot;, &quot;TLSv1.2&quot;)</code></li><li>重试机制：<code>getGitHubAccessToken()</code> 最多重试 3 次</li></ul><h3 id="5-OAuth-用户与手机号用户共存"><a href="#5-OAuth-用户与手机号用户共存" class="headerlink" title="5. OAuth 用户与手机号用户共存"></a>5. OAuth 用户与手机号用户共存</h3><p><strong>注意</strong>: OAuth 注册的用户 <code>phone</code> &#x2F; <code>password</code> 为空，登录时不能走手机号密码校验</p><p><strong>实现</strong>:</p><ul><li><code>shop_user</code> 表的 <code>phone</code> &#x2F; <code>password</code> 字段设可为空</li><li>唯一索引 <code>uk_phone</code> 只约束非空的 phone</li><li>JWT 过滤器通过 uuid（非 phone）查找用户，兼容两种登录方式</li><li>Token 签发时 type&#x3D;<code>shop</code>，与 admin 用户隔离</li></ul><h3 id="6-Session-与-token-的关系"><a href="#6-Session-与-token-的关系" class="headerlink" title="6. Session 与 token 的关系"></a>6. Session 与 token 的关系</h3><p><strong>注意</strong>: OAuth 登录后后端做 <code>response.sendRedirect()</code> 到前端并携带 token query，这是「URL 传参」，不需要 session。前端保存 token 到 localStorage 后，后续请求通过 <code>Authorization: Bearer xxx</code> 头携带。</p><hr><h2 id="测试指南"><a href="#测试指南" class="headerlink" title="测试指南"></a>测试指南</h2><h3 id="本地测试"><a href="#本地测试" class="headerlink" title="本地测试"></a>本地测试</h3><ol><li>确保 Vite 前端（8082）和后端（8081）都在运行</li><li>访问 <code>http://localhost:8082/#/shop/auth</code></li><li>如果已登录，先清除 localStorage 中的 <code>shop_token</code> 和 <code>shop_refresh_token</code></li><li>点击 “GitHub” 按钮</li><li>浏览器跳转到 GitHub 授权页，登录 GitHub 账号并授权</li><li>GitHub 回调后端 → 后端处理 → 重定向到前端回调页</li><li>浏览器自动跳转到商城首页，右上角显示用户头像和昵称</li></ol><h3 id="直接-API-测试"><a href="#直接-API-测试" class="headerlink" title="直接 API 测试"></a>直接 API 测试</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 获取授权地址（后端返回 302，用 -v 看 Location header）</span><br>curl -v http://localhost:8081/shop/oauth/github 2&gt;&amp;1 | grep -i <span class="hljs-string">&quot;location&quot;</span><br><br><span class="hljs-comment"># 2. 手动复制 location URL 到浏览器打开，完成 GitHub 授权</span><br><span class="hljs-comment">#    GitHub 会回调 http://localhost:8081/shop/oauth/callback/github?code=xxx</span><br><br><span class="hljs-comment"># 3. 查看后端日志确认登录成功</span><br></code></pre></td></tr></table></figure>]]>
      </content:encoded>
    </item>
    <item>
      <title>GitHub OAuth Device Flow（扫码登录)</title>
      <link>https://neoisconstantine-github-io.pages.dev/2025/06/17/GitHub%20OAuth%20Device%20Flow%EF%BC%88%E6%89%AB%E7%A0%81%E7%99%BB%E5%BD%95)/</link>
      <description>详解GitHub Device Authorization Flow设备授权流实现，提供扫码登录体验。包含完整流程图、前后端代码、轮询机制与安全性分析。</description>
      <author>constantine</author>
      <category domain="https://neoisconstantine-github-io.pages.dev/categories/OAuth/">OAuth</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/%E7%99%BB%E5%BD%95/">登录</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/GitHub/">GitHub</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/OAuth/">OAuth</category>
      <category domain="https://neoisconstantine-github-io.pages.dev/tags/%E6%8E%88%E6%9D%83%E7%A0%81%E6%A8%A1%E5%BC%8F/">授权码模式</category>
      <pubDate>Tue, 17 Jun 2025 14:27:50 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h2 id="概述"><a href="#概述" class="headerlink" title="概述"></a>概述</h2><p>本模块实现了 <strong>GitHub Device Authorization Flow</strong>（设备授权流），提供”扫码登录”体验。</p><p>与标准 OAuth 重定向流的区别：</p><table><thead><tr><th>特性</th><th>标准重定向流</th><th>Device Flow（扫码）</th></tr></thead><tbody><tr><td>用户在 PC 上</td><td>点按钮 → 跳 GitHub → 授权 → 跳回</td><td>显示二维码 + <code>user_code</code></td></tr><tr><td>用户在手机上</td><td>不需要</td><td>扫二维码 → 输入 code → 授权</td></tr><tr><td>回调方式</td><td>GitHub 直接回调 <code>redirect_uri</code></td><td>前端轮询后端 → 后端轮询 GitHub</td></tr><tr><td>适用场景</td><td>PC 浏览器</td><td>PC 大屏展示、无法跳转的环境</td></tr></tbody></table><hr><h2 id="完整流程"><a href="#完整流程" class="headerlink" title="完整流程"></a>完整流程</h2><figure class="highlight nix"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br></pre></td><td class="code"><pre><code class="hljs nix">┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐<br>│  前端 (浏览器)    │         │  后端 (<span class="hljs-number">8081</span>)      │         │  GitHub API      │<br>│  localhost:<span class="hljs-number">8082</span>  │         │                  │         │                  │<br>└────────┬────────┘         └────────┬─────────┘         └────────┬────────┘<br>         │                           │                           │<br>         │  ① POST <span class="hljs-symbol">/device/init</span>      │                           │<br>         ├──────────────────────────► │                           │<br>         │                           │  ② POST device<span class="hljs-symbol">/code</span>       │<br>         │                           │  ───────────────────────► │<br>         │                           │                           │<br>         │                           │  ③ 返回 device_code       │<br>         │                           │  <span class="hljs-operator">+</span> user_code              │<br>         │                           │  <span class="hljs-operator">+</span> verification_uri       │<br>         │                           │◄───────────────────────── │<br>         │                           │                           │<br>         │  ④ 返回 &#123;deviceCode,      │                           │<br>         │     userCode,             │                           │<br>         │     verificationUri&#125;      │                           │<br>         │◄──────────────────────────┤                           │<br>         │                           │                           │<br>         │  ⑤ 渲染二维码(≈ verification_uri)                      │<br>         │  显示 <span class="hljs-params">user_code:</span> DDB3-<span class="hljs-number">23</span>AD                             │<br>         │                           │                           │<br>         │  ⑥ 用户用手机扫描二维码     │                           │<br>         │  或直接访问 github.com<span class="hljs-symbol">/login/device</span>                    │<br>         │  输入 user_code 完成授权    │                           │<br>         │                           │                           │<br>         │  ⑦ GET <span class="hljs-symbol">/device/status</span>     │   每 <span class="hljs-number">5</span>s 轮询              │<br>         │  <span class="hljs-operator">?</span>deviceCode<span class="hljs-operator">=</span>xxx          │                           │<br>         ├──────────────────────────► │                           │<br>         │                           │  ⑧ POST <span class="hljs-operator">/</span>login<span class="hljs-operator">/</span>oauth<span class="hljs-symbol">/</span>    │<br>         │                           │     access_token          │<br>         │                           │  ───────────────────────► │<br>         │                           │  ◄── authorization_pending│<br>         │  ⑨ <span class="hljs-string">&quot;等待扫码中...&quot;</span>        │                           │<br>         │◄──────────────────────────┤                           │<br>         │          ════════════════════════════════════          │<br>         │         用户手机在 GitHub 上确认授权                    │<br>         │          ════════════════════════════════════          │<br>         │                           │                           │<br>         │  ⑩ 再次轮询               │                           │<br>         ├──────────────────────────► │                           │<br>         │                           │  ⑪ POST <span class="hljs-operator">/</span>login<span class="hljs-operator">/</span>oauth<span class="hljs-symbol">/</span>    │<br>         │                           │     access_token          │<br>         │                           │  ───────────────────────► │<br>         │                           │  ⑫ 返回 access_token     │<br>         │                           │◄───────────────────────── │<br>         │                           │                           │<br>         │                           │  ⑬ GET <span class="hljs-symbol">/user</span> (获取用户信息)│<br>         │                           │  ───────────────────────► │<br>         │                           │  ◄── 返回 GitHub 用户     │<br>         │                           │                           │<br>         │                           │  ⑭ 查<span class="hljs-operator">/</span>创建 ShopUser       │<br>         │                           │  生成 JWT <span class="hljs-operator">+</span> refresh token │<br>         │                           │                           │<br>         │  ⑮ 返回 token <span class="hljs-operator">+</span> 用户信息  │                           │<br>         │◄──────────────────────────┤                           │<br>         │                           │                           │<br>         │  ⑯ 保存 token 到 localStorage                         │<br>         │  跳转到 <span class="hljs-symbol">/shop</span>              │                           │<br></code></pre></td></tr></table></figure><hr><h2 id="后端实现"><a href="#后端实现" class="headerlink" title="后端实现"></a>后端实现</h2><h3 id="1-OAuthService-接口扩展"><a href="#1-OAuthService-接口扩展" class="headerlink" title="1. OAuthService 接口扩展"></a>1. OAuthService 接口扩展</h3><p><strong>文件</strong>: <code>src/main/java/com/.../service/OAuthService.java</code></p><p>新增两个接口方法：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">// 初始化设备流：调用 GitHub API 获取 device_code / user_code</span><br>Response <span class="hljs-title function_">initDeviceFlow</span><span class="hljs-params">()</span>;<br><br><span class="hljs-comment">// 轮询设备流状态：检查用户是否已授权</span><br>Response <span class="hljs-title function_">checkDeviceFlow</span><span class="hljs-params">(String deviceCode)</span>;<br></code></pre></td></tr></table></figure><h3 id="2-OAuthServiceImpl-实现"><a href="#2-OAuthServiceImpl-实现" class="headerlink" title="2. OAuthServiceImpl 实现"></a>2. OAuthServiceImpl 实现</h3><p><strong>文件</strong>: <code>src/main/java/com/.../service/impl/OAuthServiceImpl.java</code></p><h4 id="constants"><a href="#constants" class="headerlink" title="constants"></a>constants</h4><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_DEVICE_CODE_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://github.com/login/device/code&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_TOKEN_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://github.com/login/oauth/access_token&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_USER_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://api.github.com/user&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">GITHUB_EMAIL_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://api.github.com/user/emails&quot;</span>;<br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">DEVICE_KEY_PREFIX</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;weblog:device:&quot;</span>;<br></code></pre></td></tr></table></figure><h4 id="initDeviceFlow"><a href="#initDeviceFlow" class="headerlink" title="initDeviceFlow()"></a>initDeviceFlow()</h4><figure class="highlight nix"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs nix"><span class="hljs-number">1</span>. POST https:<span class="hljs-symbol">//github.com/login/device/code</span><br>   <span class="hljs-params">Body:</span> client_id, scope<span class="hljs-operator">=</span>read:user,user:email<br>   → 返回 device_code, user_code, verification_uri, interval, expires_in<br><br><span class="hljs-number">2</span>. 将 device_code 状态存入 <span class="hljs-params">Redis:</span><br>   <span class="hljs-params">KEY:</span> weblog:device:&#123;deviceCode&#125;<br>   <span class="hljs-params">VALUE:</span> &#123; deviceCode, <span class="hljs-params">status:</span> <span class="hljs-string">&quot;pending&quot;</span>, expiresIn &#125;<br>   <span class="hljs-params">TTL:</span> expires_in (GitHub 指定的过期时间)<br><br><span class="hljs-number">3</span>. 返回前端: deviceCode, userCode, verificationUri, interval<br></code></pre></td></tr></table></figure><p><strong>Request</strong>:</p><figure class="highlight http"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs http">POST https://github.com/login/device/code<br><span class="hljs-attribute">Content-Type</span><span class="hljs-punctuation">: </span>application/x-www-form-urlencoded<br><span class="hljs-attribute">Accept</span><span class="hljs-punctuation">: </span>application/json<br><br>client_id=xxx&amp;scope=read:user,user:email<br></code></pre></td></tr></table></figure><p><strong>Response</strong>:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;device_code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;3584d83530jskajsjas46af8289938c8ef79f9dc5&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;user_code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;WDJB-MJJJ&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;verification_uri&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;https://github.com/login/device&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;expires_in&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">900</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;interval&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-number">5</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><h4 id="checkDeviceFlow"><a href="#checkDeviceFlow" class="headerlink" title="checkDeviceFlow()"></a>checkDeviceFlow()</h4><figure class="highlight pgsql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><code class="hljs pgsql"><span class="hljs-number">1.</span> 从 Redis 获取设备流状态<br><span class="hljs-number">2.</span> 如果 status 已为 &quot;success&quot;，直接返回结果（清除 Redis key）<br><span class="hljs-number">3.</span> 如果 status 为 &quot;expired&quot;/&quot;denied&quot;，返回错误（清除 Redis key）<br><span class="hljs-number">4.</span> 否则 POST https://github.com/<span class="hljs-keyword">login</span>/oauth/access_token<br>   Body: client_id, client_secret, device_code, grant_type=urn:ietf:params:oauth:<span class="hljs-keyword">grant</span>-<span class="hljs-keyword">type</span>:device_code<br><br>   GitHub 可能返回:<br>   - authorization_pending → 用户未操作，返回 status: pending<br>   - slow_down → 需要放慢轮询，返回 status: pending<br>   - expired_token → 标记为 expired，返回错误<br>   - access_denied → 标记为 denied，返回错误<br>   - access_token → 授权成功！<br><br><span class="hljs-number">5.</span> 授权成功后：<br>   a. 用 access_token 获取 GitHub 用户信息 (<span class="hljs-keyword">GET</span> https://api.github.com/<span class="hljs-keyword">user</span>)<br>   b. 用 access_token 获取主邮箱 (<span class="hljs-keyword">GET</span> https://api.github.com/<span class="hljs-keyword">user</span>/emails)<br>   c. 查找 ShopUser (<span class="hljs-keyword">by</span> oauth_provider=github, oauth_id=github_id)<br>   d. 不存在则创建新用户，存在则更新昵称/头像<br>   e. 生成 JWT token + <span class="hljs-keyword">refresh</span> token<br>   f. 返回 token, refreshToken, <span class="hljs-keyword">user</span> 给前端<br></code></pre></td></tr></table></figure><h3 id="3-OAuthController-端点"><a href="#3-OAuthController-端点" class="headerlink" title="3. OAuthController 端点"></a>3. OAuthController 端点</h3><p><strong>文件</strong>: <code>src/main/java/com/.../controller/OAuthController.java</code></p><table><thead><tr><th>端点</th><th>方法</th><th>说明</th></tr></thead><tbody><tr><td><code>/shop/oauth/device/init</code></td><td>POST</td><td>初始化设备流，返回二维码数据</td></tr><tr><td><code>/shop/oauth/device/status</code></td><td>GET</td><td>轮询设备流状态，参数：<code>deviceCode</code></td></tr></tbody></table><h3 id="4-RestTemplate-配置"><a href="#4-RestTemplate-配置" class="headerlink" title="4. RestTemplate 配置"></a>4. RestTemplate 配置</h3><p><strong>文件</strong>: <code>src/main/java/com/.../config/RestTemplateConfig.java</code></p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@Bean</span><br><span class="hljs-keyword">public</span> RestTemplate <span class="hljs-title function_">restTemplate</span><span class="hljs-params">()</span> &#123;<br>    <span class="hljs-type">SimpleClientHttpRequestFactory</span> <span class="hljs-variable">factory</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">SimpleClientHttpRequestFactory</span>();<br>    factory.setConnectTimeout(<span class="hljs-number">10000</span>);    <span class="hljs-comment">// 连接超时 10s</span><br>    factory.setReadTimeout(<span class="hljs-number">30000</span>);       <span class="hljs-comment">// 读取超时 30s</span><br>    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">RestTemplate</span>(factory);<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="5-安全配置"><a href="#5-安全配置" class="headerlink" title="5. 安全配置"></a>5. 安全配置</h3><p>在 <code>WebSecurityConfig.java</code> 中确保 <code>/shop/oauth/**</code> 路径允许匿名访问：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs java">.antMatchers(<span class="hljs-string">&quot;/shop/oauth/**&quot;</span>).permitAll()<br></code></pre></td></tr></table></figure><h3 id="6-SSL-握手失败重试"><a href="#6-SSL-握手失败重试" class="headerlink" title="6. SSL 握手失败重试"></a>6. SSL 握手失败重试</h3><p>由于国内访问 GitHub 偶发 SSL 握手失败（<code>Remote host terminated the handshake</code>），<code>getGitHubAccessToken()</code> 内置重试机制：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-type">int</span> <span class="hljs-variable">maxRetries</span> <span class="hljs-operator">=</span> <span class="hljs-number">3</span>;<br><span class="hljs-type">int</span> <span class="hljs-variable">retryDelayMs</span> <span class="hljs-operator">=</span> <span class="hljs-number">1000</span>;<br><span class="hljs-comment">// 指数退避: 1s → 2s → 4s</span><br></code></pre></td></tr></table></figure><p>同时强制走 TLSv1.2 避免某些代理&#x2F;VPN 对 TLS 1.3 握手兼容性问题：</p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs java">System.setProperty(<span class="hljs-string">&quot;https.protocols&quot;</span>, <span class="hljs-string">&quot;TLSv1.2&quot;</span>);<br></code></pre></td></tr></table></figure><hr><h2 id="前端实现"><a href="#前端实现" class="headerlink" title="前端实现"></a>前端实现</h2><h3 id="1-扫码页面"><a href="#1-扫码页面" class="headerlink" title="1. 扫码页面"></a>1. 扫码页面</h3><p><strong>文件</strong>: <code>weblog-vue3/src/pages/frontend/shop-oauth-qrcode.vue</code></p><h4 id="页面结构"><a href="#页面结构" class="headerlink" title="页面结构"></a>页面结构</h4><figure class="highlight stylus"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><code class="hljs stylus">┌─ 弹窗/居中容器 ──────────────────────┐<br>│                                      │<br>│     GitHub 扫码登录                    │<br>│     使用 GitHub App 扫描二维码完成登录  │<br>│                                      │<br>│         ┌───────────────┐            │<br>│         │               │            │<br>│         │  二维码图片    │            │<br>│         │  <span class="hljs-number">220</span>x220      │            │<br>│         │               │            │<br>│         └───────────────┘            │<br>│                                      │<br>│        输入以下代码                    │<br>│        ┌──────────────┐              │<br>│        │  DDB3-<span class="hljs-number">23</span>AD   │              │<br>│        └──────────────┘              │<br>│                                      │<br>│  或访问 https:<span class="hljs-comment">//github.com/login/     │</span><br>│  device 输入以上代码完成授权            │<br>│                                      │<br>│  ⟳ 等待扫码中...                      │<br>│                                      │<br>│  <span class="hljs-selector-attr">[取消]</span>        <span class="hljs-selector-attr">[刷新二维码]</span>            │<br>└──────────────────────────────────────┘<br></code></pre></td></tr></table></figure><h4 id="关键逻辑"><a href="#关键逻辑" class="headerlink" title="关键逻辑"></a>关键逻辑</h4><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-comment">// 1. 初始化设备流</span><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">initDevice</span>(<span class="hljs-params"></span>) &#123;<br>  loading.<span class="hljs-property">value</span> = <span class="hljs-literal">true</span><br>  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">&#x27;/shop/oauth/device/init&#x27;</span>, &#123; <span class="hljs-attr">method</span>: <span class="hljs-string">&#x27;POST&#x27;</span> &#125;)<br>  <span class="hljs-keyword">const</span> json = <span class="hljs-keyword">await</span> res.<span class="hljs-title function_">json</span>()<br>  <span class="hljs-keyword">const</span> data = json.<span class="hljs-property">data</span><br><br>  deviceCode.<span class="hljs-property">value</span> = data.<span class="hljs-property">deviceCode</span><br>  userCode.<span class="hljs-property">value</span> = data.<span class="hljs-property">userCode</span><br>  verificationUri.<span class="hljs-property">value</span> = data.<span class="hljs-property">verificationUri</span><br><br>  <span class="hljs-comment">// ★ 先切到二维码视图，让 canvas 出现在 DOM 中</span><br>  loading.<span class="hljs-property">value</span> = <span class="hljs-literal">false</span><br>  <span class="hljs-keyword">await</span> <span class="hljs-title function_">nextTick</span>()  <span class="hljs-comment">// 等待 DOM 更新，确保 canvas 已渲染</span><br><br>  <span class="hljs-comment">// 此时 canvas 在 DOM 中了，绘制 QR 码</span><br>  <span class="hljs-keyword">await</span> <span class="hljs-title class_">QRCode</span>.<span class="hljs-title function_">toCanvas</span>(qrCanvas.<span class="hljs-property">value</span>, data.<span class="hljs-property">verificationUri</span>, &#123;<br>    <span class="hljs-attr">width</span>: <span class="hljs-number">220</span>,<br>    <span class="hljs-attr">margin</span>: <span class="hljs-number">2</span><br>  &#125;)<br><br>  <span class="hljs-title function_">startPolling</span>()  <span class="hljs-comment">// 开始轮询</span><br>&#125;<br><br><span class="hljs-comment">// 2. 轮询设备状态</span><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">pollDevice</span>(<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">&#x27;/shop/oauth/device/status?deviceCode=&#x27;</span> + deviceCode.<span class="hljs-property">value</span>)<br>  <span class="hljs-keyword">const</span> json = <span class="hljs-keyword">await</span> res.<span class="hljs-title function_">json</span>()<br><br>  <span class="hljs-keyword">if</span> (json.<span class="hljs-property">data</span>?.<span class="hljs-property">token</span>) &#123;<br>    <span class="hljs-comment">// 登录成功！保存 token 并跳转</span><br>    <span class="hljs-title function_">setShopTokens</span>(json.<span class="hljs-property">data</span>.<span class="hljs-property">token</span>, json.<span class="hljs-property">data</span>.<span class="hljs-property">refreshToken</span>)<br>    router.<span class="hljs-title function_">replace</span>(<span class="hljs-string">&#x27;/shop&#x27;</span>)<br>    <span class="hljs-keyword">return</span><br>  &#125;<br>  <span class="hljs-comment">// status: pending → 继续轮询</span><br>  <span class="hljs-title function_">scheduleNextPoll</span>()<br>&#125;<br></code></pre></td></tr></table></figure><h4 id="注意事项"><a href="#注意事项" class="headerlink" title="注意事项"></a>注意事项</h4><ul><li><strong><code>loading</code> 切换时机</strong>：必须先 <code>loading = false</code> 让 canvas 渲染到 DOM，再用 <code>nextTick()</code> 等待，否则 <code>qrCanvas</code> ref 为 null，QR 码画不上去</li><li>轮询间隔从 GitHub 返回的 <code>interval</code> 字段获取（通常 5s）</li><li>二维码用 <code>qrcode</code> npm 包（v1.5.4）的 <code>QRCode.toCanvas()</code> 渲染到 <code>&lt;canvas&gt;</code> 元素</li></ul><h3 id="2-登录页入口"><a href="#2-登录页入口" class="headerlink" title="2. 登录页入口"></a>2. 登录页入口</h3><p><strong>文件</strong>: <code>weblog-vue3/src/pages/frontend/shop-login.vue</code></p><p>在 GitHub 按钮旁边添加扫码入口：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><code class="hljs html"><span class="hljs-tag">&lt;<span class="hljs-name">el-button</span> @<span class="hljs-attr">click</span>=<span class="hljs-string">&quot;goQrcode&quot;</span>&gt;</span>扫码<span class="hljs-tag">&lt;/<span class="hljs-name">el-button</span>&gt;</span><br><br><span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="language-javascript"></span><br><span class="language-javascript"><span class="hljs-keyword">function</span> <span class="hljs-title function_">goQrcode</span>(<span class="hljs-params"></span>) &#123;</span><br><span class="language-javascript">  router.<span class="hljs-title function_">push</span>(<span class="hljs-string">&#x27;/shop/auth/qrcode&#x27;</span>)</span><br><span class="language-javascript">&#125;</span><br><span class="language-javascript"></span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span><br></code></pre></td></tr></table></figure><p>同时增加 OAuth 错误显示逻辑：</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-comment">// 检测 URL 中的 oauth_error 参数</span><br><span class="hljs-keyword">if</span> (route.<span class="hljs-property">query</span>.<span class="hljs-property">oauth_error</span>) &#123;<br>  <span class="hljs-title class_">ElMessage</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">&#x27;GitHub 登录失败: &#x27;</span> + route.<span class="hljs-property">query</span>.<span class="hljs-property">oauth_error</span>)<br>  router.<span class="hljs-title function_">replace</span>(<span class="hljs-string">&#x27;/shop/auth&#x27;</span>)  <span class="hljs-comment">// 清除 URL 参数</span><br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3-路由配置"><a href="#3-路由配置" class="headerlink" title="3. 路由配置"></a>3. 路由配置</h3><p><strong>文件</strong>: <code>weblog-vue3/src/router/index.js</code></p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs javascript">&#123;<br>  <span class="hljs-attr">path</span>: <span class="hljs-string">&#x27;/shop/auth/qrcode&#x27;</span>,<br>  <span class="hljs-attr">component</span>: <span class="hljs-title class_">ShopOAuthQRCode</span>,<br>  <span class="hljs-attr">meta</span>: &#123; <span class="hljs-attr">title</span>: <span class="hljs-string">&#x27;扫码登录&#x27;</span> &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="踩坑记录"><a href="#踩坑记录" class="headerlink" title="踩坑记录"></a>踩坑记录</h2><h3 id="1-GitHub-OAuth-App-须开启-Device-Flow"><a href="#1-GitHub-OAuth-App-须开启-Device-Flow" class="headerlink" title="1. GitHub OAuth App 须开启 Device Flow"></a>1. GitHub OAuth App 须开启 Device Flow</h3><p><strong>现象</strong>: 调用 <code>POST /login/device/code</code> 返回 400</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><span class="hljs-attr">&quot;error&quot;</span><span class="hljs-punctuation">:</span><span class="hljs-string">&quot;device_flow_disabled&quot;</span><span class="hljs-punctuation">,</span><span class="hljs-attr">&quot;error_description&quot;</span><span class="hljs-punctuation">:</span><span class="hljs-string">&quot;Device Flow must be explicitly enabled for this App&quot;</span><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p><strong>解决</strong>: 去 GitHub Settings → Developer settings → OAuth Apps → 选择你的 App → 勾选 <strong>“Enable Device Flow”</strong> → Save</p><h3 id="2-RestTemplate-在-4xx-响应时抛异常"><a href="#2-RestTemplate-在-4xx-响应时抛异常" class="headerlink" title="2. RestTemplate 在 4xx 响应时抛异常"></a>2. RestTemplate 在 4xx 响应时抛异常</h3><p><strong>现象</strong>: GitHub 返回 HTTP 400，但 <code>restTemplate.postForEntity()</code> 在读取响应体之前就抛出 <code>HttpClientErrorException</code></p><p><strong>解决</strong>: 在 <code>initDeviceFlow()</code> 的 catch 中先捕获 <code>HttpClientErrorException</code>，从 <code>e.getResponseBodyAsString()</code> 解析错误 JSON 体</p><h3 id="3-SSL-握手失败（国内网络问题）"><a href="#3-SSL-握手失败（国内网络问题）" class="headerlink" title="3. SSL 握手失败（国内网络问题）"></a>3. SSL 握手失败（国内网络问题）</h3><p><strong>现象</strong>: 访问 <code>https://github.com/login/oauth/access_token</code> 时 Java 报错</p><figure class="highlight stylus"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs stylus">javax<span class="hljs-selector-class">.net</span><span class="hljs-selector-class">.ssl</span><span class="hljs-selector-class">.SSLHandshakeException</span>: Remote host terminated the handshake<br></code></pre></td></tr></table></figure><p><strong>原因</strong>: </p><ul><li>国内访问 GitHub 网络不稳定，中间设备可能中断 TLS 连接</li><li>某些代理&#x2F;VPN 设备对 TLS 1.3 握手兼容性不好</li></ul><p><strong>解决</strong>:</p><ul><li>强制使用 TLSv1.2: <code>System.setProperty(&quot;https.protocols&quot;, &quot;TLSv1.2&quot;)</code></li><li>添加重试机制：<code>getGitHubAccessToken()</code> 最多重试 3 次，指数退避</li></ul><h3 id="4-前端-Canvas-ref-为-null-导致-QR-码空白"><a href="#4-前端-Canvas-ref-为-null-导致-QR-码空白" class="headerlink" title="4. 前端 Canvas ref 为 null 导致 QR 码空白"></a>4. 前端 Canvas ref 为 null 导致 QR 码空白</h3><p><strong>现象</strong>: 二维码区域显示了（有边框），但里面没有二维码图案</p><p><strong>原因</strong>: 代码中 <code>&lt;canvas ref=&quot;qrCanvas&quot;&gt;</code> 在 <code>v-if=&quot;loading&quot;</code> 条件下不存在。API 返回数据后，代码先改了 <code>deviceCode</code> 等变量但没改 <code>loading</code>，执行 <code>nextTick()</code> 时 DOM 里还是加载状态，canvas 没渲染出来，<code>qrCanvas.value = null</code></p><p><strong>解决</strong>: 绘制 QR 码前先 <code>loading.value = false</code> 让 DOM 切换到二维码视图，再 <code>await nextTick()</code> 确保 canvas 已就绪。</p><h3 id="5-OAuth-callback-中-base64-URL-安全编码问题"><a href="#5-OAuth-callback-中-base64-URL-安全编码问题" class="headerlink" title="5. OAuth callback 中 base64 URL 安全编码问题"></a>5. OAuth callback 中 base64 URL 安全编码问题</h3><p><strong>现象</strong>: 前端 <code>atob()</code> 解码用户信息时报错</p><p><strong>原因</strong>: 后端 <code>Base64.getUrlEncoder()</code> 使用了 URL 安全的 Base64（<code>-</code> 和 <code>_</code> 替代 <code>+</code> 和 <code>/</code>），前端 <code>atob()</code> 不认识这种变体</p><p><strong>解决</strong>: 解码前替换字符：</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-keyword">const</span> standardBase64 = data.<span class="hljs-property">user</span>.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/-/g</span>, <span class="hljs-string">&#x27;+&#x27;</span>).<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/_/g</span>, <span class="hljs-string">&#x27;/&#x27;</span>)<br><span class="hljs-keyword">const</span> userStr = <span class="hljs-title function_">atob</span>(standardBase64)<br></code></pre></td></tr></table></figure><hr><h2 id="配置项"><a href="#配置项" class="headerlink" title="配置项"></a>配置项</h2><h3 id="application-yml"><a href="#application-yml" class="headerlink" title="application.yml"></a>application.yml</h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">oauth:</span><br>  <span class="hljs-attr">github:</span><br>    <span class="hljs-attr">client-id:</span> <span class="hljs-string">Ov23li0zJcTQdkklkswc</span><br>    <span class="hljs-attr">client-secret:</span> <span class="hljs-string">xxxxxxxxx</span><br>    <span class="hljs-attr">redirect-uri:</span> <span class="hljs-string">http://localhost:8081/shop/oauth/callback/github</span><br>    <span class="hljs-attr">frontend-url:</span> <span class="hljs-string">http://localhost:8082</span><br></code></pre></td></tr></table></figure><h3 id="相关依赖"><a href="#相关依赖" class="headerlink" title="相关依赖"></a>相关依赖</h3><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs xml"><span class="hljs-comment">&lt;!-- spring-web（内含 RestTemplate） --&gt;</span><br><span class="hljs-tag">&lt;<span class="hljs-name">dependency</span>&gt;</span><br>    <span class="hljs-tag">&lt;<span class="hljs-name">groupId</span>&gt;</span>org.springframework.boot<span class="hljs-tag">&lt;/<span class="hljs-name">groupId</span>&gt;</span><br>    <span class="hljs-tag">&lt;<span class="hljs-name">artifactId</span>&gt;</span>spring-boot-starter-web<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br><span class="hljs-tag">&lt;/<span class="hljs-name">dependency</span>&gt;</span><br><br><span class="hljs-comment">&lt;!-- Redis（存储设备流状态） --&gt;</span><br><span class="hljs-comment">&lt;!-- 通过 spring-boot-starter-data-redis 引入 --&gt;</span><br></code></pre></td></tr></table></figure><hr><h2 id="测试指南"><a href="#测试指南" class="headerlink" title="测试指南"></a>测试指南</h2><h3 id="手动测试"><a href="#手动测试" class="headerlink" title="手动测试"></a>手动测试</h3><ol><li>确保 Vite 前端运行在 <code>localhost:8082</code>，后端运行在 <code>localhost:8081</code></li><li>访问 <code>http://localhost:8082/#/shop/auth</code></li><li>点击”扫码”按钮</li><li>页面显示二维码和 <code>user_code</code></li><li>用手机扫描二维码，或访问 <code>https://github.com/login/device</code> 输入 <code>user_code</code></li><li>在手机上确认授权</li><li>浏览器自动跳转到商城首页，登录成功</li></ol><h3 id="后端-API-直接验证"><a href="#后端-API-直接验证" class="headerlink" title="后端 API 直接验证"></a>后端 API 直接验证</h3><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 初始化设备流</span><br>curl -X POST http://localhost:8081/shop/oauth/device/init<br><br><span class="hljs-comment"># 2. 轮询状态（替换 deviceCode）</span><br>curl <span class="hljs-string">&quot;http://localhost:8081/shop/oauth/device/status?deviceCode=xxx&quot;</span><br></code></pre></td></tr></table></figure>]]>
      </content:encoded>
    </item>
  </channel>
</rss>
